From 2c0ef46f182945c50983364f9bec624aab56df5e Mon Sep 17 00:00:00 2001 From: Ruidy Date: Sat, 20 Sep 2025 00:40:36 +0200 Subject: [PATCH] refactor: introduce auth service layer --- internal/auth/service.go | 44 +++++++++++++++++++++++++++++++++ internal/auth/store.go | 28 ++++++++++----------- internal/auth/user.go | 39 +++++++++++++++++++++++++++-- internal/identity/email.go | 8 ------ internal/server/handler_auth.go | 37 ++++++++++++++------------- internal/server/server.go | 20 ++++++++------- 6 files changed, 125 insertions(+), 51 deletions(-) create mode 100644 internal/auth/service.go delete mode 100644 internal/identity/email.go diff --git a/internal/auth/service.go b/internal/auth/service.go new file mode 100644 index 0000000..8c7b8b9 --- /dev/null +++ b/internal/auth/service.go @@ -0,0 +1,44 @@ +package auth + +import ( + "context" + "errors" +) + +var ( + // ErrInvalidInput indicates the caller supplied malformed credentials. + ErrInvalidInput = errors.New("auth: invalid input") + // ErrInvalidCredentials indicates the credentials do not match any account. + ErrInvalidCredentials = errors.New("auth: invalid credentials") +) + +// Service exposes authentication business operations to HTTP handlers. +type Service struct { + store UserStore +} + +// NewService wires a Service with the provided persistence implementation. +func NewService(store UserStore) *Service { + return &Service{store: store} +} + +// Authenticate validates the provided email/password and returns the account on success. +func (s *Service) Authenticate(ctx context.Context, email UserEmail, password string) (*User, error) { + if email.IsZero() || password == "" { + return nil, ErrInvalidInput + } + + account, err := s.store.FindByEmail(ctx, email) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + return nil, ErrInvalidCredentials + } + return nil, err + } + + if !VerifyPassword(password, account.PasswordSalt, account.PasswordHash) { + return nil, ErrInvalidCredentials + } + + return account, nil +} diff --git a/internal/auth/store.go b/internal/auth/store.go index bafb827..09220b1 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -4,16 +4,17 @@ 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") +var ( + ErrUserNotFound = errors.New("auth: user not found") + ErrEmailRequired = errors.New("auth: email required") +) // UserStore defines persistence expectations for user lookups. type UserStore interface { - FindByEmail(ctx context.Context, email string) (*User, error) + FindByEmail(ctx context.Context, email UserEmail) (*User, error) Create(ctx context.Context, user User) error } @@ -29,16 +30,15 @@ func NewMemoryStore() *MemoryStore { } // FindByEmail returns a copy of the stored user. -func (s *MemoryStore) FindByEmail(_ context.Context, email string) (*User, error) { - key := identity.NormalizeEmail(email) - if key == "" { +func (s *MemoryStore) FindByEmail(_ context.Context, email UserEmail) (*User, error) { + if email.IsZero() { return nil, ErrUserNotFound } s.mu.RLock() defer s.mu.RUnlock() - user, ok := s.users[key] + user, ok := s.users[email.String()] if !ok { return nil, ErrUserNotFound } @@ -48,13 +48,11 @@ func (s *MemoryStore) FindByEmail(_ context.Context, email string) (*User, error } // 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 +func (s *MemoryStore) Create(_ context.Context, user User) error { + if user.Email.IsZero() { + return ErrEmailRequired + } s.mu.Lock() defer s.mu.Unlock() @@ -63,6 +61,6 @@ func (s *MemoryStore) Create(_ context.Context, user User) error { s.users = make(map[string]User) } - s.users[key] = user + s.users[user.Email.String()] = user return nil } diff --git a/internal/auth/user.go b/internal/auth/user.go index 1684445..86dbb95 100644 --- a/internal/auth/user.go +++ b/internal/auth/user.go @@ -1,12 +1,47 @@ package auth -import "time" +import ( + "errors" + "strings" + "time" +) // User represents authenticated account details. type User struct { ID string - Email string + Email UserEmail PasswordSalt string PasswordHash string CreatedAt time.Time } + +// UserEmail represents a canonical email string. +type UserEmail string + +// NewUserEmail constructs a canonical email value or reports an error if empty. +func NewUserEmail(raw string) (UserEmail, error) { + normalized := strings.TrimSpace(strings.ToLower(raw)) + if normalized == "" { + return "", errors.New("auth: email required") + } + return UserEmail(normalized), nil +} + +// MustUserEmail is a helper for trusted inputs when failure is non-recoverable. +func MustUserEmail(raw string) UserEmail { + email, err := NewUserEmail(raw) + if err != nil { + panic(err) + } + return email +} + +// String exposes the underlying string. +func (e UserEmail) String() string { + return string(e) +} + +// IsZero reports whether the email is unset. +func (e UserEmail) IsZero() bool { + return e == "" +} diff --git a/internal/identity/email.go b/internal/identity/email.go deleted file mode 100644 index c51ad34..0000000 --- a/internal/identity/email.go +++ /dev/null @@ -1,8 +0,0 @@ -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 594a874..1d784ef 100644 --- a/internal/server/handler_auth.go +++ b/internal/server/handler_auth.go @@ -1,10 +1,11 @@ package server import ( + "errors" + "log" "net/http" "github.com/rjnemo/auth/internal/auth" - "github.com/rjnemo/auth/internal/identity" ) func (s *Server) loginHandler() http.HandlerFunc { @@ -14,28 +15,30 @@ func (s *Server) loginHandler() http.HandlerFunc { return } - email := identity.NormalizeEmail(r.FormValue("email")) + emailInput := 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) + email, err := auth.NewUserEmail(emailInput) if err != nil { - s.renderLoginFailure(w, email) + w.WriteHeader(http.StatusBadRequest) + s.render(w, "index.html", newIndexData("", "Email and password are required.")) return } - if !auth.VerifyPassword(password, account.PasswordSalt, account.PasswordHash) { + account, err := s.authService.Authenticate(r.Context(), email, password) + switch { + case err == nil: + s.sessions.SetAuthenticated(account.Email.String()) + http.Redirect(w, r, "/in", http.StatusSeeOther) + case errors.Is(err, auth.ErrInvalidInput): + w.WriteHeader(http.StatusBadRequest) + s.render(w, "index.html", newIndexData(email.String(), "Email and password are required.")) + case errors.Is(err, auth.ErrInvalidCredentials): s.renderLoginFailure(w, email) - return + default: + log.Printf("auth: authenticate failed: %v", err) + http.Error(w, "unexpected error", http.StatusInternalServerError) } - - s.sessions.SetAuthenticated(account.Email) - http.Redirect(w, r, "/in", http.StatusSeeOther) } } @@ -46,7 +49,7 @@ func (s *Server) logoutHandler() http.HandlerFunc { } } -func (s *Server) renderLoginFailure(w http.ResponseWriter, email string) { +func (s *Server) renderLoginFailure(w http.ResponseWriter, email auth.UserEmail) { w.WriteHeader(http.StatusUnauthorized) - s.render(w, "index.html", newIndexData(email, "Invalid credentials.")) + s.render(w, "index.html", newIndexData(email.String(), "Invalid credentials.")) } diff --git a/internal/server/server.go b/internal/server/server.go index 7e4fe1c..7d343d9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -20,9 +20,9 @@ const ( // Server holds HTTP dependencies for the application. type Server struct { - templates *template.Template - users auth.UserStore - sessions *SessionManager + templates *template.Template + authService *auth.Service + sessions *SessionManager } // New constructs a Server with parsed templates and default state. @@ -37,15 +37,15 @@ func New() (*Server, error) { return nil, fmt.Errorf("parse templates: %w", err) } - users := auth.NewMemoryStore() - if err := seedUser(users); err != nil { + store := auth.NewMemoryStore() + if err := seedUser(store); err != nil { return nil, fmt.Errorf("seed user: %w", err) } return &Server{ - templates: tmpl, - users: users, - sessions: NewSessionManager(), + templates: tmpl, + authService: auth.NewService(store), + sessions: NewSessionManager(), }, nil } @@ -62,10 +62,12 @@ func seedUser(store auth.UserStore) error { return err } + email := auth.MustUserEmail(seedEmail) + ctx := context.Background() return store.Create(ctx, auth.User{ ID: "seed-user", - Email: seedEmail, + Email: email, PasswordSalt: salt, PasswordHash: hash, CreatedAt: time.Now().UTC(),