Files
minstrel/internal/api/auth.go
T
bvandeusen d86af7397d
test-go / test (push) Successful in 55s
test-go / integration (push) Successful in 4m53s
feat(auth): active sessions API with origin/current IP — #370
Server half of the active-sessions surface. Web UI follows.

The operator wants this specifically to notice a compromised account, which
sets the bar: the addresses have to be trustworthy, or the feature is worse
than absent because it looks like evidence.

Migration 0052 adds created_ip + last_ip. Two columns, not one, and the pair
is the signal: a session issued at home and now being used from elsewhere is
the shape of a stolen token, and neither column alone can show that. Typed
text, matching the user_agent column beside it — these are displayed, never
queried by subnet, and inet round-trips through pgx as a netip.Prefix that
renders "1.2.3.4/32".

The rest of the schema was already waiting. Migration 0004 anticipated this
exactly: "last_seen_at enables an 'active sessions' UI later (not wired in
this plan) without schema churn." last_seen_at is live data — the auth
middleware already touches it per request — so last_ip rides that same
UPDATE for free.

Getting the address right is the substance here. Nothing extracted a client
IP anywhere before, and both obvious approaches are wrong:

- RemoteAddr alone shows the reverse proxy on every session, which is the
  normal self-hosted deployment. Noise shaped like data.
- Trusting X-Forwarded-For lets any client choose what its victim sees. A
  security surface an attacker can write to is worse than none.

So auth.ClientIP trusts the header only when the request actually arrived
from a proxy range. Public RemoteAddr means a direct connection, so XFF is
attacker-controlled and ignored outright. Private RemoteAddr means we walk
XFF right-to-left — proxies append, so the right end is what our own
infrastructure wrote — and take the first non-proxy address. A forged XFF
only prepends to the left end, which that walk never reaches. Unit-tested,
including both spoofing shapes.

Fails closed on a public-addressed proxy (separate host, CDN): we report the
proxy rather than trusting a forgeable header. Documented at the function.

Endpoints, all scoped by user_id per rule #47:

  GET    /api/me/sessions                → list, flagging the current row
  DELETE /api/me/sessions/{id}           → 204, or 404 if not yours
  POST   /api/me/sessions/logout-others  → {"revoked": n}

Keyed on session id alone, any household member could revoke another's
session by guessing a uuid, so the delete carries user_id in its WHERE and
:execrows distinguishes "not yours" (404) from a false 204. There's a test
that asserts the row actually survives, not merely that we returned 404.

The middleware now also puts the session id in context. logout-others is
defined by exclusion, and without knowing which session is ours the
safe-looking action deletes everything including the caller's — so it
refuses rather than guesses when the id is absent, and that refusal is
tested for non-deletion too.

audit_log.action is plain text with no CHECK, so the two new actions need no
migration (rule #36 checked, not assumed).

Codegen is real sqlc 1.31.1 via the container in `make generate` — docker is
present on this workstation even though Go and sqlc aren't — rather than the
hand-written .sql.go shortcut used in milestone #268.
2026-08-05 09:17:40 -04:00

130 lines
4.2 KiB
Go

package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// sessionCookieMaxAge is the cookie lifetime. Sessions don't auto-expire
// server-side yet (future work); the cookie still caps browser-side lifetime
// so an abandoned laptop doesn't stay logged in forever.
const sessionCookieMaxAge = 30 * 24 * time.Hour
func (h *handlers) handleLogout(w http.ResponseWriter, r *http.Request) {
// The session token can be on the cookie OR bearer header — RequireUser
// accepted either. Re-resolve it here so we can delete the row.
token := sessionTokenFromHTTP(r)
if token != "" {
if err := dbq.New(h.pool).DeleteSessionByTokenHash(r.Context(), auth.HashSessionToken(token)); err != nil {
h.logger.Warn("api: delete session failed", "err", err)
// Continue — logout is best-effort; the client still gets the
// cookie cleared.
}
}
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
w.WriteHeader(http.StatusNoContent)
}
// sessionTokenFromHTTP duplicates the internal helper in internal/auth
// because that one is unexported. Cheap to repeat here; keeping the auth
// package's internal helper package-private is worth more than DRY. Must
// match that helper's trimming behavior exactly — otherwise a bearer with
// trailing whitespace authenticates via RequireUser (which trims) but logout
// hashes the padded value and silently no-ops, leaving the session alive.
func sessionTokenFromHTTP(r *http.Request) string {
if c, err := r.Cookie(auth.SessionCookieName); err == nil && c.Value != "" {
return c.Value
}
h := r.Header.Get("Authorization")
const prefix = "bearer "
if len(h) > len(prefix) && (h[:7] == "Bearer " || h[:7] == "bearer ") {
return strings.TrimSpace(h[len(prefix):])
}
return ""
}
func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, apierror.BadRequest("bad_request", "invalid JSON body"))
return
}
if req.Username == "" || req.Password == "" {
writeErr(w, apierror.BadRequest("bad_request", "username and password required"))
return
}
q := dbq.New(h.pool)
user, err := q.GetUserByUsername(r.Context(), req.Username)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, apierror.Unauthorized("invalid_credentials", "invalid username or password"))
return
}
h.logger.Error("api: user lookup failed", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
if !auth.VerifyPassword(user.PasswordHash, req.Password) {
writeErr(w, apierror.Unauthorized("invalid_credentials", "invalid username or password"))
return
}
token, err := auth.MintSessionToken()
if err != nil {
h.logger.Error("api: mint session token failed", "err", err)
writeErr(w, apierror.InternalMsg("mint failed", err))
return
}
if _, err := q.InsertSession(r.Context(), dbq.InsertSessionParams{
UserID: user.ID,
TokenHash: auth.HashSessionToken(token),
UserAgent: r.UserAgent(),
// Origin address, frozen at issue time. Compared against last_ip in
// the active-sessions surface: a session that was born somewhere the
// user recognises but is being used from somewhere they don't is the
// case this whole surface exists to surface.
Ip: auth.ClientIP(r),
}); err != nil {
h.logger.Error("api: insert session failed", "err", err)
writeErr(w, apierror.InternalMsg("insert failed", err))
return
}
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil, // dev over http stays functional; prod over https gets Secure
SameSite: http.SameSiteStrictMode,
MaxAge: int(sessionCookieMaxAge.Seconds()),
})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(LoginResponse{
Token: token,
User: UserView{
ID: user.ID,
Username: user.Username,
IsAdmin: user.IsAdmin,
},
})
}