Files
verdande/internal/store/apitokens.go
T
Kristian WindandClaude Opus 5 1bb644f4d1 feat(api): authentication — login, two-step TOTP, invites and resets
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>
2026-08-16 23:31:29 +02:00

124 lines
3.4 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"time"
"github.com/kristianwind/verdande/internal/auth"
)
type APIToken struct {
ID string
UserID string
Name string
Prefix string
CreatedAt time.Time
LastUsedAt *time.Time
ExpiresAt *time.Time
}
// CreateAPIToken returns the token in plaintext exactly once. Only its hash is
// stored, so it can never be shown again — the UI has to say so at the point of
// creation rather than offering a "reveal" that cannot work.
func (db *DB) CreateAPIToken(ctx context.Context, userID, name string, expiresAt *time.Time) (string, *APIToken, error) {
token, prefix, err := auth.NewAPIToken()
if err != nil {
return "", nil, err
}
t := &APIToken{
ID: NewID(),
UserID: userID,
Name: name,
Prefix: prefix,
CreatedAt: time.Now().UTC(),
ExpiresAt: expiresAt,
}
var expires any
if expiresAt != nil {
expires = expiresAt.Unix()
}
_, err = db.ExecContext(ctx,
`INSERT INTO api_tokens (id, user_id, name, token_hash, prefix, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.UserID, t.Name, auth.HashToken(token), t.Prefix, t.CreatedAt.Unix(), expires)
if err != nil {
return "", nil, err
}
return token, t, nil
}
// UserByAPIToken resolves a bearer token to its owner.
func (db *DB) UserByAPIToken(ctx context.Context, token string) (*User, error) {
var id, userID string
var expires sql.NullInt64
err := db.QueryRowContext(ctx,
`SELECT id, user_id, expires_at FROM api_tokens WHERE token_hash = ?`,
auth.HashToken(token)).Scan(&id, &userID, &expires)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if expires.Valid && time.Now().Unix() > expires.Int64 {
return nil, ErrNotFound
}
// last_used_at is what lets somebody delete the token they no longer recognise
// without wondering what will break. Written at most hourly: a token driving a
// sync loop would otherwise mean a write per request.
_, _ = db.ExecContext(ctx,
`UPDATE api_tokens SET last_used_at = ?
WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`,
time.Now().Unix(), id, time.Now().Add(-time.Hour).Unix())
return db.UserByID(ctx, userID)
}
func (db *DB) ListAPITokens(ctx context.Context, userID string) ([]APIToken, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, user_id, name, prefix, created_at, last_used_at, expires_at
FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []APIToken
for rows.Next() {
var t APIToken
var created int64
var lastUsed, expires sql.NullInt64
if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.Prefix, &created, &lastUsed, &expires); err != nil {
return nil, err
}
t.CreatedAt = time.Unix(created, 0).UTC()
if lastUsed.Valid {
v := time.Unix(lastUsed.Int64, 0).UTC()
t.LastUsedAt = &v
}
if expires.Valid {
v := time.Unix(expires.Int64, 0).UTC()
t.ExpiresAt = &v
}
out = append(out, t)
}
return out, rows.Err()
}
// DeleteAPIToken is scoped by user so an id from somewhere else deletes nothing.
func (db *DB) DeleteAPIToken(ctx context.Context, userID, tokenID string) error {
res, err := db.ExecContext(ctx,
`DELETE FROM api_tokens WHERE id = ? AND user_id = ?`, tokenID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}