feat(auth): RequireUser middleware (cookie or bearer)
Resolves /api/* callers from session cookie first, Authorization bearer second. Touches last_seen on success for future active- sessions UI. Adds GetUserByID query used by the middleware. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,20 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// sessionTokenBytes is the raw entropy per session token. 32 bytes of
|
||||
@@ -38,3 +47,84 @@ func HashSessionToken(token string) []byte {
|
||||
func VerifyPassword(hash, plaintext string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext)) == nil
|
||||
}
|
||||
|
||||
// SessionCookieName is the cookie the web SPA rides. Exported because handlers
|
||||
// that issue/clear the cookie (handleLogin / handleLogout) need to match it.
|
||||
const SessionCookieName = "minstrel_session"
|
||||
|
||||
// RequireUser resolves the caller from a session cookie OR Authorization
|
||||
// bearer header and puts the dbq.User in request context via userCtxKey.
|
||||
// Requests without a valid session return 401 with no body so callers don't
|
||||
// leak whether the username existed (matches the /rest/* auth posture).
|
||||
func RequireUser(pool *pgxpool.Pool) 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 == "" {
|
||||
http.Error(w, "unauthenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if pool == nil {
|
||||
// Test-only path: the test at the top of this file constructs
|
||||
// the middleware with nil pool to prove the no-token case
|
||||
// short-circuits without a DB call. Any real token here is
|
||||
// programmer error.
|
||||
http.Error(w, "unauthenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
q := dbq.New(pool)
|
||||
sess, err := q.GetSessionByTokenHash(r.Context(), HashSessionToken(token))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "unauthenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
slog.Error("api: session lookup failed", "err", err)
|
||||
http.Error(w, "auth lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
user, err := q.GetUserByID(r.Context(), sess.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Session points at a deleted user — treat as unauth,
|
||||
// best-effort cleanup.
|
||||
_ = q.DeleteSession(r.Context(), sess.ID)
|
||||
http.Error(w, "unauthenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
slog.Error("api: user lookup failed", "err", err)
|
||||
http.Error(w, "auth lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Best-effort last-seen update. A failure here shouldn't fail the
|
||||
// request; the session is still valid and this is observability.
|
||||
if err := q.TouchSessionLastSeen(r.Context(), sess.ID); err != nil {
|
||||
slog.Warn("api: touch session last_seen failed", "err", err)
|
||||
}
|
||||
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
|
||||
}
|
||||
return extractBearerToken(r.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
// extractBearerToken pulls the token out of an Authorization header in
|
||||
// either "Bearer xyz" or "bearer xyz" form. Returns "" when the header is
|
||||
// missing, malformed, or uses a different scheme.
|
||||
func extractBearerToken(header string) string {
|
||||
if header == "" {
|
||||
return ""
|
||||
}
|
||||
const prefix = "bearer "
|
||||
lower := strings.ToLower(header)
|
||||
if !strings.HasPrefix(lower, prefix) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(header[len(prefix):])
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package auth
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -50,3 +52,38 @@ func TestVerifyPassword(t *testing.T) {
|
||||
t.Error("malformed hash accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireUser_RejectsWhenNoCookieOrBearer(t *testing.T) {
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
t.Fatal("handler must not be called")
|
||||
})
|
||||
h := RequireUser(nil)(next)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBearerToken(t *testing.T) {
|
||||
cases := []struct {
|
||||
header string
|
||||
want string
|
||||
}{
|
||||
{"", ""},
|
||||
{"Bearer abc", "abc"},
|
||||
{"bearer abc", "abc"},
|
||||
{"Token abc", ""},
|
||||
{"Bearer", ""},
|
||||
{"Bearer whitespace-token ", "whitespace-token"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := extractBearerToken(c.header)
|
||||
if got != c.want {
|
||||
t.Errorf("extractBearerToken(%q) = %q, want %q", c.header, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,25 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User,
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByID = `-- name: GetUserByID :one
|
||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password FROM users WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) {
|
||||
row := q.db.QueryRow(ctx, getUserByID, id)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByUsername = `-- name: GetUserByUsername :one
|
||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password FROM users WHERE username = $1
|
||||
`
|
||||
|
||||
@@ -16,3 +16,6 @@ SELECT count(*) FROM users;
|
||||
-- Stores (or clears with NULL) the per-user Subsonic legacy credential used
|
||||
-- for t/s and p auth on /rest/*. Must be plaintext; see migration 0003.
|
||||
UPDATE users SET subsonic_password = $2 WHERE id = $1;
|
||||
|
||||
-- name: GetUserByID :one
|
||||
SELECT * FROM users WHERE id = $1;
|
||||
|
||||
Reference in New Issue
Block a user