feat(audiobooks): port ABS auth (JWT + login bridge)
Stage 2 of the ABS-compat port. Adds the handleLogin handler issuing JWT access + refresh tokens, a SiloCredValidator adapter that calls silo's auth.Service directly (in-process — the plugin previously HTTP-POSTed to /api/v1/auth/login), and an ABSSessionStore adapter backed by the abs_sessions table from migration 139. POST /abs/login and POST /login are mounted but not yet reachable until Stage 7 wires the audiobook service into the main router. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
226d721ea3
commit
7619e84c8d
@@ -148,8 +148,12 @@ func (h *Handler) Mount(parent chi.Router) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) mountRoutes(_ chi.Router) {
|
||||
// TODO Stage 2: auth routes (login, logout, refresh, authorize, me, ping, status, init)
|
||||
func (h *Handler) mountRoutes(r chi.Router) {
|
||||
// Stage 2: login (body-creds + host-proxied paths).
|
||||
r.Post("/login", h.handleLogin)
|
||||
r.Post("/abs/api/login", h.handleLogin)
|
||||
|
||||
// TODO Stage 2 (remaining): logout, refresh, authorize, me, ping, status, init
|
||||
// TODO Stage 3: library browse routes (libraries, items, item detail, cover, authors, series, search, personalized)
|
||||
// TODO Stage 4: playback session + file routes (play, file/download, public/track)
|
||||
// TODO Stage 5: progress routes (me/progress/*, me/items-in-progress, me/listening-stats, me/stats/year/*)
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package abs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned by TokenStore when a JTI is absent.
|
||||
var ErrNotFound = errors.New("abs token not found")
|
||||
|
||||
// handleLogin mints ABS access + refresh JWTs for the caller.
|
||||
//
|
||||
// Two paths:
|
||||
//
|
||||
// 1. Host-proxied: X-Silo-User-Id header is present. The silo host validated
|
||||
// the session before forwarding, so the header is trusted unconditionally.
|
||||
// ProfileID comes from X-Silo-Profile-Id (may be empty = primary profile).
|
||||
//
|
||||
// 2. Standalone body-creds: no host header. The handler delegates to
|
||||
// handleStandaloneLogin, which calls CredValidator.Validate and applies
|
||||
// rate limiting.
|
||||
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if userID := r.Header.Get("X-Silo-User-Id"); userID != "" {
|
||||
profileID := r.Header.Get("X-Silo-Profile-Id")
|
||||
name := r.Header.Get("X-Silo-Profile-Name")
|
||||
if name == "" {
|
||||
name = r.Header.Get("X-Silo-User-Name")
|
||||
}
|
||||
h.completeLogin(w, r, userID, profileID, name)
|
||||
return
|
||||
}
|
||||
h.handleStandaloneLogin(w, r)
|
||||
}
|
||||
|
||||
// handleStandaloneLogin validates body-credential login when no host-proxy
|
||||
// header is present. It checks whether standalone login is enabled, enforces
|
||||
// the per-IP rate limit, decodes the JSON body, and calls CredValidator.
|
||||
func (h *Handler) handleStandaloneLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if h.deps.Config == nil {
|
||||
http.Error(w, "config not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
enabled, err := h.deps.Config.StandaloneLoginEnabled(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "config unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !enabled {
|
||||
http.Error(w, "standalone login is disabled on this server", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if h.deps.CredValidator == nil {
|
||||
http.Error(w, "standalone login is unavailable in this deployment", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
ip := clientIP(r)
|
||||
if !h.deps.LoginLimiter.allow(ip) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
http.Error(w, "too many login attempts; try again shortly", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Username) == "" || body.Password == "" {
|
||||
http.Error(w, "username and password are required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, profileID, displayName, err := h.deps.CredValidator.Validate(r.Context(), body.Username, body.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrInvalidCredentials) || errors.Is(err, auth.ErrUserDisabled) {
|
||||
http.Error(w, "invalid username or password", http.StatusUnauthorized)
|
||||
} else {
|
||||
http.Error(w, "login service unavailable", http.StatusServiceUnavailable)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If the validator didn't return a displayName, derive it from the username
|
||||
// (the profile portion after '#', or the whole username).
|
||||
if displayName == "" {
|
||||
displayName = body.Username
|
||||
if i := strings.LastIndexByte(displayName, '#'); i >= 0 && i < len(displayName)-1 {
|
||||
displayName = displayName[i+1:]
|
||||
}
|
||||
}
|
||||
|
||||
h.completeLogin(w, r, userID, profileID, displayName)
|
||||
}
|
||||
|
||||
// completeLogin mints ABS access + refresh JWTs for the validated user and
|
||||
// writes the login response. Shared by the host-proxied and body-creds paths
|
||||
// so both return the identical response envelope.
|
||||
func (h *Handler) completeLogin(w http.ResponseWriter, r *http.Request, userID, profileID, displayName string) {
|
||||
if h.deps.Config == nil {
|
||||
http.Error(w, "config not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if h.deps.TokenStore == nil {
|
||||
http.Error(w, "token store not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
secret, err := h.deps.Config.JWTSecret(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "jwt secret unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
accessTTL, err := h.deps.Config.AccessTTL(r.Context())
|
||||
if err != nil || accessTTL == 0 {
|
||||
accessTTL = 24 * time.Hour
|
||||
}
|
||||
refreshTTL, err := h.deps.Config.RefreshTTL(r.Context())
|
||||
if err != nil || refreshTTL == 0 {
|
||||
refreshTTL = 30 * 24 * time.Hour
|
||||
}
|
||||
|
||||
accessJTI := ulid.Make().String()
|
||||
refreshJTI := ulid.Make().String()
|
||||
|
||||
access, err := IssueAccessToken(secret, userID, profileID, accessJTI, accessTTL)
|
||||
if err != nil {
|
||||
http.Error(w, "mint access token: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
refresh, err := IssueRefreshToken(secret, userID, profileID, refreshJTI, refreshTTL)
|
||||
if err != nil {
|
||||
http.Error(w, "mint refresh token: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := h.deps.TokenStore.InsertToken(r.Context(), ABSToken{
|
||||
ID: accessJTI,
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
JTI: accessJTI,
|
||||
ExpiresAt: now.Add(accessTTL),
|
||||
}); err != nil {
|
||||
http.Error(w, "persist access token: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Refresh-token insert must also succeed: a client that receives a refresh
|
||||
// token whose JTI isn't in the store will fail on its first use (the
|
||||
// bearer middleware looks up by JTI), forcing an interactive re-login.
|
||||
if err := h.deps.TokenStore.InsertToken(r.Context(), ABSToken{
|
||||
ID: refreshJTI,
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
JTI: refreshJTI,
|
||||
ExpiresAt: now.Add(refreshTTL),
|
||||
}); err != nil {
|
||||
http.Error(w, "persist refresh token: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Build user object. displayName falls back to userID when empty.
|
||||
name := displayName
|
||||
if name == "" {
|
||||
name = userID
|
||||
}
|
||||
|
||||
user := map[string]any{
|
||||
"id": userID,
|
||||
"username": name,
|
||||
"type": "user",
|
||||
"permissions": map[string]any{
|
||||
"update": true,
|
||||
"delete": true,
|
||||
"download": true,
|
||||
"accessExplicitContent": true,
|
||||
},
|
||||
// Legacy field for 2.17- clients.
|
||||
"token": access,
|
||||
}
|
||||
|
||||
// x-return-tokens opt-in: when set, embed token pair on user object too
|
||||
// (some clients read from the user envelope, others from the top level).
|
||||
if strings.EqualFold(r.Header.Get("x-return-tokens"), "true") {
|
||||
user["accessToken"] = access
|
||||
user["refreshToken"] = refresh
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"user": user,
|
||||
"serverSettings": map[string]any{
|
||||
"id": "server-settings",
|
||||
"version": "2.35.0",
|
||||
"language": "en-us",
|
||||
"buildNumber": 1,
|
||||
"authActiveAuthMethods": []string{"local"},
|
||||
},
|
||||
"ereaderDevices": []any{},
|
||||
"accessToken": access,
|
||||
"refreshToken": refresh,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package audiobooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
|
||||
)
|
||||
|
||||
// ABSSessionStore implements abs.TokenStore on the abs_sessions table
|
||||
// (migration 139). Each JTI is stored as the `token` column; revocation sets
|
||||
// `revoked_at`. The table stores integer user_id, so we parse the string
|
||||
// UserID from ABSToken back to int.
|
||||
type ABSSessionStore struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// InsertToken inserts a newly minted JTI into abs_sessions.
|
||||
// Duplicate tokens are silently ignored (ON CONFLICT DO NOTHING) so
|
||||
// concurrent requests using the same JTI are idempotent.
|
||||
func (s *ABSSessionStore) InsertToken(ctx context.Context, tok abs.ABSToken) error {
|
||||
uid, err := strconv.Atoi(tok.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("abs_session_store: invalid user id %q: %w", tok.UserID, err)
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
INSERT INTO abs_sessions
|
||||
(user_id, token, device_id, device_name, client_name, client_version, last_seen_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, now())
|
||||
ON CONFLICT (token) DO NOTHING`,
|
||||
uid,
|
||||
tok.JTI,
|
||||
tok.JTI, // device_id: use the JTI as a stable device key
|
||||
"", // device_name: unknown at login time
|
||||
"abs-compat",
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("abs_session_store: insert token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTokenByJTI looks up an abs_sessions row by its JTI (stored in `token`).
|
||||
// Returns abs.ErrNotFound when the row doesn't exist.
|
||||
func (s *ABSSessionStore) GetTokenByJTI(ctx context.Context, jti string) (abs.ABSToken, error) {
|
||||
var (
|
||||
uid int
|
||||
profileID *string
|
||||
tok abs.ABSToken
|
||||
)
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT user_id, token, revoked_at
|
||||
FROM abs_sessions
|
||||
WHERE token = $1`, jti)
|
||||
err := row.Scan(&uid, &tok.JTI, &tok.RevokedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return abs.ABSToken{}, abs.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return abs.ABSToken{}, fmt.Errorf("abs_session_store: get token: %w", err)
|
||||
}
|
||||
tok.ID = tok.JTI
|
||||
tok.UserID = strconv.Itoa(uid)
|
||||
if profileID != nil {
|
||||
tok.ProfileID = *profileID
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// RevokeTokenByJTI sets revoked_at to now() for the given JTI.
|
||||
// Idempotent: revoking an already-revoked token is a no-op.
|
||||
func (s *ABSSessionStore) RevokeTokenByJTI(ctx context.Context, jti string) error {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
UPDATE abs_sessions SET revoked_at = now()
|
||||
WHERE token = $1 AND revoked_at IS NULL`, jti)
|
||||
if err != nil {
|
||||
return fmt.Errorf("abs_session_store: revoke token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TouchToken bumps last_seen_at for active-session bookkeeping.
|
||||
// Errors are logged by the caller; we never gate a valid request on this.
|
||||
func (s *ABSSessionStore) TouchToken(ctx context.Context, jti string) error {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
UPDATE abs_sessions SET last_seen_at = now()
|
||||
WHERE token = $1`, jti)
|
||||
if err != nil {
|
||||
return fmt.Errorf("abs_session_store: touch token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package audiobooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
)
|
||||
|
||||
// SiloCredValidator implements abs.ProfileCredentialValidator using silo's
|
||||
// existing auth.Service. In-process validation avoids the HTTP round-trip
|
||||
// the plugin previously made to POST /api/v1/auth/login.
|
||||
type SiloCredValidator struct {
|
||||
Auth *auth.Service
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// Validate checks (username, password) against silo's local auth provider and
|
||||
// returns the string user ID, primary profile ID, and display name.
|
||||
//
|
||||
// The ABS compat layer represents user/profile IDs as strings; we format the
|
||||
// integer user ID and UUID profile ID accordingly. If the user has no profiles
|
||||
// yet, profileID is returned empty and the ABS handler treats the missing
|
||||
// profile as "primary".
|
||||
func (v *SiloCredValidator) Validate(
|
||||
ctx context.Context,
|
||||
username, password string,
|
||||
) (userID, profileID, displayName string, err error) {
|
||||
if v.Auth == nil {
|
||||
return "", "", "", errors.New("auth service not configured")
|
||||
}
|
||||
|
||||
// DeviceName and IP are informational for session bookkeeping only.
|
||||
_, user, err := v.Auth.Login(ctx, username, password, "abs-compat", "")
|
||||
if err != nil {
|
||||
// Propagate auth sentinel errors as-is so callers can distinguish
|
||||
// "bad credentials" (ErrInvalidCredentials) from "service down".
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
userIDStr := strconv.Itoa(user.ID)
|
||||
displayName = user.Username
|
||||
|
||||
// Resolve the primary profile for this user from the central
|
||||
// user_profiles table. Primary is the first profile created per user
|
||||
// (is_primary = true). If none exists, return empty profileID.
|
||||
pid, pname, pidErr := primaryProfileForUser(ctx, v.Pool, user.ID)
|
||||
if pidErr == nil && pid != "" {
|
||||
profileID = pid
|
||||
if pname != "" {
|
||||
displayName = pname
|
||||
}
|
||||
}
|
||||
|
||||
return userIDStr, profileID, displayName, nil
|
||||
}
|
||||
|
||||
// primaryProfileForUser returns the (id, name) of the primary profile for
|
||||
// the given user from the user_profiles table. Returns ("", "", nil) when no
|
||||
// profiles exist (newly created user, pre-profile-setup).
|
||||
func primaryProfileForUser(ctx context.Context, pool *pgxpool.Pool, userID int) (id, name string, err error) {
|
||||
if pool == nil {
|
||||
return "", "", fmt.Errorf("no pgx pool available")
|
||||
}
|
||||
row := pool.QueryRow(ctx,
|
||||
`SELECT id, name FROM user_profiles
|
||||
WHERE user_id = $1 AND is_primary = TRUE
|
||||
LIMIT 1`,
|
||||
userID,
|
||||
)
|
||||
err = row.Scan(&id, &name)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// No primary profile — acceptable for a brand-new user or a user who
|
||||
// hasn't set up profiles yet. Return empty strings; the ABS handler
|
||||
// interprets empty profileID as "use primary / no profile".
|
||||
return "", "", nil
|
||||
}
|
||||
return id, name, err
|
||||
}
|
||||
Reference in New Issue
Block a user