2026-08-16 23:31:29 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 19:43:49 +02:00
|
|
|
// DeleteUserSession ends one session, and only if it belongs to the caller.
|
|
|
|
|
//
|
|
|
|
|
// Scoped in the statement rather than checked first: an id read and then deleted
|
|
|
|
|
// in two steps is a window, and the whole point of this call is that somebody is
|
|
|
|
|
// using it because they think another person is in their account.
|
|
|
|
|
func (db *DB) DeleteUserSession(ctx context.Context, sessionID, userID string) error {
|
|
|
|
|
res, err := db.ExecContext(ctx,
|
|
|
|
|
`DELETE FROM sessions WHERE id = ? AND user_id = ?`, sessionID, userID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
|
|
|
return ErrNotFound
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 23:31:29 +02:00
|
|
|
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]
|
|
|
|
|
}
|