feat: add authentication core

This commit is contained in:
Ruidy 2025-09-20 00:16:24 +02:00
parent 6180b24cf6
commit 55a03ae248
No known key found for this signature in database
GPG key ID: 705C24D202990805
16 changed files with 340 additions and 41 deletions

View file

@ -8,7 +8,10 @@ import (
) )
func main() { func main() {
srv := server.New() srv, err := server.New()
if err != nil {
log.Fatalf("initialise server: %v", err)
}
log.Println("Starting server on http://localhost:8000") log.Println("Starting server on http://localhost:8000")
if err := http.ListenAndServe(":8000", srv.Router()); err != nil { if err := http.ListenAndServe(":8000", srv.Router()); err != nil {

50
internal/auth/password.go Normal file
View file

@ -0,0 +1,50 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
"log"
)
const saltLen = 32
// HashPassword returns a base64-encoded salt and hash for the provided plaintext.
func HashPassword(plain string) (salt string, hash string, err error) {
if plain == "" {
return "", "", fmt.Errorf("password cannot be empty")
}
rawSalt := make([]byte, saltLen)
if _, err = rand.Read(rawSalt); err != nil {
return "", "", fmt.Errorf("generate salt: %w", err)
}
salt = base64.StdEncoding.EncodeToString(rawSalt)
hash = encodeHash(rawSalt, plain)
return salt, hash, nil
}
// VerifyPassword reports whether the supplied plaintext matches the salt+hash pair.
func VerifyPassword(plain, salt, expectedHash string) bool {
if plain == "" || salt == "" || expectedHash == "" {
return false
}
rawSalt, err := base64.StdEncoding.DecodeString(salt)
if err != nil {
log.Printf("auth: invalid salt encoding: %v", err)
return false
}
calculated := encodeHash(rawSalt, plain)
return subtle.ConstantTimeCompare([]byte(calculated), []byte(expectedHash)) == 1
}
func encodeHash(salt []byte, plain string) string {
digest := sha256.Sum256(append(salt, plain...))
return base64.StdEncoding.EncodeToString(digest[:])
}

68
internal/auth/store.go Normal file
View file

@ -0,0 +1,68 @@
package auth
import (
"context"
"errors"
"sync"
"github.com/rjnemo/auth/internal/identity"
)
// ErrUserNotFound signals no user exists for the provided lookup criteria.
var ErrUserNotFound = errors.New("auth: user not found")
// UserStore defines persistence expectations for user lookups.
type UserStore interface {
FindByEmail(ctx context.Context, email string) (*User, error)
Create(ctx context.Context, user User) error
}
// MemoryStore is an in-memory implementation of UserStore for development and tests.
type MemoryStore struct {
mu sync.RWMutex
users map[string]User
}
// NewMemoryStore builds an empty MemoryStore instance.
func NewMemoryStore() *MemoryStore {
return &MemoryStore{users: make(map[string]User)}
}
// FindByEmail returns a copy of the stored user.
func (s *MemoryStore) FindByEmail(_ context.Context, email string) (*User, error) {
key := identity.NormalizeEmail(email)
if key == "" {
return nil, ErrUserNotFound
}
s.mu.RLock()
defer s.mu.RUnlock()
user, ok := s.users[key]
if !ok {
return nil, ErrUserNotFound
}
userCopy := user
return &userCopy, nil
}
// Create inserts or replaces the stored user by email.
func (s *MemoryStore) Create(_ context.Context, user User) error {
key := identity.NormalizeEmail(user.Email)
if key == "" {
return errors.New("auth: email required")
}
user.Email = key
s.mu.Lock()
defer s.mu.Unlock()
if s.users == nil {
s.users = make(map[string]User)
}
s.users[key] = user
return nil
}

12
internal/auth/user.go Normal file
View file

@ -0,0 +1,12 @@
package auth
import "time"
// User represents authenticated account details.
type User struct {
ID string
Email string
PasswordSalt string
PasswordHash string
CreatedAt time.Time
}

View file

@ -0,0 +1,8 @@
package identity
import "strings"
// NormalizeEmail trims whitespace and lowercases an email for canonical comparisons.
func NormalizeEmail(email string) string {
return strings.TrimSpace(strings.ToLower(email))
}

View file

@ -1,14 +1,52 @@
package server package server
import ( import (
"log"
"net/http" "net/http"
"github.com/rjnemo/auth/internal/auth"
"github.com/rjnemo/auth/internal/identity"
) )
func (s *Server) loginHandler() http.HandlerFunc { func (s *Server) loginHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
log.Println("Login request received") if err := r.ParseForm(); err != nil {
s.loggedIn = true http.Error(w, "invalid form submission", http.StatusBadRequest)
return
}
email := identity.NormalizeEmail(r.FormValue("email"))
password := r.FormValue("password")
if email == "" || password == "" {
w.WriteHeader(http.StatusBadRequest)
s.render(w, "index.html", newIndexData(email, "Email and password are required."))
return
}
account, err := s.users.FindByEmail(r.Context(), email)
if err != nil {
s.renderLoginFailure(w, email)
return
}
if !auth.VerifyPassword(password, account.PasswordSalt, account.PasswordHash) {
s.renderLoginFailure(w, email)
return
}
s.sessions.SetAuthenticated(account.Email)
http.Redirect(w, r, "/in", http.StatusSeeOther) http.Redirect(w, r, "/in", http.StatusSeeOther)
} }
} }
func (s *Server) logoutHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.sessions.Clear()
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
func (s *Server) renderLoginFailure(w http.ResponseWriter, email string) {
w.WriteHeader(http.StatusUnauthorized)
s.render(w, "index.html", newIndexData(email, "Invalid credentials."))
}

View file

@ -1,24 +1,15 @@
package server package server
import ( import "net/http"
"log"
"net/http"
)
func (s *Server) dashboardHandler() http.HandlerFunc { func (s *Server) dashboardHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if !s.loggedIn { if !s.sessions.IsAuthenticated() {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
if err := s.templates.ExecuteTemplate(w, "unauthorized.html", nil); err != nil { s.render(w, "unauthorized.html", newUnauthorizedData("Sign in to continue."))
log.Printf("render unauthorized: %v", err)
http.Error(w, "template render failed", http.StatusInternalServerError)
}
return return
} }
if err := s.templates.ExecuteTemplate(w, "in.html", nil); err != nil { s.render(w, "in.html", PageData{Email: s.sessions.CurrentAccount()})
log.Printf("render dashboard: %v", err)
http.Error(w, "template render failed", http.StatusInternalServerError)
}
} }
} }

View file

@ -1,15 +1,9 @@
package server package server
import ( import "net/http"
"log"
"net/http"
)
func (s *Server) indexHandler() http.HandlerFunc { func (s *Server) indexHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if err := s.templates.ExecuteTemplate(w, "index.html", nil); err != nil { s.render(w, "index.html", newIndexData("", ""))
log.Printf("render index: %v", err)
http.Error(w, "template render failed", http.StatusInternalServerError)
}
} }
} }

13
internal/server/render.go Normal file
View file

@ -0,0 +1,13 @@
package server
import (
"log"
"net/http"
)
func (s *Server) render(w http.ResponseWriter, name string, data any) {
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
log.Printf("render %s: %v", name, err)
http.Error(w, "template render failed", http.StatusInternalServerError)
}
}

View file

@ -6,4 +6,5 @@ func (s *Server) registerRoutes(r chi.Router) {
r.Get("/", s.indexHandler()) r.Get("/", s.indexHandler())
r.Get("/in", s.dashboardHandler()) r.Get("/in", s.dashboardHandler())
r.Post("/login", s.loginHandler()) r.Post("/login", s.loginHandler())
r.Post("/logout", s.logoutHandler())
} }

View file

@ -1,32 +1,52 @@
package server package server
import ( import (
"context"
"fmt"
"html/template" "html/template"
"net/http" "net/http"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/rjnemo/auth/internal/auth"
"github.com/rjnemo/auth/web" "github.com/rjnemo/auth/web"
) )
const (
seedEmail = "user@example.com"
seedPassword = "password123"
)
// Server holds HTTP dependencies for the application. // Server holds HTTP dependencies for the application.
type Server struct { type Server struct {
templates *template.Template templates *template.Template
loggedIn bool users auth.UserStore
sessions *SessionManager
} }
// New constructs a Server with parsed templates and default state. // New constructs a Server with parsed templates and default state.
func New() *Server { func New() (*Server, error) {
tmpl := template.Must(template.ParseFS( tmpl, err := template.ParseFS(
web.Templates, web.Templates,
"templates/index.html", "templates/index.html",
"templates/in.html", "templates/in.html",
"templates/unauthorized.html", "templates/unauthorized.html",
)) )
if err != nil {
return nil, fmt.Errorf("parse templates: %w", err)
}
users := auth.NewMemoryStore()
if err := seedUser(users); err != nil {
return nil, fmt.Errorf("seed user: %w", err)
}
return &Server{ return &Server{
templates: tmpl, templates: tmpl,
} users: users,
sessions: NewSessionManager(),
}, nil
} }
// Router returns the configured HTTP router. // Router returns the configured HTTP router.
@ -35,3 +55,19 @@ func (s *Server) Router() http.Handler {
s.registerRoutes(r) s.registerRoutes(r)
return r return r
} }
func seedUser(store auth.UserStore) error {
salt, hash, err := auth.HashPassword(seedPassword)
if err != nil {
return err
}
ctx := context.Background()
return store.Create(ctx, auth.User{
ID: "seed-user",
Email: seedEmail,
PasswordSalt: salt,
PasswordHash: hash,
CreatedAt: time.Now().UTC(),
})
}

View file

@ -0,0 +1,49 @@
package server
import "sync"
// SessionManager is a placeholder for future session persistence.
type SessionManager struct {
mu sync.RWMutex
authenticated bool
currentAccount string
}
// NewSessionManager constructs an empty session manager.
func NewSessionManager() *SessionManager {
return &SessionManager{}
}
// SetAuthenticated marks the provided account as the active authenticated user.
func (m *SessionManager) SetAuthenticated(email string) {
m.mu.Lock()
defer m.mu.Unlock()
m.authenticated = true
m.currentAccount = email
}
// Clear removes any active authentication data.
func (m *SessionManager) Clear() {
m.mu.Lock()
defer m.mu.Unlock()
m.authenticated = false
m.currentAccount = ""
}
// IsAuthenticated reports whether a user is currently considered logged in.
func (m *SessionManager) IsAuthenticated() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.authenticated
}
// CurrentAccount returns the email associated with the active session.
func (m *SessionManager) CurrentAccount() string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.currentAccount
}

15
internal/server/views.go Normal file
View file

@ -0,0 +1,15 @@
package server
// PageData contains fields shared by the templates for now.
type PageData struct {
Email string
Error string
}
func newIndexData(email, errMsg string) PageData {
return PageData{Email: email, Error: errMsg}
}
func newUnauthorizedData(errMsg string) PageData {
return PageData{Error: errMsg}
}

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>auth test</title> <title>Dashboard</title>
<link <link
rel="stylesheet" rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
@ -11,8 +11,12 @@
</head> </head>
<body> <body>
<main class="container"> <main class="container">
<h1>Logged in</h1> <h1>Welcome</h1>
<p>You are logged in. This is a test page for the authentication</p> <p>You are signed in as <strong>{{.Email}}</strong>.</p>
<p>This placeholder dashboard will evolve as we flesh out the auth flow.</p>
<form method="post" action="/logout">
<button type="submit" class="secondary">Sign out</button>
</form>
</main> </main>
</body> </body>
</html> </html>

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>auth test</title> <title>Auth Demo</title>
<link <link
rel="stylesheet" rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
@ -11,20 +11,36 @@
</head> </head>
<body> <body>
<main class="container"> <main class="container">
<h1>Login</h1> <h1>Sign in</h1>
<p> <p>Authenticate with the demo credentials below to view the dashboard.</p>
This is a test page for the authentication system. Please log in to <article>
continue. <header>Demo account</header>
</p> <p><strong>Email:</strong> user@example.com<br /><strong>Password:</strong> password123</p>
</article>
{{if .Error}}
<article class="secondary">
<strong>Unable to sign in:</strong> {{.Error}}
</article>
{{end}}
<form method="post" action="/login"> <form method="post" action="/login">
<label for="email">Email</label>
<input <input
type="email" type="email"
id="email" id="email"
name="email"
placeholder="Enter your email" placeholder="Enter your email"
required required
autofocus autofocus
value="{{.Email}}"
/>
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
placeholder="Password"
required
/> />
<input type="password" id="password" placeholder="password" required />
<button type="submit">Login</button> <button type="submit">Login</button>
</form> </form>
</main> </main>

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>auth test</title> <title>Unauthorized</title>
<link <link
rel="stylesheet" rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
@ -12,6 +12,7 @@
<body> <body>
<main class="container"> <main class="container">
<h1>Unauthorized</h1> <h1>Unauthorized</h1>
<p>{{if .Error}}{{.Error}}{{else}}You do not have permission to view that page.{{end}}</p>
<a href="/" role="button">Back to safety</a> <a href="/" role="button">Back to safety</a>
</main> </main>
</body> </body>