@johnnyboy11234/esg-idea-contest 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/api/openapi.yaml +44 -0
- package/backend/cmd/server/main.go +11 -1
- package/backend/db/migration/V002__audit_log_full.sql +32 -0
- package/backend/db/migration/V003__app_user.sql +36 -0
- package/backend/db/migration/V004__session.sql +19 -0
- package/backend/go.mod +1 -1
- package/backend/internal/account/account_test.go +191 -0
- package/backend/internal/account/handler.go +58 -0
- package/backend/internal/account/integration_test.go +268 -0
- package/backend/internal/account/internal/store/store.go +35 -0
- package/backend/internal/account/service.go +153 -0
- package/backend/internal/account/validate.go +100 -0
- package/backend/internal/api/api.gen.go +82 -0
- package/backend/internal/audit/event.go +119 -0
- package/backend/internal/audit/integration_test.go +270 -0
- package/backend/internal/audit/sanitize.go +102 -0
- package/backend/internal/audit/sanitize_test.go +134 -0
- package/backend/internal/audit/service.go +157 -0
- package/backend/internal/auth/internal/store/session.go +111 -0
- package/backend/internal/auth/session.go +113 -0
- package/backend/internal/auth/session_integration_test.go +245 -0
- package/backend/internal/auth/session_test.go +36 -0
- package/backend/internal/server/api.go +7 -0
- package/backend/internal/server/errors.go +80 -0
- package/backend/internal/server/errors_test.go +116 -0
- package/backend/internal/shared/db/intx_test.go +25 -0
- package/backend/internal/shared/db/mysqlerr.go +26 -0
- package/backend/internal/shared/security/security.go +102 -0
- package/backend/internal/shared/security/security_test.go +62 -0
- package/backend/internal/shared/web/problem.go +29 -7
- package/backend/internal/shared/web/request.go +27 -0
- package/backend/test/migration/app_user_test.go +112 -0
- package/docs/Adr/0001-Backend-in-go.md +3 -3
- package/docs/Open-items.md +8 -3
- package/docs/PR-plan.md +6 -4
- package/docs/PRD.md +2 -1
- package/frontend/src/shared/text/index.ts +93 -0
- package/frontend/src/shared/text/text.test.ts +22 -0
- package/package.json +1 -1
- package/frontend/src/shared/text/.gitkeep +0 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
package audit
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"log/slog"
|
|
9
|
+
"sync/atomic"
|
|
10
|
+
|
|
11
|
+
"github.com/google/uuid"
|
|
12
|
+
|
|
13
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
|
|
14
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
15
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// Service writes audit rows.
|
|
19
|
+
type Service struct {
|
|
20
|
+
db *sql.DB
|
|
21
|
+
log *slog.Logger
|
|
22
|
+
|
|
23
|
+
rejectedFailures atomic.Uint64
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// New returns a Service. log may be nil.
|
|
27
|
+
func New(database *sql.DB, log *slog.Logger) *Service {
|
|
28
|
+
if log == nil {
|
|
29
|
+
log = slog.New(slog.DiscardHandler)
|
|
30
|
+
}
|
|
31
|
+
return &Service{db: database, log: log}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// RejectedFailures counts track-B rows that could not be written (the
|
|
35
|
+
// audit-write-failures metric of PRD §10 reads this).
|
|
36
|
+
func (s *Service) RejectedFailures() uint64 { return s.rejectedFailures.Load() }
|
|
37
|
+
|
|
38
|
+
// Record is track A: it inserts the row in the caller's transaction, so the row
|
|
39
|
+
// commits or rolls back with the business write. An error must make the caller
|
|
40
|
+
// roll back (fail-closed). Any outcome is allowed.
|
|
41
|
+
func (s *Service) Record(ctx context.Context, tx *sql.Tx, e Event) error {
|
|
42
|
+
if tx == nil {
|
|
43
|
+
return errors.New("audit: Record needs the caller's transaction")
|
|
44
|
+
}
|
|
45
|
+
return insert(ctx, tx, e)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// RecordRejected is track B: it writes a rejected attempt (DENIED, FAILED or
|
|
49
|
+
// THROTTLED) in its own transaction. It is best effort: a failure is logged and
|
|
50
|
+
// counted, never returned, and a client disconnect does not drop the row (the
|
|
51
|
+
// transaction runs on a context detached from ctx, as db.InTx does). Only the
|
|
52
|
+
// central error adapter calls it (Backend.md §7).
|
|
53
|
+
func (s *Service) RecordRejected(ctx context.Context, e Event) {
|
|
54
|
+
if e.Outcome == Success {
|
|
55
|
+
s.rejectedFailed(ctx, e, errors.New("audit: RecordRejected called with outcome SUCCESS"))
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
_, err := db.InTx(ctx, s.db, func(ctx context.Context, tx *sql.Tx) error {
|
|
59
|
+
return insert(ctx, tx, e)
|
|
60
|
+
})
|
|
61
|
+
if err != nil {
|
|
62
|
+
s.rejectedFailed(ctx, e, err)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func (s *Service) rejectedFailed(ctx context.Context, e Event, err error) {
|
|
67
|
+
s.rejectedFailures.Add(1)
|
|
68
|
+
s.log.ErrorContext(ctx, "audit: rejected attempt not recorded",
|
|
69
|
+
"action", string(e.Action), "outcome", string(e.Outcome), "error", err)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type execer interface {
|
|
73
|
+
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const insertSQL = `INSERT INTO audit_log
|
|
77
|
+
(request_id, actor_emp_no, actor_role, action, outcome, target_type, target_id,
|
|
78
|
+
method, route, status, error_code, ip, user_agent, submitted_emp_no, before_json, after_json)
|
|
79
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
80
|
+
|
|
81
|
+
// insert validates and sanitizes e, then writes it. occurred_at is the column
|
|
82
|
+
// default, i.e. database time.
|
|
83
|
+
func insert(ctx context.Context, x execer, e Event) error {
|
|
84
|
+
if !e.Action.Valid() {
|
|
85
|
+
return fmt.Errorf("audit: unknown action %q", e.Action)
|
|
86
|
+
}
|
|
87
|
+
if !e.Outcome.Valid() {
|
|
88
|
+
return fmt.Errorf("audit: unknown outcome %q", e.Outcome)
|
|
89
|
+
}
|
|
90
|
+
if e.TargetType != "" && !e.TargetType.Valid() {
|
|
91
|
+
return fmt.Errorf("audit: unknown target type %q", e.TargetType)
|
|
92
|
+
}
|
|
93
|
+
if e.TargetID != uuid.Nil && e.TargetType == "" {
|
|
94
|
+
return errors.New("audit: target ID without target type")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
role, empNo := e.ActorRole, e.ActorEmpNo
|
|
98
|
+
if role == "" {
|
|
99
|
+
role, empNo = security.ActorRoleFrom(ctx), security.ActorEmpNo(ctx)
|
|
100
|
+
}
|
|
101
|
+
switch role {
|
|
102
|
+
case security.ActorUser, security.ActorAdmin, security.ActorAnonymous, security.ActorSystem:
|
|
103
|
+
default:
|
|
104
|
+
return fmt.Errorf("audit: unknown actor role %q", role)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
before, err := cleanJSON(e.Before)
|
|
108
|
+
if err != nil {
|
|
109
|
+
return fmt.Errorf("before: %w", err)
|
|
110
|
+
}
|
|
111
|
+
after, err := cleanJSON(e.After)
|
|
112
|
+
if err != nil {
|
|
113
|
+
return fmt.Errorf("after: %w", err)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
var reqID any
|
|
117
|
+
if id, err := uuid.Parse(web.RequestIDFrom(ctx)); err == nil {
|
|
118
|
+
reqID = id.String()
|
|
119
|
+
}
|
|
120
|
+
var targetID any
|
|
121
|
+
if e.TargetID != uuid.Nil {
|
|
122
|
+
targetID = e.TargetID.String()
|
|
123
|
+
}
|
|
124
|
+
var status any
|
|
125
|
+
if e.Status > 0 {
|
|
126
|
+
status = e.Status
|
|
127
|
+
}
|
|
128
|
+
var ip any
|
|
129
|
+
if e.IP.IsValid() {
|
|
130
|
+
ip = e.IP.WithZone("").String() // INET6 has no zone
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
_, err = x.ExecContext(ctx, insertSQL,
|
|
134
|
+
reqID, nullStr(clean(empNo, maxEmpNo)), string(role), string(e.Action), string(e.Outcome),
|
|
135
|
+
nullStr(string(e.TargetType)), targetID,
|
|
136
|
+
nullStr(clean(e.Method, maxMethod)), nullStr(clean(e.Route, maxRoute)), status,
|
|
137
|
+
nullStr(clean(e.ErrorCode, maxErrorCode)), ip, nullStr(clean(e.UserAgent, maxUserAgent)),
|
|
138
|
+
nullStr(clean(e.SubmittedEmpNo, maxEmpNo)), nullBytes(before), nullBytes(after))
|
|
139
|
+
if err != nil {
|
|
140
|
+
return fmt.Errorf("audit: insert: %w", err)
|
|
141
|
+
}
|
|
142
|
+
return nil
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
func nullStr(s string) any {
|
|
146
|
+
if s == "" {
|
|
147
|
+
return nil
|
|
148
|
+
}
|
|
149
|
+
return s
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func nullBytes(b []byte) any {
|
|
153
|
+
if b == nil {
|
|
154
|
+
return nil
|
|
155
|
+
}
|
|
156
|
+
return string(b) // JSON column: send text, not a binary blob
|
|
157
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Package store holds the SQL of the auth module. Only the auth module may
|
|
2
|
+
// import it (Go's internal rule); no other module touches the session table.
|
|
3
|
+
package store
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"database/sql"
|
|
8
|
+
"errors"
|
|
9
|
+
"fmt"
|
|
10
|
+
|
|
11
|
+
"github.com/google/uuid"
|
|
12
|
+
|
|
13
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// Limits are the session timeouts in seconds (PRD §6.5).
|
|
17
|
+
type Limits struct {
|
|
18
|
+
IdleSeconds int
|
|
19
|
+
AbsoluteSeconds int
|
|
20
|
+
RefreshSeconds int
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Insert stores a new session row. hash is SHA-256 of the session ID.
|
|
24
|
+
func Insert(ctx context.Context, db *sql.DB, hash []byte, userID uuid.UUID) error {
|
|
25
|
+
_, err := db.ExecContext(ctx,
|
|
26
|
+
`INSERT INTO session (id_hash, user_id) VALUES (?, ?)`, hash, userID.String())
|
|
27
|
+
if err != nil {
|
|
28
|
+
return fmt.Errorf("insert session: %w", err)
|
|
29
|
+
}
|
|
30
|
+
return nil
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Touch validates and refreshes a session in ONE statement on database time.
|
|
34
|
+
// It reports true when the row matched: the session exists, is inside both
|
|
35
|
+
// timeouts and its account is enabled. RowsAffected counts matched rows
|
|
36
|
+
// (clientFoundRows=true in the DSN), so a row that needs no refresh still
|
|
37
|
+
// counts; a test locks this. last_access_at is written only when older than
|
|
38
|
+
// the refresh interval (OI-11).
|
|
39
|
+
func Touch(ctx context.Context, db *sql.DB, hash []byte, l Limits) (bool, error) {
|
|
40
|
+
res, err := db.ExecContext(ctx,
|
|
41
|
+
`UPDATE session s JOIN app_user u ON u.id = s.user_id
|
|
42
|
+
SET s.last_access_at = IF(s.last_access_at < NOW(6) - INTERVAL ? SECOND, NOW(6), s.last_access_at)
|
|
43
|
+
WHERE s.id_hash = ?
|
|
44
|
+
AND u.enabled
|
|
45
|
+
AND s.last_access_at > NOW(6) - INTERVAL ? SECOND
|
|
46
|
+
AND s.created_at > NOW(6) - INTERVAL ? SECOND`,
|
|
47
|
+
l.RefreshSeconds, hash, l.IdleSeconds, l.AbsoluteSeconds)
|
|
48
|
+
if err != nil {
|
|
49
|
+
return false, fmt.Errorf("touch session: %w", err)
|
|
50
|
+
}
|
|
51
|
+
n, err := res.RowsAffected()
|
|
52
|
+
if err != nil {
|
|
53
|
+
return false, fmt.Errorf("touch session rows: %w", err)
|
|
54
|
+
}
|
|
55
|
+
return n == 1, nil
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Principal reads the caller behind a session. The role comes from app_user,
|
|
59
|
+
// never from the session. found is false when the session vanished between
|
|
60
|
+
// Touch and this read (logout, revoke, purge).
|
|
61
|
+
func Principal(ctx context.Context, db *sql.DB, hash []byte) (p security.Principal, found bool, err error) {
|
|
62
|
+
var id, role string
|
|
63
|
+
err = db.QueryRowContext(ctx,
|
|
64
|
+
`SELECT u.id, u.emp_no, u.role
|
|
65
|
+
FROM session s JOIN app_user u ON u.id = s.user_id
|
|
66
|
+
WHERE s.id_hash = ? AND u.enabled`, hash).Scan(&id, &p.EmpNo, &role)
|
|
67
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
68
|
+
return security.Principal{}, false, nil
|
|
69
|
+
}
|
|
70
|
+
if err != nil {
|
|
71
|
+
return security.Principal{}, false, fmt.Errorf("read session principal: %w", err)
|
|
72
|
+
}
|
|
73
|
+
if p.UserID, err = uuid.Parse(id); err != nil {
|
|
74
|
+
return security.Principal{}, false, fmt.Errorf("parse user id %q: %w", id, err)
|
|
75
|
+
}
|
|
76
|
+
p.Role = security.Role(role)
|
|
77
|
+
return p, true, nil
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Delete removes one session; deleting a missing one is not an error.
|
|
81
|
+
func Delete(ctx context.Context, db *sql.DB, hash []byte) error {
|
|
82
|
+
if _, err := db.ExecContext(ctx, `DELETE FROM session WHERE id_hash = ?`, hash); err != nil {
|
|
83
|
+
return fmt.Errorf("delete session: %w", err)
|
|
84
|
+
}
|
|
85
|
+
return nil
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// DeleteByUser removes every session of one account.
|
|
89
|
+
func DeleteByUser(ctx context.Context, db *sql.DB, userID uuid.UUID) error {
|
|
90
|
+
if _, err := db.ExecContext(ctx, `DELETE FROM session WHERE user_id = ?`, userID.String()); err != nil {
|
|
91
|
+
return fmt.Errorf("delete sessions of user: %w", err)
|
|
92
|
+
}
|
|
93
|
+
return nil
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// DeleteExpired removes sessions past either timeout and returns how many.
|
|
97
|
+
func DeleteExpired(ctx context.Context, db *sql.DB, l Limits) (int64, error) {
|
|
98
|
+
res, err := db.ExecContext(ctx,
|
|
99
|
+
`DELETE FROM session
|
|
100
|
+
WHERE last_access_at <= NOW(6) - INTERVAL ? SECOND
|
|
101
|
+
OR created_at <= NOW(6) - INTERVAL ? SECOND`,
|
|
102
|
+
l.IdleSeconds, l.AbsoluteSeconds)
|
|
103
|
+
if err != nil {
|
|
104
|
+
return 0, fmt.Errorf("purge sessions: %w", err)
|
|
105
|
+
}
|
|
106
|
+
n, err := res.RowsAffected()
|
|
107
|
+
if err != nil {
|
|
108
|
+
return 0, fmt.Errorf("purge sessions rows: %w", err)
|
|
109
|
+
}
|
|
110
|
+
return n, nil
|
|
111
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Package auth owns login, sessions, CSRF and throttling (Backend.md §6).
|
|
2
|
+
// This file is the session store (B1-07); the middleware (B1-08) builds on it.
|
|
3
|
+
package auth
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"crypto/rand"
|
|
8
|
+
"crypto/sha256"
|
|
9
|
+
"database/sql"
|
|
10
|
+
"encoding/base64"
|
|
11
|
+
"fmt"
|
|
12
|
+
|
|
13
|
+
"github.com/google/uuid"
|
|
14
|
+
|
|
15
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/auth/internal/store"
|
|
16
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// Session timeouts (PRD §6.5). Refresh is written at most once per 60 s
|
|
20
|
+
// (OI-11), so the effective idle timeout is 29-30 minutes.
|
|
21
|
+
const (
|
|
22
|
+
IdleTimeoutSeconds = 30 * 60
|
|
23
|
+
AbsoluteTimeoutSeconds = 8 * 60 * 60
|
|
24
|
+
RefreshSeconds = 60
|
|
25
|
+
|
|
26
|
+
idBytes = 32
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
var limits = store.Limits{
|
|
30
|
+
IdleSeconds: IdleTimeoutSeconds,
|
|
31
|
+
AbsoluteSeconds: AbsoluteTimeoutSeconds,
|
|
32
|
+
RefreshSeconds: RefreshSeconds,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Sessions is the server-side session store. It never sees or stores a raw
|
|
36
|
+
// session ID beyond the call that receives it; the table holds SHA-256 only.
|
|
37
|
+
type Sessions struct {
|
|
38
|
+
DB *sql.DB
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// New returns a session store on db.
|
|
42
|
+
func New(db *sql.DB) *Sessions { return &Sessions{DB: db} }
|
|
43
|
+
|
|
44
|
+
// Create issues a new session for the user and returns the ID for the cookie
|
|
45
|
+
// (32 random bytes, base64url without padding). Every login calls it, so an ID
|
|
46
|
+
// is never reused (no fixation).
|
|
47
|
+
func (s *Sessions) Create(ctx context.Context, userID uuid.UUID) (string, error) {
|
|
48
|
+
raw := make([]byte, idBytes)
|
|
49
|
+
if _, err := rand.Read(raw); err != nil {
|
|
50
|
+
return "", fmt.Errorf("session id: %w", err)
|
|
51
|
+
}
|
|
52
|
+
if err := store.Insert(ctx, s.DB, hash(raw), userID); err != nil {
|
|
53
|
+
return "", err
|
|
54
|
+
}
|
|
55
|
+
return base64.RawURLEncoding.EncodeToString(raw), nil
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Validate checks the cookie value and refreshes the session in one atomic
|
|
59
|
+
// statement, then returns the caller. ok is false for anything that is not a
|
|
60
|
+
// live session of an enabled account: malformed, unknown, idle or absolutely
|
|
61
|
+
// expired, disabled user, or removed meanwhile. err is for database failures
|
|
62
|
+
// only.
|
|
63
|
+
func (s *Sessions) Validate(ctx context.Context, id string) (p security.Principal, ok bool, err error) {
|
|
64
|
+
h, valid := parse(id)
|
|
65
|
+
if !valid {
|
|
66
|
+
return security.Principal{}, false, nil
|
|
67
|
+
}
|
|
68
|
+
if ok, err = store.Touch(ctx, s.DB, h, limits); err != nil || !ok {
|
|
69
|
+
return security.Principal{}, false, err
|
|
70
|
+
}
|
|
71
|
+
return store.Principal(ctx, s.DB, h)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Revoke deletes one session. It is idempotent: an unknown or malformed ID is
|
|
75
|
+
// not an error (logout returns 204 either way).
|
|
76
|
+
func (s *Sessions) Revoke(ctx context.Context, id string) error {
|
|
77
|
+
h, valid := parse(id)
|
|
78
|
+
if !valid {
|
|
79
|
+
return nil
|
|
80
|
+
}
|
|
81
|
+
return store.Delete(ctx, s.DB, h)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// RevokeAll deletes every session of the user. It implements
|
|
85
|
+
// account.SessionRevoker (wired in main.go; account never imports auth).
|
|
86
|
+
func (s *Sessions) RevokeAll(ctx context.Context, userID uuid.UUID) error {
|
|
87
|
+
return store.DeleteByUser(ctx, s.DB, userID)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Purge deletes expired sessions and returns how many; a background ticker
|
|
91
|
+
// calls it. Validation never depends on it: expired rows already fail Touch.
|
|
92
|
+
func (s *Sessions) Purge(ctx context.Context) (int64, error) {
|
|
93
|
+
return store.DeleteExpired(ctx, s.DB, limits)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Binding returns SHA-256 of the session ID, the value the CSRF token is bound
|
|
97
|
+
// to (D40). ok is false for a malformed ID.
|
|
98
|
+
func Binding(id string) (b []byte, ok bool) { return parse(id) }
|
|
99
|
+
|
|
100
|
+
// parse decodes a cookie value and returns its SHA-256. Anything that is not
|
|
101
|
+
// exactly 32 bytes of base64url is rejected without a database call.
|
|
102
|
+
func parse(id string) ([]byte, bool) {
|
|
103
|
+
raw, err := base64.RawURLEncoding.DecodeString(id)
|
|
104
|
+
if err != nil || len(raw) != idBytes {
|
|
105
|
+
return nil, false
|
|
106
|
+
}
|
|
107
|
+
return hash(raw), true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func hash(raw []byte) []byte {
|
|
111
|
+
sum := sha256.Sum256(raw)
|
|
112
|
+
return sum[:]
|
|
113
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
//go:build integration
|
|
2
|
+
|
|
3
|
+
package auth_test
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"crypto/sha256"
|
|
8
|
+
"database/sql"
|
|
9
|
+
"encoding/base64"
|
|
10
|
+
"testing"
|
|
11
|
+
"time"
|
|
12
|
+
|
|
13
|
+
"github.com/google/uuid"
|
|
14
|
+
|
|
15
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
|
|
16
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
|
|
17
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const bcryptHash = "$2a$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234" // shape only; satisfies ck_app_user_hash
|
|
21
|
+
|
|
22
|
+
func addUser(t *testing.T, db *sql.DB, empNo, role string, enabled bool) uuid.UUID {
|
|
23
|
+
t.Helper()
|
|
24
|
+
id := uuid.New()
|
|
25
|
+
var email any = empNo + "@example.com"
|
|
26
|
+
if role == "ADMIN" {
|
|
27
|
+
email = nil
|
|
28
|
+
}
|
|
29
|
+
if _, err := db.Exec(`INSERT INTO app_user (id, emp_no, name, email, password_hash, role, enabled) VALUES (?,?,?,?,?,?,?)`,
|
|
30
|
+
id.String(), empNo, "N "+empNo, email, bcryptHash, role, enabled); err != nil {
|
|
31
|
+
t.Fatal(err)
|
|
32
|
+
}
|
|
33
|
+
return id
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// age moves a session's timestamps into the past (DB time is the only clock,
|
|
37
|
+
// so tests change the rows rather than the clock).
|
|
38
|
+
func age(t *testing.T, db *sql.DB, id string, created, lastAccess time.Duration) {
|
|
39
|
+
t.Helper()
|
|
40
|
+
h, _ := auth.Binding(id)
|
|
41
|
+
if _, err := db.Exec(`UPDATE session SET created_at = NOW(6) - INTERVAL ? SECOND, last_access_at = NOW(6) - INTERVAL ? SECOND WHERE id_hash = ?`,
|
|
42
|
+
int(created.Seconds()), int(lastAccess.Seconds()), h); err != nil {
|
|
43
|
+
t.Fatal(err)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func lastAccess(t *testing.T, db *sql.DB, id string) time.Time {
|
|
48
|
+
t.Helper()
|
|
49
|
+
h, _ := auth.Binding(id)
|
|
50
|
+
var ts time.Time
|
|
51
|
+
if err := db.QueryRow(`SELECT last_access_at FROM session WHERE id_hash = ?`, h).Scan(&ts); err != nil {
|
|
52
|
+
t.Fatal(err)
|
|
53
|
+
}
|
|
54
|
+
return ts
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func TestSessions(t *testing.T) {
|
|
58
|
+
db := dbtest.New(t)
|
|
59
|
+
ctx := context.Background()
|
|
60
|
+
s := auth.New(db)
|
|
61
|
+
user := addUser(t, db, "100001", "USER", true)
|
|
62
|
+
|
|
63
|
+
create := func(t *testing.T, u uuid.UUID) string {
|
|
64
|
+
t.Helper()
|
|
65
|
+
id, err := s.Create(ctx, u)
|
|
66
|
+
if err != nil {
|
|
67
|
+
t.Fatal(err)
|
|
68
|
+
}
|
|
69
|
+
return id
|
|
70
|
+
}
|
|
71
|
+
valid := func(t *testing.T, id string) bool {
|
|
72
|
+
t.Helper()
|
|
73
|
+
_, ok, err := s.Validate(ctx, id)
|
|
74
|
+
if err != nil {
|
|
75
|
+
t.Fatal(err)
|
|
76
|
+
}
|
|
77
|
+
return ok
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
t.Run("create stores only the SHA-256 and returns a 32-byte ID", func(t *testing.T) {
|
|
81
|
+
id := create(t, user)
|
|
82
|
+
raw, err := base64.RawURLEncoding.DecodeString(id)
|
|
83
|
+
if err != nil || len(raw) != 32 {
|
|
84
|
+
t.Fatalf("id %q: %d bytes, %v", id, len(raw), err)
|
|
85
|
+
}
|
|
86
|
+
sum := sha256.Sum256(raw)
|
|
87
|
+
var n int
|
|
88
|
+
if err := db.QueryRow(`SELECT COUNT(*) FROM session WHERE id_hash = ?`, sum[:]).Scan(&n); err != nil || n != 1 {
|
|
89
|
+
t.Fatalf("hash row count = %d, %v", n, err)
|
|
90
|
+
}
|
|
91
|
+
if err := db.QueryRow(`SELECT COUNT(*) FROM session WHERE id_hash = ?`, raw).Scan(&n); err != nil || n != 0 {
|
|
92
|
+
t.Fatalf("raw ID stored: %d, %v", n, err)
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
t.Run("every login gets a new ID", func(t *testing.T) {
|
|
97
|
+
if a, b := create(t, user), create(t, user); a == b {
|
|
98
|
+
t.Fatal("same ID twice")
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
t.Run("validate returns the principal with the role from app_user", func(t *testing.T) {
|
|
103
|
+
admin := addUser(t, db, "200001", "ADMIN", true)
|
|
104
|
+
p, ok, err := s.Validate(ctx, create(t, admin))
|
|
105
|
+
if err != nil || !ok {
|
|
106
|
+
t.Fatalf("validate: %v %v", ok, err)
|
|
107
|
+
}
|
|
108
|
+
want := security.Principal{UserID: admin, EmpNo: "200001", Role: security.RoleAdmin}
|
|
109
|
+
if p != want {
|
|
110
|
+
t.Fatalf("principal = %+v, want %+v", p, want)
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
t.Run("unknown and malformed IDs are not valid", func(t *testing.T) {
|
|
115
|
+
unknown := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
|
116
|
+
for _, id := range []string{unknown, "", "garbage"} {
|
|
117
|
+
if valid(t, id) {
|
|
118
|
+
t.Errorf("%q accepted", id)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
t.Run("matched rows count: a valid session that needs no refresh still validates", func(t *testing.T) {
|
|
124
|
+
id := create(t, user)
|
|
125
|
+
before := lastAccess(t, db, id)
|
|
126
|
+
for i := 0; i < 2; i++ {
|
|
127
|
+
if !valid(t, id) {
|
|
128
|
+
t.Fatalf("validate #%d failed although the row matched", i+1)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if after := lastAccess(t, db, id); !after.Equal(before) {
|
|
132
|
+
t.Fatalf("last_access_at moved inside the 60 s window: %v -> %v", before, after)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
t.Run("refresh happens only when last access is older than 60 s", func(t *testing.T) {
|
|
137
|
+
id := create(t, user)
|
|
138
|
+
age(t, db, id, 0, 59*time.Second)
|
|
139
|
+
before := lastAccess(t, db, id)
|
|
140
|
+
if !valid(t, id) || !lastAccess(t, db, id).Equal(before) {
|
|
141
|
+
t.Fatal("59 s old: must validate without writing")
|
|
142
|
+
}
|
|
143
|
+
age(t, db, id, 0, 61*time.Second)
|
|
144
|
+
before = lastAccess(t, db, id)
|
|
145
|
+
if !valid(t, id) || !lastAccess(t, db, id).After(before) {
|
|
146
|
+
t.Fatal("61 s old: must validate and refresh")
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
t.Run("idle timeout is 30 minutes", func(t *testing.T) {
|
|
151
|
+
id := create(t, user)
|
|
152
|
+
age(t, db, id, 0, 29*time.Minute+50*time.Second)
|
|
153
|
+
if !valid(t, id) {
|
|
154
|
+
t.Fatal("29m50s idle must still be valid")
|
|
155
|
+
}
|
|
156
|
+
age(t, db, id, 0, 30*time.Minute+5*time.Second)
|
|
157
|
+
if valid(t, id) {
|
|
158
|
+
t.Fatal("30m05s idle must be expired")
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
t.Run("absolute timeout is 8 hours, even for an active session", func(t *testing.T) {
|
|
163
|
+
id := create(t, user)
|
|
164
|
+
age(t, db, id, 7*time.Hour+59*time.Minute, 0)
|
|
165
|
+
if !valid(t, id) {
|
|
166
|
+
t.Fatal("7h59m old must still be valid")
|
|
167
|
+
}
|
|
168
|
+
age(t, db, id, 8*time.Hour+time.Minute, 0)
|
|
169
|
+
if valid(t, id) {
|
|
170
|
+
t.Fatal("8h01m old must be expired although just used")
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
t.Run("disabled user is rejected on the next request", func(t *testing.T) {
|
|
175
|
+
u := addUser(t, db, "100002", "USER", true)
|
|
176
|
+
id := create(t, u)
|
|
177
|
+
if !valid(t, id) {
|
|
178
|
+
t.Fatal("enabled user must validate")
|
|
179
|
+
}
|
|
180
|
+
if _, err := db.Exec(`UPDATE app_user SET enabled = FALSE WHERE id = ?`, u.String()); err != nil {
|
|
181
|
+
t.Fatal(err)
|
|
182
|
+
}
|
|
183
|
+
if valid(t, id) {
|
|
184
|
+
t.Fatal("disabled user must not validate")
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
t.Run("revoke is idempotent and ends only that session", func(t *testing.T) {
|
|
189
|
+
a, b := create(t, user), create(t, user)
|
|
190
|
+
for i := 0; i < 2; i++ {
|
|
191
|
+
if err := s.Revoke(ctx, a); err != nil {
|
|
192
|
+
t.Fatal(err)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if err := s.Revoke(ctx, "garbage"); err != nil {
|
|
196
|
+
t.Fatal(err)
|
|
197
|
+
}
|
|
198
|
+
if valid(t, a) || !valid(t, b) {
|
|
199
|
+
t.Fatal("revoke must end a and leave b")
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
t.Run("revoke all ends every session of the user only", func(t *testing.T) {
|
|
204
|
+
u, other := addUser(t, db, "100003", "USER", true), addUser(t, db, "100004", "USER", true)
|
|
205
|
+
a, b, o := create(t, u), create(t, u), create(t, other)
|
|
206
|
+
if err := s.RevokeAll(ctx, u); err != nil {
|
|
207
|
+
t.Fatal(err)
|
|
208
|
+
}
|
|
209
|
+
if valid(t, a) || valid(t, b) || !valid(t, o) {
|
|
210
|
+
t.Fatal("RevokeAll must end the user's sessions and no others")
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
t.Run("purge removes expired sessions only", func(t *testing.T) {
|
|
215
|
+
u := addUser(t, db, "100005", "USER", true)
|
|
216
|
+
live, idle, old := create(t, u), create(t, u), create(t, u)
|
|
217
|
+
age(t, db, idle, time.Hour, time.Hour)
|
|
218
|
+
age(t, db, old, 9*time.Hour, 0)
|
|
219
|
+
n, err := s.Purge(ctx)
|
|
220
|
+
if err != nil || n < 2 {
|
|
221
|
+
t.Fatalf("purged %d, %v; want at least 2", n, err)
|
|
222
|
+
}
|
|
223
|
+
if !valid(t, live) {
|
|
224
|
+
t.Fatal("live session purged")
|
|
225
|
+
}
|
|
226
|
+
for _, id := range []string{idle, old} {
|
|
227
|
+
h, _ := auth.Binding(id)
|
|
228
|
+
var c int
|
|
229
|
+
if err := db.QueryRow(`SELECT COUNT(*) FROM session WHERE id_hash = ?`, h).Scan(&c); err != nil || c != 0 {
|
|
230
|
+
t.Fatalf("expired row still present: %d, %v", c, err)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
t.Run("deleting a user deletes the sessions", func(t *testing.T) {
|
|
236
|
+
u := addUser(t, db, "100006", "USER", true)
|
|
237
|
+
id := create(t, u)
|
|
238
|
+
if _, err := db.Exec(`DELETE FROM app_user WHERE id = ?`, u.String()); err != nil {
|
|
239
|
+
t.Fatal(err)
|
|
240
|
+
}
|
|
241
|
+
if valid(t, id) {
|
|
242
|
+
t.Fatal("session survived its user")
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package auth_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/sha256"
|
|
5
|
+
"encoding/base64"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestBindingIsSHA256OfDecodedID(t *testing.T) {
|
|
12
|
+
raw := make([]byte, 32)
|
|
13
|
+
for i := range raw {
|
|
14
|
+
raw[i] = byte(i)
|
|
15
|
+
}
|
|
16
|
+
want := sha256.Sum256(raw)
|
|
17
|
+
got, ok := auth.Binding(base64.RawURLEncoding.EncodeToString(raw))
|
|
18
|
+
if !ok || string(got) != string(want[:]) {
|
|
19
|
+
t.Fatalf("Binding = %x, %v; want %x", got, ok, want)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func TestBindingRejectsMalformedIDs(t *testing.T) {
|
|
24
|
+
for name, id := range map[string]string{
|
|
25
|
+
"empty": "",
|
|
26
|
+
"not base64": "!!!",
|
|
27
|
+
"too short": base64.RawURLEncoding.EncodeToString(make([]byte, 31)),
|
|
28
|
+
"too long": base64.RawURLEncoding.EncodeToString(make([]byte, 33)),
|
|
29
|
+
"padded": base64.URLEncoding.EncodeToString(make([]byte, 32)),
|
|
30
|
+
"std alpha": base64.StdEncoding.EncodeToString([]byte{0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff, 0xfe, 0xfb, 0xff}),
|
|
31
|
+
} {
|
|
32
|
+
if _, ok := auth.Binding(id); ok {
|
|
33
|
+
t.Errorf("%s: %q accepted", name, id)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -3,6 +3,7 @@ package server
|
|
|
3
3
|
import (
|
|
4
4
|
"net/http"
|
|
5
5
|
|
|
6
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/account"
|
|
6
7
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/api"
|
|
7
8
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
|
|
8
9
|
)
|
|
@@ -11,8 +12,14 @@ import (
|
|
|
11
12
|
// module has not landed yet stay api.Unimplemented (501).
|
|
12
13
|
type API struct {
|
|
13
14
|
api.Unimplemented
|
|
15
|
+
Account account.Handler
|
|
14
16
|
Deadline deadline.Handler
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
// GetDeadline serves GET /deadline.
|
|
18
20
|
func (a API) GetDeadline(w http.ResponseWriter, r *http.Request) { a.Deadline.GetDeadline(w, r) }
|
|
21
|
+
|
|
22
|
+
// Register serves POST /auth/register.
|
|
23
|
+
func (a API) Register(w http.ResponseWriter, r *http.Request, _ api.RegisterParams) {
|
|
24
|
+
a.Account.Register(w, r)
|
|
25
|
+
}
|