@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.
Files changed (40) hide show
  1. package/backend/api/openapi.yaml +44 -0
  2. package/backend/cmd/server/main.go +11 -1
  3. package/backend/db/migration/V002__audit_log_full.sql +32 -0
  4. package/backend/db/migration/V003__app_user.sql +36 -0
  5. package/backend/db/migration/V004__session.sql +19 -0
  6. package/backend/go.mod +1 -1
  7. package/backend/internal/account/account_test.go +191 -0
  8. package/backend/internal/account/handler.go +58 -0
  9. package/backend/internal/account/integration_test.go +268 -0
  10. package/backend/internal/account/internal/store/store.go +35 -0
  11. package/backend/internal/account/service.go +153 -0
  12. package/backend/internal/account/validate.go +100 -0
  13. package/backend/internal/api/api.gen.go +82 -0
  14. package/backend/internal/audit/event.go +119 -0
  15. package/backend/internal/audit/integration_test.go +270 -0
  16. package/backend/internal/audit/sanitize.go +102 -0
  17. package/backend/internal/audit/sanitize_test.go +134 -0
  18. package/backend/internal/audit/service.go +157 -0
  19. package/backend/internal/auth/internal/store/session.go +111 -0
  20. package/backend/internal/auth/session.go +113 -0
  21. package/backend/internal/auth/session_integration_test.go +245 -0
  22. package/backend/internal/auth/session_test.go +36 -0
  23. package/backend/internal/server/api.go +7 -0
  24. package/backend/internal/server/errors.go +80 -0
  25. package/backend/internal/server/errors_test.go +116 -0
  26. package/backend/internal/shared/db/intx_test.go +25 -0
  27. package/backend/internal/shared/db/mysqlerr.go +26 -0
  28. package/backend/internal/shared/security/security.go +102 -0
  29. package/backend/internal/shared/security/security_test.go +62 -0
  30. package/backend/internal/shared/web/problem.go +29 -7
  31. package/backend/internal/shared/web/request.go +27 -0
  32. package/backend/test/migration/app_user_test.go +112 -0
  33. package/docs/Adr/0001-Backend-in-go.md +3 -3
  34. package/docs/Open-items.md +8 -3
  35. package/docs/PR-plan.md +6 -4
  36. package/docs/PRD.md +2 -1
  37. package/frontend/src/shared/text/index.ts +93 -0
  38. package/frontend/src/shared/text/text.test.ts +22 -0
  39. package/package.json +1 -1
  40. 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
+ }