@things-factory/auth-base 10.1.4 → 10.1.6

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.
Files changed (52) hide show
  1. package/dist-client/auth.d.ts +9 -1
  2. package/dist-client/auth.js +12 -3
  3. package/dist-client/auth.js.map +1 -1
  4. package/dist-client/tsconfig.tsbuildinfo +1 -1
  5. package/dist-server/controllers/profile.d.ts +1 -0
  6. package/dist-server/controllers/signin.js +33 -12
  7. package/dist-server/controllers/signin.js.map +1 -1
  8. package/dist-server/controllers/unlock-user.js +21 -2
  9. package/dist-server/controllers/unlock-user.js.map +1 -1
  10. package/dist-server/router/auth-public-process-router.js +32 -5
  11. package/dist-server/router/auth-public-process-router.js.map +1 -1
  12. package/dist-server/service/privilege/privilege-directive.d.ts +46 -0
  13. package/dist-server/service/privilege/privilege-directive.js +87 -6
  14. package/dist-server/service/privilege/privilege-directive.js.map +1 -1
  15. package/dist-server/service/role/index.d.ts +2 -1
  16. package/dist-server/service/role/index.js +2 -1
  17. package/dist-server/service/role/index.js.map +1 -1
  18. package/dist-server/service/role/role-mutation.d.ts +17 -1
  19. package/dist-server/service/role/role-mutation.js +126 -7
  20. package/dist-server/service/role/role-mutation.js.map +1 -1
  21. package/dist-server/service/role/role-privilege-query.d.ts +14 -0
  22. package/dist-server/service/role/role-privilege-query.js +37 -0
  23. package/dist-server/service/role/role-privilege-query.js.map +1 -0
  24. package/dist-server/service/role/role-query.d.ts +18 -0
  25. package/dist-server/service/role/role-query.js +38 -22
  26. package/dist-server/service/role/role-query.js.map +1 -1
  27. package/dist-server/service/user/user.d.ts +1 -0
  28. package/dist-server/service/user/user.js +55 -13
  29. package/dist-server/service/user/user.js.map +1 -1
  30. package/dist-server/tsconfig.tsbuildinfo +1 -1
  31. package/dist-server/utils/account-lock-rule.d.ts +79 -0
  32. package/dist-server/utils/account-lock-rule.js +114 -0
  33. package/dist-server/utils/account-lock-rule.js.map +1 -0
  34. package/dist-server/utils/lock-duration.d.ts +3 -0
  35. package/dist-server/utils/lock-duration.js +36 -0
  36. package/dist-server/utils/lock-duration.js.map +1 -0
  37. package/package.json +2 -2
  38. package/tests/account-lock-db.test.ts +216 -0
  39. package/tests/account-lock-rule.test.ts +141 -0
  40. package/tests/compiled.ts +41 -0
  41. package/tests/has-privilege-db.test.ts +198 -0
  42. package/tests/lock-recovery.test.ts +204 -0
  43. package/tests/password-hashing.test.ts +131 -0
  44. package/tests/password-persistence.test.ts +147 -0
  45. package/tests/permission-gate.test.ts +226 -0
  46. package/tests/privilege-directive.test.ts +270 -0
  47. package/tests/role-mutation-db.test.ts +263 -0
  48. package/tests/role-privileges-db.test.ts +128 -0
  49. package/tests/signin-policy.test.ts +200 -0
  50. package/tests/token-issuance.test.ts +240 -0
  51. package/translations/en.json +161 -1
  52. package/translations/ko.json +161 -1
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 게이트 시험이 **컴파일된 산출물**(`dist-server`)을 드는 이유.
3
+ *
4
+ * 이 패키지의 엔티티는 `@Field({ nullable: true })` 처럼 **명시 타입이 없는** type-graphql
5
+ * 데코레이터를 쓴다. 그것을 읽으려면 `emitDecoratorMetadata` 가 필요한데 jest 의 변환기는
6
+ * babel 이고, babel 은 tsc 의 그 기능을 대체하지 못한다. 그래서 소스에서 `user.ts` 를 들면
7
+ * import 시점에 죽는다:
8
+ *
9
+ * Unable to infer GraphQL type from TypeScript reflection system.
10
+ * at privilege.ts:15 ← role.ts → user.ts 를 타고 올라온다
11
+ *
12
+ * legacy 데코레이터로 바꾸는 길은 이미 시도했고 되돌렸다(루트 `jest.config.js` 주석).
13
+ * 그래서 **엔티티 그래프에 닿는 시험은 tsc 산출물을 든다.** 루트 `pretest` 가 `yarn build` 이므로
14
+ * `yarn test` 경로에서는 산출물이 항상 갱신되어 있다.
15
+ *
16
+ * **대가**: 산출물은 낡을 수 있고, 타입 검사가 이 경계를 넘지 않는다 — `yarn _test` 로 빌드를
17
+ * 건너뛰고 돌리면 옛 시그니처를 시험하게 된다. 그래서 아래 로더가 산출물이 없으면
18
+ * **무엇을 해야 하는지 말하고** 죽는다(조용한 초록보다 낫다).
19
+ *
20
+ * 순수 함수(`checkin-domain-rule` · `privilege-rejection` · `privilege-deprecation`)는
21
+ * 엔티티에 닿지 않으므로 소스에서 그대로 든다. 이 로더는 그 밖의 것에만 쓴다.
22
+ */
23
+
24
+ import { existsSync } from 'fs'
25
+ import { join } from 'path'
26
+
27
+ const DIST = join(__dirname, '..', 'dist-server')
28
+
29
+ export function loadCompiled<T = any>(modulePath: string): T {
30
+ const resolved = join(DIST, `${modulePath}.js`)
31
+
32
+ if (!existsSync(resolved)) {
33
+ throw new Error(
34
+ `auth-base 산출물이 없다: ${resolved}\n` +
35
+ `이 시험은 tsc 산출물을 든다(이유는 tests/compiled.ts). 먼저 빌드할 것:\n` +
36
+ ` yarn workspace @things-factory/auth-base build:server`
37
+ )
38
+ }
39
+
40
+ return require(resolved)
41
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * `User.hasPrivilege` — **질의**를 실 DB 로 태운다.
3
+ *
4
+ * 이 함수가 이름 있는 권한 판정의 마지막 자리다(`checkPermission` 이 소유권 우회를 지나면 여기로
5
+ * 온다). `permission-gate.test.ts` 는 그 앞의 규칙만 보고 이 함수는 목으로 대신했다 — 즉
6
+ * **역할·권한·도메인 상속이 실제로 어떻게 이어지는지는 시험된 적이 없었다.**
7
+ *
8
+ * 열린 방 509개를 닫는 일이 이 질의 위에 선다. 지시자를 다 달아 놓고 질의가 틀리면, 닫은 것이
9
+ * 아니라 **아무도 못 들어오게 만든 것**이 된다.
10
+ *
11
+ * ── 상속 규약 ───────────────────────────────────────────────────────────────
12
+ * 역할은 도메인에 정의되고 자식 도메인이 **부모 1단**을 상속한다
13
+ * (`applyDomainInheritanceFilter` 가 단일 소스: `domain IN (자기, 부모)`).
14
+ * 그래서 판정은 「어느 도메인에 정의된 역할인가」를 보고, 「어느 도메인에서 부여받았나」는
15
+ * 체크인 게이트(`checkin-domain-rule`)가 본다 — 그쪽은 순수 시험이 따로 있다.
16
+ *
17
+ * ⚠ `parentId` 는 `@RelationId` 라 **저장소로 읽은 도메인에만** 실린다. 손으로 만든 객체를
18
+ * 넘기면 상속 경로가 조용히 성립하지 않는다. 그래서 아래는 전부 다시 읽어서 넘긴다.
19
+ */
20
+
21
+ import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
22
+ import { loadCompiled } from './compiled'
23
+
24
+ const { User } = loadCompiled('service/user/user')
25
+ const { Role } = loadCompiled('service/role/role')
26
+ const { Privilege } = loadCompiled('service/privilege/privilege')
27
+
28
+ let parent: any
29
+ let child: any
30
+ let sibling: any
31
+ let grandchild: any
32
+ let boardMutation: any
33
+
34
+ /** `parentId` 가 실리도록 저장소로 다시 읽는다. */
35
+ async function loadDomain(id: string) {
36
+ return await getRepository(Domain).findOneBy({ id })
37
+ }
38
+
39
+ async function makeRole(name: string, domain: any, privileges: any[]) {
40
+ return await getRepository(Role).save({ name, domain, privileges })
41
+ }
42
+
43
+ async function makeUser(memberOf: any[], roles: any[]) {
44
+ const salt = User.generateSalt()
45
+
46
+ return await getRepository(User).save({
47
+ username: `kim-${Math.random().toString(36).slice(2, 8)}`,
48
+ email: `kim-${Math.random().toString(36).slice(2, 8)}@example.com`,
49
+ name: 'Kim',
50
+ salt,
51
+ status: 'activated',
52
+ domains: memberOf,
53
+ roles
54
+ })
55
+ }
56
+
57
+ beforeAll(async () => {
58
+ await openAuthDatabase()
59
+ })
60
+
61
+ beforeEach(async () => {
62
+ await resetAuthDatabase()
63
+
64
+ const p = await getRepository(Domain).save({ name: 'group', subdomain: 'group' })
65
+ parent = await loadDomain(p.id)
66
+
67
+ const c = await getRepository(Domain).save({ name: 'plant-a', subdomain: 'plant-a', parent })
68
+ child = await loadDomain(c.id)
69
+
70
+ const s = await getRepository(Domain).save({ name: 'plant-b', subdomain: 'plant-b', parent })
71
+ sibling = await loadDomain(s.id)
72
+
73
+ const g = await getRepository(Domain).save({ name: 'line-1', subdomain: 'line-1', parent: child })
74
+ grandchild = await loadDomain(g.id)
75
+
76
+ boardMutation = await getRepository(Privilege).save({ name: 'mutation', category: 'board' })
77
+ })
78
+
79
+ afterAll(async () => {
80
+ await closeAuthDatabase()
81
+ })
82
+
83
+ describe('자기 도메인의 역할', () => {
84
+ it('그 도메인에서 통과한다', async () => {
85
+ const role = await makeRole('editor', child, [boardMutation])
86
+ const user = await makeUser([child], [role])
87
+
88
+ expect(await User.hasPrivilege('mutation', 'board', child, user)).toBe(true)
89
+ })
90
+
91
+ it('권한이 다르면 통과하지 못한다', async () => {
92
+ const role = await makeRole('editor', child, [boardMutation])
93
+ const user = await makeUser([child], [role])
94
+
95
+ expect(await User.hasPrivilege('query', 'board', child, user)).toBe(false)
96
+ expect(await User.hasPrivilege('mutation', 'twin', child, user)).toBe(false)
97
+ })
98
+
99
+ it('역할이 없으면 통과하지 못한다', async () => {
100
+ const user = await makeUser([child], [])
101
+
102
+ expect(await User.hasPrivilege('mutation', 'board', child, user)).toBe(false)
103
+ })
104
+
105
+ it('역할은 있는데 권한이 안 붙어 있으면 통과하지 못한다', async () => {
106
+ const role = await makeRole('viewer', child, [])
107
+ const user = await makeUser([child], [role])
108
+
109
+ expect(await User.hasPrivilege('mutation', 'board', child, user)).toBe(false)
110
+ })
111
+ })
112
+
113
+ describe('부모 도메인의 역할 — 1단 상속', () => {
114
+ it('부모에 정의된 역할이 자식 도메인에서 통과한다', async () => {
115
+ const role = await makeRole('group-editor', parent, [boardMutation])
116
+ const user = await makeUser([child], [role])
117
+
118
+ expect(await User.hasPrivilege('mutation', 'board', child, user)).toBe(true)
119
+ })
120
+
121
+ it('할아버지 도메인의 역할은 손자에서 통과하지 못한다 — 상속은 1단이다', async () => {
122
+ const role = await makeRole('group-editor', parent, [boardMutation])
123
+ const user = await makeUser([grandchild], [role])
124
+
125
+ expect(await User.hasPrivilege('mutation', 'board', grandchild, user)).toBe(false)
126
+ })
127
+
128
+ it('형제 도메인의 역할은 통과하지 못한다', async () => {
129
+ const role = await makeRole('b-editor', sibling, [boardMutation])
130
+ const user = await makeUser([child], [role])
131
+
132
+ expect(await User.hasPrivilege('mutation', 'board', child, user)).toBe(false)
133
+ })
134
+ })
135
+
136
+ describe('⚠ 이 질의가 멤버십을 보지 않는다', () => {
137
+ it('그 도메인의 멤버가 아니어도 역할만 있으면 통과한다', async () => {
138
+ /*
139
+ * 결함이 아니다 — 나누어 맡긴 것이다. 멤버십은 체크인 게이트가 강제하고
140
+ * (`checkin-domain-rule` 이 `memberDomainIds` 를 AND 조건으로 건다), 여기까지 온 요청은
141
+ * 이미 그 문을 지났다.
142
+ *
143
+ * 다만 **이 함수를 그 문 밖에서 부르면 격리가 성립하지 않는다.** 미들웨어를 타지 않는
144
+ * 경로(주기 작업·내부 호출)에서 쓸 때 그 사실을 알고 있어야 한다.
145
+ */
146
+ const role = await makeRole('editor', child, [boardMutation])
147
+ const user = await makeUser([sibling], [role])
148
+
149
+ expect(await User.hasPrivilege('mutation', 'board', child, user)).toBe(true)
150
+ })
151
+ })
152
+
153
+ describe('parentId 가 안 실린 도메인 객체', () => {
154
+ it('손으로 만든 객체를 넘기면 상속이 성립하지 않는다', async () => {
155
+ /*
156
+ * `parentId` 는 `@RelationId` 라 저장소로 읽은 엔티티에만 실린다. 그래서 `{ id }` 만 담은
157
+ * 객체를 넘기면 상속 경로가 **조용히** 사라진다 — 부모 역할로 들어오던 사람이 거절된다.
158
+ * 부르는 쪽이 온전히 적재된 도메인을 넘겨야 한다는 뜻이고, 그것을 시험으로 못 박아 둔다.
159
+ */
160
+ const role = await makeRole('group-editor', parent, [boardMutation])
161
+ const user = await makeUser([child], [role])
162
+
163
+ expect(await User.hasPrivilege('mutation', 'board', child, user)).toBe(true)
164
+ expect(await User.hasPrivilege('mutation', 'board', { id: child.id } as any, user)).toBe(false)
165
+ })
166
+ })
167
+
168
+ describe('getPrivilegesByDomain', () => {
169
+ it('자기 도메인과 부모 도메인의 권한을 함께 돌려준다', async () => {
170
+ const boardQuery = await getRepository(Privilege).save({ name: 'query', category: 'board' })
171
+ const twinMutation = await getRepository(Privilege).save({ name: 'mutation', category: 'twin' })
172
+
173
+ const parentRole = await makeRole('group-editor', parent, [twinMutation])
174
+ const childRole = await makeRole('editor', child, [boardMutation, boardQuery])
175
+ const user = await makeUser([child], [parentRole, childRole])
176
+
177
+ const granted = await User.getPrivilegesByDomain(user, child)
178
+
179
+ expect(granted).toEqual(
180
+ expect.arrayContaining([
181
+ { category: 'board', privilege: 'mutation' },
182
+ { category: 'board', privilege: 'query' },
183
+ { category: 'twin', privilege: 'mutation' }
184
+ ])
185
+ )
186
+ expect(granted).toHaveLength(3)
187
+ })
188
+
189
+ it('같은 권한을 두 역할로 받아도 한 번만 돌려준다', async () => {
190
+ const one = await makeRole('editor-1', child, [boardMutation])
191
+ const two = await makeRole('editor-2', child, [boardMutation])
192
+ const user = await makeUser([child], [one, two])
193
+
194
+ const granted = await User.getPrivilegesByDomain(user, child)
195
+
196
+ expect(granted).toEqual([{ category: 'board', privilege: 'mutation' }])
197
+ })
198
+ })
@@ -0,0 +1,204 @@
1
+ /**
2
+ * 잠긴 계정이 **어떤 경로로 풀리나** — 두 메일이 서로 다른 곳으로 간다.
3
+ *
4
+ * 계정이 잠기면 두 종류의 메일이 있을 수 있고, 템플릿이 거의 같아 눈으로는 잘 안 갈린다
5
+ * (둘 다 `<title>reset password</title>` · 같은 레이아웃 · 본문 한 줄만 다르다). 구별되는 것은
6
+ * 메일 **제목**뿐이다 — `Reset your password` 와 `Your account is locked`.
7
+ *
8
+ * 그런데 **결과가 다르다.** 비슷하게 보이면 안 되는 짝이라는 것을 여기서 못 박는다.
9
+ * 템플릿 자체의 개선은 따로 대기 중이다.
10
+ *
11
+ * (관리자 경로 `activateUser` 는 `failCount` 를 0 으로 두고 `ACTIVATED` 로 되돌린다 —
12
+ * type-graphql 리졸버라 jest 에서 들 수 없어 여기서는 시험하지 않는다. 그 자리가 정식 경로다.)
13
+ */
14
+
15
+ import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
16
+ import { loadCompiled } from './compiled'
17
+
18
+ const { User } = loadCompiled('service/user/user')
19
+ const { signin } = loadCompiled('controllers/signin')
20
+ const { resetPassword } = loadCompiled('controllers/reset-password')
21
+ const { unlockUser } = loadCompiled('controllers/unlock-user')
22
+ const { VerificationToken, VerificationTokenType } = loadCompiled('service/verification-token/verification-token')
23
+
24
+ const OLD = 'Passw0rd!'
25
+ const NEW = 'N3wPassw0rd!'
26
+
27
+ let domain: any
28
+
29
+ async function lockedUser() {
30
+ const salt = User.generateSalt()
31
+
32
+ return await getRepository(User).save({
33
+ username: 'kim',
34
+ email: 'kim@example.com',
35
+ name: 'Kim',
36
+ salt,
37
+ password: User.encode(OLD, salt),
38
+ status: 'locked',
39
+ failCount: 5,
40
+ domains: [domain]
41
+ })
42
+ }
43
+
44
+ async function giveToken(userId: string, token: string, type: string) {
45
+ await getRepository(VerificationToken).save({ userId, token, type })
46
+ }
47
+
48
+ /** `resetPassword` 가 컨텍스트에서 읽는 것만 담는다. */
49
+ const context = { t: (key: string) => key, lng: 'en-US' }
50
+
51
+ beforeAll(async () => {
52
+ await openAuthDatabase()
53
+ })
54
+
55
+ beforeEach(async () => {
56
+ await resetAuthDatabase()
57
+ domain = await getRepository(Domain).save({ name: 'plant-a', subdomain: 'plant-a' })
58
+ })
59
+
60
+ afterAll(async () => {
61
+ await closeAuthDatabase()
62
+ })
63
+
64
+ describe('재설정 메일 경로 (PASSWORD_RESET)', () => {
65
+ it('비밀번호는 바뀌지만 **잠금은 그대로다**', async () => {
66
+ /* `resetPassword` 는 `status` 를 건드리지 않는다 — 그 파일의 INACTIVE 검사도 주석 처리되어
67
+ 있다. 그래서 이 메일은 「비밀번호를 잊었다」를 위한 것이고 「잠겼다」의 답이 아니다. */
68
+ const user = await lockedUser()
69
+ await giveToken(user.id, 'tok', VerificationTokenType.PASSWORD_RESET)
70
+
71
+ await resetPassword('tok', NEW, context)
72
+
73
+ const after = await getRepository(User).findOneBy({ id: user.id })
74
+ expect(User.verify(after.password, NEW, after.salt)).toBe(true)
75
+ expect(after.status).toBe('locked')
76
+ expect(after.failCount).toBe(5)
77
+ })
78
+
79
+ it('그래서 새 비밀번호로도 들어갈 수 없다', async () => {
80
+ /* `signin` 의 잠금 검사가 비밀번호 대조보다 먼저다. 사용자가 겪는 것은
81
+ 「메일로 비밀번호를 바꿨는데 여전히 못 들어간다」다. */
82
+ const user = await lockedUser()
83
+ await giveToken(user.id, 'tok', VerificationTokenType.PASSWORD_RESET)
84
+
85
+ await resetPassword('tok', NEW, context)
86
+
87
+ await expect(signin({ username: 'kim', password: NEW })).rejects.toThrow()
88
+ })
89
+ })
90
+
91
+ describe('잠금 해제 메일 경로 (UNLOCK)', () => {
92
+ it('비밀번호를 바꾸고 잠금까지 되돌린다', async () => {
93
+ const user = await lockedUser()
94
+ await giveToken(user.id, 'tok', VerificationTokenType.UNLOCK)
95
+
96
+ expect(await unlockUser('tok', NEW)).toBe(true)
97
+
98
+ const after = await getRepository(User).findOneBy({ id: user.id })
99
+ expect(after.status).toBe('activated')
100
+ expect(after.failCount).toBe(0)
101
+ expect(User.verify(after.password, NEW, after.salt)).toBe(true)
102
+ })
103
+
104
+ it('풀린 뒤에는 새 비밀번호로 들어간다', async () => {
105
+ const user = await lockedUser()
106
+ await giveToken(user.id, 'tok', VerificationTokenType.UNLOCK)
107
+
108
+ await unlockUser('tok', NEW)
109
+
110
+ const result = await signin({ username: 'kim', password: NEW })
111
+ expect(result.token).toBeTruthy()
112
+ })
113
+
114
+ it('토큰을 한 번만 쓸 수 있다 — 두 번째는 거절한다(던지지 않는다)', async () => {
115
+ const user = await lockedUser()
116
+ await giveToken(user.id, 'tok', VerificationTokenType.UNLOCK)
117
+
118
+ await unlockUser('tok', NEW)
119
+
120
+ /* 예전에는 토큰 행이 사라진 뒤 `findOne` 의 `null` 을 구조 분해해서 던졌고, 화면은 이유가
121
+ 아니라 500 을 봤다. 지금은 거절로 돌아온다. */
122
+ expect(await unlockUser('tok', NEW)).toBe(false)
123
+ })
124
+
125
+ it('잠기지 않은 계정에는 아무 일도 하지 않는다', async () => {
126
+ const user = await lockedUser()
127
+ await getRepository(User).update({ id: user.id }, { status: 'activated', failCount: 0 })
128
+ await giveToken(user.id, 'tok', VerificationTokenType.UNLOCK)
129
+
130
+ /* `status != LOCKED` 이면 `false` 를 돌려준다(unlock-user.ts:48) — 비밀번호도 안 바뀐다. */
131
+ expect(await unlockUser('tok', NEW)).toBe(false)
132
+
133
+ const after = await getRepository(User).findOneBy({ id: user.id })
134
+ expect(User.verify(after.password, OLD, after.salt)).toBe(true)
135
+ })
136
+ })
137
+
138
+ describe('토큰 없는 요청 — 계정 탈취 회귀', () => {
139
+ /*
140
+ * ── 무엇이 가능했나 (2026-09-09, 고침) ──────────────────────────────────────
141
+ * `unlockUser` 가 `findOne({ where: { token, type: UNLOCK } })` 로 찾았는데, TypeORM 은
142
+ * `where` 에서 **`undefined` 조건을 떨어뜨린다.** 그래서 `token` 이 없으면 조건이
143
+ * `{ type: UNLOCK }` 만 남아 **누구의 해제 토큰이든** 맞았고, 그 계정의 비밀번호를 부르는
144
+ * 쪽이 준 값으로 바꿨다.
145
+ *
146
+ * 두 조각 다 자격 없이 손에 넣을 수 있었다. 남의 계정을 다섯 번 틀리면 잠기면서 해제 토큰이
147
+ * 만들어지고(그것을 막을 rate limit 이 없다), `/auth/unlock-user` 의 가드가
148
+ * `!(token || password)` 여서 **비밀번호만 담은 요청이 통과했다.**
149
+ *
150
+ * 실제로 전 과정을 태워 확인했다 — status `activated` · failCount 0 · 비밀번호는 공격자의
151
+ * 것 · 그 비밀번호로 로그인 성공.
152
+ *
153
+ * 두 자리를 다 고쳤다(컨트롤러의 요구 조건 · 라우터의 가드). 아래는 컨트롤러 쪽을 지킨다 —
154
+ * 라우터 가드가 되돌아가도 여기서 막힌다.
155
+ */
156
+ it('토큰 없이 부르면 잠긴 계정을 건드리지 못한다', async () => {
157
+ const user = await lockedUser()
158
+ await giveToken(user.id, 'tok', VerificationTokenType.UNLOCK)
159
+
160
+ expect(await unlockUser(undefined, 'ATTACKERpass1!')).toBe(false)
161
+
162
+ const after = await getRepository(User).findOneBy({ id: user.id })
163
+ expect(after.status).toBe('locked')
164
+ expect(after.failCount).toBe(5)
165
+ expect(User.verify(after.password, 'ATTACKERpass1!', after.salt)).toBe(false)
166
+ expect(User.verify(after.password, OLD, after.salt)).toBe(true)
167
+ })
168
+
169
+ it('빈 문자열 토큰도 같다', async () => {
170
+ const user = await lockedUser()
171
+ await giveToken(user.id, 'tok', VerificationTokenType.UNLOCK)
172
+
173
+ expect(await unlockUser('', 'ATTACKERpass1!')).toBe(false)
174
+
175
+ const after = await getRepository(User).findOneBy({ id: user.id })
176
+ expect(after.status).toBe('locked')
177
+ })
178
+
179
+ it('그 뒤에도 공격자의 비밀번호로 들어갈 수 없다', async () => {
180
+ const user = await lockedUser()
181
+ await giveToken(user.id, 'tok', VerificationTokenType.UNLOCK)
182
+
183
+ await unlockUser(undefined, 'ATTACKERpass1!')
184
+
185
+ await expect(signin({ username: 'kim', password: 'ATTACKERpass1!' })).rejects.toThrow()
186
+ })
187
+
188
+ it('비밀번호 없이 부르면 거절한다 — 던지지 않는다', async () => {
189
+ /* 예전에는 `User.encode(undefined, salt)` 가 `crypto` 안에서 던져 500 이 됐다. */
190
+ const user = await lockedUser()
191
+ await giveToken(user.id, 'tok', VerificationTokenType.UNLOCK)
192
+
193
+ expect(await unlockUser('tok', undefined)).toBe(false)
194
+
195
+ const after = await getRepository(User).findOneBy({ id: user.id })
196
+ expect(after.status).toBe('locked')
197
+ })
198
+
199
+ it('없는 토큰은 거절한다 — 던지지 않는다', async () => {
200
+ await lockedUser()
201
+
202
+ expect(await unlockUser('no-such-token', NEW)).toBe(false)
203
+ })
204
+ })
@@ -0,0 +1,131 @@
1
+ /**
2
+ * 비밀번호 저장 방식 시험.
3
+ *
4
+ * `signin` 은 `User.verify(user.password, 입력, user.salt)` 하나로 사람을 들여보낸다
5
+ * (`controllers/signin.ts:53`). 저장소의 시험 316개 중 이 함수를 부르는 것이 없었다.
6
+ *
7
+ * 아래는 **지금 그렇다**를 고정하는 시험이고, 그중 셋은 고쳐야 할 사실이다
8
+ * (`docs/design/auth-rebuild.md` §5-1):
9
+ *
10
+ * ① 해시가 **KDF 가 아니다** — HMAC-SHA256 **1회**다. 대입 저항이 사실상 없다
11
+ * ② `verify` 가 **SHA-1 도 받는다** — 옛 형식 하위호환
12
+ * ③ salt 가 없으면 **`SECRET` 을 salt 로** 쓴다 — 그 경우 전 사용자가 같은 salt 다
13
+ *
14
+ * KDF 로 옮길 때는 이 시험을 먼저 바꾼다. ①의 「1회 HMAC 과 바이트가 같다」가 깨지는 것이
15
+ * 곧 옮겨졌다는 증거다.
16
+ *
17
+ * (여기서 다루지 않는 것: 비교가 `==` 문자열 비교라 상수 시간이 아니다. 타이밍은 시험으로
18
+ * 붙잡을 수 있는 성질이 아니므로 문서에만 남긴다.)
19
+ */
20
+
21
+ import crypto from 'crypto'
22
+
23
+ import { validatePasswordByRule } from '../server/controllers/utils/password-rule.js'
24
+ import { loadCompiled } from './compiled'
25
+
26
+ const { User } = loadCompiled('service/user/user')
27
+ const { SECRET } = loadCompiled('utils/get-secret')
28
+
29
+ const PASSWORD = 'Passw0rd!'
30
+ const SALT = 'a1b2c3d4e5f6'
31
+
32
+ describe('해시 만들기 (User.encode)', () => {
33
+ it('HMAC-SHA256 을 한 번 돌린 값과 바이트가 같다 — KDF 가 아니다', () => {
34
+ const oneRound = crypto.createHmac('sha256', SALT).update(PASSWORD).digest('base64')
35
+
36
+ expect(User.encode(PASSWORD, SALT)).toBe(oneRound)
37
+ })
38
+
39
+ it('같은 입력에 같은 값을 준다 — 호출마다 무작위가 섞이지 않는다', () => {
40
+ expect(User.encode(PASSWORD, SALT)).toBe(User.encode(PASSWORD, SALT))
41
+ })
42
+
43
+ it('salt 가 다르면 값이 다르다', () => {
44
+ expect(User.encode(PASSWORD, SALT)).not.toBe(User.encode(PASSWORD, 'other-salt'))
45
+ })
46
+
47
+ it('salt 가 없으면 `SECRET` 을 salt 로 쓴다 — 그 사용자들은 salt 를 공유한다', () => {
48
+ const withSecretAsSalt = crypto.createHmac('sha256', SECRET).update(PASSWORD).digest('base64')
49
+
50
+ expect(User.encode(PASSWORD, undefined)).toBe(withSecretAsSalt)
51
+ expect(User.encode(PASSWORD, null)).toBe(withSecretAsSalt)
52
+ expect(User.encode(PASSWORD, '')).toBe(withSecretAsSalt)
53
+ })
54
+
55
+ it('salt 는 16바이트 hex 다', () => {
56
+ const salt = User.generateSalt()
57
+
58
+ expect(salt).toMatch(/^[0-9a-f]{32}$/)
59
+ expect(salt).not.toBe(User.generateSalt())
60
+ })
61
+ })
62
+
63
+ describe('해시 맞추기 (User.verify)', () => {
64
+ it('SHA-256 으로 저장된 값을 맞춘다', () => {
65
+ expect(User.verify(User.encode(PASSWORD, SALT), PASSWORD, SALT)).toBe(true)
66
+ })
67
+
68
+ it('틀린 비밀번호를 거절한다', () => {
69
+ expect(User.verify(User.encode(PASSWORD, SALT), 'Passw0rd?', SALT)).toBe(false)
70
+ })
71
+
72
+ it('salt 가 다르면 거절한다', () => {
73
+ expect(User.verify(User.encode(PASSWORD, SALT), PASSWORD, 'other-salt')).toBe(false)
74
+ })
75
+
76
+ it('SHA-1 으로 저장된 값도 받는다 — 옛 형식 하위호환', () => {
77
+ const sha1 = crypto.createHmac('sha1', SALT).update(PASSWORD).digest('base64')
78
+
79
+ expect(User.verify(sha1, PASSWORD, SALT)).toBe(true)
80
+ })
81
+
82
+ it('저장된 해시가 없으면 거절한다', () => {
83
+ /* 기계 주체의 그림자 사용자는 `password` 칸에 **토큰 원문**이 들어간다
84
+ (appliance-mutation.ts:64 · oauth2-server.ts:152). 그 값은 해시가 아니므로
85
+ 비밀번호 로그인으로는 통하지 않는다 — 노출 문제는 저장 쪽이고, 로그인 쪽은 막혀 있다. */
86
+ expect(User.verify(undefined, PASSWORD, SALT)).toBe(false)
87
+ expect(User.verify(null, PASSWORD, SALT)).toBe(false)
88
+ expect(User.verify('some-jwt-looking-string', PASSWORD, SALT)).toBe(false)
89
+ })
90
+ })
91
+
92
+ describe('서명 키 (SECRET)', () => {
93
+ it('설정이 없으면 저장소에 박힌 상수를 쓴다', () => {
94
+ /* production 에서는 던진다(get-secret.ts:7). 그 밖의 환경 — staging 포함 — 은
95
+ **공개된 상수**로 토큰을 서명한다. 이 값이 바뀌면 이 시험이 깨진다. */
96
+ expect(SECRET).toBe('0xD58F835B69D207A76CC5F84a70a1D0d4C79dAC95')
97
+ })
98
+ })
99
+
100
+ describe('비밀번호 규칙 (기본 설정)', () => {
101
+ /** 기본 규칙: 8자 이상 · 소문자 · 대문자 · 숫자 · 특수문자 · 같은 문자 3연속 금지. */
102
+ const rejects = (password: string) => expect(() => validatePasswordByRule(password, 'en-US')).toThrow()
103
+ const accepts = (password: string) => expect(() => validatePasswordByRule(password, 'en-US')).not.toThrow()
104
+
105
+ it('규칙을 모두 만족하면 통과한다', () => {
106
+ accepts('Passw0rd!')
107
+ })
108
+
109
+ it('짧으면 거절한다', () => {
110
+ rejects('Pw0rd!')
111
+ })
112
+
113
+ it('종류가 빠지면 거절한다', () => {
114
+ rejects('passw0rd!') // 대문자 없음
115
+ rejects('PASSW0RD!') // 소문자 없음
116
+ rejects('Password!') // 숫자 없음
117
+ rejects('Passw0rdd') // 특수문자 없음
118
+ })
119
+
120
+ it('같은 문자 3연속을 거절한다', () => {
121
+ rejects('Paaassw0rd!')
122
+ })
123
+
124
+ it('특수문자는 `!@#$%^&*()` 안에서만 인정한다', () => {
125
+ /* 규칙 정규식이 그 열 자만 본다(password-rule.ts:36). 그 밖의 기호(`-`·`_`·`~` 등)만
126
+ 쓴 비밀번호는 특수문자가 없는 것으로 취급되어 거절된다 — 사용자에게는
127
+ 「특수문자를 넣었는데 거절당했다」로 보인다. */
128
+ rejects('Passw0rd-')
129
+ accepts('Passw0rd#')
130
+ })
131
+ })