@johnnyboy11234/esg-idea-contest 0.2.0 → 0.2.2
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 +20 -2
- 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/integration_test.go +1 -1
- package/backend/internal/auth/csrf.go +212 -0
- package/backend/internal/auth/csrf_test.go +281 -0
- package/backend/internal/auth/internal/store/session.go +111 -0
- package/backend/internal/auth/middleware.go +66 -0
- package/backend/internal/auth/middleware_test.go +153 -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 +12 -0
- package/backend/internal/server/errors.go +10 -25
- package/backend/internal/server/errors_test.go +18 -0
- package/backend/internal/server/public.go +35 -0
- package/backend/internal/server/public_test.go +73 -0
- package/backend/internal/server/server.go +7 -3
- package/backend/internal/shared/config/config.go +23 -4
- package/backend/internal/shared/config/config_test.go +14 -8
- 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 +6 -2
- package/backend/internal/shared/security/security_test.go +6 -4
- package/backend/internal/shared/web/problem.go +6 -0
- package/backend/internal/shared/web/request.go +27 -0
- package/frontend/src/app/App.test.tsx +13 -0
- package/frontend/src/app/App.tsx +4 -0
- package/frontend/src/app/session.ts +28 -6
- package/frontend/src/shared/api/csrf.ts +51 -0
- package/frontend/src/shared/api/http.test.tsx +209 -0
- package/frontend/src/shared/api/http.ts +91 -0
- package/frontend/src/shared/api/index.ts +3 -0
- 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,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,7 +3,9 @@ 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"
|
|
8
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
|
|
7
9
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
|
|
8
10
|
)
|
|
9
11
|
|
|
@@ -11,8 +13,18 @@ import (
|
|
|
11
13
|
// module has not landed yet stay api.Unimplemented (501).
|
|
12
14
|
type API struct {
|
|
13
15
|
api.Unimplemented
|
|
16
|
+
Auth auth.Handler
|
|
17
|
+
Account account.Handler
|
|
14
18
|
Deadline deadline.Handler
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
// GetDeadline serves GET /deadline.
|
|
18
22
|
func (a API) GetDeadline(w http.ResponseWriter, r *http.Request) { a.Deadline.GetDeadline(w, r) }
|
|
23
|
+
|
|
24
|
+
// Register serves POST /auth/register.
|
|
25
|
+
func (a API) Register(w http.ResponseWriter, r *http.Request, _ api.RegisterParams) {
|
|
26
|
+
a.Account.Register(w, r)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// GetCsrfToken serves GET /auth/csrf.
|
|
30
|
+
func (a API) GetCsrfToken(w http.ResponseWriter, r *http.Request) { a.Auth.GetCsrfToken(w, r) }
|
|
@@ -5,10 +5,8 @@ import (
|
|
|
5
5
|
"encoding/json"
|
|
6
6
|
"errors"
|
|
7
7
|
"log/slog"
|
|
8
|
+
"maps"
|
|
8
9
|
"net/http"
|
|
9
|
-
"net/netip"
|
|
10
|
-
|
|
11
|
-
"github.com/go-chi/chi/v5"
|
|
12
10
|
|
|
13
11
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
|
|
14
12
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
@@ -59,37 +57,24 @@ func (a ErrorAdapter) record(r *http.Request, e *web.Error) {
|
|
|
59
57
|
if outcome == "" {
|
|
60
58
|
outcome = audit.Denied
|
|
61
59
|
}
|
|
62
|
-
|
|
60
|
+
detail := map[string]string{}
|
|
61
|
+
maps.Copy(detail, e.AuditDetail)
|
|
62
|
+
detail["reason"] = e.AuditReason // the reason always wins over a detail key
|
|
63
|
+
after, _ := json.Marshal(detail) // map of strings cannot fail
|
|
63
64
|
ev := audit.Event{
|
|
64
65
|
Action: audit.Action(e.AuditAction),
|
|
65
66
|
Outcome: outcome,
|
|
66
67
|
TargetType: audit.TargetType(e.AuditTargetType),
|
|
67
68
|
TargetID: e.AuditTargetID,
|
|
68
69
|
Method: r.Method,
|
|
69
|
-
Route:
|
|
70
|
+
Route: web.RoutePattern(r),
|
|
70
71
|
Status: e.Status,
|
|
71
72
|
ErrorCode: string(e.Code),
|
|
72
|
-
IP:
|
|
73
|
+
IP: web.ClientIP(r),
|
|
73
74
|
UserAgent: r.UserAgent(),
|
|
74
|
-
After: after,
|
|
75
|
-
}
|
|
76
|
-
a.Audit.RecordRejected(r.Context(), ev)
|
|
77
|
-
}
|
|
78
75
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
func routeTemplate(r *http.Request) string {
|
|
82
|
-
if rc := chi.RouteContext(r.Context()); rc != nil {
|
|
83
|
-
return rc.RoutePattern()
|
|
76
|
+
SubmittedEmpNo: e.AuditSubmittedEmpNo,
|
|
77
|
+
After: after,
|
|
84
78
|
}
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// remoteIP is the TCP peer. Trusted-proxy handling is not decided yet.
|
|
89
|
-
func remoteIP(r *http.Request) netip.Addr {
|
|
90
|
-
ap, err := netip.ParseAddrPort(r.RemoteAddr)
|
|
91
|
-
if err != nil {
|
|
92
|
-
return netip.Addr{}
|
|
93
|
-
}
|
|
94
|
-
return ap.Addr().Unmap()
|
|
79
|
+
a.Audit.RecordRejected(r.Context(), ev)
|
|
95
80
|
}
|
|
@@ -96,3 +96,21 @@ func TestAdapterReasonWithoutActionStillAnswers(t *testing.T) {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
func contains(s, sub string) bool { return strings.Contains(s, sub) }
|
|
99
|
+
|
|
100
|
+
func TestAdapterCarriesSubmittedEmpNoAndDetail(t *testing.T) {
|
|
101
|
+
e := web.NewError(web.CodeEmpNoTaken, "taken")
|
|
102
|
+
e.AuditReason, e.AuditAction, e.AuditOutcome = "emp_no_taken", "AUTH_REGISTER", "FAILED"
|
|
103
|
+
e.AuditSubmittedEmpNo = "089290"
|
|
104
|
+
e.AuditDetail = map[string]string{"empNo": "089290", "reason": "must not override"}
|
|
105
|
+
f := &fakeRejecter{}
|
|
106
|
+
|
|
107
|
+
rec := serve(t, server.ErrorAdapter{Audit: f}, e)
|
|
108
|
+
|
|
109
|
+
if rec.Code != 409 || len(f.got) != 1 {
|
|
110
|
+
t.Fatalf("status %d rows %d", rec.Code, len(f.got))
|
|
111
|
+
}
|
|
112
|
+
g := f.got[0]
|
|
113
|
+
if g.Outcome != audit.Failed || g.SubmittedEmpNo != "089290" || string(g.After) != `{"empNo":"089290","reason":"emp_no_taken"}` {
|
|
114
|
+
t.Fatalf("event = %+v (after %s)", g, g.After)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
package server
|
|
2
|
+
|
|
3
|
+
import "net/http"
|
|
4
|
+
|
|
5
|
+
// PublicRoutes is the explicit list of operations that need no session
|
|
6
|
+
// (Backend.md §3). It must equal the operations marked `security: []` in
|
|
7
|
+
// api/openapi.yaml; a test locks that. B1-10 adds POST /auth/login to both. Paths include the /api/v1 prefix.
|
|
8
|
+
var PublicRoutes = []struct{ Method, Path string }{
|
|
9
|
+
{http.MethodGet, "/api/v1/auth/csrf"},
|
|
10
|
+
{http.MethodPost, "/api/v1/auth/register"},
|
|
11
|
+
{http.MethodPost, "/api/v1/auth/logout"},
|
|
12
|
+
{http.MethodGet, "/api/v1/deadline"},
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// IsPublic reports whether r is exactly one of PublicRoutes. A public path with
|
|
16
|
+
// another method is not public: without a session it answers 401, with one 405.
|
|
17
|
+
func IsPublic(r *http.Request) bool {
|
|
18
|
+
for _, p := range PublicRoutes {
|
|
19
|
+
if r.Method == p.Method && r.URL.Path == p.Path {
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// WriteActions maps the writes that exist today to their audit action, so a
|
|
27
|
+
// CSRF denial is recorded (PRD §6.11). Later PRs add their routes here, with
|
|
28
|
+
// the same exact-path rule; a write on an unknown path is not audited.
|
|
29
|
+
var WriteActions = map[string]string{
|
|
30
|
+
http.MethodPost + " /api/v1/auth/register": "AUTH_REGISTER",
|
|
31
|
+
http.MethodPost + " /api/v1/auth/logout": "AUTH_LOGOUT",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ActionFor returns the audit action of r, or "" when unknown.
|
|
35
|
+
func ActionFor(r *http.Request) string { return WriteActions[r.Method+" "+r.URL.Path] }
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
package server_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"net/http"
|
|
6
|
+
"net/http/httptest"
|
|
7
|
+
"slices"
|
|
8
|
+
"testing"
|
|
9
|
+
|
|
10
|
+
"github.com/getkin/kin-openapi/openapi3"
|
|
11
|
+
|
|
12
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/server"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// The public list in code must equal the operations the spec marks
|
|
16
|
+
// `security: []`; otherwise the contract and the middleware disagree.
|
|
17
|
+
func TestPublicRoutesMatchOpenAPI(t *testing.T) {
|
|
18
|
+
doc, err := openapi3.NewLoader().LoadFromFile("../../api/openapi.yaml")
|
|
19
|
+
if err != nil {
|
|
20
|
+
t.Fatal(err)
|
|
21
|
+
}
|
|
22
|
+
var want []string
|
|
23
|
+
for path, item := range doc.Paths.Map() {
|
|
24
|
+
for method, op := range item.Operations() {
|
|
25
|
+
if op.Security != nil && len(*op.Security) == 0 {
|
|
26
|
+
want = append(want, method+" /api/v1"+path)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
var got []string
|
|
31
|
+
for _, p := range server.PublicRoutes {
|
|
32
|
+
got = append(got, p.Method+" "+p.Path)
|
|
33
|
+
}
|
|
34
|
+
slices.Sort(want)
|
|
35
|
+
slices.Sort(got)
|
|
36
|
+
if !slices.Equal(want, got) {
|
|
37
|
+
t.Errorf("PublicRoutes\n got %v\n want %v (spec, security: [])", got, want)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func TestIsPublicNeedsExactMethodAndPath(t *testing.T) {
|
|
42
|
+
tests := []struct {
|
|
43
|
+
method, path string
|
|
44
|
+
want bool
|
|
45
|
+
}{
|
|
46
|
+
{"GET", "/api/v1/auth/csrf", true},
|
|
47
|
+
{"POST", "/api/v1/auth/logout", true},
|
|
48
|
+
{"POST", "/api/v1/auth/csrf", false},
|
|
49
|
+
{"HEAD", "/api/v1/deadline", false},
|
|
50
|
+
{"GET", "/api/v1/auth/csrf/", false},
|
|
51
|
+
{"GET", "/api/v1/auth/me", false},
|
|
52
|
+
{"GET", "/api/v1/auth/../ideas", false},
|
|
53
|
+
{"GET", "/auth/csrf", false},
|
|
54
|
+
}
|
|
55
|
+
for _, tc := range tests {
|
|
56
|
+
req := httptest.NewRequestWithContext(context.Background(), tc.method, "http://x"+tc.path, nil)
|
|
57
|
+
if got := server.IsPublic(req); got != tc.want {
|
|
58
|
+
t.Errorf("%s %s = %v, want %v", tc.method, tc.path, got, tc.want)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func TestActionForCoversPublicWrites(t *testing.T) {
|
|
64
|
+
for _, p := range server.PublicRoutes {
|
|
65
|
+
if p.Method == http.MethodGet {
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
r := httptest.NewRequest(p.Method, p.Path, nil)
|
|
69
|
+
if server.ActionFor(r) == "" {
|
|
70
|
+
t.Errorf("%s %s has no audit action for a CSRF denial", p.Method, p.Path)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -22,8 +22,8 @@ const (
|
|
|
22
22
|
|
|
23
23
|
// NewPublic builds the public router. Module routes are mounted by `mount`
|
|
24
24
|
// (nil in the skeleton) under the chain: request ID → access log → recover →
|
|
25
|
-
// Accept check.
|
|
26
|
-
func NewPublic(log *slog.Logger, mount func(chi.Router)) http.Handler {
|
|
25
|
+
// extra (the session middleware, so 401 beats 406) → Accept check.
|
|
26
|
+
func NewPublic(log *slog.Logger, mount func(chi.Router), extra ...func(http.Handler) http.Handler) http.Handler {
|
|
27
27
|
r := chi.NewRouter()
|
|
28
28
|
r.NotFound(web.NotFound)
|
|
29
29
|
r.MethodNotAllowed(web.MethodNotAllowed)
|
|
@@ -32,7 +32,11 @@ func NewPublic(log *slog.Logger, mount func(chi.Router)) http.Handler {
|
|
|
32
32
|
}
|
|
33
33
|
// The chain wraps the router instead of using r.Use: chi skips r.Use
|
|
34
34
|
// middleware (also for NotFound) while no route is registered.
|
|
35
|
-
|
|
35
|
+
h := web.AcceptCheck(r)
|
|
36
|
+
for i := len(extra) - 1; i >= 0; i-- {
|
|
37
|
+
h = extra[i](h)
|
|
38
|
+
}
|
|
39
|
+
return web.RequestID(web.AccessLog(log)(web.Recover(log)(h)))
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
// Check reports whether one dependency is usable; a non-nil error makes
|
|
@@ -18,8 +18,16 @@ type Config struct {
|
|
|
18
18
|
LogLevel slog.Level
|
|
19
19
|
ShutdownTimeout time.Duration // must stay below terminationGracePeriodSeconds
|
|
20
20
|
ReadyDrainDelay time.Duration // time readiness stays false before Shutdown starts
|
|
21
|
+
CSRFKey string // HMAC key that signs CSRF tokens (>= 32 bytes)
|
|
22
|
+
CSRFKeyPrevious string // optional: still verifies for 8 h after a rotation
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
// DefaultCSRFKey is the throwaway key of the local profile only; every other
|
|
26
|
+
// profile must set ESG_CSRF_KEY.
|
|
27
|
+
const DefaultCSRFKey = "local-dev-csrf-key-not-a-secret-0000"
|
|
28
|
+
|
|
29
|
+
const minCSRFKeyBytes = 32
|
|
30
|
+
|
|
23
31
|
// DefaultDSN matches docker-compose.yml (throwaway local credentials). The query
|
|
24
32
|
// pins UTC and matched-row counts (Backend.md §2.3).
|
|
25
33
|
const DefaultDSN = "esg:esg@tcp(localhost:3306)/esg?parseTime=true&loc=UTC&time_zone=%27%2B00%3A00%27&clientFoundRows=true"
|
|
@@ -33,16 +41,27 @@ func Load(getenv func(string) string) (Config, error) {
|
|
|
33
41
|
return def
|
|
34
42
|
}
|
|
35
43
|
c := Config{
|
|
36
|
-
Profile:
|
|
37
|
-
PublicAddr:
|
|
38
|
-
ManagementAddr:
|
|
39
|
-
DBDSN:
|
|
44
|
+
Profile: get("ESG_PROFILE", "local"),
|
|
45
|
+
PublicAddr: get("ESG_PUBLIC_ADDR", ":8080"),
|
|
46
|
+
ManagementAddr: get("ESG_MANAGEMENT_ADDR", ":8081"),
|
|
47
|
+
DBDSN: get("ESG_DB_DSN", DefaultDSN),
|
|
48
|
+
CSRFKey: getenv("ESG_CSRF_KEY"),
|
|
49
|
+
CSRFKeyPrevious: getenv("ESG_CSRF_KEY_PREVIOUS"),
|
|
40
50
|
}
|
|
41
51
|
switch c.Profile {
|
|
42
52
|
case "local", "k3s", "dev":
|
|
43
53
|
default:
|
|
44
54
|
return Config{}, fmt.Errorf("ESG_PROFILE %q: want local, k3s or dev", c.Profile)
|
|
45
55
|
}
|
|
56
|
+
if c.CSRFKey == "" && c.Profile == "local" {
|
|
57
|
+
c.CSRFKey = DefaultCSRFKey
|
|
58
|
+
}
|
|
59
|
+
if len(c.CSRFKey) < minCSRFKeyBytes {
|
|
60
|
+
return Config{}, fmt.Errorf("ESG_CSRF_KEY: need at least %d bytes", minCSRFKeyBytes)
|
|
61
|
+
}
|
|
62
|
+
if c.CSRFKeyPrevious != "" && len(c.CSRFKeyPrevious) < minCSRFKeyBytes {
|
|
63
|
+
return Config{}, fmt.Errorf("ESG_CSRF_KEY_PREVIOUS: need at least %d bytes", minCSRFKeyBytes)
|
|
64
|
+
}
|
|
46
65
|
if err := c.LogLevel.UnmarshalText([]byte(strings.ToUpper(get("ESG_LOG_LEVEL", "info")))); err != nil {
|
|
47
66
|
return Config{}, fmt.Errorf("ESG_LOG_LEVEL: %w", err)
|
|
48
67
|
}
|