Merge branch 'totp'

Both branches added database migration 3: DNSSEC (merged first) keeps 3
and TOTP becomes migration 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-09 15:17:12 +00:00
commit a95dffb471
35 files changed
+1963 -43

No files matched your search

+17
View File
@@ -242,6 +242,23 @@ ALTER TABLE tokens ADD COLUMN prev_expires_at TEXT;
// 3: DNSSEC signing (the zone's dnssec-policy).
`
ALTER TABLE zones ADD COLUMN dnssec_policy TEXT NOT NULL DEFAULT '';
`,
// 4: two-factor login with TOTP. totp_pending holds a secret being set
// up until a code confirms it; totp_last_step is the newest time step
// used, so a code works only once. sessions.totp_at is when the
// session last proved a code (for actions that ask for a recent one).
`
ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN totp_pending TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN totp_enabled_at TEXT;
ALTER TABLE users ADD COLUMN totp_last_step INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sessions ADD COLUMN totp_at TEXT;
CREATE TABLE recovery_codes (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
used_at TEXT,
PRIMARY KEY (user_id, code_hash)
);
`,
}
+126
View File
@@ -0,0 +1,126 @@
package store
import (
"context"
"database/sql"
"time"
)
// SetTOTPPending stores a secret being set up (or clears it with ""). It
// becomes the user's secret once a code confirms it (EnableTOTP).
func (s *Store) SetTOTPPending(ctx context.Context, userID int64, secret string) error {
res, err := s.db.ExecContext(ctx, `UPDATE users SET totp_pending = ? WHERE id = ?`, secret, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// EnableTOTP turns two-factor login on: the secret, the step of the code
// that confirmed it (so that code can't be used again), and the hashes of
// new recovery codes, replacing any old ones.
func (s *Store) EnableTOTP(ctx context.Context, userID int64, secret string, step int64, codeHashes []string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := ts(time.Now())
res, err := tx.ExecContext(ctx, `UPDATE users SET totp_secret = ?, totp_pending = '', totp_enabled_at = ?, totp_last_step = ?, updated_at = ? WHERE id = ?`,
secret, now, step, now, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
if err := replaceRecoveryCodes(ctx, tx, userID, codeHashes); err != nil {
return err
}
return tx.Commit()
}
// DisableTOTP turns two-factor login off and deletes the recovery codes.
func (s *Store) DisableTOTP(ctx context.Context, userID int64) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, `UPDATE users SET totp_secret = '', totp_pending = '', totp_enabled_at = NULL, totp_last_step = 0, updated_at = ? WHERE id = ?`, ts(time.Now()), userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
if _, err := tx.ExecContext(ctx, `DELETE FROM recovery_codes WHERE user_id = ?`, userID); err != nil {
return err
}
return tx.Commit()
}
// UseTOTPStep records that the code of step was used. It returns false if
// that step (or a later one) was used already: each code works once, even
// with two requests at the same moment.
func (s *Store) UseTOTPStep(ctx context.Context, userID, step int64) (bool, error) {
res, err := s.db.ExecContext(ctx, `UPDATE users SET totp_last_step = ? WHERE id = ? AND totp_last_step < ? AND totp_secret != ''`, step, userID, step)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n == 1, nil
}
// ReplaceRecoveryCodes gives a user new recovery codes (their hashes),
// making the old ones useless.
func (s *Store) ReplaceRecoveryCodes(ctx context.Context, userID int64, codeHashes []string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err := replaceRecoveryCodes(ctx, tx, userID, codeHashes); err != nil {
return err
}
return tx.Commit()
}
func replaceRecoveryCodes(ctx context.Context, tx *sql.Tx, userID int64, hashes []string) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM recovery_codes WHERE user_id = ?`, userID); err != nil {
return err
}
for _, h := range hashes {
if _, err := tx.ExecContext(ctx, `INSERT INTO recovery_codes (user_id, code_hash) VALUES (?, ?)`, userID, h); err != nil {
return err
}
}
return nil
}
// UseRecoveryCode marks an unused recovery code (by its hash) used. It
// returns false if the user has no such unused code.
func (s *Store) UseRecoveryCode(ctx context.Context, userID int64, codeHash string) (bool, error) {
res, err := s.db.ExecContext(ctx, `UPDATE recovery_codes SET used_at = ? WHERE user_id = ? AND code_hash = ? AND used_at IS NULL`, ts(time.Now()), userID, codeHash)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n == 1, nil
}
// RecoveryCodesLeft counts a user's unused recovery codes.
func (s *Store) RecoveryCodesLeft(ctx context.Context, userID int64) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM recovery_codes WHERE user_id = ? AND used_at IS NULL`, userID).Scan(&n)
return n, err
}
// SetSessionTOTP records that a session proved a TOTP code at at.
func (s *Store) SetSessionTOTP(ctx context.Context, idHash string, at time.Time) error {
_, err := s.db.ExecContext(ctx, `UPDATE sessions SET totp_at = ? WHERE id_hash = ?`, ts(at), idHash)
return err
}
+10 -7
View File
@@ -18,14 +18,14 @@ func isUnique(err error) bool {
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
}
const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at`
const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at, totp_secret, totp_pending, totp_enabled_at, totp_last_step`
func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) {
u := &model.User{}
var grants, ca, ua string
var admin, disabled int
var ll sql.NullString
if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll); err != nil {
var ll, te sql.NullString
if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll, &u.TOTPSecret, &u.TOTPPending, &te, &u.TOTPLastStep); err != nil {
return nil, err
}
u.Admin, u.Disabled = admin != 0, disabled != 0
@@ -37,6 +37,7 @@ func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) {
u.Grants = []model.Grant{}
}
u.CreatedAt, u.UpdatedAt, u.LastLoginAt = parseTS(ca), parseTS(ua), parseTSPtr(ll)
u.TOTPEnabledAt = parseTSPtr(te)
return u, nil
}
@@ -263,8 +264,8 @@ func (s *Store) TouchToken(ctx context.Context, id int64, ip string) error {
// CreateSession stores a session.
func (s *Store) CreateSession(ctx context.Context, se *model.Session) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions (id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent) VALUES (?,?,?,?,?,?,?,?)`,
se.IDHash, se.UserID, se.CSRFToken, ts(se.CreatedAt), ts(se.ExpiresAt), ts(se.LastSeenAt), se.IP, se.UserAgent)
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions (id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent, totp_at) VALUES (?,?,?,?,?,?,?,?,?)`,
se.IDHash, se.UserID, se.CSRFToken, ts(se.CreatedAt), ts(se.ExpiresAt), ts(se.LastSeenAt), se.IP, se.UserAgent, tsPtr(se.TOTPAt))
return err
}
@@ -272,8 +273,9 @@ func (s *Store) CreateSession(ctx context.Context, se *model.Session) error {
func (s *Store) GetSession(ctx context.Context, idHash string) (*model.Session, error) {
se := &model.Session{}
var ca, ea, ls string
err := s.db.QueryRowContext(ctx, `SELECT id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent FROM sessions WHERE id_hash = ?`, idHash).
Scan(&se.IDHash, &se.UserID, &se.CSRFToken, &ca, &ea, &ls, &se.IP, &se.UserAgent)
var ta sql.NullString
err := s.db.QueryRowContext(ctx, `SELECT id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent, totp_at FROM sessions WHERE id_hash = ?`, idHash).
Scan(&se.IDHash, &se.UserID, &se.CSRFToken, &ca, &ea, &ls, &se.IP, &se.UserAgent, &ta)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -281,6 +283,7 @@ func (s *Store) GetSession(ctx context.Context, idHash string) (*model.Session,
return nil, err
}
se.CreatedAt, se.ExpiresAt, se.LastSeenAt = parseTS(ca), parseTS(ea), parseTS(ls)
se.TOTPAt = parseTSPtr(ta)
return se, nil
}