@johnnyboy11234/esg-idea-contest 0.2.1 → 0.2.3
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 +70 -0
- package/backend/cmd/server/main.go +17 -1
- package/backend/internal/account/logging_integration_test.go +70 -0
- package/backend/internal/api/api.gen.go +125 -0
- 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 +63 -5
- package/backend/internal/auth/logging_integration_test.go +88 -0
- package/backend/internal/auth/login.go +252 -0
- package/backend/internal/auth/login_integration_test.go +421 -0
- package/backend/internal/auth/login_test.go +68 -0
- package/backend/internal/auth/logout.go +68 -0
- package/backend/internal/auth/logout_integration_test.go +124 -0
- package/backend/internal/auth/me.go +40 -0
- package/backend/internal/auth/me_integration_test.go +70 -0
- package/backend/internal/auth/middleware.go +66 -0
- package/backend/internal/auth/middleware_test.go +153 -0
- package/backend/internal/auth/session.go +20 -1
- package/backend/internal/server/api.go +19 -0
- package/backend/internal/server/public.go +37 -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/web/problem.go +9 -0
- package/backend/internal/shared/web/retryafter_test.go +31 -0
- package/frontend/src/app/App.test.tsx +13 -0
- package/frontend/src/app/App.tsx +4 -0
- package/frontend/src/app/index.css +102 -102
- package/frontend/src/app/layouts.tsx +75 -18
- package/frontend/src/app/pages/auth.tsx +52 -78
- package/frontend/src/app/pages/login.test.tsx +218 -0
- package/frontend/src/app/pages/register.test.tsx +231 -0
- package/frontend/src/app/router.tsx +83 -34
- package/frontend/src/app/session.ts +71 -18
- package/frontend/src/features/auth/LoginForm.tsx +145 -0
- package/frontend/src/features/auth/RegisterForm.tsx +211 -0
- package/frontend/src/features/auth/login.ts +23 -0
- package/frontend/src/features/auth/register.ts +105 -0
- 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/i18n/locales/en.ts +15 -2
- package/frontend/src/shared/i18n/locales/zh-TW.ts +15 -2
- package/frontend/src/shared/text/index.ts +20 -0
- package/frontend/src/shared/text/text.test.ts +28 -1
- package/frontend/vite.config.ts +5 -1
- package/package.json +1 -1
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
package auth_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"crypto/rand"
|
|
6
|
+
"encoding/base64"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"errors"
|
|
9
|
+
"log/slog"
|
|
10
|
+
"net/http"
|
|
11
|
+
"net/http/httptest"
|
|
12
|
+
"strings"
|
|
13
|
+
"testing"
|
|
14
|
+
|
|
15
|
+
"github.com/go-chi/chi/v5"
|
|
16
|
+
"github.com/google/uuid"
|
|
17
|
+
|
|
18
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
|
|
19
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
20
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
const (
|
|
24
|
+
keyA = "0123456789abcdef0123456789abcdef"
|
|
25
|
+
keyB = "fedcba9876543210fedcba9876543210"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
func sid(t *testing.T) string {
|
|
29
|
+
t.Helper()
|
|
30
|
+
b := make([]byte, 32)
|
|
31
|
+
if _, err := rand.Read(b); err != nil {
|
|
32
|
+
t.Fatal(err)
|
|
33
|
+
}
|
|
34
|
+
return base64.RawURLEncoding.EncodeToString(b)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func mustCSRF(t *testing.T, cur string, prev ...string) *auth.CSRF {
|
|
38
|
+
t.Helper()
|
|
39
|
+
c, err := auth.NewCSRF(cur, prev...)
|
|
40
|
+
if err != nil {
|
|
41
|
+
t.Fatal(err)
|
|
42
|
+
}
|
|
43
|
+
return c
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type denial struct{ action, reason string }
|
|
47
|
+
|
|
48
|
+
// csrfChain mirrors production order: session middleware, then CSRF, then router.
|
|
49
|
+
func csrfChain(c *auth.CSRF, v auth.Validator, got *denial) http.Handler {
|
|
50
|
+
r := chi.NewRouter()
|
|
51
|
+
r.NotFound(web.NotFound)
|
|
52
|
+
r.MethodNotAllowed(web.MethodNotAllowed)
|
|
53
|
+
ok := func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
|
|
54
|
+
r.Get("/auth/csrf", auth.Handler{CSRF: c, Log: slog.New(slog.DiscardHandler)}.GetCsrfToken)
|
|
55
|
+
r.Post("/w", ok)
|
|
56
|
+
r.Put("/w", ok)
|
|
57
|
+
r.Delete("/w", ok)
|
|
58
|
+
r.Post("/auth/logout", ok)
|
|
59
|
+
fail := func(w http.ResponseWriter, req *http.Request, err error) {
|
|
60
|
+
var e *web.Error
|
|
61
|
+
if errors.As(err, &e) && got != nil {
|
|
62
|
+
*got = denial{e.AuditAction, e.AuditReason}
|
|
63
|
+
}
|
|
64
|
+
web.WriteError(w, req, err)
|
|
65
|
+
}
|
|
66
|
+
action := func(req *http.Request) string {
|
|
67
|
+
if req.URL.Path == "/auth/logout" {
|
|
68
|
+
return "AUTH_LOGOUT"
|
|
69
|
+
}
|
|
70
|
+
return ""
|
|
71
|
+
}
|
|
72
|
+
public := func(req *http.Request) bool { return req.URL.Path == "/auth/csrf" || req.URL.Path == "/auth/logout" }
|
|
73
|
+
log := slog.New(slog.DiscardHandler)
|
|
74
|
+
return web.RequestID(auth.Middleware(v, public, log)(c.Middleware(fail, action)(r)))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func fetch(t *testing.T, h http.Handler, session string) string {
|
|
78
|
+
t.Helper()
|
|
79
|
+
req := httptest.NewRequest("GET", "/auth/csrf", nil)
|
|
80
|
+
if session != "" {
|
|
81
|
+
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
|
|
82
|
+
}
|
|
83
|
+
rec := httptest.NewRecorder()
|
|
84
|
+
h.ServeHTTP(rec, req)
|
|
85
|
+
if rec.Code != 200 {
|
|
86
|
+
t.Fatalf("GET /auth/csrf = %d %s", rec.Code, rec.Body)
|
|
87
|
+
}
|
|
88
|
+
var body struct{ Token string }
|
|
89
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Token == "" {
|
|
90
|
+
t.Fatalf("body %s: %v", rec.Body, err)
|
|
91
|
+
}
|
|
92
|
+
var ck *http.Cookie
|
|
93
|
+
for _, c := range rec.Result().Cookies() {
|
|
94
|
+
if c.Name == auth.CSRFCookie {
|
|
95
|
+
ck = c
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if ck == nil || ck.Value != body.Token {
|
|
99
|
+
t.Fatalf("cookie %v does not match body token", ck)
|
|
100
|
+
}
|
|
101
|
+
if !ck.HttpOnly || !ck.Secure || ck.Path != "/" || ck.Domain != "" || ck.SameSite != http.SameSiteLaxMode {
|
|
102
|
+
t.Errorf("cookie attributes: %+v", ck)
|
|
103
|
+
}
|
|
104
|
+
if rec.Header().Get("Cache-Control") != "no-store" {
|
|
105
|
+
t.Errorf("Cache-Control = %q", rec.Header().Get("Cache-Control"))
|
|
106
|
+
}
|
|
107
|
+
return body.Token
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func write(method, path, session, cookie, header string) *http.Request {
|
|
111
|
+
req := httptest.NewRequest(method, path, strings.NewReader("ignored"))
|
|
112
|
+
if session != "" {
|
|
113
|
+
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
|
|
114
|
+
}
|
|
115
|
+
if cookie != "" {
|
|
116
|
+
req.AddCookie(&http.Cookie{Name: auth.CSRFCookie, Value: cookie})
|
|
117
|
+
}
|
|
118
|
+
if header != "" {
|
|
119
|
+
req.Header.Set(auth.CSRFHeader, header)
|
|
120
|
+
}
|
|
121
|
+
return req
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func TestCSRFWrites(t *testing.T) {
|
|
125
|
+
c := mustCSRF(t, keyA)
|
|
126
|
+
s1, s2 := sid(t), sid(t)
|
|
127
|
+
user := security.Principal{UserID: uuid.New(), EmpNo: "U1", Role: security.RoleUser}
|
|
128
|
+
v := &fakeValidator{live: map[string]security.Principal{s1: user, s2: user}}
|
|
129
|
+
h := csrfChain(c, v, nil)
|
|
130
|
+
|
|
131
|
+
anon, tok1, tok2 := fetch(t, h, ""), fetch(t, h, s1), fetch(t, h, s2)
|
|
132
|
+
other := mustCSRF(t, keyB)
|
|
133
|
+
foreign, _ := other.Issue([]byte("x"))
|
|
134
|
+
|
|
135
|
+
tests := []struct {
|
|
136
|
+
name string
|
|
137
|
+
method, path string
|
|
138
|
+
session, cookie, header string
|
|
139
|
+
status int
|
|
140
|
+
}{
|
|
141
|
+
{"session token, own session", "POST", "/w", s1, tok1, tok1, 204},
|
|
142
|
+
{"anonymous token, no session", "POST", "/auth/logout", "", anon, anon, 204},
|
|
143
|
+
{"PUT and DELETE are checked", "PUT", "/w", s1, "", "", 403},
|
|
144
|
+
{"DELETE with token", "DELETE", "/w", s1, tok1, tok1, 204},
|
|
145
|
+
{"no header", "POST", "/w", s1, tok1, "", 403},
|
|
146
|
+
{"no cookie", "POST", "/w", s1, "", tok1, 403},
|
|
147
|
+
{"cookie and header differ", "POST", "/w", s1, tok1, tok2, 403},
|
|
148
|
+
{"other session's token", "POST", "/w", s1, tok2, tok2, 403},
|
|
149
|
+
{"anonymous token against a session", "POST", "/w", s1, anon, anon, 403},
|
|
150
|
+
{"session token without a session (logout)", "POST", "/auth/logout", "", tok1, tok1, 403},
|
|
151
|
+
{"session token, stale session cookie (logout)", "POST", "/auth/logout", sid(t), tok1, tok1, 403},
|
|
152
|
+
{"signed by an unknown key", "POST", "/w", s1, foreign, foreign, 403},
|
|
153
|
+
{"garbage", "POST", "/w", s1, "abc", "abc", 403},
|
|
154
|
+
{"truncated signature", "POST", "/w", s1, tok1[:len(tok1)-2], tok1[:len(tok1)-2], 403},
|
|
155
|
+
{"no dot", "POST", "/w", s1, strings.ReplaceAll(tok1, ".", ""), strings.ReplaceAll(tok1, ".", ""), 403},
|
|
156
|
+
{"403 beats 404 on unknown path", "POST", "/nope", s1, "", "", 403},
|
|
157
|
+
{"unknown path, valid token, is 404", "POST", "/nope", s1, tok1, tok1, 404},
|
|
158
|
+
{"401 beats 403", "POST", "/w", "", "", "", 401},
|
|
159
|
+
}
|
|
160
|
+
for _, tc := range tests {
|
|
161
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
162
|
+
rec := httptest.NewRecorder()
|
|
163
|
+
h.ServeHTTP(rec, write(tc.method, tc.path, tc.session, tc.cookie, tc.header))
|
|
164
|
+
if rec.Code != tc.status {
|
|
165
|
+
t.Fatalf("status = %d, want %d (%s)", rec.Code, tc.status, rec.Body)
|
|
166
|
+
}
|
|
167
|
+
if tc.status == 403 && !strings.Contains(rec.Body.String(), `"code":"CSRF_INVALID"`) {
|
|
168
|
+
t.Errorf("body %s", rec.Body)
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
func TestCSRFSafeMethodsAreNotChecked(t *testing.T) {
|
|
175
|
+
h := csrfChain(mustCSRF(t, keyA), &fakeValidator{}, nil)
|
|
176
|
+
for _, m := range []string{"GET", "HEAD", "OPTIONS"} {
|
|
177
|
+
rec := httptest.NewRecorder()
|
|
178
|
+
h.ServeHTTP(rec, httptest.NewRequest(m, "/auth/csrf", nil))
|
|
179
|
+
if rec.Code == 403 {
|
|
180
|
+
t.Errorf("%s was CSRF-checked", m)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
func TestCSRFNeverReadsBody(t *testing.T) {
|
|
186
|
+
h := csrfChain(mustCSRF(t, keyA), &fakeValidator{}, nil)
|
|
187
|
+
req := write("POST", "/auth/logout", "", "", "")
|
|
188
|
+
req.Body = failReader{t}
|
|
189
|
+
h.ServeHTTP(httptest.NewRecorder(), req)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
type failReader struct{ t *testing.T }
|
|
193
|
+
|
|
194
|
+
func (f failReader) Read([]byte) (int, error) { f.t.Error("body was read"); return 0, context.Canceled }
|
|
195
|
+
func (failReader) Close() error { return nil }
|
|
196
|
+
|
|
197
|
+
func TestCSRFDenialAuditFields(t *testing.T) {
|
|
198
|
+
c := mustCSRF(t, keyA)
|
|
199
|
+
anon := fetch(t, csrfChain(c, &fakeValidator{}, nil), "")
|
|
200
|
+
for _, tc := range []struct{ name, cookie, header, reason string }{
|
|
201
|
+
{"missing", "", "", "csrf_missing"},
|
|
202
|
+
{"mismatch", anon, anon + "x", "csrf_mismatch"},
|
|
203
|
+
{"bad signature", "a.b", "a.b", "csrf_invalid_binding"},
|
|
204
|
+
} {
|
|
205
|
+
var d denial
|
|
206
|
+
rec := httptest.NewRecorder()
|
|
207
|
+
csrfChain(c, &fakeValidator{}, &d).ServeHTTP(rec, write("POST", "/auth/logout", "", tc.cookie, tc.header))
|
|
208
|
+
if rec.Code != 403 || d != (denial{"AUTH_LOGOUT", tc.reason}) {
|
|
209
|
+
t.Errorf("%s: status %d, audit %+v", tc.name, rec.Code, d)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Unrouted write: denied, but nothing to audit.
|
|
213
|
+
var d denial
|
|
214
|
+
csrfChain(c, &fakeValidator{live: map[string]security.Principal{"x": {}}}, &d).
|
|
215
|
+
ServeHTTP(httptest.NewRecorder(), write("POST", "/w", "", "", ""))
|
|
216
|
+
if d != (denial{}) {
|
|
217
|
+
t.Errorf("unknown action was audited: %+v", d)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
func TestCSRFTokensAreFresh(t *testing.T) {
|
|
222
|
+
h := csrfChain(mustCSRF(t, keyA), &fakeValidator{}, nil)
|
|
223
|
+
first, second := fetch(t, h, ""), fetch(t, h, "")
|
|
224
|
+
if first == second {
|
|
225
|
+
t.Error("two tokens are equal")
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
func TestCSRFKeyRotation(t *testing.T) {
|
|
230
|
+
b := []byte("binding")
|
|
231
|
+
old := mustCSRF(t, keyA)
|
|
232
|
+
tok, _ := old.Issue(b)
|
|
233
|
+
|
|
234
|
+
rotated := mustCSRF(t, keyB, keyA)
|
|
235
|
+
if !rotated.Verify(tok, b) {
|
|
236
|
+
t.Error("previous key must still verify")
|
|
237
|
+
}
|
|
238
|
+
if fresh, _ := rotated.Issue(b); !mustCSRF(t, keyB).Verify(fresh, b) || old.Verify(fresh, b) {
|
|
239
|
+
t.Error("new tokens must be signed by the current key only")
|
|
240
|
+
}
|
|
241
|
+
if mustCSRF(t, keyB).Verify(tok, b) {
|
|
242
|
+
t.Error("after the previous key is dropped the old token must fail")
|
|
243
|
+
}
|
|
244
|
+
if !mustCSRF(t, keyB, "").Verify(mustTok(t, mustCSRF(t, keyB), b), b) {
|
|
245
|
+
t.Error("empty previous key means none")
|
|
246
|
+
}
|
|
247
|
+
if old.Verify(tok, []byte("other")) {
|
|
248
|
+
t.Error("binding must be part of the signature")
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
func mustTok(t *testing.T, c *auth.CSRF, b []byte) string {
|
|
253
|
+
t.Helper()
|
|
254
|
+
tok, err := c.Issue(b)
|
|
255
|
+
if err != nil {
|
|
256
|
+
t.Fatal(err)
|
|
257
|
+
}
|
|
258
|
+
return tok
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
func TestNewCSRFRejectsShortKeys(t *testing.T) {
|
|
262
|
+
if _, err := auth.NewCSRF("short"); err == nil {
|
|
263
|
+
t.Error("short current key accepted")
|
|
264
|
+
}
|
|
265
|
+
if _, err := auth.NewCSRF(keyA, "short"); err == nil {
|
|
266
|
+
t.Error("short previous key accepted")
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
func TestCSRFSessionWithUnbindableIDIs500(t *testing.T) {
|
|
271
|
+
// A validator that accepts an ID Binding cannot parse is a wiring bug: fail closed.
|
|
272
|
+
v := &fakeValidator{live: map[string]security.Principal{"not-an-id": {Role: security.RoleUser}}}
|
|
273
|
+
h := csrfChain(mustCSRF(t, keyA), v, nil)
|
|
274
|
+
req := httptest.NewRequest("GET", "/auth/csrf", nil)
|
|
275
|
+
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: "not-an-id"})
|
|
276
|
+
rec := httptest.NewRecorder()
|
|
277
|
+
h.ServeHTTP(rec, req)
|
|
278
|
+
if rec.Code != 500 {
|
|
279
|
+
t.Errorf("status = %d", rec.Code)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -20,9 +20,14 @@ type Limits struct {
|
|
|
20
20
|
RefreshSeconds int
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// Execer is *sql.DB or *sql.Tx.
|
|
24
|
+
type Execer interface {
|
|
25
|
+
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
// Insert stores a new session row. hash is SHA-256 of the session ID.
|
|
24
|
-
func Insert(ctx context.Context,
|
|
25
|
-
_, err :=
|
|
29
|
+
func Insert(ctx context.Context, x Execer, hash []byte, userID uuid.UUID) error {
|
|
30
|
+
_, err := x.ExecContext(ctx,
|
|
26
31
|
`INSERT INTO session (id_hash, user_id) VALUES (?, ?)`, hash, userID.String())
|
|
27
32
|
if err != nil {
|
|
28
33
|
return fmt.Errorf("insert session: %w", err)
|
|
@@ -77,9 +82,10 @@ func Principal(ctx context.Context, db *sql.DB, hash []byte) (p security.Princip
|
|
|
77
82
|
return p, true, nil
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
// Delete removes one session; deleting a missing one is not an error.
|
|
81
|
-
|
|
82
|
-
|
|
85
|
+
// Delete removes one session; deleting a missing one is not an error. x is the
|
|
86
|
+
// *sql.DB, or the caller's *sql.Tx so logout commits with its audit row.
|
|
87
|
+
func Delete(ctx context.Context, x Execer, hash []byte) error {
|
|
88
|
+
if _, err := x.ExecContext(ctx, `DELETE FROM session WHERE id_hash = ?`, hash); err != nil {
|
|
83
89
|
return fmt.Errorf("delete session: %w", err)
|
|
84
90
|
}
|
|
85
91
|
return nil
|
|
@@ -109,3 +115,55 @@ func DeleteExpired(ctx context.Context, db *sql.DB, l Limits) (int64, error) {
|
|
|
109
115
|
}
|
|
110
116
|
return n, nil
|
|
111
117
|
}
|
|
118
|
+
|
|
119
|
+
// Account is the login view of app_user: what is needed to check a password
|
|
120
|
+
// and answer the login response. PasswordHash never leaves the auth module.
|
|
121
|
+
type Account struct {
|
|
122
|
+
ID uuid.UUID
|
|
123
|
+
EmpNo string
|
|
124
|
+
Name string
|
|
125
|
+
Email *string // NULL for Admin
|
|
126
|
+
PasswordHash string
|
|
127
|
+
Role security.Role
|
|
128
|
+
Enabled bool
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// AccountByEmpNo reads one account by employee ID; found is false when none
|
|
132
|
+
// exists.
|
|
133
|
+
func AccountByEmpNo(ctx context.Context, db *sql.DB, empNo string) (a Account, found bool, err error) {
|
|
134
|
+
var id, role string
|
|
135
|
+
err = db.QueryRowContext(ctx,
|
|
136
|
+
`SELECT id, emp_no, name, email, password_hash, role, enabled
|
|
137
|
+
FROM app_user WHERE emp_no = ?`, empNo).
|
|
138
|
+
Scan(&id, &a.EmpNo, &a.Name, &a.Email, &a.PasswordHash, &role, &a.Enabled)
|
|
139
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
140
|
+
return Account{}, false, nil
|
|
141
|
+
}
|
|
142
|
+
if err != nil {
|
|
143
|
+
return Account{}, false, fmt.Errorf("read account: %w", err)
|
|
144
|
+
}
|
|
145
|
+
if a.ID, err = uuid.Parse(id); err != nil {
|
|
146
|
+
return Account{}, false, fmt.Errorf("parse user id %q: %w", id, err)
|
|
147
|
+
}
|
|
148
|
+
a.Role = security.Role(role)
|
|
149
|
+
return a, true, nil
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// AccountByID reads one account by its user ID; found is false when none
|
|
153
|
+
// exists.
|
|
154
|
+
func AccountByID(ctx context.Context, db *sql.DB, id uuid.UUID) (a Account, found bool, err error) {
|
|
155
|
+
var sid, role string
|
|
156
|
+
err = db.QueryRowContext(ctx,
|
|
157
|
+
`SELECT id, emp_no, name, email, password_hash, role, enabled
|
|
158
|
+
FROM app_user WHERE id = ?`, id.String()).
|
|
159
|
+
Scan(&sid, &a.EmpNo, &a.Name, &a.Email, &a.PasswordHash, &role, &a.Enabled)
|
|
160
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
161
|
+
return Account{}, false, nil
|
|
162
|
+
}
|
|
163
|
+
if err != nil {
|
|
164
|
+
return Account{}, false, fmt.Errorf("read account by id: %w", err)
|
|
165
|
+
}
|
|
166
|
+
a.ID = id
|
|
167
|
+
a.Role = security.Role(role)
|
|
168
|
+
return a, true, nil
|
|
169
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
//go:build integration
|
|
2
|
+
|
|
3
|
+
package auth_test
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"bytes"
|
|
7
|
+
"log/slog"
|
|
8
|
+
"net/http"
|
|
9
|
+
"net/http/httptest"
|
|
10
|
+
"strings"
|
|
11
|
+
"sync"
|
|
12
|
+
"testing"
|
|
13
|
+
|
|
14
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
type syncBuf struct {
|
|
18
|
+
mu sync.Mutex
|
|
19
|
+
b bytes.Buffer
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func (s *syncBuf) Write(p []byte) (int, error) {
|
|
23
|
+
s.mu.Lock()
|
|
24
|
+
defer s.mu.Unlock()
|
|
25
|
+
return s.b.Write(p)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func (s *syncBuf) String() string {
|
|
29
|
+
s.mu.Lock()
|
|
30
|
+
defer s.mu.Unlock()
|
|
31
|
+
return s.b.String()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// TestLogsNeverContainSecrets drives login (good and bad), csrf, me, logout and
|
|
35
|
+
// a rejected write at DEBUG level and asserts no secret reaches any log line
|
|
36
|
+
// (PRD §6.5 / §8.2: no password, session ID, CSRF token or Cookie in logs).
|
|
37
|
+
func TestLogsNeverContainSecrets(t *testing.T) {
|
|
38
|
+
buf := &syncBuf{}
|
|
39
|
+
e := newLoginEnvLog(t, slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
|
40
|
+
e.addUser(t, "123456", "USER", true)
|
|
41
|
+
|
|
42
|
+
const wrongPW = "Wr0ng-Secret-Passw0rd!"
|
|
43
|
+
secrets := []string{password, wrongPW}
|
|
44
|
+
|
|
45
|
+
e.login(t, creds("123456", wrongPW))
|
|
46
|
+
e.login(t, creds("999999", wrongPW))
|
|
47
|
+
e.login(t, `{"empNo":"123456","password":"`+wrongPW+`"`) // malformed JSON echoing the password
|
|
48
|
+
|
|
49
|
+
rec := e.login(t, creds("123456", password))
|
|
50
|
+
sess := findCookie(rec, auth.SessionCookie)
|
|
51
|
+
if sess == nil {
|
|
52
|
+
t.Fatalf("login failed: %d", rec.Code)
|
|
53
|
+
}
|
|
54
|
+
secrets = append(secrets, sess.Value)
|
|
55
|
+
|
|
56
|
+
tok, ck := e.csrf(t, sess)
|
|
57
|
+
secrets = append(secrets, tok, ck.Value)
|
|
58
|
+
|
|
59
|
+
if rec := e.me(t, true, sess.Value); rec.Code != 200 {
|
|
60
|
+
t.Fatalf("me: %d", rec.Code)
|
|
61
|
+
}
|
|
62
|
+
// Write with a bad CSRF token: denied, and the submitted token must not be logged.
|
|
63
|
+
bad := httptest.NewRequest("POST", "/api/v1/auth/logout", nil)
|
|
64
|
+
bad.Header.Set("X-CSRF-Token", "forged-token-value-123")
|
|
65
|
+
bad.AddCookie(sess)
|
|
66
|
+
bad.AddCookie(&http.Cookie{Name: "__Host-csrf", Value: "forged-cookie-value-456"})
|
|
67
|
+
e.do(bad)
|
|
68
|
+
secrets = append(secrets, "forged-token-value-123", "forged-cookie-value-456")
|
|
69
|
+
|
|
70
|
+
if rec := e.logout(tok, ck, sess); rec.Code != 204 {
|
|
71
|
+
t.Fatalf("logout: %d", rec.Code)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
out := buf.String()
|
|
75
|
+
if strings.TrimSpace(out) == "" {
|
|
76
|
+
t.Fatal("no log output captured; the test would prove nothing")
|
|
77
|
+
}
|
|
78
|
+
for _, s := range secrets {
|
|
79
|
+
if s != "" && strings.Contains(out, s) {
|
|
80
|
+
t.Errorf("log contains secret %q", s)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for _, name := range []string{"Set-Cookie", "set-cookie", "__Host-session=", "X-CSRF-Token"} {
|
|
84
|
+
if strings.Contains(out, name) {
|
|
85
|
+
t.Errorf("log contains header material %q", name)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|