`last_seen_at` has been written on every request since sessions existed —
throttled to once a minute, deliberately, so a list could one day say
"denne enhed, lige nu" — and nothing has ever read it. The store even had
ListSessions. What was missing was two routes and a panel.
GET /auth/sessions lists them, most recently used first, with the current
one marked so nobody has to work out which row is the browser they are
reading it in. DELETE /auth/sessions/{id} ends one. Ending the current
session is allowed and is simply logging out: refusing it would be a rule
with no reason behind it, and somebody clearing every device wants this
one gone too.
Both are behind requireSession rather than the ambient auth, the same as
the API tokens. A leaked token that could list its owner's devices and end
their sessions turns a theft into a lockout.
The delete is scoped in the statement rather than checked and then run.
An id read in one step and deleted in the next is a window, and the whole
reason somebody is on this screen is that they think another person is in
their account.
The device names are a handful of substring tests, not a parsing library.
The question this answers is "is one of these me?", and for that a wrong
guess between two Chromium forks costs nothing while a dependency that has
to be kept current costs something every year. Order is the whole
correctness of it: an iPhone's user agent says Mac OS X, Edge says Chrome,
and Chrome says Safari. The raw string is sent alongside and sits in the
row's title, so anything the summary gets wrong is still visible — and
something that is not a browser at all shows its own name rather than
"Ukendt", because a CalDAV client is a real session.
Times are relative. The question is "was that me, just now?", and an
absolute timestamp makes the reader do the subtraction; past a week it
switches to a date, because "for 23 dage siden" is a number nobody can
place.
The smoke test signs in a second time in a second browser context, ends
that session from the first, and checks the other browser is really signed
out — and that this one is not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
190 lines
6.0 KiB
Go
190 lines
6.0 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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]
|
|
}
|