refactor: introduce auth service layer

This commit is contained in:
Ruidy 2025-09-20 00:40:36 +02:00
parent 55a03ae248
commit 2c0ef46f18
No known key found for this signature in database
GPG key ID: 705C24D202990805
6 changed files with 125 additions and 51 deletions

44
internal/auth/service.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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 == ""
}

View file

@ -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))
}

View file

@ -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."))
}

View file

@ -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(),