@johnnyboy11234/esg-idea-contest 0.2.2 → 0.2.4
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 +10 -2
- package/backend/internal/account/logging_integration_test.go +70 -0
- package/backend/internal/api/api.gen.go +125 -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/session.go +20 -1
- package/backend/internal/server/api.go +14 -0
- package/backend/internal/server/public.go +3 -1
- package/backend/internal/shared/web/problem.go +9 -0
- package/backend/internal/shared/web/retryafter_test.go +31 -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 +53 -22
- 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/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,252 @@
|
|
|
1
|
+
package auth
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"net/http"
|
|
9
|
+
"net/netip"
|
|
10
|
+
"regexp"
|
|
11
|
+
"time"
|
|
12
|
+
|
|
13
|
+
"golang.org/x/crypto/bcrypt"
|
|
14
|
+
|
|
15
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/api"
|
|
16
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
|
|
17
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/auth/internal/store"
|
|
18
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/db"
|
|
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
|
+
// bcryptCost is the only cost the table accepts (PRD §6.4, OI-13); the
|
|
25
|
+
// dummy hash uses it too, so unknown and known accounts cost the same.
|
|
26
|
+
bcryptCost = 10
|
|
27
|
+
// bcryptMaxBytes is bcrypt's input limit. Longer input is cut for the dummy
|
|
28
|
+
// comparison and never matches (ADR G3).
|
|
29
|
+
bcryptMaxBytes = 72
|
|
30
|
+
|
|
31
|
+
maxLoginBody = 16 << 10 // error-codes.md §2: 16 KiB → 413
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
var empNoFormat = regexp.MustCompile(`^[0-9]{6}$`)
|
|
35
|
+
|
|
36
|
+
// Throttle is the login throttling hook (PRD §6.5). B1b-04 implements it on
|
|
37
|
+
// login_lock / login_attempt; until then NoThrottle is wired. Errors fail the
|
|
38
|
+
// login closed (500): an unreachable counter must not disable the limit.
|
|
39
|
+
type Throttle interface {
|
|
40
|
+
// Check runs before any password work. A positive retryAfter means the
|
|
41
|
+
// attempt is throttled: it answers 429, runs no bcrypt and is not counted.
|
|
42
|
+
// key is the submitted employee-ID string as typed; implementations strip
|
|
43
|
+
// control characters and cut it to 32 characters.
|
|
44
|
+
Check(ctx context.Context, key string, ip netip.Addr) (retryAfter time.Duration, err error)
|
|
45
|
+
// Fail counts one failed attempt (also for unknown accounts).
|
|
46
|
+
Fail(ctx context.Context, key string, ip netip.Addr) error
|
|
47
|
+
// Succeed clears the key's counter.
|
|
48
|
+
Succeed(ctx context.Context, key string) error
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// NoThrottle never throttles.
|
|
52
|
+
type NoThrottle struct{}
|
|
53
|
+
|
|
54
|
+
// Check implements Throttle.
|
|
55
|
+
func (NoThrottle) Check(context.Context, string, netip.Addr) (time.Duration, error) { return 0, nil }
|
|
56
|
+
|
|
57
|
+
// Fail implements Throttle.
|
|
58
|
+
func (NoThrottle) Fail(context.Context, string, netip.Addr) error { return nil }
|
|
59
|
+
|
|
60
|
+
// Succeed implements Throttle.
|
|
61
|
+
func (NoThrottle) Succeed(context.Context, string) error { return nil }
|
|
62
|
+
|
|
63
|
+
// Recorder writes the success audit row in the login transaction.
|
|
64
|
+
// *audit.Service implements it.
|
|
65
|
+
type Recorder interface {
|
|
66
|
+
Record(ctx context.Context, tx *sql.Tx, e audit.Event) error
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Logins implements POST /auth/login.
|
|
70
|
+
type Logins struct {
|
|
71
|
+
Sessions *Sessions
|
|
72
|
+
Audit Recorder
|
|
73
|
+
Throttle Throttle
|
|
74
|
+
|
|
75
|
+
dummyHash []byte
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// NewLogins builds the service and computes the dummy bcrypt hash once
|
|
79
|
+
// (ADR G3), so a request never pays for it and unknown accounts cost one
|
|
80
|
+
// cost-10 comparison like known ones.
|
|
81
|
+
func NewLogins(s *Sessions, rec Recorder, t Throttle) (*Logins, error) {
|
|
82
|
+
if t == nil {
|
|
83
|
+
t = NoThrottle{}
|
|
84
|
+
}
|
|
85
|
+
h, err := bcrypt.GenerateFromPassword([]byte("dummy password for timing"), bcryptCost)
|
|
86
|
+
if err != nil {
|
|
87
|
+
return nil, fmt.Errorf("dummy hash: %w", err)
|
|
88
|
+
}
|
|
89
|
+
return &Logins{Sessions: s, Audit: rec, Throttle: t, dummyHash: h}, nil
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// LoginCommand is the raw input.
|
|
93
|
+
type LoginCommand struct{ EmpNo, Password string }
|
|
94
|
+
|
|
95
|
+
// Request is the transport facts the audit row needs.
|
|
96
|
+
type Request struct {
|
|
97
|
+
Method, Route, UserAgent string
|
|
98
|
+
IP netip.Addr
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// LoggedIn is the result of a successful login.
|
|
102
|
+
type LoggedIn struct {
|
|
103
|
+
SessionID string
|
|
104
|
+
EmpNo string
|
|
105
|
+
Name string
|
|
106
|
+
Email *string
|
|
107
|
+
Role security.Role
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Login checks the credentials and opens a session. Every refusal is a
|
|
111
|
+
// *web.Error carrying the AUTH_LOGIN audit fields (FAILED or THROTTLED), so the
|
|
112
|
+
// central adapter writes the track-B row; success writes its row in the same
|
|
113
|
+
// transaction as the session (fail-closed).
|
|
114
|
+
//
|
|
115
|
+
// Unknown, badly formatted and disabled accounts and wrong passwords answer
|
|
116
|
+
// the same 401 after the same one bcrypt comparison.
|
|
117
|
+
func (l *Logins) Login(ctx context.Context, cmd LoginCommand, req Request) (LoggedIn, error) {
|
|
118
|
+
retry, err := l.Throttle.Check(ctx, cmd.EmpNo, req.IP)
|
|
119
|
+
if err != nil {
|
|
120
|
+
return LoggedIn{}, fmt.Errorf("login throttle check: %w", err)
|
|
121
|
+
}
|
|
122
|
+
if retry > 0 {
|
|
123
|
+
e := web.NewError(web.CodeLoginThrottled, "too many login attempts")
|
|
124
|
+
e.RetryAfter = retry
|
|
125
|
+
return LoggedIn{}, loginRefused(e, audit.Throttled, "throttled", cmd.EmpNo)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
var (
|
|
129
|
+
acct store.Account
|
|
130
|
+
found bool
|
|
131
|
+
reason = "bad_format"
|
|
132
|
+
)
|
|
133
|
+
if empNoFormat.MatchString(cmd.EmpNo) {
|
|
134
|
+
if acct, found, err = store.AccountByEmpNo(ctx, l.Sessions.DB, cmd.EmpNo); err != nil {
|
|
135
|
+
return LoggedIn{}, err
|
|
136
|
+
}
|
|
137
|
+
reason = "unknown_account"
|
|
138
|
+
}
|
|
139
|
+
hash := l.dummyHash
|
|
140
|
+
if found {
|
|
141
|
+
hash, reason = []byte(acct.PasswordHash), ""
|
|
142
|
+
if !acct.Enabled {
|
|
143
|
+
hash, reason = l.dummyHash, "disabled"
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
ok := comparePassword(hash, cmd.Password)
|
|
147
|
+
switch {
|
|
148
|
+
case reason != "":
|
|
149
|
+
case len(cmd.Password) > bcryptMaxBytes:
|
|
150
|
+
reason = "password_too_long"
|
|
151
|
+
case !ok:
|
|
152
|
+
reason = "wrong_password"
|
|
153
|
+
}
|
|
154
|
+
if reason != "" {
|
|
155
|
+
if err := l.Throttle.Fail(ctx, cmd.EmpNo, req.IP); err != nil {
|
|
156
|
+
return LoggedIn{}, fmt.Errorf("login throttle fail: %w", err)
|
|
157
|
+
}
|
|
158
|
+
return LoggedIn{}, loginRefused(web.NewError(web.CodeInvalidCredentials, "invalid employee ID or password"),
|
|
159
|
+
audit.Failed, reason, cmd.EmpNo)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if err := l.Throttle.Succeed(ctx, cmd.EmpNo); err != nil {
|
|
163
|
+
return LoggedIn{}, fmt.Errorf("login throttle clear: %w", err)
|
|
164
|
+
}
|
|
165
|
+
var sid string
|
|
166
|
+
_, err = db.InTx(ctx, l.Sessions.DB, func(ctx context.Context, tx *sql.Tx) error {
|
|
167
|
+
var err error
|
|
168
|
+
if sid, err = l.Sessions.CreateIn(ctx, tx, acct.ID); err != nil {
|
|
169
|
+
return err
|
|
170
|
+
}
|
|
171
|
+
return l.Audit.Record(ctx, tx, audit.Event{
|
|
172
|
+
Action: audit.AuthLogin, Outcome: audit.Success,
|
|
173
|
+
ActorRole: security.Principal{Role: acct.Role}.ActorRole(), ActorEmpNo: acct.EmpNo,
|
|
174
|
+
TargetType: audit.TargetUser, TargetID: acct.ID,
|
|
175
|
+
Method: req.Method, Route: req.Route, Status: http.StatusOK,
|
|
176
|
+
IP: req.IP, UserAgent: req.UserAgent,
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
if err != nil {
|
|
180
|
+
return LoggedIn{}, err
|
|
181
|
+
}
|
|
182
|
+
return LoggedIn{SessionID: sid, EmpNo: acct.EmpNo, Name: acct.Name, Email: acct.Email, Role: acct.Role}, nil
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// comparePassword runs exactly one cost-10 comparison. Input beyond bcrypt's
|
|
186
|
+
// 72 bytes is cut for the comparison against the dummy hash and never
|
|
187
|
+
// succeeds: newer x/crypto refuses such input before doing any work, which
|
|
188
|
+
// would make it fast and so distinguishable (ADR G3).
|
|
189
|
+
func comparePassword(hash []byte, password string) bool {
|
|
190
|
+
pw := []byte(password)
|
|
191
|
+
if len(pw) > bcryptMaxBytes {
|
|
192
|
+
_ = bcrypt.CompareHashAndPassword(hash, pw[:bcryptMaxBytes])
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
195
|
+
return bcrypt.CompareHashAndPassword(hash, pw) == nil
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
func loginRefused(e *web.Error, outcome audit.Outcome, reason, submitted string) *web.Error {
|
|
199
|
+
e.AuditAction = string(audit.AuthLogin)
|
|
200
|
+
e.AuditOutcome = string(outcome)
|
|
201
|
+
e.AuditReason = reason
|
|
202
|
+
e.AuditSubmittedEmpNo = submitted
|
|
203
|
+
return e
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// loginBody holds pointers so DecodeJSON can tell absent/null (400) from empty.
|
|
207
|
+
type loginBody struct {
|
|
208
|
+
EmpNo *string `json:"empNo"`
|
|
209
|
+
Password *string `json:"password"`
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// LoginHandler is the HTTP adapter of Logins. Fail is the central error
|
|
213
|
+
// adapter (server.ErrorAdapter.Write); the module cannot import server.
|
|
214
|
+
type LoginHandler struct {
|
|
215
|
+
Logins *Logins
|
|
216
|
+
Fail func(w http.ResponseWriter, r *http.Request, err error)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Login serves POST /auth/login: 200 {empNo, name, email, role} and the
|
|
220
|
+
// session cookie.
|
|
221
|
+
func (h LoginHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|
222
|
+
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBody)
|
|
223
|
+
var b loginBody
|
|
224
|
+
if err := web.DecodeJSON(r, &b); err != nil {
|
|
225
|
+
h.Fail(w, r, auditMalformed(err))
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
res, err := h.Logins.Login(r.Context(), LoginCommand{EmpNo: *b.EmpNo, Password: *b.Password}, Request{
|
|
229
|
+
Method: r.Method, Route: web.RoutePattern(r), UserAgent: r.UserAgent(), IP: web.ClientIP(r),
|
|
230
|
+
})
|
|
231
|
+
if err != nil {
|
|
232
|
+
h.Fail(w, r, err)
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
http.SetCookie(w, &http.Cookie{
|
|
236
|
+
Name: SessionCookie, Value: res.SessionID, Path: "/",
|
|
237
|
+
Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
|
238
|
+
})
|
|
239
|
+
web.WriteJSON(w, http.StatusOK, api.LoginResponse{
|
|
240
|
+
EmpNo: res.EmpNo, Name: res.Name, Email: res.Email, Role: api.LoginResponseRole(res.Role),
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// auditMalformed marks a 400 as a FAILED login. Other transport errors (413,
|
|
245
|
+
// 415) are not audited.
|
|
246
|
+
func auditMalformed(err error) error {
|
|
247
|
+
var e *web.Error
|
|
248
|
+
if errors.As(err, &e) && e.Code == web.CodeMalformedRequest {
|
|
249
|
+
return loginRefused(e, audit.Failed, "malformed_request", "")
|
|
250
|
+
}
|
|
251
|
+
return err
|
|
252
|
+
}
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
//go:build integration
|
|
2
|
+
|
|
3
|
+
package auth_test
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"database/sql"
|
|
8
|
+
"encoding/json"
|
|
9
|
+
"log/slog"
|
|
10
|
+
"net/http"
|
|
11
|
+
"net/http/httptest"
|
|
12
|
+
"net/netip"
|
|
13
|
+
"strings"
|
|
14
|
+
"sync"
|
|
15
|
+
"testing"
|
|
16
|
+
"time"
|
|
17
|
+
|
|
18
|
+
"github.com/google/uuid"
|
|
19
|
+
"golang.org/x/crypto/bcrypt"
|
|
20
|
+
|
|
21
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
|
|
22
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
|
|
23
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/server"
|
|
24
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
|
|
25
|
+
"github.com/brian-huanggg/AwardRegistration/backend/test/apispec"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const password = "Correct-Horse-1"
|
|
29
|
+
|
|
30
|
+
type fakeThrottle struct {
|
|
31
|
+
mu sync.Mutex
|
|
32
|
+
retry time.Duration
|
|
33
|
+
err error
|
|
34
|
+
checks, fails, oks int
|
|
35
|
+
lastKey string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func (f *fakeThrottle) Check(_ context.Context, key string, _ netip.Addr) (time.Duration, error) {
|
|
39
|
+
f.mu.Lock()
|
|
40
|
+
defer f.mu.Unlock()
|
|
41
|
+
f.checks++
|
|
42
|
+
f.lastKey = key
|
|
43
|
+
return f.retry, f.err
|
|
44
|
+
}
|
|
45
|
+
func (f *fakeThrottle) Fail(context.Context, string, netip.Addr) error {
|
|
46
|
+
f.mu.Lock()
|
|
47
|
+
defer f.mu.Unlock()
|
|
48
|
+
f.fails++
|
|
49
|
+
return nil
|
|
50
|
+
}
|
|
51
|
+
func (f *fakeThrottle) Succeed(context.Context, string) error {
|
|
52
|
+
f.mu.Lock()
|
|
53
|
+
defer f.mu.Unlock()
|
|
54
|
+
f.oks++
|
|
55
|
+
return nil
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type loginEnv struct {
|
|
59
|
+
db *sql.DB
|
|
60
|
+
h http.Handler
|
|
61
|
+
th *fakeThrottle
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func newLoginEnv(t *testing.T) loginEnv {
|
|
65
|
+
t.Helper()
|
|
66
|
+
return newLoginEnvLog(t, slog.New(slog.DiscardHandler))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func newLoginEnvLog(t *testing.T, log *slog.Logger) loginEnv {
|
|
70
|
+
t.Helper()
|
|
71
|
+
db := dbtest.New(t)
|
|
72
|
+
auditSvc := audit.New(db, nil)
|
|
73
|
+
errs := server.ErrorAdapter{Audit: auditSvc}
|
|
74
|
+
sessions := auth.New(db)
|
|
75
|
+
th := &fakeThrottle{}
|
|
76
|
+
logins, err := auth.NewLogins(sessions, auditSvc, th)
|
|
77
|
+
if err != nil {
|
|
78
|
+
t.Fatal(err)
|
|
79
|
+
}
|
|
80
|
+
csrf, err := auth.NewCSRF(strings.Repeat("k", 32))
|
|
81
|
+
if err != nil {
|
|
82
|
+
t.Fatal(err)
|
|
83
|
+
}
|
|
84
|
+
si := server.API{
|
|
85
|
+
Auth: auth.Handler{CSRF: csrf, Log: log},
|
|
86
|
+
Logins: auth.LoginHandler{Logins: logins, Fail: errs.Write},
|
|
87
|
+
Logouts: auth.LogoutHandler{Logouts: &auth.Logouts{Sessions: sessions, Audit: auditSvc}, Fail: errs.Write},
|
|
88
|
+
Me: auth.MeHandler{Sessions: sessions, Fail: errs.Write},
|
|
89
|
+
}
|
|
90
|
+
chain := server.NewPublic(log, server.Mount(si),
|
|
91
|
+
auth.Middleware(sessions, server.IsPublic, log),
|
|
92
|
+
csrf.Middleware(errs.Write, server.ActionFor))
|
|
93
|
+
return loginEnv{db: db, h: apispec.Load(t).Validate(t, chain), th: th}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func (e loginEnv) addUser(t *testing.T, empNo, role string, enabled bool) uuid.UUID {
|
|
97
|
+
t.Helper()
|
|
98
|
+
hash, err := bcrypt.GenerateFromPassword([]byte(password), 10)
|
|
99
|
+
if err != nil {
|
|
100
|
+
t.Fatal(err)
|
|
101
|
+
}
|
|
102
|
+
id := uuid.New()
|
|
103
|
+
var email any = empNo + "@example.com"
|
|
104
|
+
if role == "ADMIN" {
|
|
105
|
+
email = nil
|
|
106
|
+
}
|
|
107
|
+
if _, err := e.db.Exec(`INSERT INTO app_user (id, emp_no, name, email, password_hash, role, enabled) VALUES (?,?,?,?,?,?,?)`,
|
|
108
|
+
id.String(), empNo, "N "+empNo, email, string(hash), role, enabled); err != nil {
|
|
109
|
+
t.Fatal(err)
|
|
110
|
+
}
|
|
111
|
+
return id
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func (e loginEnv) do(req *http.Request) *httptest.ResponseRecorder {
|
|
115
|
+
rec := httptest.NewRecorder()
|
|
116
|
+
e.h.ServeHTTP(rec, req)
|
|
117
|
+
return rec
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// csrf fetches a token the way the client does, optionally with a session.
|
|
121
|
+
func (e loginEnv) csrf(t *testing.T, session *http.Cookie) (token string, cookie *http.Cookie) {
|
|
122
|
+
t.Helper()
|
|
123
|
+
req := httptest.NewRequest("GET", "/api/v1/auth/csrf", nil)
|
|
124
|
+
if session != nil {
|
|
125
|
+
req.AddCookie(session)
|
|
126
|
+
}
|
|
127
|
+
rec := e.do(req)
|
|
128
|
+
var b struct{ Token string }
|
|
129
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil || rec.Code != 200 {
|
|
130
|
+
t.Fatalf("csrf: %d %s", rec.Code, rec.Body)
|
|
131
|
+
}
|
|
132
|
+
return b.Token, findCookie(rec, auth.CSRFCookie)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
func findCookie(rec *httptest.ResponseRecorder, name string) *http.Cookie {
|
|
136
|
+
for _, c := range rec.Result().Cookies() {
|
|
137
|
+
if c.Name == name {
|
|
138
|
+
return c
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return nil
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func (e loginEnv) login(t *testing.T, body string) *httptest.ResponseRecorder {
|
|
145
|
+
t.Helper()
|
|
146
|
+
tok, ck := e.csrf(t, nil)
|
|
147
|
+
return e.loginWith(body, tok, ck)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func (e loginEnv) loginWith(body, tok string, ck *http.Cookie, extra ...*http.Cookie) *httptest.ResponseRecorder {
|
|
151
|
+
req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(body))
|
|
152
|
+
req.Header.Set("Content-Type", "application/json")
|
|
153
|
+
req.Header.Set("X-CSRF-Token", tok)
|
|
154
|
+
req.Header.Set("User-Agent", "it/1")
|
|
155
|
+
req.RemoteAddr = "192.0.2.9:4000"
|
|
156
|
+
req.AddCookie(ck)
|
|
157
|
+
for _, c := range extra {
|
|
158
|
+
req.AddCookie(c)
|
|
159
|
+
}
|
|
160
|
+
return e.do(req)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
func creds(empNo, pw string) string {
|
|
164
|
+
b, _ := json.Marshal(map[string]string{"empNo": empNo, "password": pw})
|
|
165
|
+
return string(b)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
type row struct {
|
|
169
|
+
Action, Outcome, Actor, ErrorCode string
|
|
170
|
+
Empno, Submitted, After sql.NullString
|
|
171
|
+
Status sql.NullInt64
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
func (e loginEnv) audits(t *testing.T) []row {
|
|
175
|
+
t.Helper()
|
|
176
|
+
rs, err := e.db.Query(`SELECT action, outcome, actor_role, COALESCE(error_code,''), actor_emp_no,
|
|
177
|
+
submitted_emp_no, CAST(after_json AS CHAR), status FROM audit_log WHERE action='AUTH_LOGIN' ORDER BY id`)
|
|
178
|
+
if err != nil {
|
|
179
|
+
t.Fatal(err)
|
|
180
|
+
}
|
|
181
|
+
defer rs.Close()
|
|
182
|
+
var out []row
|
|
183
|
+
for rs.Next() {
|
|
184
|
+
var r row
|
|
185
|
+
if err := rs.Scan(&r.Action, &r.Outcome, &r.Actor, &r.ErrorCode, &r.Empno, &r.Submitted, &r.After, &r.Status); err != nil {
|
|
186
|
+
t.Fatal(err)
|
|
187
|
+
}
|
|
188
|
+
out = append(out, r)
|
|
189
|
+
}
|
|
190
|
+
return out
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
func (e loginEnv) sessions(t *testing.T) int {
|
|
194
|
+
t.Helper()
|
|
195
|
+
var n int
|
|
196
|
+
if err := e.db.QueryRow(`SELECT COUNT(*) FROM session`).Scan(&n); err != nil {
|
|
197
|
+
t.Fatal(err)
|
|
198
|
+
}
|
|
199
|
+
return n
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
func code(t *testing.T, rec *httptest.ResponseRecorder) string {
|
|
203
|
+
t.Helper()
|
|
204
|
+
var p struct{ Code string }
|
|
205
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
|
|
206
|
+
t.Fatal(err)
|
|
207
|
+
}
|
|
208
|
+
return p.Code
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
func TestLoginSuccess(t *testing.T) {
|
|
212
|
+
e := newLoginEnv(t)
|
|
213
|
+
id := e.addUser(t, "123456", "USER", true)
|
|
214
|
+
|
|
215
|
+
rec := e.login(t, creds("123456", password))
|
|
216
|
+
if rec.Code != 200 {
|
|
217
|
+
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
|
218
|
+
}
|
|
219
|
+
var body map[string]any
|
|
220
|
+
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
|
221
|
+
if len(body) != 4 || body["empNo"] != "123456" || body["name"] != "N 123456" ||
|
|
222
|
+
body["email"] != "123456@example.com" || body["role"] != "USER" {
|
|
223
|
+
t.Errorf("body = %v", body)
|
|
224
|
+
}
|
|
225
|
+
c := findCookie(rec, auth.SessionCookie)
|
|
226
|
+
if c == nil || !c.Secure || !c.HttpOnly || c.SameSite != http.SameSiteLaxMode || c.Path != "/" || c.Domain != "" {
|
|
227
|
+
t.Fatalf("session cookie = %+v", c)
|
|
228
|
+
}
|
|
229
|
+
if _, ok := auth.Binding(c.Value); !ok {
|
|
230
|
+
t.Errorf("cookie value %q is not a session ID", c.Value)
|
|
231
|
+
}
|
|
232
|
+
if e.sessions(t) != 1 {
|
|
233
|
+
t.Error("want exactly one session row")
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
rows := e.audits(t)
|
|
237
|
+
if len(rows) != 1 {
|
|
238
|
+
t.Fatalf("audit = %+v", rows)
|
|
239
|
+
}
|
|
240
|
+
r := rows[0]
|
|
241
|
+
if r.Outcome != "SUCCESS" || r.Actor != "USER" || r.Empno.String != "123456" || r.Status.Int64 != 200 || r.Submitted.Valid {
|
|
242
|
+
t.Errorf("audit row = %+v", r)
|
|
243
|
+
}
|
|
244
|
+
var target string
|
|
245
|
+
if err := e.db.QueryRow(`SELECT target_id FROM audit_log WHERE action='AUTH_LOGIN'`).Scan(&target); err != nil || target != id.String() {
|
|
246
|
+
t.Errorf("target %q err %v", target, err)
|
|
247
|
+
}
|
|
248
|
+
if e.th.oks != 1 || e.th.fails != 0 {
|
|
249
|
+
t.Errorf("throttle ok=%d fail=%d", e.th.oks, e.th.fails)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
func TestLoginAdminHasNullEmail(t *testing.T) {
|
|
254
|
+
e := newLoginEnv(t)
|
|
255
|
+
e.addUser(t, "000001", "ADMIN", true)
|
|
256
|
+
rec := e.login(t, creds("000001", password))
|
|
257
|
+
if rec.Code != 200 {
|
|
258
|
+
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
|
259
|
+
}
|
|
260
|
+
var body map[string]any
|
|
261
|
+
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
|
262
|
+
if v, ok := body["email"]; !ok || v != nil || body["role"] != "ADMIN" {
|
|
263
|
+
t.Errorf("body = %v: want email null, role ADMIN", body)
|
|
264
|
+
}
|
|
265
|
+
if rows := e.audits(t); len(rows) != 1 || rows[0].Actor != "ADMIN" {
|
|
266
|
+
t.Errorf("audit = %+v", rows)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Each login issues a new ID (no fixation), and the anonymous CSRF token that
|
|
271
|
+
// preceded it stops working once the session exists (B1-10 acceptance).
|
|
272
|
+
func TestLoginNewSessionIDAndOldCSRFInvalid(t *testing.T) {
|
|
273
|
+
e := newLoginEnv(t)
|
|
274
|
+
e.addUser(t, "123456", "USER", true)
|
|
275
|
+
|
|
276
|
+
tok, ck := e.csrf(t, nil)
|
|
277
|
+
first := e.loginWith(creds("123456", password), tok, ck)
|
|
278
|
+
s1 := findCookie(first, auth.SessionCookie)
|
|
279
|
+
if first.Code != 200 || s1 == nil {
|
|
280
|
+
t.Fatalf("first login: %d %s", first.Code, first.Body)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// The same anonymous token against the new session: bound to nothing now.
|
|
284
|
+
stale := e.loginWith(creds("123456", password), tok, ck, s1)
|
|
285
|
+
if stale.Code != 403 || code(t, stale) != "CSRF_INVALID" {
|
|
286
|
+
t.Errorf("old anonymous token with a session: %d %s", stale.Code, stale.Body)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// A session-bound token works and a second login yields another ID.
|
|
290
|
+
tok2, ck2 := e.csrf(t, s1)
|
|
291
|
+
second := e.loginWith(creds("123456", password), tok2, ck2, s1)
|
|
292
|
+
s2 := findCookie(second, auth.SessionCookie)
|
|
293
|
+
if second.Code != 200 || s2 == nil || s2.Value == s1.Value {
|
|
294
|
+
t.Fatalf("second login: %d, session IDs equal=%v", second.Code, s2 != nil && s2.Value == s1.Value)
|
|
295
|
+
}
|
|
296
|
+
if e.sessions(t) != 2 {
|
|
297
|
+
t.Error("concurrent sessions are allowed: want two rows")
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Every refusal is the same 401 with the same body, whatever the reason.
|
|
302
|
+
func TestLoginFailuresAreIndistinguishable(t *testing.T) {
|
|
303
|
+
e := newLoginEnv(t)
|
|
304
|
+
e.addUser(t, "123456", "USER", true)
|
|
305
|
+
e.addUser(t, "222222", "USER", false)
|
|
306
|
+
|
|
307
|
+
tests := []struct{ name, body, reason string }{
|
|
308
|
+
{"unknown account", creds("999999", password), "unknown_account"},
|
|
309
|
+
{"badly formatted ID", creds("12ab", password), "bad_format"},
|
|
310
|
+
{"empty ID", creds("", password), "bad_format"},
|
|
311
|
+
{"wrong password", creds("123456", "Wrong-Horse-99"), "wrong_password"},
|
|
312
|
+
{"empty password", creds("123456", ""), "wrong_password"},
|
|
313
|
+
{"disabled account, right password", creds("222222", password), "disabled"},
|
|
314
|
+
{"over 72 bytes, even with the right prefix", creds("123456", password+strings.Repeat("x", 70)), "password_too_long"},
|
|
315
|
+
}
|
|
316
|
+
var first string
|
|
317
|
+
for i, tc := range tests {
|
|
318
|
+
rec := e.login(t, tc.body)
|
|
319
|
+
if rec.Code != 401 || code(t, rec) != "INVALID_CREDENTIALS" {
|
|
320
|
+
t.Fatalf("%s: %d %s", tc.name, rec.Code, rec.Body)
|
|
321
|
+
}
|
|
322
|
+
var p map[string]any
|
|
323
|
+
_ = json.Unmarshal(rec.Body.Bytes(), &p)
|
|
324
|
+
delete(p, "requestId")
|
|
325
|
+
b, _ := json.Marshal(p)
|
|
326
|
+
if i == 0 {
|
|
327
|
+
first = string(b)
|
|
328
|
+
} else if string(b) != first {
|
|
329
|
+
t.Errorf("%s: body %s differs from %s", tc.name, b, first)
|
|
330
|
+
}
|
|
331
|
+
if rec.Header().Get("Set-Cookie") != "" {
|
|
332
|
+
t.Errorf("%s: sets a cookie", tc.name)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if e.sessions(t) != 0 {
|
|
336
|
+
t.Error("a failed login created a session")
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
rows := e.audits(t)
|
|
340
|
+
if len(rows) != len(tests) {
|
|
341
|
+
t.Fatalf("audit rows = %d", len(rows))
|
|
342
|
+
}
|
|
343
|
+
for i, tc := range tests {
|
|
344
|
+
r := rows[i]
|
|
345
|
+
if r.Outcome != "FAILED" || r.Actor != "ANONYMOUS" || r.ErrorCode != "INVALID_CREDENTIALS" || r.Status.Int64 != 401 {
|
|
346
|
+
t.Errorf("%s: row %+v", tc.name, r)
|
|
347
|
+
}
|
|
348
|
+
if !strings.Contains(r.After.String, `"`+tc.reason+`"`) {
|
|
349
|
+
t.Errorf("%s: after %q lacks reason %s", tc.name, r.After.String, tc.reason)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if rows[0].Submitted.String != "999999" {
|
|
353
|
+
t.Errorf("submitted = %q", rows[0].Submitted.String)
|
|
354
|
+
}
|
|
355
|
+
if strings.Contains(rows[3].After.String, password) || strings.Contains(rows[3].After.String, "Wrong-Horse") {
|
|
356
|
+
t.Error("audit row holds a password")
|
|
357
|
+
}
|
|
358
|
+
if e.th.fails != len(tests) {
|
|
359
|
+
t.Errorf("throttle fails = %d, want %d (unknown accounts count too)", e.th.fails, len(tests))
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
func TestLoginMalformedBodyIs400AndAudited(t *testing.T) {
|
|
364
|
+
e := newLoginEnv(t)
|
|
365
|
+
for _, body := range []string{`{"empNo":"123456"}`, `{"empNo":123456,"password":"x"}`, `{`, `{"empNo":null,"password":"x"}`} {
|
|
366
|
+
rec := e.login(t, body)
|
|
367
|
+
if rec.Code != 400 || code(t, rec) != "MALFORMED_REQUEST" {
|
|
368
|
+
t.Errorf("%s: %d %s", body, rec.Code, rec.Body)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
rows := e.audits(t)
|
|
372
|
+
if len(rows) != 4 || rows[0].Outcome != "FAILED" {
|
|
373
|
+
t.Errorf("audit = %+v", rows)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
func TestLoginThrottled(t *testing.T) {
|
|
378
|
+
e := newLoginEnv(t)
|
|
379
|
+
e.addUser(t, "123456", "USER", true)
|
|
380
|
+
e.th.retry = 90*time.Second + 300*time.Millisecond
|
|
381
|
+
|
|
382
|
+
rec := e.login(t, creds("123456", password)) // correct password still throttled
|
|
383
|
+
if rec.Code != 429 || code(t, rec) != "LOGIN_THROTTLED" {
|
|
384
|
+
t.Fatalf("%d %s", rec.Code, rec.Body)
|
|
385
|
+
}
|
|
386
|
+
if got := rec.Header().Get("Retry-After"); got != "91" {
|
|
387
|
+
t.Errorf("Retry-After = %q, want 91 (rounded up)", got)
|
|
388
|
+
}
|
|
389
|
+
if e.sessions(t) != 0 || e.th.fails != 0 || e.th.oks != 0 {
|
|
390
|
+
t.Errorf("throttled attempt: sessions=%d fails=%d oks=%d", e.sessions(t), e.th.fails, e.th.oks)
|
|
391
|
+
}
|
|
392
|
+
rows := e.audits(t)
|
|
393
|
+
if len(rows) != 1 || rows[0].Outcome != "THROTTLED" || rows[0].Status.Int64 != 429 || rows[0].Submitted.String != "123456" {
|
|
394
|
+
t.Errorf("audit = %+v", rows)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
func TestLoginThrottleErrorFailsClosed(t *testing.T) {
|
|
399
|
+
e := newLoginEnv(t)
|
|
400
|
+
e.addUser(t, "123456", "USER", true)
|
|
401
|
+
e.th.err = context.DeadlineExceeded
|
|
402
|
+
rec := e.login(t, creds("123456", password))
|
|
403
|
+
if rec.Code != 500 || e.sessions(t) != 0 {
|
|
404
|
+
t.Errorf("status %d sessions %d: want 500 and no session", rec.Code, e.sessions(t))
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// The session and its audit row commit together: when the audit insert fails,
|
|
409
|
+
// no session survives.
|
|
410
|
+
func TestLoginAuditFailureRollsBackSession(t *testing.T) {
|
|
411
|
+
e := newLoginEnv(t)
|
|
412
|
+
e.addUser(t, "123456", "USER", true)
|
|
413
|
+
if _, err := e.db.Exec(`CREATE TRIGGER no_login_audit BEFORE INSERT ON audit_log FOR EACH ROW
|
|
414
|
+
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'audit down'`); err != nil {
|
|
415
|
+
t.Fatal(err)
|
|
416
|
+
}
|
|
417
|
+
rec := e.login(t, creds("123456", password))
|
|
418
|
+
if rec.Code != 500 || e.sessions(t) != 0 {
|
|
419
|
+
t.Errorf("status %d sessions %d: want 500 and no session", rec.Code, e.sessions(t))
|
|
420
|
+
}
|
|
421
|
+
}
|