tenseleyflow/shithub / 09851c8

Browse files

Add 2FA + audit-log sqlc queries (atomic counter bump, recovery consume, audit insert)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
09851c8e93e09a8977190bfc454f004cdca5fc0c
Parents
cf0e239
Tree
f457ad9

8 changed files

StatusFile+-
A internal/users/queries/auth_audit_log.sql 12 0
A internal/users/queries/user_recovery_codes.sql 18 0
A internal/users/queries/user_totp.sql 42 0
A internal/users/sqlc/auth_audit_log.sql.go 80 0
M internal/users/sqlc/models.go 30 0
M internal/users/sqlc/querier.go 26 0
A internal/users/sqlc/user_recovery_codes.sql.go 68 0
A internal/users/sqlc/user_totp.sql.go 127 0
internal/users/queries/auth_audit_log.sqladded
@@ -0,0 +1,12 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+-- name: InsertAuditLog :exec
4
+INSERT INTO auth_audit_log (actor_id, action, target_type, target_id, meta)
5
+VALUES ($1, $2, $3, $4, $5);
6
+
7
+-- name: ListAuditLogForTarget :many
8
+SELECT id, actor_id, action, target_type, target_id, meta, created_at
9
+FROM auth_audit_log
10
+WHERE target_type = $1 AND target_id = $2
11
+ORDER BY created_at DESC
12
+LIMIT $3;
internal/users/queries/user_recovery_codes.sqladded
@@ -0,0 +1,18 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+-- name: InsertRecoveryCode :exec
4
+INSERT INTO user_recovery_codes (user_id, code_hash) VALUES ($1, $2);
5
+
6
+-- name: ConsumeRecoveryCode :execrows
7
+-- Atomically marks a code as used iff it exists for the user, matches the
8
+-- supplied hash, and isn't already used. Rows-affected==1 means accepted;
9
+-- 0 means rejected.
10
+UPDATE user_recovery_codes
11
+SET used_at = now()
12
+WHERE user_id = $1 AND code_hash = $2 AND used_at IS NULL;
13
+
14
+-- name: DeleteUserRecoveryCodes :exec
15
+DELETE FROM user_recovery_codes WHERE user_id = $1;
16
+
17
+-- name: CountUnusedRecoveryCodes :one
18
+SELECT count(*) FROM user_recovery_codes WHERE user_id = $1 AND used_at IS NULL;
internal/users/queries/user_totp.sqladded
@@ -0,0 +1,42 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+-- name: UpsertUserTOTP :one
4
+-- Inserts a new pending TOTP row, or replaces an existing pending row for
5
+-- the same user. Confirmed rows are NOT replaced — disable+regenerate
6
+-- must go through the dedicated query.
7
+INSERT INTO user_totp (user_id, secret_encrypted, secret_nonce)
8
+VALUES ($1, $2, $3)
9
+ON CONFLICT (user_id) DO UPDATE
10
+SET secret_encrypted = EXCLUDED.secret_encrypted,
11
+    secret_nonce     = EXCLUDED.secret_nonce,
12
+    confirmed_at     = NULL,
13
+    last_used_counter = 0
14
+WHERE user_totp.confirmed_at IS NULL
15
+RETURNING id, user_id, secret_encrypted, secret_nonce, confirmed_at,
16
+          last_used_counter, created_at, updated_at;
17
+
18
+-- name: GetUserTOTP :one
19
+SELECT id, user_id, secret_encrypted, secret_nonce, confirmed_at,
20
+       last_used_counter, created_at, updated_at
21
+FROM user_totp
22
+WHERE user_id = $1;
23
+
24
+-- name: ConfirmUserTOTP :execrows
25
+-- Sets confirmed_at on a pending row. Returns the number of rows updated;
26
+-- callers MUST check this to handle the parallel-enrollment race
27
+-- (only one of two concurrent confirms wins).
28
+UPDATE user_totp
29
+SET confirmed_at      = now(),
30
+    last_used_counter = $2
31
+WHERE user_id = $1 AND confirmed_at IS NULL;
32
+
33
+-- name: BumpTOTPCounter :execrows
34
+-- Atomically advances last_used_counter only when the proposed counter is
35
+-- strictly greater. Returns rows affected — 0 means a replay attempt and
36
+-- the caller should reject the code.
37
+UPDATE user_totp
38
+SET last_used_counter = $2
39
+WHERE user_id = $1 AND $2::bigint > last_used_counter;
40
+
41
+-- name: DeleteUserTOTP :exec
42
+DELETE FROM user_totp WHERE user_id = $1;
internal/users/sqlc/auth_audit_log.sql.goadded
@@ -0,0 +1,80 @@
1
+// Code generated by sqlc. DO NOT EDIT.
2
+// versions:
3
+//   sqlc v1.31.1
4
+// source: auth_audit_log.sql
5
+
6
+package usersdb
7
+
8
+import (
9
+	"context"
10
+
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+)
13
+
14
+const insertAuditLog = `-- name: InsertAuditLog :exec
15
+
16
+INSERT INTO auth_audit_log (actor_id, action, target_type, target_id, meta)
17
+VALUES ($1, $2, $3, $4, $5)
18
+`
19
+
20
+type InsertAuditLogParams struct {
21
+	ActorID    pgtype.Int8
22
+	Action     string
23
+	TargetType string
24
+	TargetID   pgtype.Int8
25
+	Meta       []byte
26
+}
27
+
28
+// SPDX-License-Identifier: AGPL-3.0-or-later
29
+func (q *Queries) InsertAuditLog(ctx context.Context, db DBTX, arg InsertAuditLogParams) error {
30
+	_, err := db.Exec(ctx, insertAuditLog,
31
+		arg.ActorID,
32
+		arg.Action,
33
+		arg.TargetType,
34
+		arg.TargetID,
35
+		arg.Meta,
36
+	)
37
+	return err
38
+}
39
+
40
+const listAuditLogForTarget = `-- name: ListAuditLogForTarget :many
41
+SELECT id, actor_id, action, target_type, target_id, meta, created_at
42
+FROM auth_audit_log
43
+WHERE target_type = $1 AND target_id = $2
44
+ORDER BY created_at DESC
45
+LIMIT $3
46
+`
47
+
48
+type ListAuditLogForTargetParams struct {
49
+	TargetType string
50
+	TargetID   pgtype.Int8
51
+	Limit      int32
52
+}
53
+
54
+func (q *Queries) ListAuditLogForTarget(ctx context.Context, db DBTX, arg ListAuditLogForTargetParams) ([]AuthAuditLog, error) {
55
+	rows, err := db.Query(ctx, listAuditLogForTarget, arg.TargetType, arg.TargetID, arg.Limit)
56
+	if err != nil {
57
+		return nil, err
58
+	}
59
+	defer rows.Close()
60
+	items := []AuthAuditLog{}
61
+	for rows.Next() {
62
+		var i AuthAuditLog
63
+		if err := rows.Scan(
64
+			&i.ID,
65
+			&i.ActorID,
66
+			&i.Action,
67
+			&i.TargetType,
68
+			&i.TargetID,
69
+			&i.Meta,
70
+			&i.CreatedAt,
71
+		); err != nil {
72
+			return nil, err
73
+		}
74
+		items = append(items, i)
75
+	}
76
+	if err := rows.Err(); err != nil {
77
+		return nil, err
78
+	}
79
+	return items, nil
80
+}
internal/users/sqlc/models.gomodified
@@ -8,6 +8,16 @@ import (
88
 	"github.com/jackc/pgx/v5/pgtype"
99
 )
1010
 
11
+type AuthAuditLog struct {
12
+	ID         int64
13
+	ActorID    pgtype.Int8
14
+	Action     string
15
+	TargetType string
16
+	TargetID   pgtype.Int8
17
+	Meta       []byte
18
+	CreatedAt  pgtype.Timestamptz
19
+}
20
+
1121
 type AuthThrottle struct {
1222
 	ID              int64
1323
 	Scope           string
@@ -69,6 +79,26 @@ type UserEmail struct {
6979
 	CreatedAt             pgtype.Timestamptz
7080
 }
7181
 
82
+type UserRecoveryCode struct {
83
+	ID          int64
84
+	UserID      int64
85
+	CodeHash    []byte
86
+	UsedAt      pgtype.Timestamptz
87
+	GeneratedAt pgtype.Timestamptz
88
+	CreatedAt   pgtype.Timestamptz
89
+}
90
+
91
+type UserTotp struct {
92
+	ID              int64
93
+	UserID          int64
94
+	SecretEncrypted []byte
95
+	SecretNonce     []byte
96
+	ConfirmedAt     pgtype.Timestamptz
97
+	LastUsedCounter int64
98
+	CreatedAt       pgtype.Timestamptz
99
+	UpdatedAt       pgtype.Timestamptz
100
+}
101
+
72102
 type UsernameRedirect struct {
73103
 	OldUsername string
74104
 	UserID      int64
internal/users/sqlc/querier.gomodified
@@ -16,8 +16,21 @@ type Querier interface {
1616
 	// window is older than the supplied window-start cutoff, resets to 1 and
1717
 	// starts a new window. Returns the post-bump (hits, window_started_at).
1818
 	BumpAuthThrottle(ctx context.Context, db DBTX, arg BumpAuthThrottleParams) (BumpAuthThrottleRow, error)
19
+	// Atomically advances last_used_counter only when the proposed counter is
20
+	// strictly greater. Returns rows affected — 0 means a replay attempt and
21
+	// the caller should reject the code.
22
+	BumpTOTPCounter(ctx context.Context, db DBTX, arg BumpTOTPCounterParams) (int64, error)
23
+	// Sets confirmed_at on a pending row. Returns the number of rows updated;
24
+	// callers MUST check this to handle the parallel-enrollment race
25
+	// (only one of two concurrent confirms wins).
26
+	ConfirmUserTOTP(ctx context.Context, db DBTX, arg ConfirmUserTOTPParams) (int64, error)
1927
 	ConsumeEmailVerification(ctx context.Context, db DBTX, id int64) error
2028
 	ConsumePasswordReset(ctx context.Context, db DBTX, id int64) error
29
+	// Atomically marks a code as used iff it exists for the user, matches the
30
+	// supplied hash, and isn't already used. Rows-affected==1 means accepted;
31
+	// 0 means rejected.
32
+	ConsumeRecoveryCode(ctx context.Context, db DBTX, arg ConsumeRecoveryCodeParams) (int64, error)
33
+	CountUnusedRecoveryCodes(ctx context.Context, db DBTX, userID int64) (int64, error)
2134
 	CountUsers(ctx context.Context, db DBTX) (int64, error)
2235
 	// SPDX-License-Identifier: AGPL-3.0-or-later
2336
 	CreateEmailVerification(ctx context.Context, db DBTX, arg CreateEmailVerificationParams) (EmailVerification, error)
@@ -29,6 +42,8 @@ type Querier interface {
2942
 	CreateUserEmail(ctx context.Context, db DBTX, arg CreateUserEmailParams) (UserEmail, error)
3043
 	DeleteExpiredEmailVerifications(ctx context.Context, db DBTX) error
3144
 	DeleteExpiredPasswordResets(ctx context.Context, db DBTX) error
45
+	DeleteUserRecoveryCodes(ctx context.Context, db DBTX, userID int64) error
46
+	DeleteUserTOTP(ctx context.Context, db DBTX, userID int64) error
3247
 	GetEmailVerificationByTokenHash(ctx context.Context, db DBTX, tokenHash []byte) (EmailVerification, error)
3348
 	GetPasswordResetByTokenHash(ctx context.Context, db DBTX, tokenHash []byte) (PasswordReset, error)
3449
 	GetUserByID(ctx context.Context, db DBTX, id int64) (User, error)
@@ -36,9 +51,15 @@ type Querier interface {
3651
 	GetUserEmailByAddress(ctx context.Context, db DBTX, email string) (UserEmail, error)
3752
 	GetUserEmailByID(ctx context.Context, db DBTX, id int64) (UserEmail, error)
3853
 	GetUserEmailByVerificationHash(ctx context.Context, db DBTX, verificationTokenHash []byte) (UserEmail, error)
54
+	GetUserTOTP(ctx context.Context, db DBTX, userID int64) (UserTotp, error)
55
+	// SPDX-License-Identifier: AGPL-3.0-or-later
56
+	InsertAuditLog(ctx context.Context, db DBTX, arg InsertAuditLogParams) error
57
+	// SPDX-License-Identifier: AGPL-3.0-or-later
58
+	InsertRecoveryCode(ctx context.Context, db DBTX, arg InsertRecoveryCodeParams) error
3959
 	// Sets the FK only. Does NOT flip users.email_verified — that happens via
4060
 	// MarkUserEmailPrimaryVerified after the user clicks the verification link.
4161
 	LinkUserPrimaryEmail(ctx context.Context, db DBTX, arg LinkUserPrimaryEmailParams) error
62
+	ListAuditLogForTarget(ctx context.Context, db DBTX, arg ListAuditLogForTargetParams) ([]AuthAuditLog, error)
4263
 	ListUserEmailsForUser(ctx context.Context, db DBTX, userID int64) ([]UserEmail, error)
4364
 	// Called after MarkUserEmailVerified for the primary email, to flip the
4465
 	// denormalized users.email_verified flag.
@@ -51,6 +72,11 @@ type Querier interface {
5172
 	SuspendUser(ctx context.Context, db DBTX, arg SuspendUserParams) error
5273
 	TouchUserLastLogin(ctx context.Context, db DBTX, id int64) error
5374
 	UpdateUserPassword(ctx context.Context, db DBTX, arg UpdateUserPasswordParams) error
75
+	// SPDX-License-Identifier: AGPL-3.0-or-later
76
+	// Inserts a new pending TOTP row, or replaces an existing pending row for
77
+	// the same user. Confirmed rows are NOT replaced — disable+regenerate
78
+	// must go through the dedicated query.
79
+	UpsertUserTOTP(ctx context.Context, db DBTX, arg UpsertUserTOTPParams) (UserTotp, error)
5480
 }
5581
 
5682
 var _ Querier = (*Queries)(nil)
internal/users/sqlc/user_recovery_codes.sql.goadded
@@ -0,0 +1,68 @@
1
+// Code generated by sqlc. DO NOT EDIT.
2
+// versions:
3
+//   sqlc v1.31.1
4
+// source: user_recovery_codes.sql
5
+
6
+package usersdb
7
+
8
+import (
9
+	"context"
10
+)
11
+
12
+const consumeRecoveryCode = `-- name: ConsumeRecoveryCode :execrows
13
+UPDATE user_recovery_codes
14
+SET used_at = now()
15
+WHERE user_id = $1 AND code_hash = $2 AND used_at IS NULL
16
+`
17
+
18
+type ConsumeRecoveryCodeParams struct {
19
+	UserID   int64
20
+	CodeHash []byte
21
+}
22
+
23
+// Atomically marks a code as used iff it exists for the user, matches the
24
+// supplied hash, and isn't already used. Rows-affected==1 means accepted;
25
+// 0 means rejected.
26
+func (q *Queries) ConsumeRecoveryCode(ctx context.Context, db DBTX, arg ConsumeRecoveryCodeParams) (int64, error) {
27
+	result, err := db.Exec(ctx, consumeRecoveryCode, arg.UserID, arg.CodeHash)
28
+	if err != nil {
29
+		return 0, err
30
+	}
31
+	return result.RowsAffected(), nil
32
+}
33
+
34
+const countUnusedRecoveryCodes = `-- name: CountUnusedRecoveryCodes :one
35
+SELECT count(*) FROM user_recovery_codes WHERE user_id = $1 AND used_at IS NULL
36
+`
37
+
38
+func (q *Queries) CountUnusedRecoveryCodes(ctx context.Context, db DBTX, userID int64) (int64, error) {
39
+	row := db.QueryRow(ctx, countUnusedRecoveryCodes, userID)
40
+	var count int64
41
+	err := row.Scan(&count)
42
+	return count, err
43
+}
44
+
45
+const deleteUserRecoveryCodes = `-- name: DeleteUserRecoveryCodes :exec
46
+DELETE FROM user_recovery_codes WHERE user_id = $1
47
+`
48
+
49
+func (q *Queries) DeleteUserRecoveryCodes(ctx context.Context, db DBTX, userID int64) error {
50
+	_, err := db.Exec(ctx, deleteUserRecoveryCodes, userID)
51
+	return err
52
+}
53
+
54
+const insertRecoveryCode = `-- name: InsertRecoveryCode :exec
55
+
56
+INSERT INTO user_recovery_codes (user_id, code_hash) VALUES ($1, $2)
57
+`
58
+
59
+type InsertRecoveryCodeParams struct {
60
+	UserID   int64
61
+	CodeHash []byte
62
+}
63
+
64
+// SPDX-License-Identifier: AGPL-3.0-or-later
65
+func (q *Queries) InsertRecoveryCode(ctx context.Context, db DBTX, arg InsertRecoveryCodeParams) error {
66
+	_, err := db.Exec(ctx, insertRecoveryCode, arg.UserID, arg.CodeHash)
67
+	return err
68
+}
internal/users/sqlc/user_totp.sql.goadded
@@ -0,0 +1,127 @@
1
+// Code generated by sqlc. DO NOT EDIT.
2
+// versions:
3
+//   sqlc v1.31.1
4
+// source: user_totp.sql
5
+
6
+package usersdb
7
+
8
+import (
9
+	"context"
10
+)
11
+
12
+const bumpTOTPCounter = `-- name: BumpTOTPCounter :execrows
13
+UPDATE user_totp
14
+SET last_used_counter = $2
15
+WHERE user_id = $1 AND $2::bigint > last_used_counter
16
+`
17
+
18
+type BumpTOTPCounterParams struct {
19
+	UserID          int64
20
+	LastUsedCounter int64
21
+}
22
+
23
+// Atomically advances last_used_counter only when the proposed counter is
24
+// strictly greater. Returns rows affected — 0 means a replay attempt and
25
+// the caller should reject the code.
26
+func (q *Queries) BumpTOTPCounter(ctx context.Context, db DBTX, arg BumpTOTPCounterParams) (int64, error) {
27
+	result, err := db.Exec(ctx, bumpTOTPCounter, arg.UserID, arg.LastUsedCounter)
28
+	if err != nil {
29
+		return 0, err
30
+	}
31
+	return result.RowsAffected(), nil
32
+}
33
+
34
+const confirmUserTOTP = `-- name: ConfirmUserTOTP :execrows
35
+UPDATE user_totp
36
+SET confirmed_at      = now(),
37
+    last_used_counter = $2
38
+WHERE user_id = $1 AND confirmed_at IS NULL
39
+`
40
+
41
+type ConfirmUserTOTPParams struct {
42
+	UserID          int64
43
+	LastUsedCounter int64
44
+}
45
+
46
+// Sets confirmed_at on a pending row. Returns the number of rows updated;
47
+// callers MUST check this to handle the parallel-enrollment race
48
+// (only one of two concurrent confirms wins).
49
+func (q *Queries) ConfirmUserTOTP(ctx context.Context, db DBTX, arg ConfirmUserTOTPParams) (int64, error) {
50
+	result, err := db.Exec(ctx, confirmUserTOTP, arg.UserID, arg.LastUsedCounter)
51
+	if err != nil {
52
+		return 0, err
53
+	}
54
+	return result.RowsAffected(), nil
55
+}
56
+
57
+const deleteUserTOTP = `-- name: DeleteUserTOTP :exec
58
+DELETE FROM user_totp WHERE user_id = $1
59
+`
60
+
61
+func (q *Queries) DeleteUserTOTP(ctx context.Context, db DBTX, userID int64) error {
62
+	_, err := db.Exec(ctx, deleteUserTOTP, userID)
63
+	return err
64
+}
65
+
66
+const getUserTOTP = `-- name: GetUserTOTP :one
67
+SELECT id, user_id, secret_encrypted, secret_nonce, confirmed_at,
68
+       last_used_counter, created_at, updated_at
69
+FROM user_totp
70
+WHERE user_id = $1
71
+`
72
+
73
+func (q *Queries) GetUserTOTP(ctx context.Context, db DBTX, userID int64) (UserTotp, error) {
74
+	row := db.QueryRow(ctx, getUserTOTP, userID)
75
+	var i UserTotp
76
+	err := row.Scan(
77
+		&i.ID,
78
+		&i.UserID,
79
+		&i.SecretEncrypted,
80
+		&i.SecretNonce,
81
+		&i.ConfirmedAt,
82
+		&i.LastUsedCounter,
83
+		&i.CreatedAt,
84
+		&i.UpdatedAt,
85
+	)
86
+	return i, err
87
+}
88
+
89
+const upsertUserTOTP = `-- name: UpsertUserTOTP :one
90
+
91
+INSERT INTO user_totp (user_id, secret_encrypted, secret_nonce)
92
+VALUES ($1, $2, $3)
93
+ON CONFLICT (user_id) DO UPDATE
94
+SET secret_encrypted = EXCLUDED.secret_encrypted,
95
+    secret_nonce     = EXCLUDED.secret_nonce,
96
+    confirmed_at     = NULL,
97
+    last_used_counter = 0
98
+WHERE user_totp.confirmed_at IS NULL
99
+RETURNING id, user_id, secret_encrypted, secret_nonce, confirmed_at,
100
+          last_used_counter, created_at, updated_at
101
+`
102
+
103
+type UpsertUserTOTPParams struct {
104
+	UserID          int64
105
+	SecretEncrypted []byte
106
+	SecretNonce     []byte
107
+}
108
+
109
+// SPDX-License-Identifier: AGPL-3.0-or-later
110
+// Inserts a new pending TOTP row, or replaces an existing pending row for
111
+// the same user. Confirmed rows are NOT replaced — disable+regenerate
112
+// must go through the dedicated query.
113
+func (q *Queries) UpsertUserTOTP(ctx context.Context, db DBTX, arg UpsertUserTOTPParams) (UserTotp, error) {
114
+	row := db.QueryRow(ctx, upsertUserTOTP, arg.UserID, arg.SecretEncrypted, arg.SecretNonce)
115
+	var i UserTotp
116
+	err := row.Scan(
117
+		&i.ID,
118
+		&i.UserID,
119
+		&i.SecretEncrypted,
120
+		&i.SecretNonce,
121
+		&i.ConfirmedAt,
122
+		&i.LastUsedCounter,
123
+		&i.CreatedAt,
124
+		&i.UpdatedAt,
125
+	)
126
+	return i, err
127
+}