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
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sessionTokenBytes is the raw entropy per session token. 32 bytes of
|
// 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 {
|
func VerifyPassword(hash, plaintext string) bool {
|
||||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext)) == nil
|
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 (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -50,3 +52,38 @@ func TestVerifyPassword(t *testing.T) {
|
|||||||
t.Error("malformed hash accepted")
|
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
|
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
|
const getUserByUsername = `-- name: GetUserByUsername :one
|
||||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password FROM users WHERE username = $1
|
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
|
-- 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.
|
-- for t/s and p auth on /rest/*. Must be plaintext; see migration 0003.
|
||||||
UPDATE users SET subsonic_password = $2 WHERE id = $1;
|
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