Email: user@example.com
Password: password123
Logged in
-You are logged in. This is a test page for the authentication
+Welcome
+You are signed in as {{.Email}}.
+This placeholder dashboard will evolve as we flesh out the auth flow.
+diff --git a/cmd/server/main.go b/cmd/server/main.go index b120d7d..9426911 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -8,7 +8,10 @@ import ( ) 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") if err := http.ListenAndServe(":8000", srv.Router()); err != nil { diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..9acee87 --- /dev/null +++ b/internal/auth/password.go @@ -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[:]) +} diff --git a/internal/auth/store.go b/internal/auth/store.go new file mode 100644 index 0000000..bafb827 --- /dev/null +++ b/internal/auth/store.go @@ -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 +} diff --git a/internal/auth/user.go b/internal/auth/user.go new file mode 100644 index 0000000..1684445 --- /dev/null +++ b/internal/auth/user.go @@ -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 +} diff --git a/internal/identity/email.go b/internal/identity/email.go new file mode 100644 index 0000000..c51ad34 --- /dev/null +++ b/internal/identity/email.go @@ -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)) +} diff --git a/internal/server/handler_auth.go b/internal/server/handler_auth.go index a6af4bc..594a874 100644 --- a/internal/server/handler_auth.go +++ b/internal/server/handler_auth.go @@ -1,14 +1,52 @@ package server import ( - "log" "net/http" + + "github.com/rjnemo/auth/internal/auth" + "github.com/rjnemo/auth/internal/identity" ) func (s *Server) loginHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - log.Println("Login request received") - s.loggedIn = true + if err := r.ParseForm(); err != nil { + 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) } } + +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.")) +} diff --git a/internal/server/handler_dashboard.go b/internal/server/handler_dashboard.go index 0d82359..ba538fa 100644 --- a/internal/server/handler_dashboard.go +++ b/internal/server/handler_dashboard.go @@ -1,24 +1,15 @@ package server -import ( - "log" - "net/http" -) +import "net/http" func (s *Server) dashboardHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if !s.loggedIn { + if !s.sessions.IsAuthenticated() { w.WriteHeader(http.StatusUnauthorized) - if err := s.templates.ExecuteTemplate(w, "unauthorized.html", nil); err != nil { - log.Printf("render unauthorized: %v", err) - http.Error(w, "template render failed", http.StatusInternalServerError) - } + s.render(w, "unauthorized.html", newUnauthorizedData("Sign in to continue.")) return } - if err := s.templates.ExecuteTemplate(w, "in.html", nil); err != nil { - log.Printf("render dashboard: %v", err) - http.Error(w, "template render failed", http.StatusInternalServerError) - } + s.render(w, "in.html", PageData{Email: s.sessions.CurrentAccount()}) } } diff --git a/internal/server/handler_public.go b/internal/server/handler_public.go index 9635740..94688c9 100644 --- a/internal/server/handler_public.go +++ b/internal/server/handler_public.go @@ -1,15 +1,9 @@ package server -import ( - "log" - "net/http" -) +import "net/http" func (s *Server) indexHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if err := s.templates.ExecuteTemplate(w, "index.html", nil); err != nil { - log.Printf("render index: %v", err) - http.Error(w, "template render failed", http.StatusInternalServerError) - } + s.render(w, "index.html", newIndexData("", "")) } } diff --git a/internal/server/render.go b/internal/server/render.go new file mode 100644 index 0000000..592952a --- /dev/null +++ b/internal/server/render.go @@ -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) + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 65a6122..2db3b82 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -6,4 +6,5 @@ func (s *Server) registerRoutes(r chi.Router) { r.Get("/", s.indexHandler()) r.Get("/in", s.dashboardHandler()) r.Post("/login", s.loginHandler()) + r.Post("/logout", s.logoutHandler()) } diff --git a/internal/server/server.go b/internal/server/server.go index bba204c..7e4fe1c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,32 +1,52 @@ package server import ( + "context" + "fmt" "html/template" "net/http" + "time" "github.com/go-chi/chi/v5" + "github.com/rjnemo/auth/internal/auth" "github.com/rjnemo/auth/web" ) +const ( + seedEmail = "user@example.com" + seedPassword = "password123" +) + // Server holds HTTP dependencies for the application. type Server struct { templates *template.Template - loggedIn bool + users auth.UserStore + sessions *SessionManager } // New constructs a Server with parsed templates and default state. -func New() *Server { - tmpl := template.Must(template.ParseFS( +func New() (*Server, error) { + tmpl, err := template.ParseFS( web.Templates, "templates/index.html", "templates/in.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{ templates: tmpl, - } + users: users, + sessions: NewSessionManager(), + }, nil } // Router returns the configured HTTP router. @@ -35,3 +55,19 @@ func (s *Server) Router() http.Handler { s.registerRoutes(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(), + }) +} diff --git a/internal/server/session.go b/internal/server/session.go new file mode 100644 index 0000000..40cc4f0 --- /dev/null +++ b/internal/server/session.go @@ -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 +} diff --git a/internal/server/views.go b/internal/server/views.go new file mode 100644 index 0000000..b4309de --- /dev/null +++ b/internal/server/views.go @@ -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} +} diff --git a/web/templates/in.html b/web/templates/in.html index 8144cf4..7823fe3 100644 --- a/web/templates/in.html +++ b/web/templates/in.html @@ -3,7 +3,7 @@
-You are logged in. This is a test page for the authentication
+You are signed in as {{.Email}}.
+This placeholder dashboard will evolve as we flesh out the auth flow.
+- This is a test page for the authentication system. Please log in to - continue. -
+Authenticate with the demo credentials below to view the dashboard.
+Email: user@example.com
Password: password123
{{if .Error}}{{.Error}}{{else}}You do not have permission to view that page.{{end}}
Back to safety