mitbringsl/backend/internal/httpapi/auth.go
Tronax a5ef8cf3ba
Backend Phase B (2/2): OIDC auth + config validation
- internal/auth/oidc.go: OIDCService mit go-oidc v3
  - id_token-Verifikation via JWKS (Signatur, iss, aud, exp)
  - Provider-Caching (sync.Map, lazy init per Issuer-URL)
  - Unterstützt Google + Generic OIDC
- internal/auth/user.go: GetByOIDCSubject + CreateOIDCUser
  (find-or-create via (oidc_issuer, oidc_subject))
- internal/httpapi/auth.go: POST /auth/oidc Handler
  (id_token verifiziern → find-or-create User → issueSession)
- internal/httpapi/api.go: /auth/oidc Route verdrahtet
- internal/config/config.go: OIDC-Validierung
  (enabled → client_id + issuer Pflicht)
- go.mod/go.sum: go-oidc/v3 + oauth2 Abhängigkeiten
- AGENTS.md: Phase B vollständig als erledigt markiert

Verifiziert: E2E gegen lokalen Mock-IdP (Discovery → JWKS →
signiertes id_token → User angelegt → 2. Login gleicher User →
tampered Token → 401). Alle Fehlerpfade geprüft.

go build ./... && go vet ./... && go test ./internal/auth/... 
2026-08-05 19:45:00 +02:00

325 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
oidc *auth.OIDCService
cfg *config.Config
}
// NewAuthHandler constructs an AuthHandler from the configured stores.
// oidc may be nil when no provider is enabled; the OIDC endpoint then returns
// 400 for every request.
func NewAuthHandler(users *auth.UserStore, sessions *auth.SessionStore, oidc *auth.OIDCService, cfg *config.Config) *AuthHandler {
return &AuthHandler{users: users, sessions: sessions, oidc: oidc, 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 oidcRequest struct {
Provider string `json:"provider"` // "google" | "generic"
IDToken string `json:"id_token"`
}
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)
}
// OIDC accepts an id_token that the Android app obtained via its own code+PKCE
// flow, verifies it against the configured provider, then finds-or-creates the
// user and issues a backend session (same shape as Login).
//
// Request body: {"provider": "google"|"generic", "id_token": "<jwt>"}
func (h *AuthHandler) OIDC(w http.ResponseWriter, r *http.Request) {
var req oidcRequest
if !decodeJSON(w, r, &req) {
return
}
provider := strings.TrimSpace(req.Provider)
idToken := strings.TrimSpace(req.IDToken)
if provider == "" || idToken == "" {
renderError(w, http.StatusBadRequest, "Bad request",
"Both 'provider' and 'id_token' are required.")
return
}
if h.oidc == nil {
renderError(w, http.StatusBadRequest, "OIDC disabled",
"No OIDC provider is configured on this server.")
return
}
claims, err := h.oidc.Verify(r.Context(), provider, idToken)
if err != nil {
if errors.Is(err, auth.ErrProviderUnknown) {
renderError(w, http.StatusBadRequest, "Unknown provider", err.Error())
return
}
slog.Info("oidc verify failed", "provider", provider, "error", err)
renderError(w, http.StatusUnauthorized, "Invalid id_token",
"The id_token could not be verified.")
return
}
// find-or-create user by (issuer, subject)
user, err := h.users.GetByOIDCSubject(r.Context(), claims.Issuer, claims.Subject)
if err != nil {
if !errors.Is(err, auth.ErrUserNotFound) {
slog.Error("oidc user lookup failed", "error", err, "issuer", claims.Issuer)
renderError(w, http.StatusInternalServerError, "Internal error", "Could not load user.")
return
}
user, err = h.users.CreateOIDCUser(r.Context(), claims.Issuer, claims.Subject, claims.Email, claims.Name)
if err != nil {
slog.Error("oidc user create failed", "error", err, "issuer", claims.Issuer)
renderError(w, http.StatusConflict, "Account conflict",
"This account cannot be linked automatically. Contact support.")
return
}
slog.Info("oidc user created", "user_id", user.ID, "issuer", claims.Issuer)
}
h.issueSession(w, r, user, http.StatusOK)
}
// --- 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))
})
}