mirror of
https://github.com/rjNemo/auth
synced 2026-06-06 00:16:40 +00:00
refactor: introduce auth service layer
This commit is contained in:
parent
55a03ae248
commit
2c0ef46f18
6 changed files with 125 additions and 51 deletions
44
internal/auth/service.go
Normal file
44
internal/auth/service.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 == ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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."))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue