Files
verdande/internal/store/invites.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

315 lines
9.7 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/kristianwind/verdande/internal/auth"
)
var ErrAlreadySetUp = errors.New("store: this instance already has an account")
type Invite struct {
ID string
Email string
ProjectID string // empty for an invite to the instance rather than to a project
Role Role
CreatedBy string
CreatedAt time.Time
ExpiresAt time.Time
}
// CreateFirstAdmin creates the very first account, and fails if one already exists.
//
// The check and the insert are in one transaction because the endpoint behind this
// is necessarily unauthenticated: two requests arriving together must not both find
// an empty database and both create an administrator. With SQLite serialising
// writes, the second transaction sees the first one's row and loses.
func (db *DB) CreateFirstAdmin(ctx context.Context, u *User, inboxName string) error {
u.Email = NormalizeEmail(u.Email)
if u.ID == "" {
u.ID = NewID()
}
if u.AvatarColor == "" {
u.AvatarColor = avatarColorFor(u.Email)
}
u.IsAdmin = true
now := time.Now().UTC()
u.CreatedAt, u.UpdatedAt = now, now
return db.Tx(ctx, func(tx *sql.Tx) error {
var n int
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&n); err != nil {
return err
}
if n > 0 {
return ErrAlreadySetUp
}
_, err := tx.ExecContext(ctx,
`INSERT INTO users (id, email, name, password_hash, totp_enabled, avatar_color,
timezone, locale, is_admin, created_at, updated_at)
VALUES (?, ?, ?, ?, 0, ?, ?, ?, 1, ?, ?)`,
u.ID, u.Email, u.Name, u.PasswordHash, u.AvatarColor,
orDefault(u.Timezone, "Europe/Copenhagen"), orDefault(u.Locale, "da"),
now.Unix(), now.Unix())
if err != nil {
return fmt.Errorf("insert first admin: %w", err)
}
_, err = tx.ExecContext(ctx,
`INSERT INTO projects (id, name, color, owner_id, is_inbox, sort_order, created_at, updated_at)
VALUES (?, ?, 'graphite', ?, 1, 0, ?, ?)`,
NewID(), inboxName, u.ID, now.Unix(), now.Unix())
return err
})
}
// CreateInvite issues an invite and returns the token for the emailed link.
// projectID may be empty, which invites somebody to the instance rather than to a
// particular project.
func (db *DB) CreateInvite(ctx context.Context, email, projectID string, role Role, createdBy string, ttl time.Duration) (string, *Invite, error) {
if !role.Valid() {
return "", nil, fmt.Errorf("store: %q is not a role", role)
}
token, err := auth.NewToken()
if err != nil {
return "", nil, err
}
now := time.Now().UTC()
inv := &Invite{
ID: NewID(),
Email: NormalizeEmail(email),
ProjectID: projectID,
Role: role,
CreatedBy: createdBy,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
}
var project any
if projectID != "" {
project = projectID
}
_, err = db.ExecContext(ctx,
`INSERT INTO invites (id, email, project_id, role, token_hash, created_by, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
inv.ID, inv.Email, project, string(inv.Role), auth.HashToken(token),
inv.CreatedBy, inv.CreatedAt.Unix(), inv.ExpiresAt.Unix())
if err != nil {
return "", nil, err
}
return token, inv, nil
}
// InviteByToken resolves an invite link. An expired or already-accepted invite is
// reported as not found: to whoever is holding the link, those are the same thing.
func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error) {
if token == "" {
return nil, ErrInviteInvalid
}
var inv Invite
var projectID sql.NullString
var created, expires int64
var accepted sql.NullInt64
err := db.QueryRowContext(ctx,
`SELECT id, email, project_id, role, created_by, created_at, expires_at, accepted_at
FROM invites WHERE token_hash = ?`, auth.HashToken(token)).
Scan(&inv.ID, &inv.Email, &projectID, &inv.Role, &inv.CreatedBy, &created, &expires, &accepted)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInviteInvalid
}
if err != nil {
return nil, err
}
if accepted.Valid || time.Now().Unix() > expires {
return nil, ErrInviteInvalid
}
inv.ProjectID = projectID.String
inv.CreatedAt = time.Unix(created, 0).UTC()
inv.ExpiresAt = time.Unix(expires, 0).UTC()
return &inv, nil
}
// AcceptInvite marks the invite used and grants the membership it promised, in one
// transaction. Splitting the two would allow a signup to fail partway and leave a
// live invite behind, which is a link that still works after it has been used.
//
// The UPDATE is guarded on accepted_at IS NULL, so two requests racing with the
// same link produce one membership and one failure rather than two memberships.
func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
`UPDATE invites SET accepted_at = ? WHERE id = ? AND accepted_at IS NULL`,
time.Now().Unix(), inviteID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrInviteInvalid
}
var projectID sql.NullString
var role Role
if err := tx.QueryRowContext(ctx,
`SELECT project_id, role FROM invites WHERE id = ?`, inviteID).
Scan(&projectID, &role); err != nil {
return err
}
if !projectID.Valid || projectID.String == "" {
return nil // an invite to the instance, with no project attached
}
// ON CONFLICT rather than a plain insert: somebody may already be a member
// through another route, and being invited again should not fail.
_, err = tx.ExecContext(ctx,
`INSERT INTO project_members (project_id, user_id, role, added_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (project_id, user_id) DO UPDATE SET role = excluded.role`,
projectID.String, userID, string(role), time.Now().Unix())
return err
})
}
// --- password resets ----------------------------------------------------------
// CreatePasswordReset invalidates any outstanding reset for the user before issuing
// a new one, so asking twice does not leave two working links in two inboxes.
func (db *DB) CreatePasswordReset(ctx context.Context, userID string, ttl time.Duration) (string, error) {
token, err := auth.NewToken()
if err != nil {
return "", err
}
now := time.Now()
err = db.Tx(ctx, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx,
`UPDATE password_resets SET used_at = ? WHERE user_id = ? AND used_at IS NULL`,
now.Unix(), userID); err != nil {
return err
}
_, err := tx.ExecContext(ctx,
`INSERT INTO password_resets (id, user_id, token_hash, created_at, expires_at)
VALUES (?, ?, ?, ?, ?)`,
NewID(), userID, auth.HashToken(token), now.Unix(), now.Add(ttl).Unix())
return err
})
if err != nil {
return "", err
}
return token, nil
}
// UsePasswordReset consumes a reset token and returns whose it was. Guarded on
// used_at IS NULL so the link works exactly once.
func (db *DB) UsePasswordReset(ctx context.Context, token string) (string, error) {
if token == "" {
return "", ErrNotFound
}
hash := auth.HashToken(token)
var userID string
err := db.Tx(ctx, func(tx *sql.Tx) error {
var expires int64
var used sql.NullInt64
err := tx.QueryRowContext(ctx,
`SELECT user_id, expires_at, used_at FROM password_resets WHERE token_hash = ?`, hash).
Scan(&userID, &expires, &used)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if used.Valid || time.Now().Unix() > expires {
return ErrNotFound
}
_, err = tx.ExecContext(ctx,
`UPDATE password_resets SET used_at = ? WHERE token_hash = ? AND used_at IS NULL`,
time.Now().Unix(), hash)
return err
})
if err != nil {
return "", err
}
return userID, nil
}
// --- TOTP recovery codes -------------------------------------------------------
// ReplaceRecoveryCodes swaps the whole set. Codes are only ever issued as a batch,
// and a new batch must retire the old one — otherwise a printout somebody threw
// away still works.
func (db *DB) ReplaceRecoveryCodes(ctx context.Context, userID string, hashes []string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx,
`DELETE FROM totp_recovery_codes WHERE user_id = ?`, userID); err != nil {
return err
}
now := time.Now().Unix()
for _, h := range hashes {
if _, err := tx.ExecContext(ctx,
`INSERT INTO totp_recovery_codes (id, user_id, code_hash, created_at)
VALUES (?, ?, ?, ?)`, NewID(), userID, h, now); err != nil {
return err
}
}
return nil
})
}
// UseRecoveryCode spends a code if it matches an unused one. Guarded on
// used_at IS NULL, so a code works exactly once even if submitted twice at once.
func (db *DB) UseRecoveryCode(ctx context.Context, userID, code string) (bool, error) {
if code == "" {
return false, nil
}
rows, err := db.QueryContext(ctx,
`SELECT id, code_hash FROM totp_recovery_codes WHERE user_id = ? AND used_at IS NULL`, userID)
if err != nil {
return false, err
}
var ids, hashes []string
for rows.Next() {
var id, hash string
if err := rows.Scan(&id, &hash); err != nil {
rows.Close()
return false, err
}
ids = append(ids, id)
hashes = append(hashes, hash)
}
rows.Close()
if err := rows.Err(); err != nil {
return false, err
}
i := auth.MatchRecoveryCode(hashes, code)
if i < 0 {
return false, nil
}
res, err := db.ExecContext(ctx,
`UPDATE totp_recovery_codes SET used_at = ? WHERE id = ? AND used_at IS NULL`,
time.Now().Unix(), ids[i])
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n == 1, nil
}
// CountRecoveryCodes reports how many are left, so the UI can warn before the last
// one is gone.
func (db *DB) CountRecoveryCodes(ctx context.Context, userID string) (int, error) {
var n int
err := db.QueryRowContext(ctx,
`SELECT count(*) FROM totp_recovery_codes WHERE user_id = ? AND used_at IS NULL`,
userID).Scan(&n)
return n, err
}