feat(server): HMAC stream token auth path (UPnP slice 1/6)
Adds SignStreamToken / VerifyStreamToken (HMAC-SHA256 over trackID|exp) and modifies handleGetStream to accept either the existing session cookie OR a valid signed token. Stream route moved out of the authed group so the handler's own auth check runs and the token bypass is reachable. Enables Sonos / UPnP speakers to fetch the stream URL without carrying the user's session cookie - they cannot. The token is short-lived (max 24h per the design); expiry checked at request time only, not per-byte, so long tracks play through. streamSecret field on handlers is nil for now; Task 2 wires the loader (env var with auto-generated fallback persisted in app_preferences). Adds auth.OptionalUser - the permissive sibling of RequireUser that attaches the user to context when a valid cookie / bearer is present but does NOT 401 on absence. The stream route is wrapped with it so the handler can fall through to the token path when no session is present. newLibraryRouter (test fixture) gets a synthetic-user middleware on the stream route so existing media_test tests keep passing without seeding a real session row - production traffic uses auth.OptionalUser, the test path uses auth.UserCtxKeyForTest(). Five tests cover round-trip, tampered token rejection, expiry, wrong-track-ID, and wrong-secret rejection. CI verifies.
This commit is contained in:
@@ -112,6 +112,52 @@ func RequireUser(pool *pgxpool.Pool) func(http.Handler) http.Handler {
|
||||
// middleware. Do not use this outside _test.go files.
|
||||
func UserCtxKeyForTest() any { return userCtxKey }
|
||||
|
||||
// OptionalUser is RequireUser's permissive sibling: it resolves the caller
|
||||
// from the session cookie or bearer header and attaches the user to context
|
||||
// when present + valid, but does NOT 401 on absence. The downstream handler
|
||||
// runs unconditionally and is responsible for its own auth check via
|
||||
// UserFromContext (or its own bespoke path — see /api/tracks/{id}/stream's
|
||||
// streamAuthOk, which accepts EITHER a user-in-context OR a signed query
|
||||
// token for UPnP / Sonos speakers that don't carry the user's cookie).
|
||||
//
|
||||
// Invalid tokens (stale session row, deleted user) silently drop through
|
||||
// without attaching the user. The handler treats "no user in context" as
|
||||
// "not authenticated" the same way it treats a missing cookie.
|
||||
//
|
||||
// Database lookup failures fall through too — a transient DB blip should not
|
||||
// 5xx a stream request that may have a perfectly valid signed token. The
|
||||
// error is logged so the operator can correlate.
|
||||
func OptionalUser(pool *pgxpool.Pool, logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := sessionTokenFromRequest(r)
|
||||
if token == "" || pool == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
q := dbq.New(pool)
|
||||
sess, err := q.GetSessionByTokenHash(r.Context(), HashSessionToken(token))
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) && logger != nil {
|
||||
logger.Warn("api: optional session lookup failed", "err", err)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
user, err := q.GetUserByID(r.Context(), sess.UserID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) && logger != nil {
|
||||
logger.Warn("api: optional user lookup failed", "err", err)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userCtxKey, user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sessionTokenFromRequest(r *http.Request) string {
|
||||
if c, err := r.Cookie(SessionCookieName); err == nil && c.Value != "" {
|
||||
return c.Value
|
||||
|
||||
Reference in New Issue
Block a user