Sessions are stored as hashes, so a database dump contains nothing that can be pasted into a browser. A login that has passed the password but not the second factor is modelled as a pending session: it expires, is revocable, and reaches exactly one endpoint. Signup is invite-only and takes the email address from the invite rather than the form, so a link cannot be redirected to somebody else. The first admin is created by an endpoint that refuses once any account exists, checked and inserted in one transaction because it is necessarily unauthenticated. Login failures and forgotten-password requests answer identically whether or not the address has an account — otherwise the form is an account-existence oracle. Password rules are a length floor and nothing else: composition rules push people towards worse passwords. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
173 lines
5.4 KiB
Go
173 lines
5.4 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/kristianwind/verdande/internal/auth"
|
|
)
|
|
|
|
type Session struct {
|
|
ID string // the hash, which is what is stored — never the cookie value
|
|
UserID string
|
|
UA string
|
|
IP string
|
|
PendingTOTP bool
|
|
CreatedAt time.Time
|
|
LastSeenAt time.Time
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// CreateSession issues a session and returns the value to put in the cookie.
|
|
//
|
|
// Only the hash is stored. A session cookie is a bearer credential — whoever holds
|
|
// it is logged in — so a database dump must not contain anything that can be pasted
|
|
// into a browser.
|
|
//
|
|
// pendingTOTP marks a login that has passed the password step but not the second
|
|
// factor. Such a session is accepted by exactly one endpoint.
|
|
func (db *DB) CreateSession(ctx context.Context, userID, ua, ip string, ttl time.Duration, pendingTOTP bool) (string, *Session, error) {
|
|
token, err := auth.NewToken()
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
now := time.Now().UTC()
|
|
s := &Session{
|
|
ID: auth.HashToken(token),
|
|
UserID: userID,
|
|
UA: truncate(ua, 500),
|
|
IP: truncate(ip, 100),
|
|
PendingTOTP: pendingTOTP,
|
|
CreatedAt: now,
|
|
LastSeenAt: now,
|
|
ExpiresAt: now.Add(ttl),
|
|
}
|
|
|
|
_, err = db.ExecContext(ctx,
|
|
`INSERT INTO sessions (id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
s.ID, s.UserID, s.UA, s.IP, boolToInt(s.PendingTOTP),
|
|
s.CreatedAt.Unix(), s.LastSeenAt.Unix(), s.ExpiresAt.Unix())
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return token, s, nil
|
|
}
|
|
|
|
// SessionByToken looks up a session by the cookie value and returns it with its
|
|
// user. An expired session is reported as not found and deleted on the way past:
|
|
// expiry is checked here rather than left to the nightly sweep, so a stale cookie
|
|
// stops working at the moment it should.
|
|
func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User, error) {
|
|
id := auth.HashToken(token)
|
|
|
|
var s Session
|
|
var pending int
|
|
var created, lastSeen, expires int64
|
|
err := db.QueryRowContext(ctx,
|
|
`SELECT id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at
|
|
FROM sessions WHERE id = ?`, id).
|
|
Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &created, &lastSeen, &expires)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
s.PendingTOTP = pending == 1
|
|
s.CreatedAt = time.Unix(created, 0).UTC()
|
|
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
|
|
s.ExpiresAt = time.Unix(expires, 0).UTC()
|
|
|
|
if time.Now().After(s.ExpiresAt) {
|
|
_, _ = db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, s.ID)
|
|
return nil, nil, ErrNotFound
|
|
}
|
|
|
|
user, err := db.UserByID(ctx, s.UserID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// last_seen_at drives the session list in settings ("this device, 2 minutes
|
|
// ago"). It is written at most once a minute: every request would be a write
|
|
// per request, which on SQLite means a write lock per request.
|
|
if time.Since(s.LastSeenAt) > time.Minute {
|
|
_, _ = db.ExecContext(ctx,
|
|
`UPDATE sessions SET last_seen_at = ? WHERE id = ?`, time.Now().Unix(), s.ID)
|
|
}
|
|
return &s, user, nil
|
|
}
|
|
|
|
// PromoteSession completes a two-step login: the session stops being pending and
|
|
// its clock restarts, so the full lifetime is counted from finishing the login and
|
|
// not from typing the password.
|
|
func (db *DB) PromoteSession(ctx context.Context, sessionID string, ttl time.Duration) error {
|
|
res, err := db.ExecContext(ctx,
|
|
`UPDATE sessions SET pending_totp = 0, expires_at = ? WHERE id = ? AND pending_totp = 1`,
|
|
time.Now().Add(ttl).Unix(), sessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (db *DB) DeleteSession(ctx context.Context, sessionID string) error {
|
|
_, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, sessionID)
|
|
return err
|
|
}
|
|
|
|
func (db *DB) DeleteUserSessions(ctx context.Context, userID string) error {
|
|
_, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID)
|
|
return err
|
|
}
|
|
|
|
func (db *DB) ListSessions(ctx context.Context, userID string) ([]Session, error) {
|
|
rows, err := db.QueryContext(ctx,
|
|
`SELECT id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at
|
|
FROM sessions WHERE user_id = ? AND pending_totp = 0 ORDER BY last_seen_at DESC`, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []Session
|
|
for rows.Next() {
|
|
var s Session
|
|
var pending int
|
|
var created, lastSeen, expires int64
|
|
if err := rows.Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &created, &lastSeen, &expires); err != nil {
|
|
return nil, err
|
|
}
|
|
s.PendingTOTP = pending == 1
|
|
s.CreatedAt = time.Unix(created, 0).UTC()
|
|
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
|
|
s.ExpiresAt = time.Unix(expires, 0).UTC()
|
|
out = append(out, s)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// PurgeExpiredSessions is run by the nightly sweep. SessionByToken already refuses
|
|
// an expired session, so this only reclaims space and keeps the session list honest.
|
|
func (db *DB) PurgeExpiredSessions(ctx context.Context) (int64, error) {
|
|
res, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`, time.Now().Unix())
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.RowsAffected()
|
|
}
|
|
|
|
func truncate(s string, max int) string {
|
|
if len(s) <= max {
|
|
return s
|
|
}
|
|
return s[:max]
|
|
}
|