Files
minstrel/internal/db/dbq/sessions.sql.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

177 lines
4.7 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: sessions.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const deleteOtherSessionsForUser = `-- name: DeleteOtherSessionsForUser :execrows
DELETE FROM sessions WHERE user_id = $1 AND id <> $2
`
type DeleteOtherSessionsForUserParams struct {
UserID pgtype.UUID
ID pgtype.UUID
}
// "Log out everywhere else." Excludes the caller's own session so the action
// doesn't log them out of the page they just used to invoke it.
func (q *Queries) DeleteOtherSessionsForUser(ctx context.Context, arg DeleteOtherSessionsForUserParams) (int64, error) {
result, err := q.db.Exec(ctx, deleteOtherSessionsForUser, arg.UserID, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const deleteSession = `-- name: DeleteSession :exec
DELETE FROM sessions WHERE id = $1
`
func (q *Queries) DeleteSession(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteSession, id)
return err
}
const deleteSessionByTokenHash = `-- name: DeleteSessionByTokenHash :exec
DELETE FROM sessions WHERE token_hash = $1
`
func (q *Queries) DeleteSessionByTokenHash(ctx context.Context, tokenHash []byte) error {
_, err := q.db.Exec(ctx, deleteSessionByTokenHash, tokenHash)
return err
}
const deleteSessionForUser = `-- name: DeleteSessionForUser :execrows
DELETE FROM sessions WHERE id = $1 AND user_id = $2
`
type DeleteSessionForUserParams struct {
ID pgtype.UUID
UserID pgtype.UUID
}
// Scoped by user_id, not just id (rule #47). Keyed on the id alone, any
// household member could revoke another member's session by guessing a uuid.
// execrows lets the handler answer 404 rather than a false 204 when the row
// isn't theirs.
func (q *Queries) DeleteSessionForUser(ctx context.Context, arg DeleteSessionForUserParams) (int64, error) {
result, err := q.db.Exec(ctx, deleteSessionForUser, arg.ID, arg.UserID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions WHERE token_hash = $1
`
func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (Session, error) {
row := q.db.QueryRow(ctx, getSessionByTokenHash, tokenHash)
var i Session
err := row.Scan(
&i.ID,
&i.UserID,
&i.TokenHash,
&i.UserAgent,
&i.CreatedAt,
&i.LastSeenAt,
&i.CreatedIp,
&i.LastIp,
)
return i, err
}
const insertSession = `-- name: InsertSession :one
INSERT INTO sessions (user_id, token_hash, user_agent, created_ip, last_ip)
VALUES ($1, $2, $3, $4, $4)
RETURNING id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip
`
type InsertSessionParams struct {
UserID pgtype.UUID
TokenHash []byte
UserAgent string
Ip string
}
// created_ip and last_ip start equal: at issue time the origin IS the current
// location. They diverge as the session is used from elsewhere, which is what
// makes a stolen token visible in the active-sessions surface.
func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (Session, error) {
row := q.db.QueryRow(ctx, insertSession,
arg.UserID,
arg.TokenHash,
arg.UserAgent,
arg.Ip,
)
var i Session
err := row.Scan(
&i.ID,
&i.UserID,
&i.TokenHash,
&i.UserAgent,
&i.CreatedAt,
&i.LastSeenAt,
&i.CreatedIp,
&i.LastIp,
)
return i, err
}
const listSessionsForUser = `-- name: ListSessionsForUser :many
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions WHERE user_id = $1 ORDER BY last_seen_at DESC
`
// Most-recently-active first: the row a user is most likely to act on is the
// one that moved last, and an unfamiliar entry at the top is the alarm.
func (q *Queries) ListSessionsForUser(ctx context.Context, userID pgtype.UUID) ([]Session, error) {
rows, err := q.db.Query(ctx, listSessionsForUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Session
for rows.Next() {
var i Session
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.TokenHash,
&i.UserAgent,
&i.CreatedAt,
&i.LastSeenAt,
&i.CreatedIp,
&i.LastIp,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const touchSessionLastSeen = `-- name: TouchSessionLastSeen :exec
UPDATE sessions SET last_seen_at = now(), last_ip = $2 WHERE id = $1
`
type TouchSessionLastSeenParams struct {
ID pgtype.UUID
LastIp string
}
func (q *Queries) TouchSessionLastSeen(ctx context.Context, arg TouchSessionLastSeenParams) error {
_, err := q.db.Exec(ctx, touchSessionLastSeen, arg.ID, arg.LastIp)
return err
}