mirror of
https://github.com/rjNemo/auth
synced 2026-06-12 11:26:39 +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"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/rjnemo/auth/internal/identity"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrUserNotFound signals no user exists for the provided lookup criteria.
|
// 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.
|
// UserStore defines persistence expectations for user lookups.
|
||||||
type UserStore interface {
|
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
|
Create(ctx context.Context, user User) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,16 +30,15 @@ func NewMemoryStore() *MemoryStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindByEmail returns a copy of the stored user.
|
// FindByEmail returns a copy of the stored user.
|
||||||
func (s *MemoryStore) FindByEmail(_ context.Context, email string) (*User, error) {
|
func (s *MemoryStore) FindByEmail(_ context.Context, email UserEmail) (*User, error) {
|
||||||
key := identity.NormalizeEmail(email)
|
if email.IsZero() {
|
||||||
if key == "" {
|
|
||||||
return nil, ErrUserNotFound
|
return nil, ErrUserNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
user, ok := s.users[key]
|
user, ok := s.users[email.String()]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ErrUserNotFound
|
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.
|
// 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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
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 = make(map[string]User)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.users[key] = user
|
s.users[user.Email.String()] = user
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,47 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// User represents authenticated account details.
|
// User represents authenticated account details.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string
|
ID string
|
||||||
Email string
|
Email UserEmail
|
||||||
PasswordSalt string
|
PasswordSalt string
|
||||||
PasswordHash string
|
PasswordHash string
|
||||||
CreatedAt time.Time
|
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
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/rjnemo/auth/internal/auth"
|
"github.com/rjnemo/auth/internal/auth"
|
||||||
"github.com/rjnemo/auth/internal/identity"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) loginHandler() http.HandlerFunc {
|
func (s *Server) loginHandler() http.HandlerFunc {
|
||||||
|
|
@ -14,28 +15,30 @@ func (s *Server) loginHandler() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
email := identity.NormalizeEmail(r.FormValue("email"))
|
emailInput := r.FormValue("email")
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
|
|
||||||
if email == "" || password == "" {
|
email, err := auth.NewUserEmail(emailInput)
|
||||||
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 {
|
if err != nil {
|
||||||
s.renderLoginFailure(w, email)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
s.render(w, "index.html", newIndexData("", "Email and password are required."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !auth.VerifyPassword(password, account.PasswordSalt, account.PasswordHash) {
|
account, err := s.authService.Authenticate(r.Context(), email, password)
|
||||||
s.renderLoginFailure(w, email)
|
switch {
|
||||||
return
|
case err == nil:
|
||||||
}
|
s.sessions.SetAuthenticated(account.Email.String())
|
||||||
|
|
||||||
s.sessions.SetAuthenticated(account.Email)
|
|
||||||
http.Redirect(w, r, "/in", http.StatusSeeOther)
|
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)
|
||||||
|
default:
|
||||||
|
log.Printf("auth: authenticate failed: %v", err)
|
||||||
|
http.Error(w, "unexpected error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
s.render(w, "index.html", newIndexData(email, "Invalid credentials."))
|
s.render(w, "index.html", newIndexData(email.String(), "Invalid credentials."))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ const (
|
||||||
// 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
|
||||||
users auth.UserStore
|
authService *auth.Service
|
||||||
sessions *SessionManager
|
sessions *SessionManager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,14 +37,14 @@ func New() (*Server, error) {
|
||||||
return nil, fmt.Errorf("parse templates: %w", err)
|
return nil, fmt.Errorf("parse templates: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
users := auth.NewMemoryStore()
|
store := auth.NewMemoryStore()
|
||||||
if err := seedUser(users); err != nil {
|
if err := seedUser(store); err != nil {
|
||||||
return nil, fmt.Errorf("seed user: %w", err)
|
return nil, fmt.Errorf("seed user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
templates: tmpl,
|
templates: tmpl,
|
||||||
users: users,
|
authService: auth.NewService(store),
|
||||||
sessions: NewSessionManager(),
|
sessions: NewSessionManager(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -62,10 +62,12 @@ func seedUser(store auth.UserStore) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
email := auth.MustUserEmail(seedEmail)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
return store.Create(ctx, auth.User{
|
return store.Create(ctx, auth.User{
|
||||||
ID: "seed-user",
|
ID: "seed-user",
|
||||||
Email: seedEmail,
|
Email: email,
|
||||||
PasswordSalt: salt,
|
PasswordSalt: salt,
|
||||||
PasswordHash: hash,
|
PasswordHash: hash,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue