@johnnyboy11234/esg-idea-contest 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/api/openapi.yaml +83 -0
- package/backend/cmd/server/main.go +35 -2
- package/backend/db/migration/V005__idea.sql +49 -0
- package/backend/db/migration/V006__login_lock.sql +15 -0
- package/backend/db/migration/V007__login_attempt.sql +15 -0
- package/backend/db/migration/V008__pending_file_deletion.sql +27 -0
- package/backend/db/migration/V009__login_throttle_audit.sql +13 -0
- package/backend/go.mod +17 -3
- package/backend/go.sum +48 -7
- package/backend/internal/account/account_test.go +6 -6
- package/backend/internal/account/integration_test.go +45 -9
- package/backend/internal/account/keyboard.go +75 -0
- package/backend/internal/account/keyboard_test.go +43 -0
- package/backend/internal/account/logging_integration_test.go +1 -1
- package/backend/internal/account/password.go +125 -0
- package/backend/internal/account/validate.go +4 -21
- package/backend/internal/api/api.gen.go +224 -0
- package/backend/internal/auth/internal/store/login_attempt.go +53 -0
- package/backend/internal/auth/internal/store/login_lock.go +77 -0
- package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
- package/backend/internal/auth/internal/store/session.go +15 -7
- package/backend/internal/auth/login.go +16 -4
- package/backend/internal/auth/login_integration_test.go +14 -1
- package/backend/internal/auth/login_ip_integration_test.go +153 -0
- package/backend/internal/auth/login_lock_integration_test.go +205 -0
- package/backend/internal/auth/login_test.go +15 -0
- package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
- package/backend/internal/auth/purge.go +93 -0
- package/backend/internal/auth/purge_integration_test.go +220 -0
- package/backend/internal/auth/purge_test.go +51 -0
- package/backend/internal/auth/session.go +0 -6
- package/backend/internal/auth/session_integration_test.go +3 -3
- package/backend/internal/auth/throttle.go +122 -0
- package/backend/internal/deadline/gate.go +80 -0
- package/backend/internal/deadline/gate_integration_test.go +67 -0
- package/backend/internal/deadline/gate_test.go +61 -0
- package/backend/internal/idea/create.go +352 -0
- package/backend/internal/idea/create_integration_test.go +650 -0
- package/backend/internal/idea/handler.go +148 -0
- package/backend/internal/idea/handler_integration_test.go +107 -0
- package/backend/internal/idea/integration_test.go +149 -0
- package/backend/internal/idea/internal/store/list.go +146 -0
- package/backend/internal/idea/internal/store/list_test.go +33 -0
- package/backend/internal/idea/internal/store/store.go +166 -0
- package/backend/internal/idea/list.go +166 -0
- package/backend/internal/idea/list_integration_test.go +252 -0
- package/backend/internal/idea/list_test.go +96 -0
- package/backend/internal/idea/service.go +53 -0
- package/backend/internal/server/api.go +17 -0
- package/backend/internal/server/public.go +1 -0
- package/backend/internal/server/slides.go +21 -0
- package/backend/internal/shared/config/config.go +44 -1
- package/backend/internal/shared/config/config_test.go +21 -13
- package/backend/internal/shared/db/intx_integration_test.go +179 -0
- package/backend/internal/shared/dbtest/dbtest.go +21 -3
- package/backend/internal/shared/dbtest/faults.go +162 -0
- package/backend/internal/shared/job/job.go +54 -0
- package/backend/internal/shared/job/job_test.go +105 -0
- package/backend/internal/shared/web/multipart.go +175 -0
- package/backend/internal/shared/web/multipart_test.go +169 -0
- package/backend/internal/shared/web/problem.go +1 -0
- package/backend/internal/shared/web/request.go +42 -1
- package/backend/internal/shared/web/web_test.go +36 -0
- package/backend/internal/storage/expectedfilename.go +43 -0
- package/backend/internal/storage/expectedfilename_test.go +16 -0
- package/backend/internal/storage/internal/store/pending.go +41 -0
- package/backend/internal/storage/minio.go +139 -0
- package/backend/internal/storage/minio_integration_test.go +87 -0
- package/backend/internal/storage/minio_test.go +84 -0
- package/backend/internal/storage/pending.go +144 -0
- package/backend/internal/storage/pending_integration_test.go +324 -0
- package/backend/internal/storage/pending_test.go +23 -0
- package/backend/internal/storage/validator.go +100 -0
- package/backend/internal/storage/validator_test.go +95 -0
- package/backend/test/contract/expected_filename_test.go +60 -0
- package/backend/test/contract/password_test.go +46 -0
- package/backend/test/matrix/load_test.go +12 -0
- package/backend/test/matrix/matrix_integration_test.go +86 -5
- package/backend/test/migration/pending_file_deletion_test.go +46 -0
- package/docs/API/api-endpoints.md +1 -1
- package/docs/API/error-codes.md +1 -1
- package/frontend/src/app/App.tsx +11 -2
- package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
- package/frontend/src/features/auth/IdleWarning.tsx +66 -0
- package/frontend/src/shared/api/activity.ts +20 -0
- package/frontend/src/shared/api/http.ts +2 -0
- package/frontend/src/shared/api/index.ts +1 -0
- package/frontend/src/shared/i18n/locales/en.ts +1 -0
- package/frontend/src/shared/i18n/locales/zh-TW.ts +1 -0
- package/package.json +1 -1
- package/testdata/expected-filename.json +93 -0
- package/testdata/password.json +425 -0
- package/testdata/permission-matrix.json +48 -0
- package/testdata/schema/expected-filename.schema.json +32 -0
- package/testdata/schema/password.schema.json +59 -0
- package/testdata/schema/permission-matrix.schema.json +21 -0
|
@@ -32,7 +32,7 @@ func newEnv(t *testing.T) env {
|
|
|
32
32
|
auditSvc := audit.New(db, nil)
|
|
33
33
|
errs := server.ErrorAdapter{Audit: auditSvc}
|
|
34
34
|
api := server.API{Account: account.Handler{
|
|
35
|
-
Service: &account.Service{DB: db, Audit: auditSvc, Policy: account.
|
|
35
|
+
Service: &account.Service{DB: db, Audit: auditSvc, Policy: account.DefaultPolicy()},
|
|
36
36
|
Fail: errs.Write,
|
|
37
37
|
}}
|
|
38
38
|
h := apispec.Load(t).Validate(t, server.NewPublic(slog.New(slog.DiscardHandler), server.Mount(api)))
|
|
@@ -49,6 +49,8 @@ func (e env) register(body string) *httptest.ResponseRecorder {
|
|
|
49
49
|
return rec
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
const goodPW = "Correct-Horse-1"
|
|
53
|
+
|
|
52
54
|
func reg(empNo, name, email, pw string) string {
|
|
53
55
|
b, _ := json.Marshal(map[string]string{"empNo": empNo, "name": name, "email": email, "password": pw})
|
|
54
56
|
return string(b)
|
|
@@ -150,16 +152,16 @@ func TestRegisterSuccess(t *testing.T) {
|
|
|
150
152
|
|
|
151
153
|
func TestRegisterConflicts(t *testing.T) {
|
|
152
154
|
e := newEnv(t)
|
|
153
|
-
if rec := e.register(reg("089290", "A", "a@x.com",
|
|
155
|
+
if rec := e.register(reg("089290", "A", "a@x.com", goodPW)); rec.Code != 201 {
|
|
154
156
|
t.Fatal(rec.Body)
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
cases := []struct {
|
|
158
160
|
name, body, code, reason string
|
|
159
161
|
}{
|
|
160
|
-
{"same employee ID", reg("089290", "B", "b@x.com",
|
|
161
|
-
{"same email, any case", reg("089291", "B", " A@X.COM ",
|
|
162
|
-
{"both taken reports the employee ID", reg("089290", "B", "a@x.com",
|
|
162
|
+
{"same employee ID", reg("089290", "B", "b@x.com", goodPW), "EMP_NO_TAKEN", "emp_no_taken"},
|
|
163
|
+
{"same email, any case", reg("089291", "B", " A@X.COM ", goodPW), "EMAIL_TAKEN", "email_taken"},
|
|
164
|
+
{"both taken reports the employee ID", reg("089290", "B", "a@x.com", goodPW), "EMP_NO_TAKEN", "emp_no_taken"},
|
|
163
165
|
}
|
|
164
166
|
for _, tc := range cases {
|
|
165
167
|
rec := e.register(tc.body)
|
|
@@ -189,14 +191,14 @@ func TestRegisterConflicts(t *testing.T) {
|
|
|
189
191
|
|
|
190
192
|
func TestRegisterValidationBeatsConflictAndIsAudited(t *testing.T) {
|
|
191
193
|
e := newEnv(t)
|
|
192
|
-
e.register(reg("089290", "A", "a@x.com",
|
|
194
|
+
e.register(reg("089290", "A", "a@x.com", goodPW))
|
|
193
195
|
|
|
194
196
|
// The employee ID is taken, but the email is malformed: 422, never 409.
|
|
195
|
-
rec := e.register(reg("089290", "B", "a b@x.com",
|
|
197
|
+
rec := e.register(reg("089290", "B", "a b@x.com", goodPW))
|
|
196
198
|
if rec.Code != 422 || problemCode(t, rec) != "VALIDATION_FAILED" {
|
|
197
199
|
t.Fatalf("%d %s", rec.Code, rec.Body)
|
|
198
200
|
}
|
|
199
|
-
rec = e.register(reg("12 4", "B/C", "ok@x.com",
|
|
201
|
+
rec = e.register(reg("12 4", "B/C", "ok@x.com", goodPW))
|
|
200
202
|
if rec.Code != 422 {
|
|
201
203
|
t.Fatalf("%d %s", rec.Code, rec.Body)
|
|
202
204
|
}
|
|
@@ -248,7 +250,7 @@ func TestRegisterRaceHasOneWinner(t *testing.T) {
|
|
|
248
250
|
wg.Add(1)
|
|
249
251
|
go func() {
|
|
250
252
|
defer wg.Done()
|
|
251
|
-
codes[i] = e.register(reg("089290", "A", "race"+string(rune('a'+i))+"@x.com",
|
|
253
|
+
codes[i] = e.register(reg("089290", "A", "race"+string(rune('a'+i))+"@x.com", goodPW)).Code
|
|
252
254
|
}()
|
|
253
255
|
}
|
|
254
256
|
wg.Wait()
|
|
@@ -266,3 +268,37 @@ func TestRegisterRaceHasOneWinner(t *testing.T) {
|
|
|
266
268
|
t.Fatalf("codes = %v, users = %d", codes, e.users(t))
|
|
267
269
|
}
|
|
268
270
|
}
|
|
271
|
+
|
|
272
|
+
func TestRegisterWeakPasswordListsEveryRuleInTableOrder(t *testing.T) {
|
|
273
|
+
e := newEnv(t)
|
|
274
|
+
// Format and policy errors come back together; the password contains the
|
|
275
|
+
// employee ID, repeats a character and has only two classes.
|
|
276
|
+
rec := e.register(reg("089290", "A", "nope", "aaa089290"))
|
|
277
|
+
if rec.Code != 422 || problemCode(t, rec) != "VALIDATION_FAILED" {
|
|
278
|
+
t.Fatalf("%d %s", rec.Code, rec.Body)
|
|
279
|
+
}
|
|
280
|
+
var body struct {
|
|
281
|
+
Errors []struct{ Field, Code, Rule string }
|
|
282
|
+
}
|
|
283
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
284
|
+
t.Fatal(err)
|
|
285
|
+
}
|
|
286
|
+
var got []string
|
|
287
|
+
for _, f := range body.Errors {
|
|
288
|
+
got = append(got, f.Field+":"+f.Code+":"+f.Rule)
|
|
289
|
+
}
|
|
290
|
+
want := "email:INVALID_FORMAT: password:PASSWORD_POLICY:LENGTH password:PASSWORD_POLICY:COMPLEXITY " +
|
|
291
|
+
"password:PASSWORD_POLICY:REPEAT password:PASSWORD_POLICY:CONTAINS_EMP_NO"
|
|
292
|
+
if strings.Join(got, " ") != want {
|
|
293
|
+
t.Errorf("errors = %v\nwant %s", got, want)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Alone, a weak password makes PASSWORD_POLICY the top-level code.
|
|
297
|
+
rec = e.register(reg("089290", "A", "a@x.com", "short"))
|
|
298
|
+
if rec.Code != 422 || problemCode(t, rec) != "PASSWORD_POLICY" {
|
|
299
|
+
t.Fatalf("%d %s", rec.Code, rec.Body)
|
|
300
|
+
}
|
|
301
|
+
if e.users(t) != 0 {
|
|
302
|
+
t.Error("a rejected registration created a user")
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
package account
|
|
2
|
+
|
|
3
|
+
import "strings"
|
|
4
|
+
|
|
5
|
+
const minKeyboardRun = 4
|
|
6
|
+
|
|
7
|
+
// keyboardRows are the four rows of US QWERTY (PRD §6.4). Rows do not join
|
|
8
|
+
// and do not wrap: the end of one row is not adjacent to the start of the next.
|
|
9
|
+
var keyboardRows = [...]string{
|
|
10
|
+
"1234567890-=",
|
|
11
|
+
"qwertyuiop[]",
|
|
12
|
+
"asdfghjkl;'",
|
|
13
|
+
"zxcvbnm,./",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// shiftedRows holds the shifted glyph of each key, position for position with
|
|
17
|
+
// keyboardRows (letters are folded by case instead).
|
|
18
|
+
var shiftedRows = [...]string{
|
|
19
|
+
"!@#$%^&*()_+",
|
|
20
|
+
"QWERTYUIOP{}",
|
|
21
|
+
"ASDFGHJKL:\"",
|
|
22
|
+
"ZXCVBNM<>?",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// keyPos is a key's row and column on the keyboard.
|
|
26
|
+
type keyPos struct{ row, col int8 }
|
|
27
|
+
|
|
28
|
+
// keyOf maps every unshifted and shifted glyph to its key. Glyphs on no row
|
|
29
|
+
// (` ~ \ | and anything non-ASCII) are absent and break a run.
|
|
30
|
+
var keyOf = func() map[rune]keyPos {
|
|
31
|
+
m := make(map[rune]keyPos, 2*len(keyboardRows[0])*4)
|
|
32
|
+
for r := range keyboardRows {
|
|
33
|
+
for c, g := range keyboardRows[r] {
|
|
34
|
+
m[g] = keyPos{int8(r), int8(c)}
|
|
35
|
+
}
|
|
36
|
+
for c, g := range shiftedRows[r] {
|
|
37
|
+
m[g] = keyPos{int8(r), int8(c)}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return m
|
|
41
|
+
}()
|
|
42
|
+
|
|
43
|
+
// violatesKeyboardRun: four or more adjacent keys on one row, read left to
|
|
44
|
+
// right or right to left, case-insensitive, a shifted glyph counting as its
|
|
45
|
+
// key (`!@#$` is `1234`). The same key repeated is not a run (that is REPEAT),
|
|
46
|
+
// and a run must keep one direction ("qwer" yes, "qwqw" no).
|
|
47
|
+
func violatesKeyboardRun(pw, _ string) bool {
|
|
48
|
+
var prev keyPos
|
|
49
|
+
have, run, dir := false, 0, int8(0)
|
|
50
|
+
for _, r := range strings.ToLower(pw) {
|
|
51
|
+
k, ok := keyOf[r]
|
|
52
|
+
if !ok {
|
|
53
|
+
have, run, dir = false, 0, 0
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
step := int8(0)
|
|
57
|
+
if have && k.row == prev.row {
|
|
58
|
+
step = k.col - prev.col
|
|
59
|
+
}
|
|
60
|
+
if step == 1 || step == -1 {
|
|
61
|
+
if run >= 2 && step == dir {
|
|
62
|
+
run++
|
|
63
|
+
} else {
|
|
64
|
+
run, dir = 2, step
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
run, dir = 1, 0
|
|
68
|
+
}
|
|
69
|
+
prev, have = k, true
|
|
70
|
+
if run >= minKeyboardRun {
|
|
71
|
+
return true
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
package account
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
func TestViolatesKeyboardRun(t *testing.T) {
|
|
6
|
+
for _, tc := range []struct {
|
|
7
|
+
pw string
|
|
8
|
+
want bool
|
|
9
|
+
}{
|
|
10
|
+
{"", false},
|
|
11
|
+
{"qwe", false},
|
|
12
|
+
{"qwer", true},
|
|
13
|
+
{"rewq", true},
|
|
14
|
+
{"QwEr", true},
|
|
15
|
+
{"1234", true},
|
|
16
|
+
{"4321", true},
|
|
17
|
+
{"!@#$", true},
|
|
18
|
+
{"1@3$", true},
|
|
19
|
+
{"=-09", true},
|
|
20
|
+
{"[poi", true},
|
|
21
|
+
{"zxcv", true},
|
|
22
|
+
{"/.,m", true},
|
|
23
|
+
{"asdf", true},
|
|
24
|
+
{"';lk", true},
|
|
25
|
+
{"{POI", true},
|
|
26
|
+
{"xqwertyx", true},
|
|
27
|
+
{"p-asdf", true}, // rows do not join: only asdf counts
|
|
28
|
+
{"pasd", false}, // no wrap row to row
|
|
29
|
+
{"0-=q", false}, // "0-=" is 3 keys, then a new row
|
|
30
|
+
{"qwewq", false}, // direction change restarts the count
|
|
31
|
+
{"qw~er", false},
|
|
32
|
+
{"qwdf", false},
|
|
33
|
+
{"qqqq", false},
|
|
34
|
+
{"1111", false},
|
|
35
|
+
{"`123", false},
|
|
36
|
+
{"éqwe", false},
|
|
37
|
+
{"aaa-sdf", false},
|
|
38
|
+
} {
|
|
39
|
+
if got := violatesKeyboardRun(tc.pw, ""); got != tc.want {
|
|
40
|
+
t.Errorf("violatesKeyboardRun(%q) = %v, want %v", tc.pw, got, tc.want)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -43,7 +43,7 @@ func TestRegisterLogsNeverContainPassword(t *testing.T) {
|
|
|
43
43
|
auditSvc := audit.New(db, log)
|
|
44
44
|
errs := server.ErrorAdapter{Audit: auditSvc, Log: log}
|
|
45
45
|
api := server.API{Account: account.Handler{
|
|
46
|
-
Service: &account.Service{DB: db, Audit: auditSvc, Policy: account.
|
|
46
|
+
Service: &account.Service{DB: db, Audit: auditSvc, Policy: account.DefaultPolicy()},
|
|
47
47
|
Fail: errs.Write,
|
|
48
48
|
}}
|
|
49
49
|
e := env{db: db, h: apispec.Load(t).Validate(t, server.NewPublic(log, server.Mount(api)))}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
package account
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"unicode/utf8"
|
|
6
|
+
|
|
7
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// Password policy (PRD §6.4). Rule ids are the wire contract (errors[].rule).
|
|
11
|
+
const (
|
|
12
|
+
RuleLength = "LENGTH"
|
|
13
|
+
RuleCharset = "CHARSET"
|
|
14
|
+
RuleComplexity = "COMPLEXITY"
|
|
15
|
+
RuleCommon = "COMMON_PASSWORD"
|
|
16
|
+
RuleKeyboardRun = "KEYBOARD_RUN"
|
|
17
|
+
RuleSequence = "SEQUENCE"
|
|
18
|
+
RuleRepeat = "REPEAT"
|
|
19
|
+
RuleContainsEmpNo = "CONTAINS_EMP_NO"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
const (
|
|
23
|
+
minPassLen = 12
|
|
24
|
+
maxPassLen = 64 // code points
|
|
25
|
+
minPassClasses = 3
|
|
26
|
+
maxRepeat = 2 // 3+ identical characters in a row is a violation
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
// Rule is one row of the §6.4 table. Violated reports whether password breaks
|
|
30
|
+
// the rule; empNo is "" when the submitted employee ID is malformed.
|
|
31
|
+
type Rule struct {
|
|
32
|
+
ID string
|
|
33
|
+
Violated func(password, empNo string) bool
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Policy runs its rules in order and reports every violated one. The slice
|
|
37
|
+
// order is the PRD §6.4 table order, which is part of the contract
|
|
38
|
+
// (testdata/password.json): later PRs add their rule at its table position.
|
|
39
|
+
type Policy struct{ Rules []Rule }
|
|
40
|
+
|
|
41
|
+
// DefaultPolicy returns the rules implemented so far, in table order.
|
|
42
|
+
// SEQUENCE and COMMON_PASSWORD arrive with B1b-03 and later.
|
|
43
|
+
func DefaultPolicy() *Policy {
|
|
44
|
+
return &Policy{Rules: []Rule{
|
|
45
|
+
{RuleLength, violatesLength},
|
|
46
|
+
{RuleCharset, violatesCharset},
|
|
47
|
+
{RuleComplexity, violatesComplexity},
|
|
48
|
+
{RuleKeyboardRun, violatesKeyboardRun},
|
|
49
|
+
{RuleRepeat, violatesRepeat},
|
|
50
|
+
{RuleContainsEmpNo, violatesEmpNo},
|
|
51
|
+
}}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Check implements PasswordPolicy. Each violation is a PASSWORD_POLICY error
|
|
55
|
+
// on field "password" carrying the rule id.
|
|
56
|
+
func (p *Policy) Check(password, empNo string) []web.FieldError {
|
|
57
|
+
var out []web.FieldError
|
|
58
|
+
for _, r := range p.Rules {
|
|
59
|
+
if r.Violated(password, empNo) {
|
|
60
|
+
out = append(out, web.FieldError{Field: fieldPassword, Code: web.CodePasswordPolicy, Rule: r.ID})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func violatesLength(pw, _ string) bool {
|
|
67
|
+
n := utf8.RuneCountInString(pw)
|
|
68
|
+
return n < minPassLen || n > maxPassLen
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func violatesCharset(pw, _ string) bool {
|
|
72
|
+
for _, r := range pw {
|
|
73
|
+
if r < 0x20 || r > 0x7E { // also catches U+FFFD from invalid UTF-8
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// violatesComplexity counts upper, lower, digit and symbol; a symbol is any
|
|
81
|
+
// printable non-alphanumeric ASCII character, space included. Characters
|
|
82
|
+
// outside printable ASCII belong to no class (CHARSET reports them).
|
|
83
|
+
func violatesComplexity(pw, _ string) bool {
|
|
84
|
+
var upper, lower, digit, symbol bool
|
|
85
|
+
for _, r := range pw {
|
|
86
|
+
switch {
|
|
87
|
+
case r >= 'A' && r <= 'Z':
|
|
88
|
+
upper = true
|
|
89
|
+
case r >= 'a' && r <= 'z':
|
|
90
|
+
lower = true
|
|
91
|
+
case r >= '0' && r <= '9':
|
|
92
|
+
digit = true
|
|
93
|
+
case r >= 0x20 && r <= 0x7E:
|
|
94
|
+
symbol = true
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
n := 0
|
|
98
|
+
for _, c := range [...]bool{upper, lower, digit, symbol} {
|
|
99
|
+
if c {
|
|
100
|
+
n++
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return n < minPassClasses
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// violatesRepeat: the same character three or more times in a row, compared
|
|
107
|
+
// exactly (case-sensitive: "aAa" is fine).
|
|
108
|
+
func violatesRepeat(pw, _ string) bool {
|
|
109
|
+
run, prev := 0, rune(-1)
|
|
110
|
+
for _, r := range pw {
|
|
111
|
+
if r == prev {
|
|
112
|
+
run++
|
|
113
|
+
} else {
|
|
114
|
+
run, prev = 1, r
|
|
115
|
+
}
|
|
116
|
+
if run > maxRepeat {
|
|
117
|
+
return true
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func violatesEmpNo(pw, empNo string) bool {
|
|
124
|
+
return empNo != "" && strings.Contains(pw, empNo)
|
|
125
|
+
}
|
|
@@ -9,10 +9,9 @@ import (
|
|
|
9
9
|
|
|
10
10
|
// Field limits (PRD §6.3).
|
|
11
11
|
const (
|
|
12
|
-
empNoLen
|
|
13
|
-
maxNameLen
|
|
14
|
-
maxEmailLen
|
|
15
|
-
maxPassBytes = 72 // bcrypt silently ignores (x/crypto: rejects) anything longer
|
|
12
|
+
empNoLen = 6
|
|
13
|
+
maxNameLen = 50
|
|
14
|
+
maxEmailLen = 254
|
|
16
15
|
)
|
|
17
16
|
|
|
18
17
|
// Field names as they appear in errors[]; the order is the form order.
|
|
@@ -65,28 +64,12 @@ func checkEmail(s string) (string, text.Problem) {
|
|
|
65
64
|
}
|
|
66
65
|
|
|
67
66
|
// PasswordPolicy checks a candidate password and lists every violation, in
|
|
68
|
-
// PRD §6.4 table order. empNo is the submitted employee ID when it is
|
|
67
|
+
// PRD §6.4 table order (see Policy). empNo is the submitted employee ID when it is
|
|
69
68
|
// well-formed and "" otherwise (the CONTAINS_EMP_NO rule is then skipped).
|
|
70
69
|
type PasswordPolicy interface {
|
|
71
70
|
Check(password, empNo string) []web.FieldError
|
|
72
71
|
}
|
|
73
72
|
|
|
74
|
-
// StubPolicy is the placeholder until B1b-01 delivers the §6.4 rules. It only
|
|
75
|
-
// refuses what the account cannot store: an empty password and one bcrypt
|
|
76
|
-
// cannot hash (longer than 72 bytes). Everything else is accepted.
|
|
77
|
-
type StubPolicy struct{}
|
|
78
|
-
|
|
79
|
-
// Check implements PasswordPolicy.
|
|
80
|
-
func (StubPolicy) Check(password, _ string) []web.FieldError {
|
|
81
|
-
switch {
|
|
82
|
-
case password == "":
|
|
83
|
-
return []web.FieldError{{Field: fieldPassword, Code: web.CodeRequired}}
|
|
84
|
-
case len(password) > maxPassBytes:
|
|
85
|
-
return []web.FieldError{{Field: fieldPassword, Code: web.CodeOutOfRange}}
|
|
86
|
-
}
|
|
87
|
-
return nil
|
|
88
|
-
}
|
|
89
|
-
|
|
90
73
|
// topCode is the response code of a 422: the first field error's code when it
|
|
91
74
|
// is a specific registered code, else VALIDATION_FAILED (error-codes.md §2).
|
|
92
75
|
func topCode(fields []web.FieldError) web.ErrorCode {
|
|
@@ -71,6 +71,27 @@ func (e LoginResponseRole) Valid() bool {
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// CreateIdeaForm defines model for CreateIdeaForm.
|
|
75
|
+
type CreateIdeaForm struct {
|
|
76
|
+
// Category COOL_IDEA or OUTSTANDING_IMPACT
|
|
77
|
+
Category string `json:"category"`
|
|
78
|
+
|
|
79
|
+
// Direction One of the five ESG directions (PRD 6.6)
|
|
80
|
+
Direction string `json:"direction"`
|
|
81
|
+
|
|
82
|
+
// Name 1-75 code points after clean-up; no / \ : * ? " < > |
|
|
83
|
+
Name string `json:"name"`
|
|
84
|
+
|
|
85
|
+
// Org AAID, BSID, ICSD, TSID, PLED or DEID
|
|
86
|
+
Org string `json:"org"`
|
|
87
|
+
|
|
88
|
+
// Slides One PDF or PPTX, at most 5 MiB, named {I|O}_{org}_{leaderName}_{leaderEmpNo}_{name}.{pdf|pptx}
|
|
89
|
+
Slides openapi_types.File `json:"slides"`
|
|
90
|
+
|
|
91
|
+
// Summary 1-300 code points after clean-up
|
|
92
|
+
Summary string `json:"summary"`
|
|
93
|
+
}
|
|
94
|
+
|
|
74
95
|
// CsrfTokenResponse defines model for CsrfTokenResponse.
|
|
75
96
|
type CsrfTokenResponse struct {
|
|
76
97
|
Token string `json:"token"`
|
|
@@ -93,6 +114,9 @@ type FieldError struct {
|
|
|
93
114
|
Code string `json:"code"`
|
|
94
115
|
Expected *string `json:"expected,omitempty"`
|
|
95
116
|
Field string `json:"field"`
|
|
117
|
+
|
|
118
|
+
// Rule PASSWORD_POLICY only, the violated rule id (PRD 6.4)
|
|
119
|
+
Rule *string `json:"rule,omitempty"`
|
|
96
120
|
}
|
|
97
121
|
|
|
98
122
|
// IdeaDetail defines model for IdeaDetail.
|
|
@@ -245,6 +269,33 @@ type RegisterParams struct {
|
|
|
245
269
|
type ListIdeasParams struct {
|
|
246
270
|
Page *int `form:"page,omitempty" json:"page,omitempty"`
|
|
247
271
|
Size *int `form:"size,omitempty" json:"size,omitempty"`
|
|
272
|
+
|
|
273
|
+
// Org Closed set; an invalid value is 422 INVALID_ENUM (never ignored). Validated in the handler.
|
|
274
|
+
Org *string `form:"org,omitempty" json:"org,omitempty"`
|
|
275
|
+
Category *string `form:"category,omitempty" json:"category,omitempty"`
|
|
276
|
+
Direction *string `form:"direction,omitempty" json:"direction,omitempty"`
|
|
277
|
+
LeaderEmpNo *string `form:"leaderEmpNo,omitempty" json:"leaderEmpNo,omitempty"`
|
|
278
|
+
|
|
279
|
+
// CreatedFrom ISO 8601 instant with offset or Z; inclusive. Validated in the handler.
|
|
280
|
+
CreatedFrom *string `form:"createdFrom,omitempty" json:"createdFrom,omitempty"`
|
|
281
|
+
|
|
282
|
+
// CreatedTo ISO 8601 instant with offset or Z; exclusive. Validated in the handler.
|
|
283
|
+
CreatedTo *string `form:"createdTo,omitempty" json:"createdTo,omitempty"`
|
|
284
|
+
|
|
285
|
+
// Q Case-insensitive "name contains"; `%` and `_` are literal.
|
|
286
|
+
Q *string `form:"q,omitempty" json:"q,omitempty"`
|
|
287
|
+
|
|
288
|
+
// Sort createdAt (default) or name; anything else is 422 INVALID_ENUM. Validated in the handler.
|
|
289
|
+
Sort *string `form:"sort,omitempty" json:"sort,omitempty"`
|
|
290
|
+
|
|
291
|
+
// Order asc or desc (default desc for createdAt, asc for name); anything else is 422 INVALID_ENUM. Validated in the handler.
|
|
292
|
+
Order *string `form:"order,omitempty" json:"order,omitempty"`
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// CreateIdeaParams defines parameters for CreateIdea.
|
|
296
|
+
type CreateIdeaParams struct {
|
|
297
|
+
// XCSRFToken Session-bound token from GET /auth/csrf. Mandatory on every write, but enforced by the CSRF middleware (403 CSRF_INVALID), so the generated wrapper must not reject its absence first (400).
|
|
298
|
+
XCSRFToken *CsrfToken `json:"X-CSRF-Token,omitempty"`
|
|
248
299
|
}
|
|
249
300
|
|
|
250
301
|
// LoginJSONRequestBody defines body for Login for application/json ContentType.
|
|
@@ -253,6 +304,9 @@ type LoginJSONRequestBody = LoginRequest
|
|
|
253
304
|
// RegisterJSONRequestBody defines body for Register for application/json ContentType.
|
|
254
305
|
type RegisterJSONRequestBody = RegisterRequest
|
|
255
306
|
|
|
307
|
+
// CreateIdeaMultipartRequestBody defines body for CreateIdea for multipart/form-data ContentType.
|
|
308
|
+
type CreateIdeaMultipartRequestBody = CreateIdeaForm
|
|
309
|
+
|
|
256
310
|
// ServerInterface represents all server handlers.
|
|
257
311
|
type ServerInterface interface {
|
|
258
312
|
// GetCsrfToken Session-bound CSRF token (anonymous before login)
|
|
@@ -276,6 +330,9 @@ type ServerInterface interface {
|
|
|
276
330
|
// ListIdeas List Ideas visible to the caller
|
|
277
331
|
// (GET /ideas)
|
|
278
332
|
ListIdeas(w http.ResponseWriter, r *http.Request, params ListIdeasParams)
|
|
333
|
+
// CreateIdea Create an Idea with its slides (User only)
|
|
334
|
+
// (POST /ideas)
|
|
335
|
+
CreateIdea(w http.ResponseWriter, r *http.Request, params CreateIdeaParams)
|
|
279
336
|
// GetIdea Idea detail
|
|
280
337
|
// (GET /ideas/{id})
|
|
281
338
|
GetIdea(w http.ResponseWriter, r *http.Request, id IdeaId)
|
|
@@ -327,6 +384,12 @@ func (_ Unimplemented) ListIdeas(w http.ResponseWriter, r *http.Request, params
|
|
|
327
384
|
w.WriteHeader(http.StatusNotImplemented)
|
|
328
385
|
}
|
|
329
386
|
|
|
387
|
+
// CreateIdea Create an Idea with its slides (User only)
|
|
388
|
+
// (POST /ideas)
|
|
389
|
+
func (_ Unimplemented) CreateIdea(w http.ResponseWriter, r *http.Request, params CreateIdeaParams) {
|
|
390
|
+
w.WriteHeader(http.StatusNotImplemented)
|
|
391
|
+
}
|
|
392
|
+
|
|
330
393
|
// GetIdea Idea detail
|
|
331
394
|
// (GET /ideas/{id})
|
|
332
395
|
func (_ Unimplemented) GetIdea(w http.ResponseWriter, r *http.Request, id IdeaId) {
|
|
@@ -542,6 +605,123 @@ func (siw *ServerInterfaceWrapper) ListIdeas(w http.ResponseWriter, r *http.Requ
|
|
|
542
605
|
return
|
|
543
606
|
}
|
|
544
607
|
|
|
608
|
+
// ------------- Optional query parameter "org" -------------
|
|
609
|
+
|
|
610
|
+
err = runtime.BindQueryParameterWithOptions("form", true, false, "org", r.URL.Query(), ¶ms.Org, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
|
611
|
+
if err != nil {
|
|
612
|
+
var requiredError *runtime.RequiredParameterError
|
|
613
|
+
if errors.As(err, &requiredError) {
|
|
614
|
+
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "org"})
|
|
615
|
+
} else {
|
|
616
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "org", Err: err})
|
|
617
|
+
}
|
|
618
|
+
return
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ------------- Optional query parameter "category" -------------
|
|
622
|
+
|
|
623
|
+
err = runtime.BindQueryParameterWithOptions("form", true, false, "category", r.URL.Query(), ¶ms.Category, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
|
624
|
+
if err != nil {
|
|
625
|
+
var requiredError *runtime.RequiredParameterError
|
|
626
|
+
if errors.As(err, &requiredError) {
|
|
627
|
+
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "category"})
|
|
628
|
+
} else {
|
|
629
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "category", Err: err})
|
|
630
|
+
}
|
|
631
|
+
return
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ------------- Optional query parameter "direction" -------------
|
|
635
|
+
|
|
636
|
+
err = runtime.BindQueryParameterWithOptions("form", true, false, "direction", r.URL.Query(), ¶ms.Direction, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
|
637
|
+
if err != nil {
|
|
638
|
+
var requiredError *runtime.RequiredParameterError
|
|
639
|
+
if errors.As(err, &requiredError) {
|
|
640
|
+
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "direction"})
|
|
641
|
+
} else {
|
|
642
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "direction", Err: err})
|
|
643
|
+
}
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// ------------- Optional query parameter "leaderEmpNo" -------------
|
|
648
|
+
|
|
649
|
+
err = runtime.BindQueryParameterWithOptions("form", true, false, "leaderEmpNo", r.URL.Query(), ¶ms.LeaderEmpNo, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
|
650
|
+
if err != nil {
|
|
651
|
+
var requiredError *runtime.RequiredParameterError
|
|
652
|
+
if errors.As(err, &requiredError) {
|
|
653
|
+
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "leaderEmpNo"})
|
|
654
|
+
} else {
|
|
655
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "leaderEmpNo", Err: err})
|
|
656
|
+
}
|
|
657
|
+
return
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ------------- Optional query parameter "createdFrom" -------------
|
|
661
|
+
|
|
662
|
+
err = runtime.BindQueryParameterWithOptions("form", true, false, "createdFrom", r.URL.Query(), ¶ms.CreatedFrom, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
|
663
|
+
if err != nil {
|
|
664
|
+
var requiredError *runtime.RequiredParameterError
|
|
665
|
+
if errors.As(err, &requiredError) {
|
|
666
|
+
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "createdFrom"})
|
|
667
|
+
} else {
|
|
668
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "createdFrom", Err: err})
|
|
669
|
+
}
|
|
670
|
+
return
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ------------- Optional query parameter "createdTo" -------------
|
|
674
|
+
|
|
675
|
+
err = runtime.BindQueryParameterWithOptions("form", true, false, "createdTo", r.URL.Query(), ¶ms.CreatedTo, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
|
676
|
+
if err != nil {
|
|
677
|
+
var requiredError *runtime.RequiredParameterError
|
|
678
|
+
if errors.As(err, &requiredError) {
|
|
679
|
+
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "createdTo"})
|
|
680
|
+
} else {
|
|
681
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "createdTo", Err: err})
|
|
682
|
+
}
|
|
683
|
+
return
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ------------- Optional query parameter "q" -------------
|
|
687
|
+
|
|
688
|
+
err = runtime.BindQueryParameterWithOptions("form", true, false, "q", r.URL.Query(), ¶ms.Q, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
|
689
|
+
if err != nil {
|
|
690
|
+
var requiredError *runtime.RequiredParameterError
|
|
691
|
+
if errors.As(err, &requiredError) {
|
|
692
|
+
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "q"})
|
|
693
|
+
} else {
|
|
694
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "q", Err: err})
|
|
695
|
+
}
|
|
696
|
+
return
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// ------------- Optional query parameter "sort" -------------
|
|
700
|
+
|
|
701
|
+
err = runtime.BindQueryParameterWithOptions("form", true, false, "sort", r.URL.Query(), ¶ms.Sort, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
|
702
|
+
if err != nil {
|
|
703
|
+
var requiredError *runtime.RequiredParameterError
|
|
704
|
+
if errors.As(err, &requiredError) {
|
|
705
|
+
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "sort"})
|
|
706
|
+
} else {
|
|
707
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sort", Err: err})
|
|
708
|
+
}
|
|
709
|
+
return
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ------------- Optional query parameter "order" -------------
|
|
713
|
+
|
|
714
|
+
err = runtime.BindQueryParameterWithOptions("form", true, false, "order", r.URL.Query(), ¶ms.Order, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
|
715
|
+
if err != nil {
|
|
716
|
+
var requiredError *runtime.RequiredParameterError
|
|
717
|
+
if errors.As(err, &requiredError) {
|
|
718
|
+
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "order"})
|
|
719
|
+
} else {
|
|
720
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "order", Err: err})
|
|
721
|
+
}
|
|
722
|
+
return
|
|
723
|
+
}
|
|
724
|
+
|
|
545
725
|
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
546
726
|
siw.Handler.ListIdeas(w, r, params)
|
|
547
727
|
}))
|
|
@@ -553,6 +733,47 @@ func (siw *ServerInterfaceWrapper) ListIdeas(w http.ResponseWriter, r *http.Requ
|
|
|
553
733
|
handler.ServeHTTP(w, r)
|
|
554
734
|
}
|
|
555
735
|
|
|
736
|
+
// CreateIdea operation middleware
|
|
737
|
+
func (siw *ServerInterfaceWrapper) CreateIdea(w http.ResponseWriter, r *http.Request) {
|
|
738
|
+
|
|
739
|
+
var err error
|
|
740
|
+
_ = err
|
|
741
|
+
|
|
742
|
+
// Parameter object where we will unmarshal all parameters from the context
|
|
743
|
+
var params CreateIdeaParams
|
|
744
|
+
|
|
745
|
+
headers := r.Header
|
|
746
|
+
|
|
747
|
+
// ------------- Optional header parameter "X-CSRF-Token" -------------
|
|
748
|
+
if valueList, found := headers[http.CanonicalHeaderKey("X-CSRF-Token")]; found {
|
|
749
|
+
var XCSRFToken CsrfToken
|
|
750
|
+
n := len(valueList)
|
|
751
|
+
if n != 1 {
|
|
752
|
+
siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "X-CSRF-Token", Count: n})
|
|
753
|
+
return
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
err = runtime.BindStyledParameterWithOptions("simple", "X-CSRF-Token", valueList[0], &XCSRFToken, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""})
|
|
757
|
+
if err != nil {
|
|
758
|
+
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "X-CSRF-Token", Err: err})
|
|
759
|
+
return
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
params.XCSRFToken = &XCSRFToken
|
|
763
|
+
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
767
|
+
siw.Handler.CreateIdea(w, r, params)
|
|
768
|
+
}))
|
|
769
|
+
|
|
770
|
+
for _, middleware := range siw.HandlerMiddlewares {
|
|
771
|
+
handler = middleware(handler)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
handler.ServeHTTP(w, r)
|
|
775
|
+
}
|
|
776
|
+
|
|
556
777
|
// GetIdea operation middleware
|
|
557
778
|
func (siw *ServerInterfaceWrapper) GetIdea(w http.ResponseWriter, r *http.Request) {
|
|
558
779
|
|
|
@@ -713,6 +934,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
|
|
|
713
934
|
r.Group(func(r chi.Router) {
|
|
714
935
|
r.Get(options.BaseURL+"/ideas", wrapper.ListIdeas)
|
|
715
936
|
})
|
|
937
|
+
r.Group(func(r chi.Router) {
|
|
938
|
+
r.Post(options.BaseURL+"/ideas", wrapper.CreateIdea)
|
|
939
|
+
})
|
|
716
940
|
r.Group(func(r chi.Router) {
|
|
717
941
|
r.Get(options.BaseURL+"/ideas/{id}", wrapper.GetIdea)
|
|
718
942
|
})
|