mitbringsl/backend/internal/httpapi/auth.go
Tronax 3f187f1ede
Feature: Server-driven auth method discovery + OIDC-only enforcement
Backend:
- New AUTH_PASSWORD_ENABLED flag (default true). When false, email/password
  registration and login return 403; the server enforces OIDC-only login.
- New OIDC_GENERIC_DISPLAY_NAME so the app can show 'Authentik'/'Keycloak'
  instead of a generic 'OIDC' label.
- New public endpoint GET /api/config returns which auth methods the
  server offers (password_enabled + per-provider OIDC capabilities).
  No auth required, so the login screen can query it before logging in.
- .env.example and docker-compose.yml expose the new env vars.

App:
- DTOs + MitbringslApi.getServerConfig() for /api/config.
- AuthViewModel: new 'connect' flow. The user enters the server URL,
  taps 'Verbinden', and the app fetches /api/config. The returned
  ServerAuthConfig drives which login options are shown:
    * password-only -> email/password form
    * OIDC-only     -> OIDC token form
    * both          -> toggle between the two
  If the server offers no method, a clear error is shown.
- AuthScreen: split into ConnectView (server URL) and LoginView (the
  login form matching the server's capabilities). The mode toggle only
  appears when the server offers more than one method.
2026-08-06 10:28:12 +02:00

335 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) {
if !h.cfg.AuthPasswordEnabled {
renderError(w, http.StatusForbidden, "Password auth disabled",
"Email/password registration is disabled on this server. Use OIDC.")
return
}
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) {
if !h.cfg.AuthPasswordEnabled {
renderError(w, http.StatusForbidden, "Password auth disabled",
"Email/password login is disabled on this server. Use OIDC.")
return
}
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))
})
}