Foundation: config, schema/migrations, auth, stable IDs, metadata probing
This commit is contained in:
@@ -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[:])
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user