@johnnyboy11234/esg-idea-contest 0.3.0 → 0.3.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 +119 -0
- package/backend/cmd/server/main.go +36 -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/internal/passwords/NOTICE +36 -0
- package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
- package/backend/internal/account/internal/passwords/passwords.go +37 -0
- package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
- 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 +131 -0
- package/backend/internal/account/sequence.go +54 -0
- package/backend/internal/account/sequence_test.go +38 -0
- package/backend/internal/account/validate.go +4 -21
- package/backend/internal/api/api.gen.go +262 -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 +356 -0
- package/backend/internal/idea/create_integration_test.go +680 -0
- package/backend/internal/idea/download.go +193 -0
- package/backend/internal/idea/download_integration_test.go +311 -0
- package/backend/internal/idea/download_test.go +25 -0
- package/backend/internal/idea/handler.go +151 -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 +54 -0
- package/backend/internal/server/api.go +22 -0
- package/backend/internal/server/binding.go +4 -0
- package/backend/internal/server/public.go +1 -0
- package/backend/internal/server/server.go +20 -1
- package/backend/internal/server/slides.go +21 -0
- package/backend/internal/server/slides_test.go +82 -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/transport.go +4 -3
- 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/arch/arch_test.go +5 -1
- 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 +92 -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/docs/Architecture/Architecture.md +1 -1
- package/docs/Architecture/Backend.md +2 -1
- package/frontend/Dockerfile +2 -0
- package/frontend/package-lock.json +1 -0
- package/frontend/package.json +1 -0
- package/frontend/src/app/App.tsx +11 -2
- package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
- package/frontend/src/app/pages/ideas.tsx +23 -9
- package/frontend/src/app/pages/login.test.tsx +38 -1
- package/frontend/src/app/pages/register.test.tsx +32 -0
- package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
- package/frontend/src/features/auth/IdleWarning.tsx +66 -0
- package/frontend/src/features/auth/LoginForm.tsx +21 -3
- package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
- package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
- package/frontend/src/features/auth/RegisterForm.tsx +11 -2
- package/frontend/src/features/auth/common-passwords.ts +31 -0
- package/frontend/src/features/auth/password-policy.test.ts +64 -0
- package/frontend/src/features/auth/password-policy.ts +168 -0
- package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
- package/frontend/src/features/auth/useCountdown.ts +28 -0
- package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
- package/frontend/src/features/idea/create.ts +123 -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/api/types.generated.ts +319 -1
- package/frontend/src/shared/i18n/locales/en.ts +46 -4
- package/frontend/src/shared/i18n/locales/zh-TW.ts +46 -4
- package/frontend/src/shared/slides/index.ts +74 -0
- package/frontend/src/shared/slides/slides.test.ts +51 -0
- package/frontend/src/shared/ui/copy-button.tsx +52 -0
- package/frontend/src/shared/ui/native-select.tsx +27 -0
- package/frontend/src/shared/ui/textarea.tsx +19 -0
- package/frontend/src/test-setup.ts +37 -2
- package/frontend/vite.config.ts +3 -1
- package/package.json +1 -1
- package/testdata/expected-filename.json +93 -0
- package/testdata/password.json +582 -0
- package/testdata/permission-matrix.json +71 -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 +22 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Package passwords holds the bundled common-password blocklist (PRD §6.4,
|
|
2
|
+
// rule COMMON_PASSWORD). Source and licence of the list: NOTICE in this
|
|
3
|
+
// directory. The frontend lazy-loads the same file for its live checklist.
|
|
4
|
+
package passwords
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
_ "embed"
|
|
8
|
+
"strings"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
//go:embed common-passwords.txt
|
|
12
|
+
var commonList string
|
|
13
|
+
|
|
14
|
+
// common holds every list entry folded to lower case. The list is printable
|
|
15
|
+
// ASCII, so strings.ToLower is an exact ASCII case fold here.
|
|
16
|
+
var common = func() map[string]struct{} {
|
|
17
|
+
lines := strings.Split(commonList, "\n")
|
|
18
|
+
m := make(map[string]struct{}, len(lines))
|
|
19
|
+
for _, l := range lines {
|
|
20
|
+
l = strings.TrimSuffix(l, "\r")
|
|
21
|
+
if l != "" {
|
|
22
|
+
m[strings.ToLower(l)] = struct{}{}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return m
|
|
26
|
+
}()
|
|
27
|
+
|
|
28
|
+
// IsCommon reports whether pw equals a list entry, ignoring case. The whole
|
|
29
|
+
// password is compared as submitted: nothing is trimmed, and a list entry
|
|
30
|
+
// inside a longer password does not match.
|
|
31
|
+
func IsCommon(pw string) bool {
|
|
32
|
+
_, ok := common[strings.ToLower(pw)]
|
|
33
|
+
return ok
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Len is the number of distinct entries (after case folding).
|
|
37
|
+
func Len() int { return len(common) }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
package passwords
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
func TestIsCommon(t *testing.T) {
|
|
6
|
+
for _, tc := range []struct {
|
|
7
|
+
pw string
|
|
8
|
+
want bool
|
|
9
|
+
}{
|
|
10
|
+
{"password", true},
|
|
11
|
+
{"PASSWORD", true},
|
|
12
|
+
{"PassWord", true},
|
|
13
|
+
{"123456", true},
|
|
14
|
+
{"films+pic+galeries", true},
|
|
15
|
+
{"Films+Pic+Galeries", true},
|
|
16
|
+
{"eyphed", true}, // last line of the file
|
|
17
|
+
{"", false},
|
|
18
|
+
{" password", false}, // nothing is trimmed
|
|
19
|
+
{"password ", false},
|
|
20
|
+
{"password1!", false}, // not on the list
|
|
21
|
+
{"xpasswordx", false}, // an entry inside a longer password does not match
|
|
22
|
+
{"Correct-Horse-1", false},
|
|
23
|
+
} {
|
|
24
|
+
if got := IsCommon(tc.pw); got != tc.want {
|
|
25
|
+
t.Errorf("IsCommon(%q) = %v, want %v", tc.pw, got, tc.want)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func TestListIsLoaded(t *testing.T) {
|
|
31
|
+
// 10,001 lines, no case-folded duplicates (NOTICE).
|
|
32
|
+
if got := Len(); got != 10001 {
|
|
33
|
+
t.Errorf("Len() = %d, want 10001", got)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -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,131 @@
|
|
|
1
|
+
package account
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"unicode/utf8"
|
|
6
|
+
|
|
7
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/account/internal/passwords"
|
|
8
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// Password policy (PRD §6.4). Rule ids are the wire contract (errors[].rule).
|
|
12
|
+
const (
|
|
13
|
+
RuleLength = "LENGTH"
|
|
14
|
+
RuleCharset = "CHARSET"
|
|
15
|
+
RuleComplexity = "COMPLEXITY"
|
|
16
|
+
RuleCommon = "COMMON_PASSWORD"
|
|
17
|
+
RuleKeyboardRun = "KEYBOARD_RUN"
|
|
18
|
+
RuleSequence = "SEQUENCE"
|
|
19
|
+
RuleRepeat = "REPEAT"
|
|
20
|
+
RuleContainsEmpNo = "CONTAINS_EMP_NO"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
const (
|
|
24
|
+
minPassLen = 12
|
|
25
|
+
maxPassLen = 64 // code points
|
|
26
|
+
minPassClasses = 3
|
|
27
|
+
maxRepeat = 2 // 3+ identical characters in a row is a violation
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// Rule is one row of the §6.4 table. Violated reports whether password breaks
|
|
31
|
+
// the rule; empNo is "" when the submitted employee ID is malformed.
|
|
32
|
+
type Rule struct {
|
|
33
|
+
ID string
|
|
34
|
+
Violated func(password, empNo string) bool
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Policy runs its rules in order and reports every violated one. The slice
|
|
38
|
+
// order is the PRD §6.4 table order, which is part of the contract
|
|
39
|
+
// (testdata/password.json): later PRs add their rule at its table position.
|
|
40
|
+
type Policy struct{ Rules []Rule }
|
|
41
|
+
|
|
42
|
+
// DefaultPolicy returns all eight rules of the PRD §6.4 table, in table order.
|
|
43
|
+
func DefaultPolicy() *Policy {
|
|
44
|
+
return &Policy{Rules: []Rule{
|
|
45
|
+
{RuleLength, violatesLength},
|
|
46
|
+
{RuleCharset, violatesCharset},
|
|
47
|
+
{RuleComplexity, violatesComplexity},
|
|
48
|
+
{RuleCommon, violatesCommon},
|
|
49
|
+
{RuleKeyboardRun, violatesKeyboardRun},
|
|
50
|
+
{RuleSequence, violatesSequence},
|
|
51
|
+
{RuleRepeat, violatesRepeat},
|
|
52
|
+
{RuleContainsEmpNo, violatesEmpNo},
|
|
53
|
+
}}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Check implements PasswordPolicy. Each violation is a PASSWORD_POLICY error
|
|
57
|
+
// on field "password" carrying the rule id.
|
|
58
|
+
func (p *Policy) Check(password, empNo string) []web.FieldError {
|
|
59
|
+
var out []web.FieldError
|
|
60
|
+
for _, r := range p.Rules {
|
|
61
|
+
if r.Violated(password, empNo) {
|
|
62
|
+
out = append(out, web.FieldError{Field: fieldPassword, Code: web.CodePasswordPolicy, Rule: r.ID})
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func violatesLength(pw, _ string) bool {
|
|
69
|
+
n := utf8.RuneCountInString(pw)
|
|
70
|
+
return n < minPassLen || n > maxPassLen
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func violatesCharset(pw, _ string) bool {
|
|
74
|
+
for _, r := range pw {
|
|
75
|
+
if r < 0x20 || r > 0x7E { // also catches U+FFFD from invalid UTF-8
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// violatesComplexity counts upper, lower, digit and symbol; a symbol is any
|
|
83
|
+
// printable non-alphanumeric ASCII character, space included. Characters
|
|
84
|
+
// outside printable ASCII belong to no class (CHARSET reports them).
|
|
85
|
+
func violatesComplexity(pw, _ string) bool {
|
|
86
|
+
var upper, lower, digit, symbol bool
|
|
87
|
+
for _, r := range pw {
|
|
88
|
+
switch {
|
|
89
|
+
case r >= 'A' && r <= 'Z':
|
|
90
|
+
upper = true
|
|
91
|
+
case r >= 'a' && r <= 'z':
|
|
92
|
+
lower = true
|
|
93
|
+
case r >= '0' && r <= '9':
|
|
94
|
+
digit = true
|
|
95
|
+
case r >= 0x20 && r <= 0x7E:
|
|
96
|
+
symbol = true
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
n := 0
|
|
100
|
+
for _, c := range [...]bool{upper, lower, digit, symbol} {
|
|
101
|
+
if c {
|
|
102
|
+
n++
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return n < minPassClasses
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// violatesCommon: the whole password equals an entry of the bundled Top-10,000
|
|
109
|
+
// list, ignoring case (internal/passwords/NOTICE for the source).
|
|
110
|
+
func violatesCommon(pw, _ string) bool { return passwords.IsCommon(pw) }
|
|
111
|
+
|
|
112
|
+
// violatesRepeat: the same character three or more times in a row, compared
|
|
113
|
+
// exactly (case-sensitive: "aAa" is fine).
|
|
114
|
+
func violatesRepeat(pw, _ string) bool {
|
|
115
|
+
run, prev := 0, rune(-1)
|
|
116
|
+
for _, r := range pw {
|
|
117
|
+
if r == prev {
|
|
118
|
+
run++
|
|
119
|
+
} else {
|
|
120
|
+
run, prev = 1, r
|
|
121
|
+
}
|
|
122
|
+
if run > maxRepeat {
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func violatesEmpNo(pw, empNo string) bool {
|
|
130
|
+
return empNo != "" && strings.Contains(pw, empNo)
|
|
131
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
package account
|
|
2
|
+
|
|
3
|
+
const minSequence = 4
|
|
4
|
+
|
|
5
|
+
// seqKey places a character on one of two alphabets: letters a–z (case
|
|
6
|
+
// folded) and digits 0–9. ok is false for anything else, which breaks a
|
|
7
|
+
// sequence. Letters and digits never join each other ("89ab" is two runs of 2),
|
|
8
|
+
// and neighbours in ASCII outside an alphabet do not count ("xyz{" is 3).
|
|
9
|
+
func seqKey(r rune) (alpha int8, pos int8, ok bool) {
|
|
10
|
+
switch {
|
|
11
|
+
case r >= 'a' && r <= 'z':
|
|
12
|
+
return 0, int8(r - 'a'), true
|
|
13
|
+
case r >= 'A' && r <= 'Z':
|
|
14
|
+
return 0, int8(r - 'A'), true
|
|
15
|
+
case r >= '0' && r <= '9':
|
|
16
|
+
return 1, int8(r - '0'), true
|
|
17
|
+
}
|
|
18
|
+
return 0, 0, false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// violatesSequence: four or more consecutive letters or digits, ascending or
|
|
22
|
+
// descending, case-insensitive ("abcd", "DcBa", "4321"). Sequences do not wrap
|
|
23
|
+
// ("xyza", "9012" are fine), the same character repeated is not a sequence
|
|
24
|
+
// (that is REPEAT), and a run must keep one direction ("abcba" is fine).
|
|
25
|
+
// Mirrors violatesKeyboardRun.
|
|
26
|
+
func violatesSequence(pw, _ string) bool {
|
|
27
|
+
var prevAlpha, prevPos int8
|
|
28
|
+
have, run, dir := false, 0, int8(0)
|
|
29
|
+
for _, r := range pw {
|
|
30
|
+
a, p, ok := seqKey(r)
|
|
31
|
+
if !ok {
|
|
32
|
+
have, run, dir = false, 0, 0
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
step := int8(0)
|
|
36
|
+
if have && a == prevAlpha {
|
|
37
|
+
step = p - prevPos
|
|
38
|
+
}
|
|
39
|
+
if step == 1 || step == -1 {
|
|
40
|
+
if run >= 2 && step == dir {
|
|
41
|
+
run++
|
|
42
|
+
} else {
|
|
43
|
+
run, dir = 2, step
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
run, dir = 1, 0
|
|
47
|
+
}
|
|
48
|
+
prevAlpha, prevPos, have = a, p, true
|
|
49
|
+
if run >= minSequence {
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
package account
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
func TestViolatesSequence(t *testing.T) {
|
|
6
|
+
for _, tc := range []struct {
|
|
7
|
+
pw string
|
|
8
|
+
want bool
|
|
9
|
+
}{
|
|
10
|
+
{"", false},
|
|
11
|
+
{"abc", false},
|
|
12
|
+
{"abcd", true},
|
|
13
|
+
{"dcba", true},
|
|
14
|
+
{"aBcD", true},
|
|
15
|
+
{"WXYZ", true},
|
|
16
|
+
{"0123", true},
|
|
17
|
+
{"9876", true},
|
|
18
|
+
{"3210", true},
|
|
19
|
+
{"x-abcdefg-y", true}, // longer run, inside other text
|
|
20
|
+
{"xyza", false}, // no wrap
|
|
21
|
+
{"9012", false}, // no wrap
|
|
22
|
+
{"zyxw", true},
|
|
23
|
+
{"abcba", false}, // direction change restarts the count
|
|
24
|
+
{"abbc", false}, // a repeat breaks the run
|
|
25
|
+
{"aaaa", false}, // repeat is not a sequence
|
|
26
|
+
{"89ab", false}, // letters and digits do not join
|
|
27
|
+
{"xyz{", false}, // ASCII neighbour outside the alphabet
|
|
28
|
+
{"/012", false}, // ASCII neighbour outside the alphabet
|
|
29
|
+
{"ab-cd", false}, // a separator breaks the run
|
|
30
|
+
{"abécd", false}, // non-ASCII breaks the run
|
|
31
|
+
{"ab12cd", false}, // alphabets switching break the run
|
|
32
|
+
{"abc0123", true},
|
|
33
|
+
} {
|
|
34
|
+
if got := violatesSequence(tc.pw, ""); got != tc.want {
|
|
35
|
+
t.Errorf("violatesSequence(%q) = %v, want %v", tc.pw, got, tc.want)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -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 {
|