Argon2id password hashing (PHC format, self-encoded/decoded without an external lib) with constant-time verification, UserStore (create/get by email and id) and SessionStore (opaque crypto/rand tokens, SHA-256 hashed in DB, create/lookup/revoke, last_seen_at bump on lookup). HTTP layer: Register/Login/Logout handlers + RequireAuth middleware. Login uses a dummy-hash path so unknown-email and wrong-password yield the same timing/shape, narrowing user enumeration. Tokens accepted via Bearer header (native clients) or session cookie (HttpOnly, SameSite=Lax). Routes wired in api.go: POST /auth/register, /auth/login, /auth/logout. Verified with go test, go vet and an end-to-end smoke test against a real PostgreSQL container (register/login/logout/duplicate/short-pw/wrong-pw). OIDC (Phase B part 2) follows next; the issueSession helper is reused.
261 lines
8.4 KiB
Go
261 lines
8.4 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"log/slog"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/mitbringsl/backend/internal/auth"
|
||
"github.com/mitbringsl/backend/internal/config"
|
||
)
|
||
|
||
// passwordMinLen is the minimum acceptable password length for new accounts.
|
||
const passwordMinLen = 8
|
||
|
||
// AuthHandler exposes the email/password + session endpoints. OIDC is added in
|
||
// Phase B part 2.
|
||
type AuthHandler struct {
|
||
users *auth.UserStore
|
||
sessions *auth.SessionStore
|
||
cfg *config.Config
|
||
}
|
||
|
||
// NewAuthHandler constructs an AuthHandler from the configured stores.
|
||
func NewAuthHandler(users *auth.UserStore, sessions *auth.SessionStore, cfg *config.Config) *AuthHandler {
|
||
return &AuthHandler{users: users, sessions: sessions, cfg: cfg}
|
||
}
|
||
|
||
// --- request / response bodies ----------------------------------------------
|
||
|
||
type registerRequest struct {
|
||
Email string `json:"email"`
|
||
Password string `json:"password"`
|
||
DisplayName string `json:"display_name"`
|
||
}
|
||
|
||
type loginRequest struct {
|
||
Email string `json:"email"`
|
||
Password string `json:"password"`
|
||
}
|
||
|
||
type authResponse struct {
|
||
Token string `json:"token"`
|
||
ExpiresAt time.Time `json:"expires_at"`
|
||
User userDTO `json:"user"`
|
||
}
|
||
|
||
type userDTO struct {
|
||
ID string `json:"id"`
|
||
Email string `json:"email"`
|
||
DisplayName string `json:"display_name,omitempty"`
|
||
}
|
||
|
||
// --- handlers ---------------------------------------------------------------
|
||
|
||
// Register creates a new email/password account and immediately issues a session.
|
||
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||
var req registerRequest
|
||
if !decodeJSON(w, r, &req) {
|
||
return
|
||
}
|
||
email := strings.TrimSpace(strings.ToLower(req.Email))
|
||
if !isValidEmail(email) {
|
||
renderError(w, http.StatusBadRequest, "Invalid email", "A valid email address is required.")
|
||
return
|
||
}
|
||
if len(req.Password) < passwordMinLen {
|
||
renderError(w, http.StatusBadRequest, "Password too short",
|
||
"Password must be at least 8 characters.")
|
||
return
|
||
}
|
||
|
||
hash, err := auth.HashPassword(req.Password, auth.DefaultArgon2Params)
|
||
if err != nil {
|
||
slog.Error("hash password failed", "error", err)
|
||
renderError(w, http.StatusInternalServerError, "Internal error", "Could not hash password.")
|
||
return
|
||
}
|
||
|
||
user, err := h.users.CreateUser(r.Context(), email, hash, strings.TrimSpace(req.DisplayName))
|
||
if err != nil {
|
||
if errors.Is(err, auth.ErrEmailTaken) {
|
||
renderError(w, http.StatusConflict, "Email already registered", err.Error())
|
||
return
|
||
}
|
||
slog.Error("create user failed", "error", err)
|
||
renderError(w, http.StatusInternalServerError, "Internal error", "Could not create user.")
|
||
return
|
||
}
|
||
|
||
h.issueSession(w, r, user, http.StatusCreated)
|
||
}
|
||
|
||
// Login verifies credentials and issues a session. Uses a constant-shape error
|
||
// path so a wrong password and an unknown email yield the same response.
|
||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||
var req loginRequest
|
||
if !decodeJSON(w, r, &req) {
|
||
return
|
||
}
|
||
email := strings.TrimSpace(strings.ToLower(req.Email))
|
||
|
||
// Pre-hash a dummy so the timing path is similar regardless of user existence.
|
||
dummyHash := dummyHashForTiming()
|
||
user, err := h.users.GetUserByEmail(r.Context(), email)
|
||
realHash := ""
|
||
if err == nil && user.PasswordHash != nil {
|
||
realHash = *user.PasswordHash
|
||
}
|
||
|
||
compareHash := realHash
|
||
if compareHash == "" {
|
||
compareHash = dummyHash
|
||
}
|
||
verifyErr := auth.VerifyPassword(req.Password, compareHash)
|
||
|
||
if err != nil || user.PasswordHash == nil || verifyErr != nil {
|
||
_ = auth.VerifyPassword(req.Password, dummyHash) // absorb dummy cost
|
||
renderError(w, http.StatusUnauthorized, "Invalid credentials", "Email or password is incorrect.")
|
||
return
|
||
}
|
||
h.issueSession(w, r, user, http.StatusOK)
|
||
}
|
||
|
||
// Logout revokes the caller's current session. Always returns 204, even if no
|
||
// session was present, so the client can treat logout as best-effort.
|
||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||
if token := tokenFromRequest(r, h.cfg.SessionCookieName); token != "" {
|
||
if err := h.sessions.Revoke(r.Context(), token); err != nil {
|
||
slog.Warn("revoke session failed", "error", err)
|
||
}
|
||
}
|
||
clearSessionCookie(w, h.cfg)
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// --- helpers ----------------------------------------------------------------
|
||
|
||
// issueSession creates a session, sets the cookie (for browsers) and writes the
|
||
// JSON body containing the raw token (for native clients like the Android app).
|
||
func (h *AuthHandler) issueSession(w http.ResponseWriter, r *http.Request, u auth.User, status int) {
|
||
token, sess, err := h.sessions.Create(r.Context(), u.ID, r.UserAgent(), clientIP(r))
|
||
if err != nil {
|
||
slog.Error("create session failed", "error", err, "user_id", u.ID)
|
||
renderError(w, http.StatusInternalServerError, "Internal error", "Could not create session.")
|
||
return
|
||
}
|
||
setSessionCookie(w, h.cfg, token, sess.ExpiresAt)
|
||
renderJSON(w, status, authResponse{
|
||
Token: token,
|
||
ExpiresAt: sess.ExpiresAt,
|
||
User: toUserDTO(u),
|
||
})
|
||
}
|
||
|
||
func toUserDTO(u auth.User) userDTO {
|
||
dto := userDTO{ID: u.ID.String(), Email: u.Email}
|
||
if u.DisplayName != nil {
|
||
dto.DisplayName = *u.DisplayName
|
||
}
|
||
return dto
|
||
}
|
||
|
||
// tokenFromRequest extracts the bearer token from the Authorization header, or
|
||
// falls back to the session cookie.
|
||
func tokenFromRequest(r *http.Request, cookieName string) string {
|
||
if h := r.Header.Get("Authorization"); h != "" {
|
||
if scheme, cred, ok := strings.Cut(h, " "); ok && strings.EqualFold(scheme, "Bearer") {
|
||
return strings.TrimSpace(cred)
|
||
}
|
||
}
|
||
if c, err := r.Cookie(cookieName); err == nil {
|
||
return c.Value
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func setSessionCookie(w http.ResponseWriter, cfg *config.Config, token string, expires time.Time) {
|
||
http.SetCookie(w, &http.Cookie{
|
||
Name: cfg.SessionCookieName,
|
||
Value: token,
|
||
Path: "/",
|
||
Expires: expires,
|
||
MaxAge: int(time.Until(expires).Seconds()),
|
||
HttpOnly: true,
|
||
Secure: cfg.IsProduction(),
|
||
SameSite: http.SameSiteLaxMode,
|
||
})
|
||
}
|
||
|
||
func clearSessionCookie(w http.ResponseWriter, cfg *config.Config) {
|
||
http.SetCookie(w, &http.Cookie{
|
||
Name: cfg.SessionCookieName,
|
||
Value: "",
|
||
Path: "/",
|
||
MaxAge: -1,
|
||
HttpOnly: true,
|
||
Secure: cfg.IsProduction(),
|
||
SameSite: http.SameSiteLaxMode,
|
||
})
|
||
}
|
||
|
||
// isValidEmail does a pragmatic shape check; full RFC validation is not worth it
|
||
// here – the verification round-trip happens at the IdP / app layer.
|
||
func isValidEmail(s string) bool {
|
||
at := strings.IndexByte(s, '@')
|
||
if at <= 0 || at == len(s)-1 {
|
||
return false
|
||
}
|
||
return strings.IndexByte(s[at+1:], '.') >= 0
|
||
}
|
||
|
||
// clientIP extracts the peer IP, preferring X-Forwarded-For (Caddy is the only
|
||
// upstream in front of the backend).
|
||
func clientIP(r *http.Request) string {
|
||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||
if i := strings.IndexByte(xff, ','); i >= 0 {
|
||
return strings.TrimSpace(xff[:i])
|
||
}
|
||
return strings.TrimSpace(xff)
|
||
}
|
||
host := r.RemoteAddr
|
||
if i := strings.LastIndex(host, ":"); i > 0 {
|
||
host = host[:i]
|
||
}
|
||
return host
|
||
}
|
||
|
||
// dummyHashForTiming returns a precomputed Argon2id PHC string used to give the
|
||
// same cost when an email is unknown, narrowing user-enumereration timing gaps.
|
||
// Generated once with DefaultArgon2Params.
|
||
var dummyHashForTiming = func() func() string {
|
||
// Generated at init so we always have a valid PHC string available.
|
||
h, err := auth.HashPassword("dummy-timing-padding-password", auth.DefaultArgon2Params)
|
||
if err != nil {
|
||
// Should never happen with a working crypto/rand; fall back to a
|
||
// syntactically valid but never-matching hash.
|
||
return func() string { return "$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" }
|
||
}
|
||
return func() string { return h }
|
||
}()
|
||
|
||
// --- auth middleware --------------------------------------------------------
|
||
|
||
// RequireAuth wraps next so that it only runs for authenticated requests. On
|
||
// success the userID and sessionID are placed in the request context.
|
||
func (a *API) RequireAuth(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
token := tokenFromRequest(r, a.cfg.SessionCookieName)
|
||
sess, err := a.auth.sessions.Lookup(r.Context(), token)
|
||
if err != nil {
|
||
renderError(w, http.StatusUnauthorized, "Unauthorized", "Valid session required.")
|
||
return
|
||
}
|
||
ctx := context.WithValue(r.Context(), ctxKeyUserID, sess.UserID)
|
||
ctx = context.WithValue(ctx, ctxKeySessionID, sess.ID)
|
||
next.ServeHTTP(w, r.WithContext(ctx))
|
||
})
|
||
}
|