Files
paliad/internal/auth/auth.go
m b8f95f5d7a feat: user onboarding flow — first-login profile capture (t-paliad-019)
New users were stuck on the dashboard with a dead-end "Bitte schließen Sie das
Onboarding ab" message because nothing created the paliad.users row that all
matter-management features depend on. This adds the missing Phase D flow.

Backend
- UserService.Create: validates display_name / office / role, inserts the
  paliad.users row with (id, email) from the verified JWT claims (never from
  the request body — prevents onboarding as someone else).
- Admin bootstrap: only the very first paliad.users row may self-assign
  role='admin'; subsequent requests get ErrAdminBootstrapOnly (403). Guarded
  by pg_advisory_xact_lock so two concurrent first-logins can't race past
  the count=0 check under READ COMMITTED.
- POST /api/onboarding + GET /onboarding; the page is authenticated but NOT
  behind the onboarding gate (it's the one page users without a paliad.users
  row may reach).
- gateOnboarded middleware wraps the matter-management pages (Dashboard,
  Akten, Fristen, Termine, Einstellungen/CalDAV) and 302s to /onboarding
  when the caller has no paliad.users row. Knowledge-platform pages
  (Kostenrechner, Glossar, Links, Downloads, Gerichte, Gebührentabellen,
  Checklisten, Fristenrechner) stay ungated.
- auth.VerifiedClaims now carries the email claim; auth.ClaimsFromContext
  exposes it to handlers. GET /api/me includes the email in the 404 body so
  the onboarding form can pre-fill the display name from the local-part.

Frontend
- frontend/src/onboarding.tsx + src/client/onboarding.ts: centred card on the
  existing .login-card styling. Fields: display_name (required, pre-filled
  from email local-part), office (dropdown from /api/offices), role
  (dropdown, default associate), practice_group (optional).
- Dashboard client: toggleOnboardingHint now redirects to /onboarding
  instead of showing the dead-end hint — belt-and-braces behind the server
  gate in case the DB lookup fell through.
- DE + EN i18n keys for every label, placeholder, and error.
- Added onboarding to build.ts.

Tests: internal/services/user_service_test.go covers the valid path,
per-field validation, duplicate (ErrUserAlreadyOnboarded), and the
admin-bootstrap gate. Follows the existing TEST_DATABASE_URL skip pattern.
2026-04-18 19:13:57 +02:00

372 lines
11 KiB
Go

package auth
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
const (
// SessionCookieName + RefreshCookieName are the canonical cookie names
// issued after the patholo → paliad rename (2026-04-18). New logins and
// refreshes always write these; old patholo_* cookies are still read via
// the legacy fallback below so existing users stay logged in through the
// deploy. Remove the legacy names after 2026-05-18 (30-day cookie max age).
SessionCookieName = "paliad_session"
RefreshCookieName = "paliad_refresh"
// Legacy cookie names — read-only fallback during the rename grace period.
LegacySessionCookieName = "patholo_session"
LegacyRefreshCookieName = "patholo_refresh"
CookieMaxAge = 30 * 24 * 60 * 60 // 30 days
)
type Client struct {
URL string
AnonKey string
JWTSecret []byte
HTTP *http.Client
}
func NewClient(supabaseURL, anonKey string, jwtSecret []byte) *Client {
if len(jwtSecret) == 0 {
log.Fatal("SUPABASE_JWT_SECRET must be set — session cookies cannot be trusted without signature verification")
}
return &Client{
URL: strings.TrimRight(supabaseURL, "/"),
AnonKey: anonKey,
JWTSecret: jwtSecret,
HTTP: &http.Client{Timeout: 10 * time.Second},
}
}
type TokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
// SignIn authenticates a user with email and password.
func (c *Client) SignIn(email, password string) (*TokenResponse, error) {
return c.tokenRequest("password", map[string]string{
"email": email,
"password": password,
})
}
// RefreshSession exchanges a refresh token for a new access token.
func (c *Client) RefreshSession(refreshToken string) (*TokenResponse, error) {
return c.tokenRequest("refresh_token", map[string]string{
"refresh_token": refreshToken,
})
}
func (c *Client) tokenRequest(grantType string, body map[string]string) (*TokenResponse, error) {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
}
endpoint := fmt.Sprintf("%s/auth/v1/token?grant_type=%s", c.URL, grantType)
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("apikey", c.AnonKey)
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, parseErrorMessage(respBody))
}
var result TokenResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
if result.AccessToken == "" {
return nil, errors.New("empty access_token")
}
return &result, nil
}
// SignUp registers a new user. Returns tokens if auto-confirm is enabled, nil otherwise.
func (c *Client) SignUp(email, password string) (*TokenResponse, error) {
jsonBody, err := json.Marshal(map[string]string{
"email": email,
"password": password,
})
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
}
endpoint := fmt.Sprintf("%s/auth/v1/signup", c.URL)
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("apikey", c.AnonKey)
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, parseErrorMessage(respBody))
}
var result TokenResponse
if err := json.Unmarshal(respBody, &result); err != nil || result.AccessToken == "" {
return nil, nil // registered but no auto-login (email confirmation pending)
}
return &result, nil
}
// SignOut invalidates the user's session on Supabase.
func (c *Client) SignOut(accessToken string) {
endpoint := fmt.Sprintf("%s/auth/v1/logout", c.URL)
req, err := http.NewRequest("POST", endpoint, nil)
if err != nil {
return
}
req.Header.Set("apikey", c.AnonKey)
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := c.HTTP.Do(req)
if err != nil {
return
}
resp.Body.Close()
}
// VerifiedClaims is the subset of Supabase JWT claims the app cares about.
// Populated only after signature + expiry verification succeed.
type VerifiedClaims struct {
Sub string
Email string
Exp time.Time
}
// VerifyToken parses and fully validates a Supabase access token. It verifies
// the HS256 signature against the shared secret, enforces the standard
// exp/nbf/iat checks, and extracts the `sub` claim as the authenticated
// user's UUID string. Returns an error if the signature is wrong, the token
// is expired, or any required claim is missing.
func (c *Client) VerifyToken(token string) (*VerifiedClaims, error) {
parsed, err := jwt.Parse(token, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return c.JWTSecret, nil
}, jwt.WithValidMethods([]string{"HS256"}))
if err != nil {
return nil, err
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok || !parsed.Valid {
return nil, errors.New("invalid claims")
}
sub, _ := claims["sub"].(string)
if sub == "" {
return nil, errors.New("no sub claim")
}
email, _ := claims["email"].(string)
var exp time.Time
if expClaim, err := claims.GetExpirationTime(); err == nil && expClaim != nil {
exp = expClaim.Time
}
return &VerifiedClaims{Sub: sub, Email: email, Exp: exp}, nil
}
// readSessionCookie returns the value of the session cookie and whether the
// value came from the legacy patholo_session fallback. Empty string if
// neither cookie is present.
func readSessionCookie(r *http.Request) (value string, fromLegacy bool) {
if c, err := r.Cookie(SessionCookieName); err == nil && c.Value != "" {
return c.Value, false
}
if c, err := r.Cookie(LegacySessionCookieName); err == nil && c.Value != "" {
return c.Value, true
}
return "", false
}
// readRefreshCookie mirrors readSessionCookie for the refresh token.
func readRefreshCookie(r *http.Request) string {
for _, name := range []string{RefreshCookieName, LegacyRefreshCookieName} {
if c, err := r.Cookie(name); err == nil && c.Value != "" {
return c.Value
}
}
return ""
}
// Middleware requires a valid, signature-verified session for protected routes.
// Browser requests get a 302 to /login; API requests get a 401 JSON response.
func (c *Client) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sessionValue, fromLegacy := readSessionCookie(r)
if sessionValue == "" {
rejectUnauthenticated(w, r)
return
}
claims, err := c.VerifyToken(sessionValue)
if err == nil {
// If the request authenticated via the legacy patholo_session
// cookie, upgrade the browser to paliad_session so it stops
// depending on the legacy fallback.
if fromLegacy {
refresh := ""
if rc, rerr := r.Cookie(LegacyRefreshCookieName); rerr == nil {
refresh = rc.Value
}
SetAuthCookies(w, r, &TokenResponse{AccessToken: sessionValue, RefreshToken: refresh})
}
ctx := withVerifiedClaims(r.Context(), claims)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// Only attempt refresh if the failure was expiry. Any other signature
// error means the token is forged or tampered with — reject.
if !errors.Is(err, jwt.ErrTokenExpired) {
ClearAuthCookies(w)
rejectUnauthenticated(w, r)
return
}
refreshValue := readRefreshCookie(r)
if refreshValue == "" {
ClearAuthCookies(w)
rejectUnauthenticated(w, r)
return
}
tokens, err := c.RefreshSession(refreshValue)
if err != nil {
log.Printf("token refresh failed: %v", err)
ClearAuthCookies(w)
rejectUnauthenticated(w, r)
return
}
newClaims, err := c.VerifyToken(tokens.AccessToken)
if err != nil {
log.Printf("refreshed token failed verification: %v", err)
ClearAuthCookies(w)
rejectUnauthenticated(w, r)
return
}
SetAuthCookies(w, r, tokens)
ctx := withVerifiedClaims(r.Context(), newClaims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func rejectUnauthenticated(w http.ResponseWriter, r *http.Request) {
if isAPIRequest(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthenticated"}`))
return
}
http.Redirect(w, r, "/login", http.StatusFound)
}
func isAPIRequest(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, "/api/")
}
// SetAuthCookies writes session and refresh token cookies under the current
// paliad_* names and expires any legacy patholo_* cookies from the rename
// grace period in the same response.
func SetAuthCookies(w http.ResponseWriter, r *http.Request, tokens *TokenResponse) {
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
for _, c := range []*http.Cookie{
{Name: SessionCookieName, Value: tokens.AccessToken},
{Name: RefreshCookieName, Value: tokens.RefreshToken},
} {
http.SetCookie(w, &http.Cookie{
Name: c.Name,
Value: c.Value,
Path: "/",
MaxAge: CookieMaxAge,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure,
})
}
// Expire legacy cookies now that the browser has the paliad_* pair.
for _, name := range []string{LegacySessionCookieName, LegacyRefreshCookieName} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
}
}
// ClearAuthCookies removes session and refresh token cookies under both the
// current and legacy names so a stale patholo_* cookie can't resurrect a
// logged-out session.
func ClearAuthCookies(w http.ResponseWriter) {
for _, name := range []string{SessionCookieName, RefreshCookieName, LegacySessionCookieName, LegacyRefreshCookieName} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
}
}
func parseErrorMessage(body []byte) string {
var resp struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
Message string `json:"message"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return string(body)
}
for _, msg := range []string{resp.ErrorDescription, resp.Message, resp.Msg, resp.Error} {
if msg != "" {
return msg
}
}
return string(body)
}