Foundation: config, schema/migrations, auth, stable IDs, metadata probing

This commit is contained in:
2026-08-22 23:29:16 -05:00
commit d4ea7a4ca9
40 changed files with 3874 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
package auth
import (
"crypto/sha256"
"encoding/hex"
)
// HashAPIKey returns a stable, non-reversible fingerprint of a raw API
// key for storage and lookup (API keys are high-entropy random tokens,
// not user-chosen passwords, so a plain fast hash is sufficient here).
func HashAPIKey(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
+59
View File
@@ -0,0 +1,59 @@
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"net/http"
)
const csrfCookieName = "vibesonic_csrf"
// CSRF implements the double-submit-cookie pattern: a random token is set
// in a cookie readable by JS-free forms via a template field, and checked
// against the same value submitted in the form body on state-changing
// requests.
type CSRF struct {
CookieSecure bool
}
// Token returns the CSRF token for this request, setting a fresh cookie
// if none exists yet.
func (c *CSRF) Token(w http.ResponseWriter, r *http.Request) string {
if ck, err := r.Cookie(csrfCookieName); err == nil && ck.Value != "" {
return ck.Value
}
b := make([]byte, 32)
_, _ = rand.Read(b)
token := base64.RawURLEncoding.EncodeToString(b)
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: token,
Path: "/",
HttpOnly: false, // must be readable by the template render, not JS; kept simple since it never leaves same-origin form submits
Secure: c.CookieSecure,
SameSite: http.SameSiteLaxMode,
})
return token
}
// Protect wraps a handler, rejecting POST/PUT/PATCH/DELETE requests whose
// csrf_token form value doesn't match the csrf cookie.
func (c *CSRF) Protect(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
ck, err := r.Cookie(csrfCookieName)
if err != nil || ck.Value == "" {
http.Error(w, "403 Forbidden: missing CSRF cookie", http.StatusForbidden)
return
}
submitted := r.FormValue("csrf_token")
if subtle.ConstantTimeCompare([]byte(submitted), []byte(ck.Value)) != 1 {
http.Error(w, "403 Forbidden: invalid CSRF token", http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}
+75
View File
@@ -0,0 +1,75 @@
package auth
import (
"database/sql"
"net"
"net/http"
"net/netip"
"git.noroutes.net/NoRoutes/vibetunes/internal/config"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
// ExtAuth resolves users from a trusted reverse proxy's identity header,
// Navidrome-style. It never talks to an identity provider itself: the
// proxy is assumed to have already authenticated the browser user.
//
// Security-critical: the identity header is only trusted when the actual
// TCP peer address (r.RemoteAddr) matches a configured trusted CIDR.
// Headers like X-Forwarded-For/X-Real-IP/Forwarded are never consulted
// here, because they are attacker-controlled unless a dedicated trusted-
// proxy chain abstraction validates them — an arbitrary client could
// otherwise set the identity header directly and spoof any user.
type ExtAuth struct {
DB *sql.DB
Config config.ExtAuthConfig
}
// IdentityFromRequest returns the trusted username asserted by the
// reverse proxy for this request, or "" if external auth is disabled, no
// header was sent, or the connection did not originate from a trusted
// peer.
func (e *ExtAuth) IdentityFromRequest(r *http.Request) string {
if !e.Config.Enabled {
return ""
}
if !e.peerTrusted(r) {
return ""
}
return r.Header.Get(e.Config.UserHeader)
}
func (e *ExtAuth) peerTrusted(r *http.Request) bool {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
addr, err := netip.ParseAddr(host)
if err != nil {
return false
}
for _, prefix := range e.Config.TrustedSources {
if prefix.Contains(addr) {
return true
}
}
return false
}
// ResolveUser returns the VibeSonic user for a trusted external identity,
// creating one if AutoCreateUsers is set and no such user exists yet. It
// never creates a second, separate "external" user type: external users
// are ordinary users with ExternalAuth set.
func (e *ExtAuth) ResolveUser(username string) (*database.User, error) {
user, err := database.GetUserByUsername(e.DB, username)
if err == nil {
return user, nil
}
if err != database.ErrNotFound {
return nil, err
}
if !e.Config.AutoCreateUsers {
return nil, database.ErrNotFound
}
return database.CreateExternalUser(e.DB, username)
}
+126
View File
@@ -0,0 +1,126 @@
package auth
import (
"net/http"
"net/http/httptest"
"net/netip"
"testing"
"git.noroutes.net/NoRoutes/vibetunes/internal/config"
)
func mustPrefix(t *testing.T, s string) netip.Prefix {
t.Helper()
p, err := netip.ParsePrefix(s)
if err != nil {
t.Fatalf("ParsePrefix(%q): %v", s, err)
}
return p
}
func TestExtAuthTrustsConfiguredPeer(t *testing.T) {
db := openTestDB(t)
e := &ExtAuth{DB: db, Config: config.ExtAuthConfig{
Enabled: true,
UserHeader: "Remote-User",
TrustedSources: []netip.Prefix{mustPrefix(t, "127.0.0.1/32")},
}}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "127.0.0.1:54321"
req.Header.Set("Remote-User", "alice")
if got := e.IdentityFromRequest(req); got != "alice" {
t.Fatalf("IdentityFromRequest = %q, want alice", got)
}
}
func TestExtAuthRejectsUntrustedPeer(t *testing.T) {
db := openTestDB(t)
e := &ExtAuth{DB: db, Config: config.ExtAuthConfig{
Enabled: true,
UserHeader: "Remote-User",
TrustedSources: []netip.Prefix{mustPrefix(t, "127.0.0.1/32")},
}}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "203.0.113.7:54321" // arbitrary internet client, not the trusted proxy
req.Header.Set("Remote-User", "alice")
if got := e.IdentityFromRequest(req); got != "" {
t.Fatalf("IdentityFromRequest = %q, want empty for untrusted peer", got)
}
}
// TestExtAuthIgnoresForwardedHeadersFromPeer is the core anti-spoofing
// guarantee: an arbitrary internet client cannot impersonate a trusted
// proxy by setting X-Forwarded-For/X-Real-IP/Forwarded, because those
// headers are never consulted to decide whether the peer is trusted —
// only the real TCP RemoteAddr is.
func TestExtAuthIgnoresForwardedHeadersFromPeer(t *testing.T) {
db := openTestDB(t)
e := &ExtAuth{DB: db, Config: config.ExtAuthConfig{
Enabled: true,
UserHeader: "Remote-User",
TrustedSources: []netip.Prefix{mustPrefix(t, "127.0.0.1/32")},
}}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "203.0.113.7:54321" // real connection is from an untrusted internet host
req.Header.Set("X-Forwarded-For", "127.0.0.1")
req.Header.Set("X-Real-IP", "127.0.0.1")
req.Header.Set("Forwarded", "for=127.0.0.1")
req.Header.Set("Remote-User", "admin")
if got := e.IdentityFromRequest(req); got != "" {
t.Fatalf("IdentityFromRequest = %q, want empty: forwarded headers must not establish trust", got)
}
}
func TestExtAuthDisabledNeverTrusts(t *testing.T) {
db := openTestDB(t)
e := &ExtAuth{DB: db, Config: config.ExtAuthConfig{
Enabled: false,
UserHeader: "Remote-User",
TrustedSources: []netip.Prefix{mustPrefix(t, "0.0.0.0/0")},
}}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "127.0.0.1:1"
req.Header.Set("Remote-User", "alice")
if got := e.IdentityFromRequest(req); got != "" {
t.Fatalf("IdentityFromRequest = %q, want empty when ExtAuth disabled", got)
}
}
func TestExtAuthNoTrustedSourcesNeverTrusts(t *testing.T) {
db := openTestDB(t)
e := &ExtAuth{DB: db, Config: config.ExtAuthConfig{Enabled: true, UserHeader: "Remote-User"}}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "127.0.0.1:1"
req.Header.Set("Remote-User", "alice")
if got := e.IdentityFromRequest(req); got != "" {
t.Fatalf("IdentityFromRequest = %q, want empty with no trusted sources configured", got)
}
}
func TestExtAuthResolveUserAutoCreates(t *testing.T) {
db := openTestDB(t)
e := &ExtAuth{DB: db, Config: config.ExtAuthConfig{AutoCreateUsers: true}}
u, err := e.ResolveUser("newperson")
if err != nil {
t.Fatalf("ResolveUser: %v", err)
}
if !u.ExternalAuth {
t.Fatal("auto-created user should have ExternalAuth set")
}
if u.PasswordHash.Valid {
t.Fatal("auto-created external user should have no password hash")
}
}
func TestExtAuthResolveUserNoAutoCreate(t *testing.T) {
db := openTestDB(t)
e := &ExtAuth{DB: db, Config: config.ExtAuthConfig{AutoCreateUsers: false}}
if _, err := e.ResolveUser("ghost"); err == nil {
t.Fatal("expected error when auto-create is disabled and user doesn't exist")
}
}
+66
View File
@@ -0,0 +1,66 @@
package auth
import (
"net/http"
"net/url"
)
// Middleware resolves the current user for each request, from a session
// cookie first and falling back to trusted external auth, then makes it
// available via UserFromContext.
type Middleware struct {
Sessions *Sessions
ExtAuth *ExtAuth
}
// Authenticate resolves the current user (session cookie, then trusted
// external auth) and stores it in the request context, without requiring
// one to be present. Handlers that need to know whether someone is
// signed in (e.g. the login page, the home page) should use this.
func (m *Middleware) Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := m.Sessions.CurrentUser(r)
if user == nil && m.ExtAuth != nil {
if username := m.ExtAuth.IdentityFromRequest(r); username != "" {
if u, err := m.ExtAuth.ResolveUser(username); err == nil && !u.Disabled {
user = u
_ = m.Sessions.Issue(w, r, u.ID)
}
}
}
if user != nil {
r = r.WithContext(WithUser(r.Context(), user))
}
next.ServeHTTP(w, r)
})
}
// RequireUser redirects unauthenticated requests to /login, preserving
// the original destination.
func (m *Middleware) RequireUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if UserFromContext(r.Context()) == nil {
http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound)
return
}
next.ServeHTTP(w, r)
})
}
// RequireAdmin is like RequireUser but also rejects non-admin users with
// 403. Authorization is enforced here, on the server, not merely by
// hiding admin links in the UI.
func (m *Middleware) RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
if user == nil {
http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound)
return
}
if !user.IsAdmin {
http.Error(w, "403 Forbidden: administrator access required", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
+87
View File
@@ -0,0 +1,87 @@
// Package auth handles password hashing, cookie sessions, CSRF
// protection, and optional trusted-reverse-proxy external authentication.
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// Argon2id parameters, tuned for a self-hosted single/small-household
// server rather than a high-throughput multi-tenant service.
const (
argonTime = 1
argonMemory = 64 * 1024 // KiB
argonThreads = 4
argonKeyLen = 32
saltLen = 16
)
var ErrInvalidHash = errors.New("invalid password hash format")
// HashPassword returns an encoded argon2id hash in the standard
// $argon2id$v=19$m=...,t=...,p=...$salt$hash format.
func HashPassword(password string) (string, error) {
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemory, argonTime, argonThreads, b64Salt, b64Hash), nil
}
// VerifyPassword checks password against an encoded hash produced by
// HashPassword, using constant-time comparison.
func VerifyPassword(password, encoded string) (bool, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, ErrInvalidHash
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return false, ErrInvalidHash
}
var memory, timeCost uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
return false, ErrInvalidHash
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, ErrInvalidHash
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, ErrInvalidHash
}
got := argon2.IDKey([]byte(password), salt, timeCost, memory, threads, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
// GenerateRandomPassword returns a strong, human-typeable temporary
// password for the first-run administrator account.
func GenerateRandomPassword() (string, error) {
b := make([]byte, 18)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// GenerateAPIKey returns a random API key and its SHA-256 hash for
// storage (the raw key is shown to the user only once).
func GenerateAPIKey() (raw string, err error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "vs_" + base64.RawURLEncoding.EncodeToString(b), nil
}
+71
View File
@@ -0,0 +1,71 @@
package auth
import "testing"
func TestHashAndVerifyPassword(t *testing.T) {
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
ok, err := VerifyPassword("correct horse battery staple", hash)
if err != nil {
t.Fatalf("VerifyPassword: %v", err)
}
if !ok {
t.Fatal("VerifyPassword returned false for the correct password")
}
}
func TestVerifyPasswordRejectsWrongPassword(t *testing.T) {
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
ok, err := VerifyPassword("wrong password", hash)
if err != nil {
t.Fatalf("VerifyPassword: %v", err)
}
if ok {
t.Fatal("VerifyPassword returned true for the wrong password")
}
}
func TestVerifyPasswordRejectsMalformedHash(t *testing.T) {
if _, err := VerifyPassword("anything", "not-a-hash"); err != ErrInvalidHash {
t.Fatalf("VerifyPassword error = %v, want ErrInvalidHash", err)
}
}
func TestHashPasswordProducesUniqueSalts(t *testing.T) {
h1, err := HashPassword("same password")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
h2, err := HashPassword("same password")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if h1 == h2 {
t.Fatal("two hashes of the same password should differ due to random salts")
}
}
func TestGenerateAPIKeyAndHash(t *testing.T) {
k1, err := GenerateAPIKey()
if err != nil {
t.Fatalf("GenerateAPIKey: %v", err)
}
k2, err := GenerateAPIKey()
if err != nil {
t.Fatalf("GenerateAPIKey: %v", err)
}
if k1 == k2 {
t.Fatal("two generated API keys should differ")
}
if HashAPIKey(k1) == HashAPIKey(k2) {
t.Fatal("hashes of two different keys should differ")
}
if HashAPIKey(k1) != HashAPIKey(k1) {
t.Fatal("hashing the same key twice should be stable")
}
}
+97
View File
@@ -0,0 +1,97 @@
package auth
import (
"context"
"database/sql"
"net"
"net/http"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
const (
SessionCookieName = "vibesonic_session"
SessionTTL = 30 * 24 * time.Hour
)
// Sessions issues and validates cookie sessions backed by the sessions
// table, so they can be listed and revoked server-side.
type Sessions struct {
DB *sql.DB
CookieSecure bool
}
func (s *Sessions) Issue(w http.ResponseWriter, r *http.Request, userID string) error {
sess, err := database.CreateSession(s.DB, userID, SessionTTL, r.UserAgent(), clientIP(r))
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: sess.ID,
Path: "/",
HttpOnly: true,
Secure: s.CookieSecure,
SameSite: http.SameSiteLaxMode,
Expires: sess.ExpiresAt,
})
return nil
}
func (s *Sessions) Clear(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(SessionCookieName); err == nil {
_ = database.DeleteSession(s.DB, c.Value)
}
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: s.CookieSecure,
})
}
// CurrentUser resolves the authenticated user from the session cookie, or
// nil if there is none / it is invalid / expired / the user is disabled.
func (s *Sessions) CurrentUser(r *http.Request) *database.User {
c, err := r.Cookie(SessionCookieName)
if err != nil {
return nil
}
sess, err := database.GetSession(s.DB, c.Value)
if err != nil {
return nil
}
if time.Now().After(sess.ExpiresAt) {
_ = database.DeleteSession(s.DB, sess.ID)
return nil
}
user, err := database.GetUserByID(s.DB, sess.UserID)
if err != nil || user.Disabled {
return nil
}
return user
}
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
type contextKey string
const userContextKey contextKey = "vibesonic_user"
func WithUser(ctx context.Context, u *database.User) context.Context {
return context.WithValue(ctx, userContextKey, u)
}
func UserFromContext(ctx context.Context) *database.User {
u, _ := ctx.Value(userContextKey).(*database.User)
return u
}
+112
View File
@@ -0,0 +1,112 @@
package auth
import (
"database/sql"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := database.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("database.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestSessionIssueAndCurrentUser(t *testing.T) {
db := openTestDB(t)
user, err := database.CreateUser(db, "alice", "alice@example.com", "", false)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
sessions := &Sessions{DB: db, CookieSecure: false}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
if err := sessions.Issue(rec, req, user.ID); err != nil {
t.Fatalf("Issue: %v", err)
}
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
for _, c := range rec.Result().Cookies() {
req2.AddCookie(c)
}
got := sessions.CurrentUser(req2)
if got == nil || got.ID != user.ID {
t.Fatalf("CurrentUser = %v, want %s", got, user.ID)
}
}
func TestSessionClearRevokesCookie(t *testing.T) {
db := openTestDB(t)
user, err := database.CreateUser(db, "bob", "", "", false)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
sessions := &Sessions{DB: db, CookieSecure: false}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
if err := sessions.Issue(rec, req, user.ID); err != nil {
t.Fatalf("Issue: %v", err)
}
var cookies []*http.Cookie
for _, c := range rec.Result().Cookies() {
cookies = append(cookies, c)
}
logoutReq := httptest.NewRequest(http.MethodPost, "/logout", nil)
for _, c := range cookies {
logoutReq.AddCookie(c)
}
logoutRec := httptest.NewRecorder()
sessions.Clear(logoutRec, logoutReq)
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
for _, c := range cookies {
req2.AddCookie(c)
}
if got := sessions.CurrentUser(req2); got != nil {
t.Fatalf("CurrentUser after Clear = %v, want nil", got)
}
}
func TestSessionRejectsUnknownCookie(t *testing.T) {
db := openTestDB(t)
sessions := &Sessions{DB: db, CookieSecure: false}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "does-not-exist"})
if got := sessions.CurrentUser(req); got != nil {
t.Fatalf("CurrentUser = %v, want nil for unknown session", got)
}
}
func TestSessionRejectsDisabledUser(t *testing.T) {
db := openTestDB(t)
user, err := database.CreateUser(db, "carol", "", "", false)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := database.SetUserDisabled(db, user.ID, true); err != nil {
t.Fatalf("SetUserDisabled: %v", err)
}
sessions := &Sessions{DB: db, CookieSecure: false}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
if err := sessions.Issue(rec, req, user.ID); err != nil {
t.Fatalf("Issue: %v", err)
}
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
for _, c := range rec.Result().Cookies() {
req2.AddCookie(c)
}
if got := sessions.CurrentUser(req2); got != nil {
t.Fatalf("CurrentUser = %v, want nil for disabled user", got)
}
}