Files
minstrel/internal/db/dbq/sessions.sql.go
T
bvandeusenandClaude Opus 5.5 755f997b0d feat(auth): sessions expire server-side; password change and reset end other sessions (M462 #4978)
Sessions had no server-side expiry: only the web cookie's 30-day Max-Age
limited them, and a bearer token (Android) lived until revoked by hand.
GetSessionByTokenHash and ListSessionsForUser now ignore sessions idle for
30 days or older than a year, and the GC worker deletes them hourly.

A password change was a plain UPDATE, so a session opened with the old
password survived it. Now:
- self-service change signs out every other device and keeps this one;
- reset by email ends every session the account has;
- an admin reset ends the target's sessions (keeping the admin's own when
  they reset themselves).

The success copy on web and Android says the other devices were signed out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:32:39 -04:00

211 lines
6.1 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 deleteSessionsForUser = `-- name: DeleteSessionsForUser :execrows
DELETE FROM sessions WHERE user_id = $1
`
// Every session the user has, the caller's included. A password reset uses
// it: whoever forgot the password is not signed in anywhere they trust, and
// whoever may have learned it must be signed out everywhere.
func (q *Queries) DeleteSessionsForUser(ctx context.Context, userID pgtype.UUID) (int64, error) {
result, err := q.db.Exec(ctx, deleteSessionsForUser, 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
AND last_seen_at > now() - interval '30 days'
AND created_at > now() - interval '365 days'
`
// Expired sessions are invisible here, so they fail auth the moment they
// lapse rather than whenever the GC sweep next runs. Two limits:
//
// idle 30 days — a token nobody has used in a month is abandoned, and
// matches the web cookie's lifetime;
// absolute 1 year — even a token in daily use is re-issued yearly, so a
// stolen one that is being used quietly does not live
// forever.
//
// Keep in step with ListSessionsForUser and GcDeleteExpiredSessions.
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
AND last_seen_at > now() - interval '30 days'
AND created_at > now() - interval '365 days'
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.
// Expired rows are left out: they no longer authenticate, so listing them
// as active would be wrong. Same limits as GetSessionByTokenHash.
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
}