@things-factory/auth-base 10.1.3 → 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.
- package/dist-client/auth.d.ts +9 -1
- package/dist-client/auth.js +12 -3
- package/dist-client/auth.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/controllers/profile.d.ts +1 -0
- package/dist-server/controllers/signin.js +33 -12
- package/dist-server/controllers/signin.js.map +1 -1
- package/dist-server/controllers/unlock-user.js +21 -2
- package/dist-server/controllers/unlock-user.js.map +1 -1
- package/dist-server/router/auth-public-process-router.js +32 -5
- package/dist-server/router/auth-public-process-router.js.map +1 -1
- package/dist-server/service/privilege/privilege-directive.d.ts +46 -0
- package/dist-server/service/privilege/privilege-directive.js +87 -6
- package/dist-server/service/privilege/privilege-directive.js.map +1 -1
- package/dist-server/service/role/index.d.ts +2 -1
- package/dist-server/service/role/index.js +2 -1
- package/dist-server/service/role/index.js.map +1 -1
- package/dist-server/service/role/role-mutation.d.ts +17 -1
- package/dist-server/service/role/role-mutation.js +126 -7
- package/dist-server/service/role/role-mutation.js.map +1 -1
- package/dist-server/service/role/role-privilege-query.d.ts +14 -0
- package/dist-server/service/role/role-privilege-query.js +37 -0
- package/dist-server/service/role/role-privilege-query.js.map +1 -0
- package/dist-server/service/role/role-query.d.ts +18 -0
- package/dist-server/service/role/role-query.js +38 -22
- package/dist-server/service/role/role-query.js.map +1 -1
- package/dist-server/service/user/user.d.ts +1 -0
- package/dist-server/service/user/user.js +55 -13
- package/dist-server/service/user/user.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/dist-server/utils/account-lock-rule.d.ts +79 -0
- package/dist-server/utils/account-lock-rule.js +114 -0
- package/dist-server/utils/account-lock-rule.js.map +1 -0
- package/dist-server/utils/lock-duration.d.ts +3 -0
- package/dist-server/utils/lock-duration.js +36 -0
- package/dist-server/utils/lock-duration.js.map +1 -0
- package/package.json +4 -4
- package/tests/account-lock-db.test.ts +216 -0
- package/tests/account-lock-rule.test.ts +141 -0
- package/tests/compiled.ts +41 -0
- package/tests/has-privilege-db.test.ts +198 -0
- package/tests/lock-recovery.test.ts +204 -0
- package/tests/password-hashing.test.ts +131 -0
- package/tests/password-persistence.test.ts +147 -0
- package/tests/permission-gate.test.ts +226 -0
- package/tests/privilege-directive.test.ts +270 -0
- package/tests/role-mutation-db.test.ts +263 -0
- package/tests/role-privileges-db.test.ts +128 -0
- package/tests/signin-policy.test.ts +200 -0
- package/tests/token-issuance.test.ts +240 -0
- package/translations/en.json +161 -1
- package/translations/ko.json +161 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 계정 잠금이 아직 유효한지를 판정하는 규칙 (순수 함수).
|
|
3
|
+
*
|
|
4
|
+
* ## 왜 따로 있나
|
|
5
|
+
*
|
|
6
|
+
* 잠금을 보는 자리가 둘이다 — `signin`(들어오려는 사람)과 `User.checkAuth`(요청마다 토큰을
|
|
7
|
+
* 확인하는 자리). 두 곳이 각자 「아직 잠겼나」를 판단하면 서로 다른 답을 낼 수 있고, 그러면
|
|
8
|
+
* 로그인은 되는데 요청은 거절되는(또는 그 반대) 상태가 생긴다. 판정은 한 곳에 둔다
|
|
9
|
+
* (`checkin-domain-rule` 과 같은 규율).
|
|
10
|
+
*
|
|
11
|
+
* ## 자동해제를 주기 작업으로 하지 않는 이유
|
|
12
|
+
*
|
|
13
|
+
* 계정이 10만이면 「만료된 잠금을 풀어 주는 일」이 따로 일이 된다. 잠금은 **읽는 시점에**
|
|
14
|
+
* 판정한다 — 그 계정을 보는 사람이 있을 때만 계산하면 되고, 그때가 유일하게 답이 필요한
|
|
15
|
+
* 순간이다.
|
|
16
|
+
*
|
|
17
|
+
* 행을 정리하는 것은 판정과 별개다. `signin` 은 판정이 「풀렸다」로 나오면 그 자리에서 행을
|
|
18
|
+
* 되돌리고(`ACTIVATED` · `failCount` 0 · `lockedAt` 비움) 계속 진행한다. `checkAuth` 는
|
|
19
|
+
* 매 요청 경로이므로 **읽기만 한다** — 판정만 보고 통과시키고, 행 정리는 다음 `signin` 이 한다.
|
|
20
|
+
*
|
|
21
|
+
* ## 잠금이 곧 셈의 창이다
|
|
22
|
+
*
|
|
23
|
+
* 자동해제 때 `failCount` 를 0 으로 두므로, 실패 셈은 「한 번의 잠금 안에서」만 쌓인다.
|
|
24
|
+
* 그래서 칸을 하나만 더해도 창이 생긴다.
|
|
25
|
+
*
|
|
26
|
+
* 남는 것: 잠기지 **않은** 동안의 셈은 여전히 누적이다(성공하면 0 으로 돌아간다). 그래서 오타를
|
|
27
|
+
* 드물게 다섯 번 내는 사람은 한 번 잠긴다 — 그 뒤 자동으로 풀리므로 견딜 수 있지만, 창을
|
|
28
|
+
* 정확히 하려면 「실패가 시작된 시각」 칸이 하나 더 필요하다.
|
|
29
|
+
*/
|
|
30
|
+
/** 판정에 필요한 사용자 측 사실들 — 조회는 부르는 쪽이 한다. */
|
|
31
|
+
export type AccountLockFacts = {
|
|
32
|
+
/** `UserStatus` 값. `'locked'` 가 아니면 잠금이 아니다. */
|
|
33
|
+
status?: string | null;
|
|
34
|
+
/** 잠긴 시각. 이 변경 전에 잠긴 행에는 없다. */
|
|
35
|
+
lockedAt?: Date | string | null;
|
|
36
|
+
};
|
|
37
|
+
/** 잠금이 풀리지 않는 설정값 — 0 이하는 「자동해제 없음」이다. */
|
|
38
|
+
export declare const LOCK_FOREVER = 0;
|
|
39
|
+
/**
|
|
40
|
+
* `UserStatus.LOCKED` 의 값.
|
|
41
|
+
*
|
|
42
|
+
* 엔티티를 import 하지 않는다 — 그러면 이 모듈이 type-graphql 데코레이터 그래프를 끌고 들어와
|
|
43
|
+
* 순수 시험이 닿지 못한다(`tests/compiled.ts`). 대신 시험이 이 값과 `UserStatus.LOCKED` 가
|
|
44
|
+
* 같은지 확인하므로, 한쪽만 바뀌면 시험이 깨진다.
|
|
45
|
+
*/
|
|
46
|
+
export declare const LOCKED_STATUS = "locked";
|
|
47
|
+
/**
|
|
48
|
+
* 잠금이 언제 풀리나. 잠금이 아니거나 자동해제가 없으면 `null`.
|
|
49
|
+
*
|
|
50
|
+
* `lockedAt` 이 없으면 계산할 수 없으므로 `null` 을 준다 — 그때 `isAccountLocked` 는
|
|
51
|
+
* **잠금을 유지한다**(아래 참조).
|
|
52
|
+
*/
|
|
53
|
+
export declare function accountLockExpiresAt(facts: AccountLockFacts, durationMinutes: number): Date | null;
|
|
54
|
+
/**
|
|
55
|
+
* 잠금이 아직 유효한가.
|
|
56
|
+
*
|
|
57
|
+
* ## 한 칸이 두 물음에 답하지 않는다
|
|
58
|
+
*
|
|
59
|
+
* 섞이기 쉬운 두 사실이다.
|
|
60
|
+
*
|
|
61
|
+
* ```
|
|
62
|
+
* ① 이 계정이 잠겼나 ← `status` 가 답한다. 이 함수의 문이다
|
|
63
|
+
* ② 언제 자동으로 풀리나 ← `lockedAt` 이 답한다. 잠긴 것에 대해서만 묻는다
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* 그래서 `status` 를 먼저 보고, 잠금이 아니면 **`lockedAt` 을 아예 보지 않는다.** 이 순서를
|
|
67
|
+
* 뒤집으면 사고가 양쪽으로 난다 — `lockedAt` 만 보고 판정하면 **한 번도 잠긴 적 없는 계정**
|
|
68
|
+
* (칸이 비어 있다)까지 잠김이 되어 배포하는 순간 전원이 못 들어오고, 반대로 짜면 지금 잠긴
|
|
69
|
+
* 계정이 전부 풀린다.
|
|
70
|
+
*
|
|
71
|
+
* `lockedAt` 이 비어 있다는 것은 「잠겼다」가 아니라 **「자동 해제가 없다」**는 뜻이다. 이 변경
|
|
72
|
+
* 전에 잠긴 행이 그렇고, 그 경우 관리자(`activateUser`)와 잠금 해제 메일이 푸는 길로 남는다 —
|
|
73
|
+
* 계산할 수 없는 것을 풀어 주는 쪽으로 기울면 잠긴 계정 전체가 조용히 열린다.
|
|
74
|
+
*
|
|
75
|
+
* @param now 판정 시각. 시험이 시계를 넘길 수 있게 인자로 받는다.
|
|
76
|
+
*/
|
|
77
|
+
export declare function isAccountLocked(facts: AccountLockFacts, now: Date, durationMinutes: number): boolean;
|
|
78
|
+
/** 잠금이 시간이 지나 풀렸는가 — 행을 되돌려야 하는지 묻는 자리. */
|
|
79
|
+
export declare function isAccountLockExpired(facts: AccountLockFacts, now: Date, durationMinutes: number): boolean;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 계정 잠금이 아직 유효한지를 판정하는 규칙 (순수 함수).
|
|
4
|
+
*
|
|
5
|
+
* ## 왜 따로 있나
|
|
6
|
+
*
|
|
7
|
+
* 잠금을 보는 자리가 둘이다 — `signin`(들어오려는 사람)과 `User.checkAuth`(요청마다 토큰을
|
|
8
|
+
* 확인하는 자리). 두 곳이 각자 「아직 잠겼나」를 판단하면 서로 다른 답을 낼 수 있고, 그러면
|
|
9
|
+
* 로그인은 되는데 요청은 거절되는(또는 그 반대) 상태가 생긴다. 판정은 한 곳에 둔다
|
|
10
|
+
* (`checkin-domain-rule` 과 같은 규율).
|
|
11
|
+
*
|
|
12
|
+
* ## 자동해제를 주기 작업으로 하지 않는 이유
|
|
13
|
+
*
|
|
14
|
+
* 계정이 10만이면 「만료된 잠금을 풀어 주는 일」이 따로 일이 된다. 잠금은 **읽는 시점에**
|
|
15
|
+
* 판정한다 — 그 계정을 보는 사람이 있을 때만 계산하면 되고, 그때가 유일하게 답이 필요한
|
|
16
|
+
* 순간이다.
|
|
17
|
+
*
|
|
18
|
+
* 행을 정리하는 것은 판정과 별개다. `signin` 은 판정이 「풀렸다」로 나오면 그 자리에서 행을
|
|
19
|
+
* 되돌리고(`ACTIVATED` · `failCount` 0 · `lockedAt` 비움) 계속 진행한다. `checkAuth` 는
|
|
20
|
+
* 매 요청 경로이므로 **읽기만 한다** — 판정만 보고 통과시키고, 행 정리는 다음 `signin` 이 한다.
|
|
21
|
+
*
|
|
22
|
+
* ## 잠금이 곧 셈의 창이다
|
|
23
|
+
*
|
|
24
|
+
* 자동해제 때 `failCount` 를 0 으로 두므로, 실패 셈은 「한 번의 잠금 안에서」만 쌓인다.
|
|
25
|
+
* 그래서 칸을 하나만 더해도 창이 생긴다.
|
|
26
|
+
*
|
|
27
|
+
* 남는 것: 잠기지 **않은** 동안의 셈은 여전히 누적이다(성공하면 0 으로 돌아간다). 그래서 오타를
|
|
28
|
+
* 드물게 다섯 번 내는 사람은 한 번 잠긴다 — 그 뒤 자동으로 풀리므로 견딜 수 있지만, 창을
|
|
29
|
+
* 정확히 하려면 「실패가 시작된 시각」 칸이 하나 더 필요하다.
|
|
30
|
+
*/
|
|
31
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
+
exports.LOCKED_STATUS = exports.LOCK_FOREVER = void 0;
|
|
33
|
+
exports.accountLockExpiresAt = accountLockExpiresAt;
|
|
34
|
+
exports.isAccountLocked = isAccountLocked;
|
|
35
|
+
exports.isAccountLockExpired = isAccountLockExpired;
|
|
36
|
+
/** 잠금이 풀리지 않는 설정값 — 0 이하는 「자동해제 없음」이다. */
|
|
37
|
+
exports.LOCK_FOREVER = 0;
|
|
38
|
+
/**
|
|
39
|
+
* `UserStatus.LOCKED` 의 값.
|
|
40
|
+
*
|
|
41
|
+
* 엔티티를 import 하지 않는다 — 그러면 이 모듈이 type-graphql 데코레이터 그래프를 끌고 들어와
|
|
42
|
+
* 순수 시험이 닿지 못한다(`tests/compiled.ts`). 대신 시험이 이 값과 `UserStatus.LOCKED` 가
|
|
43
|
+
* 같은지 확인하므로, 한쪽만 바뀌면 시험이 깨진다.
|
|
44
|
+
*/
|
|
45
|
+
exports.LOCKED_STATUS = 'locked';
|
|
46
|
+
/**
|
|
47
|
+
* 잠금이 언제 풀리나. 잠금이 아니거나 자동해제가 없으면 `null`.
|
|
48
|
+
*
|
|
49
|
+
* `lockedAt` 이 없으면 계산할 수 없으므로 `null` 을 준다 — 그때 `isAccountLocked` 는
|
|
50
|
+
* **잠금을 유지한다**(아래 참조).
|
|
51
|
+
*/
|
|
52
|
+
function accountLockExpiresAt(facts, durationMinutes) {
|
|
53
|
+
if (!isLockedStatus(facts)) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (!(durationMinutes > exports.LOCK_FOREVER)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const lockedAt = toDate(facts?.lockedAt);
|
|
60
|
+
if (!lockedAt) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return new Date(lockedAt.getTime() + durationMinutes * 60 * 1000);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 잠금이 아직 유효한가.
|
|
67
|
+
*
|
|
68
|
+
* ## 한 칸이 두 물음에 답하지 않는다
|
|
69
|
+
*
|
|
70
|
+
* 섞이기 쉬운 두 사실이다.
|
|
71
|
+
*
|
|
72
|
+
* ```
|
|
73
|
+
* ① 이 계정이 잠겼나 ← `status` 가 답한다. 이 함수의 문이다
|
|
74
|
+
* ② 언제 자동으로 풀리나 ← `lockedAt` 이 답한다. 잠긴 것에 대해서만 묻는다
|
|
75
|
+
* ```
|
|
76
|
+
*
|
|
77
|
+
* 그래서 `status` 를 먼저 보고, 잠금이 아니면 **`lockedAt` 을 아예 보지 않는다.** 이 순서를
|
|
78
|
+
* 뒤집으면 사고가 양쪽으로 난다 — `lockedAt` 만 보고 판정하면 **한 번도 잠긴 적 없는 계정**
|
|
79
|
+
* (칸이 비어 있다)까지 잠김이 되어 배포하는 순간 전원이 못 들어오고, 반대로 짜면 지금 잠긴
|
|
80
|
+
* 계정이 전부 풀린다.
|
|
81
|
+
*
|
|
82
|
+
* `lockedAt` 이 비어 있다는 것은 「잠겼다」가 아니라 **「자동 해제가 없다」**는 뜻이다. 이 변경
|
|
83
|
+
* 전에 잠긴 행이 그렇고, 그 경우 관리자(`activateUser`)와 잠금 해제 메일이 푸는 길로 남는다 —
|
|
84
|
+
* 계산할 수 없는 것을 풀어 주는 쪽으로 기울면 잠긴 계정 전체가 조용히 열린다.
|
|
85
|
+
*
|
|
86
|
+
* @param now 판정 시각. 시험이 시계를 넘길 수 있게 인자로 받는다.
|
|
87
|
+
*/
|
|
88
|
+
function isAccountLocked(facts, now, durationMinutes) {
|
|
89
|
+
if (!isLockedStatus(facts)) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
const expiresAt = accountLockExpiresAt(facts, durationMinutes);
|
|
93
|
+
if (!expiresAt) {
|
|
94
|
+
/* 자동해제가 없거나(`durationMinutes <= 0`) 언제 풀릴지 모른다(`lockedAt` 없음). */
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
return now.getTime() < expiresAt.getTime();
|
|
98
|
+
}
|
|
99
|
+
/** 잠금이 시간이 지나 풀렸는가 — 행을 되돌려야 하는지 묻는 자리. */
|
|
100
|
+
function isAccountLockExpired(facts, now, durationMinutes) {
|
|
101
|
+
return isLockedStatus(facts) && !isAccountLocked(facts, now, durationMinutes);
|
|
102
|
+
}
|
|
103
|
+
function isLockedStatus(facts) {
|
|
104
|
+
return facts?.status === exports.LOCKED_STATUS;
|
|
105
|
+
}
|
|
106
|
+
/** `Date` 로 오기도 하고 드라이버에 따라 문자열로 오기도 한다. */
|
|
107
|
+
function toDate(value) {
|
|
108
|
+
if (!value) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
112
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=account-lock-rule.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"account-lock-rule.js","sourceRoot":"","sources":["../../server/utils/account-lock-rule.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;;;AA4BH,oDAeC;AAyBD,0CAYC;AAGD,oDAEC;AA3ED,0CAA0C;AAC7B,QAAA,YAAY,GAAG,CAAC,CAAA;AAE7B;;;;;;GAMG;AACU,QAAA,aAAa,GAAG,QAAQ,CAAA;AAErC;;;;;GAKG;AACH,SAAgB,oBAAoB,CAAC,KAAuB,EAAE,eAAuB;IACnF,IAAI,CAAC,cAAc,CAAC,KAAK,CAAC,EAAE,CAAC;QAC3B,OAAO,IAAI,CAAA;IACb,CAAC;IAED,IAAI,CAAC,CAAC,eAAe,GAAG,oBAAY,CAAC,EAAE,CAAC;QACtC,OAAO,IAAI,CAAA;IACb,CAAC;IAED,MAAM,QAAQ,GAAG,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAA;IACxC,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,OAAO,IAAI,CAAA;IACb,CAAC;IAED,OAAO,IAAI,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,GAAG,eAAe,GAAG,EAAE,GAAG,IAAI,CAAC,CAAA;AACnE,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,SAAgB,eAAe,CAAC,KAAuB,EAAE,GAAS,EAAE,eAAuB;IACzF,IAAI,CAAC,cAAc,CAAC,KAAK,CAAC,EAAE,CAAC;QAC3B,OAAO,KAAK,CAAA;IACd,CAAC;IAED,MAAM,SAAS,GAAG,oBAAoB,CAAC,KAAK,EAAE,eAAe,CAAC,CAAA;IAC9D,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,kEAAkE;QAClE,OAAO,IAAI,CAAA;IACb,CAAC;IAED,OAAO,GAAG,CAAC,OAAO,EAAE,GAAG,SAAS,CAAC,OAAO,EAAE,CAAA;AAC5C,CAAC;AAED,2CAA2C;AAC3C,SAAgB,oBAAoB,CAAC,KAAuB,EAAE,GAAS,EAAE,eAAuB;IAC9F,OAAO,cAAc,CAAC,KAAK,CAAC,IAAI,CAAC,eAAe,CAAC,KAAK,EAAE,GAAG,EAAE,eAAe,CAAC,CAAA;AAC/E,CAAC;AAED,SAAS,cAAc,CAAC,KAAuB;IAC7C,OAAO,KAAK,EAAE,MAAM,KAAK,qBAAa,CAAA;AACxC,CAAC;AAED,4CAA4C;AAC5C,SAAS,MAAM,CAAC,KAAuC;IACrD,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,OAAO,IAAI,CAAA;IACb,CAAC;IAED,MAAM,IAAI,GAAG,KAAK,YAAY,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,CAAA;IAE5D,OAAO,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAA;AACnD,CAAC","sourcesContent":["/**\n * 계정 잠금이 아직 유효한지를 판정하는 규칙 (순수 함수).\n *\n * ## 왜 따로 있나\n *\n * 잠금을 보는 자리가 둘이다 — `signin`(들어오려는 사람)과 `User.checkAuth`(요청마다 토큰을\n * 확인하는 자리). 두 곳이 각자 「아직 잠겼나」를 판단하면 서로 다른 답을 낼 수 있고, 그러면\n * 로그인은 되는데 요청은 거절되는(또는 그 반대) 상태가 생긴다. 판정은 한 곳에 둔다\n * (`checkin-domain-rule` 과 같은 규율).\n *\n * ## 자동해제를 주기 작업으로 하지 않는 이유\n *\n * 계정이 10만이면 「만료된 잠금을 풀어 주는 일」이 따로 일이 된다. 잠금은 **읽는 시점에**\n * 판정한다 — 그 계정을 보는 사람이 있을 때만 계산하면 되고, 그때가 유일하게 답이 필요한\n * 순간이다.\n *\n * 행을 정리하는 것은 판정과 별개다. `signin` 은 판정이 「풀렸다」로 나오면 그 자리에서 행을\n * 되돌리고(`ACTIVATED` · `failCount` 0 · `lockedAt` 비움) 계속 진행한다. `checkAuth` 는\n * 매 요청 경로이므로 **읽기만 한다** — 판정만 보고 통과시키고, 행 정리는 다음 `signin` 이 한다.\n *\n * ## 잠금이 곧 셈의 창이다\n *\n * 자동해제 때 `failCount` 를 0 으로 두므로, 실패 셈은 「한 번의 잠금 안에서」만 쌓인다.\n * 그래서 칸을 하나만 더해도 창이 생긴다.\n *\n * 남는 것: 잠기지 **않은** 동안의 셈은 여전히 누적이다(성공하면 0 으로 돌아간다). 그래서 오타를\n * 드물게 다섯 번 내는 사람은 한 번 잠긴다 — 그 뒤 자동으로 풀리므로 견딜 수 있지만, 창을\n * 정확히 하려면 「실패가 시작된 시각」 칸이 하나 더 필요하다.\n */\n\n/** 판정에 필요한 사용자 측 사실들 — 조회는 부르는 쪽이 한다. */\nexport type AccountLockFacts = {\n /** `UserStatus` 값. `'locked'` 가 아니면 잠금이 아니다. */\n status?: string | null\n /** 잠긴 시각. 이 변경 전에 잠긴 행에는 없다. */\n lockedAt?: Date | string | null\n}\n\n/** 잠금이 풀리지 않는 설정값 — 0 이하는 「자동해제 없음」이다. */\nexport const LOCK_FOREVER = 0\n\n/**\n * `UserStatus.LOCKED` 의 값.\n *\n * 엔티티를 import 하지 않는다 — 그러면 이 모듈이 type-graphql 데코레이터 그래프를 끌고 들어와\n * 순수 시험이 닿지 못한다(`tests/compiled.ts`). 대신 시험이 이 값과 `UserStatus.LOCKED` 가\n * 같은지 확인하므로, 한쪽만 바뀌면 시험이 깨진다.\n */\nexport const LOCKED_STATUS = 'locked'\n\n/**\n * 잠금이 언제 풀리나. 잠금이 아니거나 자동해제가 없으면 `null`.\n *\n * `lockedAt` 이 없으면 계산할 수 없으므로 `null` 을 준다 — 그때 `isAccountLocked` 는\n * **잠금을 유지한다**(아래 참조).\n */\nexport function accountLockExpiresAt(facts: AccountLockFacts, durationMinutes: number): Date | null {\n if (!isLockedStatus(facts)) {\n return null\n }\n\n if (!(durationMinutes > LOCK_FOREVER)) {\n return null\n }\n\n const lockedAt = toDate(facts?.lockedAt)\n if (!lockedAt) {\n return null\n }\n\n return new Date(lockedAt.getTime() + durationMinutes * 60 * 1000)\n}\n\n/**\n * 잠금이 아직 유효한가.\n *\n * ## 한 칸이 두 물음에 답하지 않는다\n *\n * 섞이기 쉬운 두 사실이다.\n *\n * ```\n * ① 이 계정이 잠겼나 ← `status` 가 답한다. 이 함수의 문이다\n * ② 언제 자동으로 풀리나 ← `lockedAt` 이 답한다. 잠긴 것에 대해서만 묻는다\n * ```\n *\n * 그래서 `status` 를 먼저 보고, 잠금이 아니면 **`lockedAt` 을 아예 보지 않는다.** 이 순서를\n * 뒤집으면 사고가 양쪽으로 난다 — `lockedAt` 만 보고 판정하면 **한 번도 잠긴 적 없는 계정**\n * (칸이 비어 있다)까지 잠김이 되어 배포하는 순간 전원이 못 들어오고, 반대로 짜면 지금 잠긴\n * 계정이 전부 풀린다.\n *\n * `lockedAt` 이 비어 있다는 것은 「잠겼다」가 아니라 **「자동 해제가 없다」**는 뜻이다. 이 변경\n * 전에 잠긴 행이 그렇고, 그 경우 관리자(`activateUser`)와 잠금 해제 메일이 푸는 길로 남는다 —\n * 계산할 수 없는 것을 풀어 주는 쪽으로 기울면 잠긴 계정 전체가 조용히 열린다.\n *\n * @param now 판정 시각. 시험이 시계를 넘길 수 있게 인자로 받는다.\n */\nexport function isAccountLocked(facts: AccountLockFacts, now: Date, durationMinutes: number): boolean {\n if (!isLockedStatus(facts)) {\n return false\n }\n\n const expiresAt = accountLockExpiresAt(facts, durationMinutes)\n if (!expiresAt) {\n /* 자동해제가 없거나(`durationMinutes <= 0`) 언제 풀릴지 모른다(`lockedAt` 없음). */\n return true\n }\n\n return now.getTime() < expiresAt.getTime()\n}\n\n/** 잠금이 시간이 지나 풀렸는가 — 행을 되돌려야 하는지 묻는 자리. */\nexport function isAccountLockExpired(facts: AccountLockFacts, now: Date, durationMinutes: number): boolean {\n return isLockedStatus(facts) && !isAccountLocked(facts, now, durationMinutes)\n}\n\nfunction isLockedStatus(facts: AccountLockFacts): boolean {\n return facts?.status === LOCKED_STATUS\n}\n\n/** `Date` 로 오기도 하고 드라이버에 따라 문자열로 오기도 한다. */\nfunction toDate(value: Date | string | null | undefined): Date | null {\n if (!value) {\n return null\n }\n\n const date = value instanceof Date ? value : new Date(value)\n\n return Number.isNaN(date.getTime()) ? null : date\n}\n"]}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LOCK_FOREVER = void 0;
|
|
4
|
+
exports.lockDurationMinutes = lockDurationMinutes;
|
|
5
|
+
const env_1 = require("@things-factory/env");
|
|
6
|
+
const account_lock_rule_js_1 = require("./account-lock-rule.js");
|
|
7
|
+
Object.defineProperty(exports, "LOCK_FOREVER", { enumerable: true, get: function () { return account_lock_rule_js_1.LOCK_FOREVER; } });
|
|
8
|
+
/**
|
|
9
|
+
* 잠금이 스스로 풀리기까지의 시간(분). **설정 이름이 있는 유일한 자리다.**
|
|
10
|
+
*
|
|
11
|
+
* 판정 규칙(`account-lock-rule.ts`)은 이 값을 인자로 받는다 — 설정을 읽지 않으므로 순수하게
|
|
12
|
+
* 남고, 시험이 시계와 기간을 둘 다 넘길 수 있다. 그 값을 어디서 가져오나는 이 파일이 안다.
|
|
13
|
+
*
|
|
14
|
+
* 잠금은 비밀번호 실패가 만드는 것이므로 기존 `password` 설정 블록 안에 둔다.
|
|
15
|
+
*
|
|
16
|
+
* password:
|
|
17
|
+
* lockDurationMinutes: 10 # 기본값. 0 이하면 자동해제 없음(관리자·메일로만 풀린다)
|
|
18
|
+
*
|
|
19
|
+
* ⚠ 이름은 아키텍트 확인 대기다 — 블록의 자리(`password`)는 확인받았고 칸 이름은 이것으로
|
|
20
|
+
* 진행했다. 바뀌면 이 파일 한 줄만 고친다.
|
|
21
|
+
*/
|
|
22
|
+
const DEFAULT_LOCK_DURATION_MINUTES = 10;
|
|
23
|
+
function lockDurationMinutes() {
|
|
24
|
+
const configured = (env_1.config.get('password') || {}).lockDurationMinutes;
|
|
25
|
+
if (configured === undefined || configured === null) {
|
|
26
|
+
return DEFAULT_LOCK_DURATION_MINUTES;
|
|
27
|
+
}
|
|
28
|
+
const minutes = Number(configured);
|
|
29
|
+
/*
|
|
30
|
+
* 설정에 읽을 수 없는 값이 들어오면 **기본값으로 돌아간다.** `NaN` 을 그대로 흘리면
|
|
31
|
+
* 판정 규칙이 그것을 「자동해제 없음」으로 읽어(0 보다 크지 않으므로) 잠금이 영구가 된다 —
|
|
32
|
+
* 오타 하나가 조용히 정책을 바꾸는 자리가 된다.
|
|
33
|
+
*/
|
|
34
|
+
return Number.isFinite(minutes) ? minutes : DEFAULT_LOCK_DURATION_MINUTES;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=lock-duration.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lock-duration.js","sourceRoot":"","sources":["../../server/utils/lock-duration.ts"],"names":[],"mappings":";;;AAoBA,kDAeC;AAnCD,6CAA4C;AAE5C,iEAAqD;AAmC5C,6FAnCA,mCAAY,OAmCA;AAjCrB;;;;;;;;;;;;;GAaG;AACH,MAAM,6BAA6B,GAAG,EAAE,CAAA;AAExC,SAAgB,mBAAmB;IACjC,MAAM,UAAU,GAAG,CAAC,YAAM,CAAC,GAAG,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC,CAAC,mBAAmB,CAAA;IAErE,IAAI,UAAU,KAAK,SAAS,IAAI,UAAU,KAAK,IAAI,EAAE,CAAC;QACpD,OAAO,6BAA6B,CAAA;IACtC,CAAC;IAED,MAAM,OAAO,GAAG,MAAM,CAAC,UAAU,CAAC,CAAA;IAElC;;;;OAIG;IACH,OAAO,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,6BAA6B,CAAA;AAC3E,CAAC","sourcesContent":["import { config } from '@things-factory/env'\n\nimport { LOCK_FOREVER } from './account-lock-rule.js'\n\n/**\n * 잠금이 스스로 풀리기까지의 시간(분). **설정 이름이 있는 유일한 자리다.**\n *\n * 판정 규칙(`account-lock-rule.ts`)은 이 값을 인자로 받는다 — 설정을 읽지 않으므로 순수하게\n * 남고, 시험이 시계와 기간을 둘 다 넘길 수 있다. 그 값을 어디서 가져오나는 이 파일이 안다.\n *\n * 잠금은 비밀번호 실패가 만드는 것이므로 기존 `password` 설정 블록 안에 둔다.\n *\n * password:\n * lockDurationMinutes: 10 # 기본값. 0 이하면 자동해제 없음(관리자·메일로만 풀린다)\n *\n * ⚠ 이름은 아키텍트 확인 대기다 — 블록의 자리(`password`)는 확인받았고 칸 이름은 이것으로\n * 진행했다. 바뀌면 이 파일 한 줄만 고친다.\n */\nconst DEFAULT_LOCK_DURATION_MINUTES = 10\n\nexport function lockDurationMinutes(): number {\n const configured = (config.get('password') || {}).lockDurationMinutes\n\n if (configured === undefined || configured === null) {\n return DEFAULT_LOCK_DURATION_MINUTES\n }\n\n const minutes = Number(configured)\n\n /*\n * 설정에 읽을 수 없는 값이 들어오면 **기본값으로 돌아간다.** `NaN` 을 그대로 흘리면\n * 판정 규칙이 그것을 「자동해제 없음」으로 읽어(0 보다 크지 않으므로) 잠금이 영구가 된다 —\n * 오타 하나가 조용히 정책을 바꾸는 자리가 된다.\n */\n return Number.isFinite(minutes) ? minutes : DEFAULT_LOCK_DURATION_MINUTES\n}\n\nexport { LOCK_FOREVER }\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/auth-base",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.6",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
"@reduxjs/toolkit": "^2.2.5",
|
|
35
35
|
"@simplewebauthn/browser": "^13.0.0",
|
|
36
36
|
"@simplewebauthn/server": "^13.0.0",
|
|
37
|
-
"@things-factory/email-base": "^10.1.
|
|
37
|
+
"@things-factory/email-base": "^10.1.4",
|
|
38
38
|
"@things-factory/env": "^10.1.3",
|
|
39
|
-
"@things-factory/shell": "^10.1.
|
|
39
|
+
"@things-factory/shell": "^10.1.4",
|
|
40
40
|
"@things-factory/utils": "^10.1.3",
|
|
41
41
|
"@types/webappsec-credential-management": "^0.6.9",
|
|
42
42
|
"jsonwebtoken": "^9.0.0",
|
|
@@ -48,5 +48,5 @@
|
|
|
48
48
|
"passport-jwt": "^4.0.0",
|
|
49
49
|
"passport-local": "^1.0.0"
|
|
50
50
|
},
|
|
51
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "963aeb9ac470be5aea05bb5842a0a92afcab6036"
|
|
52
52
|
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 잠금이 스스로 풀리는 것 — 실 경로(`signin` · `checkAuth`)를 태워 본다.
|
|
3
|
+
*
|
|
4
|
+
* `account-lock-rule.test.ts` 는 규칙만 본다. 규칙이 옳아도 **부르는 자리**가 틀리면 계정은
|
|
5
|
+
* 영구히 잠긴 채로 남는다. 그래서 여기서는 실제 컨트롤러를 태우고 **행을 다시 읽는다.**
|
|
6
|
+
*
|
|
7
|
+
* 시계는 넘길 수 없으므로(컨트롤러가 안에서 `new Date()` 를 읽는다) **`lockedAt` 을 과거로
|
|
8
|
+
* 옮겨** 시간이 지난 상태를 만든다.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { config } from '@things-factory/env'
|
|
12
|
+
|
|
13
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
14
|
+
import { loadCompiled } from './compiled'
|
|
15
|
+
|
|
16
|
+
const { User } = loadCompiled('service/user/user')
|
|
17
|
+
const { signin } = loadCompiled('controllers/signin')
|
|
18
|
+
|
|
19
|
+
const PASSWORD = 'Passw0rd!'
|
|
20
|
+
const WRONG = 'Wr0ngPass!'
|
|
21
|
+
|
|
22
|
+
/** 기본값은 10분이다 — 설정이 없을 때의 값(`utils/lock-duration.ts`). */
|
|
23
|
+
const DEFAULT_MINUTES = 10
|
|
24
|
+
|
|
25
|
+
let domain: any
|
|
26
|
+
|
|
27
|
+
async function seedUser(overrides: any = {}) {
|
|
28
|
+
const salt = User.generateSalt()
|
|
29
|
+
|
|
30
|
+
return await getRepository(User).save({
|
|
31
|
+
username: 'kim',
|
|
32
|
+
email: 'kim@example.com',
|
|
33
|
+
name: 'Kim',
|
|
34
|
+
salt,
|
|
35
|
+
password: User.encode(PASSWORD, salt),
|
|
36
|
+
status: 'activated',
|
|
37
|
+
domains: [domain],
|
|
38
|
+
...overrides
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const attempt = (password: string) => signin({ username: 'kim', password })
|
|
43
|
+
|
|
44
|
+
const reload = (id: string) => getRepository(User).findOneBy({ id })
|
|
45
|
+
|
|
46
|
+
/** 잠긴 시각을 `minutes` 분 전으로 옮긴다 — 시간이 지난 상태를 만드는 방법. */
|
|
47
|
+
async function ageLock(id: string, minutes: number) {
|
|
48
|
+
await getRepository(User).update({ id }, { lockedAt: new Date(Date.now() - minutes * 60 * 1000) })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function lockOut() {
|
|
52
|
+
const user = await seedUser()
|
|
53
|
+
for (let i = 0; i < 5; i++) {
|
|
54
|
+
await expect(attempt(WRONG)).rejects.toThrow()
|
|
55
|
+
}
|
|
56
|
+
return user
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
beforeAll(async () => {
|
|
60
|
+
await openAuthDatabase()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
beforeEach(async () => {
|
|
64
|
+
await resetAuthDatabase()
|
|
65
|
+
domain = await getRepository(Domain).save({ name: 'plant-a', subdomain: 'plant-a' })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
afterAll(async () => {
|
|
69
|
+
await closeAuthDatabase()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('잠글 때 시각을 남긴다', () => {
|
|
73
|
+
it('다섯 번째 실패에 `lockedAt` 이 써진다', async () => {
|
|
74
|
+
const before = Date.now()
|
|
75
|
+
const user = await lockOut()
|
|
76
|
+
const after = Date.now()
|
|
77
|
+
|
|
78
|
+
const locked = await reload(user.id)
|
|
79
|
+
|
|
80
|
+
expect(locked.status).toBe('locked')
|
|
81
|
+
expect(locked.lockedAt).toBeTruthy()
|
|
82
|
+
|
|
83
|
+
const at = new Date(locked.lockedAt).getTime()
|
|
84
|
+
expect(at).toBeGreaterThanOrEqual(before - 1000)
|
|
85
|
+
expect(at).toBeLessThanOrEqual(after + 1000)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('네 번째까지는 시각이 없다', async () => {
|
|
89
|
+
const user = await seedUser()
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < 4; i++) {
|
|
92
|
+
await expect(attempt(WRONG)).rejects.toThrow()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const still = await reload(user.id)
|
|
96
|
+
expect(still.status).toBe('activated')
|
|
97
|
+
expect(still.lockedAt).toBeFalsy()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('기본 10분 안에는 잠겨 있다', () => {
|
|
102
|
+
it('맞는 비밀번호도 거절한다', async () => {
|
|
103
|
+
const user = await lockOut()
|
|
104
|
+
await ageLock(user.id, DEFAULT_MINUTES - 1)
|
|
105
|
+
|
|
106
|
+
await expect(attempt(PASSWORD)).rejects.toThrow()
|
|
107
|
+
expect((await reload(user.id)).status).toBe('locked')
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('시간이 지나면 스스로 풀린다', () => {
|
|
112
|
+
it('맞는 비밀번호로 들어가진다', async () => {
|
|
113
|
+
const user = await lockOut()
|
|
114
|
+
await ageLock(user.id, DEFAULT_MINUTES + 1)
|
|
115
|
+
|
|
116
|
+
const result = await attempt(PASSWORD)
|
|
117
|
+
|
|
118
|
+
expect(result.token).toBeTruthy()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('행이 되돌아간다 — 상태·셈·시각 셋 다', async () => {
|
|
122
|
+
const user = await lockOut()
|
|
123
|
+
await ageLock(user.id, DEFAULT_MINUTES + 1)
|
|
124
|
+
|
|
125
|
+
await attempt(PASSWORD)
|
|
126
|
+
|
|
127
|
+
const released = await reload(user.id)
|
|
128
|
+
expect(released.status).toBe('activated')
|
|
129
|
+
expect(released.failCount).toBe(0)
|
|
130
|
+
expect(released.lockedAt).toBeFalsy()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('풀린 뒤 틀려도 셈이 1 부터 다시 센다 — 잠금이 곧 창이다', async () => {
|
|
134
|
+
const user = await lockOut()
|
|
135
|
+
await ageLock(user.id, DEFAULT_MINUTES + 1)
|
|
136
|
+
|
|
137
|
+
/* 틀린 비밀번호로도 잠금이 풀린다 — 판정이 대조보다 먼저다. */
|
|
138
|
+
await expect(attempt(WRONG)).rejects.toThrow()
|
|
139
|
+
|
|
140
|
+
const after = await reload(user.id)
|
|
141
|
+
expect(after.status).toBe('activated')
|
|
142
|
+
expect(after.failCount).toBe(1)
|
|
143
|
+
expect(after.lockedAt).toBeFalsy()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('다시 잠기려면 다섯 번을 새로 틀려야 한다', async () => {
|
|
147
|
+
const user = await lockOut()
|
|
148
|
+
await ageLock(user.id, DEFAULT_MINUTES + 1)
|
|
149
|
+
|
|
150
|
+
for (let i = 0; i < 4; i++) {
|
|
151
|
+
await expect(attempt(WRONG)).rejects.toThrow()
|
|
152
|
+
}
|
|
153
|
+
expect((await reload(user.id)).status).toBe('activated')
|
|
154
|
+
|
|
155
|
+
await expect(attempt(WRONG)).rejects.toThrow()
|
|
156
|
+
expect((await reload(user.id)).status).toBe('locked')
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('`lockedAt` 이 없는 잠긴 행 — 이 변경 전에 잠긴 것', () => {
|
|
161
|
+
it('풀리지 않는다', async () => {
|
|
162
|
+
/* 조용히 열리는 쪽으로 기울면 잠긴 계정 전체가 한 번에 열린다. 관리자(`activateUser`)와
|
|
163
|
+
잠금 해제 메일이 푸는 길로 남긴다. */
|
|
164
|
+
const user = await seedUser({ status: 'locked', failCount: 5, lockedAt: null })
|
|
165
|
+
|
|
166
|
+
await expect(attempt(PASSWORD)).rejects.toThrow()
|
|
167
|
+
expect((await reload(user.id)).status).toBe('locked')
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('요청 경로(`checkAuth`)도 같은 판정을 쓴다', () => {
|
|
172
|
+
it('잠긴 동안에는 토큰을 거절한다', async () => {
|
|
173
|
+
const user = await lockOut()
|
|
174
|
+
await ageLock(user.id, DEFAULT_MINUTES - 1)
|
|
175
|
+
|
|
176
|
+
await expect(User.checkAuth({ username: 'kim' })).rejects.toThrow()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('시간이 지나면 통과시킨다 — 그리고 **행을 고치지 않는다**', async () => {
|
|
180
|
+
/* 매 요청 지나는 경로라 쓰기를 얹지 않는다. 행 정리는 다음 `signin` 이 한다. */
|
|
181
|
+
const user = await lockOut()
|
|
182
|
+
await ageLock(user.id, DEFAULT_MINUTES + 1)
|
|
183
|
+
|
|
184
|
+
const authed = await User.checkAuth({ username: 'kim' })
|
|
185
|
+
expect(authed.id).toBe(user.id)
|
|
186
|
+
|
|
187
|
+
const untouched = await reload(user.id)
|
|
188
|
+
expect(untouched.status).toBe('locked')
|
|
189
|
+
expect(untouched.lockedAt).toBeTruthy()
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
describe('설정으로 자동해제를 끌 수 있다', () => {
|
|
194
|
+
afterEach(() => {
|
|
195
|
+
config.set('password', undefined)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('0 이면 시간이 지나도 풀리지 않는다', async () => {
|
|
199
|
+
config.set('password', { lockDurationMinutes: 0 })
|
|
200
|
+
|
|
201
|
+
const user = await lockOut()
|
|
202
|
+
await ageLock(user.id, 60 * 24 * 365)
|
|
203
|
+
|
|
204
|
+
await expect(attempt(PASSWORD)).rejects.toThrow()
|
|
205
|
+
expect((await reload(user.id)).status).toBe('locked')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('설정한 분이 지나면 풀린다', async () => {
|
|
209
|
+
config.set('password', { lockDurationMinutes: 1 })
|
|
210
|
+
|
|
211
|
+
const user = await lockOut()
|
|
212
|
+
await ageLock(user.id, 2)
|
|
213
|
+
|
|
214
|
+
expect((await attempt(PASSWORD)).token).toBeTruthy()
|
|
215
|
+
})
|
|
216
|
+
})
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 계정 잠금 판정 규칙 시험 — 순수 함수라 엔티티에 닿지 않는다.
|
|
3
|
+
*
|
|
4
|
+
* 이 규칙이 하는 일은 하나다: 「이 잠금이 아직 유효한가」. 그 답을 두 곳이 쓴다 —
|
|
5
|
+
* `signin`(들어오려는 사람)과 `User.checkAuth`(요청마다). 두 곳이 각자 판단하면 로그인은
|
|
6
|
+
* 되는데 요청은 거절되는 상태가 생기므로 규칙을 한 곳에 두었고, 여기서 그 규칙만 본다.
|
|
7
|
+
*
|
|
8
|
+
* 시계를 인자로 받으므로 「10분 뒤」를 기다리지 않고 시험한다.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
AccountLockFacts,
|
|
13
|
+
LOCKED_STATUS,
|
|
14
|
+
LOCK_FOREVER,
|
|
15
|
+
accountLockExpiresAt,
|
|
16
|
+
isAccountLocked,
|
|
17
|
+
isAccountLockExpired
|
|
18
|
+
} from '../server/utils/account-lock-rule.js'
|
|
19
|
+
import { loadCompiled } from './compiled'
|
|
20
|
+
|
|
21
|
+
const MINUTES = 10
|
|
22
|
+
const AT = new Date('2026-09-10T09:00:00.000Z')
|
|
23
|
+
const minutesAfter = (n: number) => new Date(AT.getTime() + n * 60 * 1000)
|
|
24
|
+
|
|
25
|
+
const locked = (overrides: Partial<AccountLockFacts> = {}): AccountLockFacts => ({
|
|
26
|
+
status: LOCKED_STATUS,
|
|
27
|
+
lockedAt: AT,
|
|
28
|
+
...overrides
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('엔티티와 값이 어긋나지 않는다', () => {
|
|
32
|
+
it('`LOCKED_STATUS` 가 `UserStatus.LOCKED` 와 같다', () => {
|
|
33
|
+
/* 이 규칙은 엔티티를 import 하지 않는다(그러면 순수 시험이 닿지 못한다). 그래서 두 값이
|
|
34
|
+
어긋나는 것을 막는 것은 이 시험뿐이다. */
|
|
35
|
+
const { UserStatus } = loadCompiled('service/user/user')
|
|
36
|
+
|
|
37
|
+
expect(LOCKED_STATUS).toBe(UserStatus.LOCKED)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('잠금이 아닌 상태', () => {
|
|
42
|
+
it('활성 계정은 잠금이 아니다', () => {
|
|
43
|
+
for (const status of ['activated', 'inactive', 'deleted', 'banned', 'password_reset_required']) {
|
|
44
|
+
expect(isAccountLocked({ status, lockedAt: AT }, AT, MINUTES)).toBe(false)
|
|
45
|
+
expect(isAccountLockExpired({ status, lockedAt: AT }, AT, MINUTES)).toBe(false)
|
|
46
|
+
expect(accountLockExpiresAt({ status, lockedAt: AT }, MINUTES)).toBeNull()
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('사실이 비어 있어도 던지지 않는다', () => {
|
|
51
|
+
for (const facts of [undefined, null, {}, { status: null }] as any[]) {
|
|
52
|
+
expect(isAccountLocked(facts, AT, MINUTES)).toBe(false)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('시간이 지나면 풀린다', () => {
|
|
58
|
+
it('설정한 시간 안에는 잠겨 있다', () => {
|
|
59
|
+
expect(isAccountLocked(locked(), AT, MINUTES)).toBe(true)
|
|
60
|
+
expect(isAccountLocked(locked(), minutesAfter(9), MINUTES)).toBe(true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('설정한 시간이 지나면 풀린다', () => {
|
|
64
|
+
expect(isAccountLocked(locked(), minutesAfter(10), MINUTES)).toBe(false)
|
|
65
|
+
expect(isAccountLocked(locked(), minutesAfter(60), MINUTES)).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('경계는 만료 시각을 포함하지 않는다 — 정확히 10분이면 풀린 것이다', () => {
|
|
69
|
+
const facts = locked()
|
|
70
|
+
|
|
71
|
+
expect(isAccountLocked(facts, minutesAfter(10 - 1 / 60000), MINUTES)).toBe(true)
|
|
72
|
+
expect(isAccountLocked(facts, minutesAfter(10), MINUTES)).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('풀린 것을 따로 물을 수 있다 — 행을 되돌려야 하는지 판단하는 자리', () => {
|
|
76
|
+
expect(isAccountLockExpired(locked(), minutesAfter(5), MINUTES)).toBe(false)
|
|
77
|
+
expect(isAccountLockExpired(locked(), minutesAfter(15), MINUTES)).toBe(true)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('만료 시각을 계산해 준다', () => {
|
|
81
|
+
expect(accountLockExpiresAt(locked(), MINUTES)).toEqual(minutesAfter(10))
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('드라이버가 문자열로 준 시각도 읽는다', () => {
|
|
85
|
+
/* sqlite·mssql 은 `Date` 대신 문자열을 주기도 한다. */
|
|
86
|
+
const facts = locked({ lockedAt: AT.toISOString() })
|
|
87
|
+
|
|
88
|
+
expect(isAccountLocked(facts, minutesAfter(5), MINUTES)).toBe(true)
|
|
89
|
+
expect(isAccountLocked(facts, minutesAfter(15), MINUTES)).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('문은 `status` 가 지킨다 — `lockedAt` 이 아니다', () => {
|
|
94
|
+
it('한 번도 잠긴 적 없는 계정은 `lockedAt` 이 없어도 열려 있다', () => {
|
|
95
|
+
/*
|
|
96
|
+
* 이 한 줄이 배포 사고 하나를 막는다. 판정이 `lockedAt` 을 먼저 보면 칸이 비어 있는
|
|
97
|
+
* **모든** 계정(= 한 번도 잠긴 적 없는 계정 전부)이 잠김이 되어 아무도 못 들어온다.
|
|
98
|
+
* 그래서 `status` 를 먼저 보고, 잠금이 아니면 `lockedAt` 을 아예 보지 않는다.
|
|
99
|
+
*/
|
|
100
|
+
const never = { status: 'activated' } as AccountLockFacts
|
|
101
|
+
|
|
102
|
+
expect(isAccountLocked(never, AT, MINUTES)).toBe(false)
|
|
103
|
+
expect(isAccountLockExpired(never, AT, MINUTES)).toBe(false)
|
|
104
|
+
expect(accountLockExpiresAt(never, MINUTES)).toBeNull()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('자동해제를 끈 설정에서도 잠기지 않은 계정은 열려 있다', () => {
|
|
108
|
+
/* 「자동해제 없음」이 「전원 잠금」이 되지 않는다. */
|
|
109
|
+
expect(isAccountLocked({ status: 'activated' }, AT, LOCK_FOREVER)).toBe(false)
|
|
110
|
+
expect(isAccountLocked({ status: 'activated', lockedAt: null }, AT, LOCK_FOREVER)).toBe(false)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('잠긴 계정에서 `lockedAt` 이 없을 때', () => {
|
|
115
|
+
it('「잠겼다」가 아니라 **「자동 해제가 없다」**가 된다', () => {
|
|
116
|
+
/*
|
|
117
|
+
* 이 변경 전에 잠긴 행에는 칸이 비어 있다. 언제 풀릴지 계산할 수 없으니 자동으로는 안
|
|
118
|
+
* 풀린다 — 풀어 주는 쪽으로 기울면 **잠긴 계정 전체가 조용히 열린다.** 관리자
|
|
119
|
+
* (`activateUser`)와 잠금 해제 메일이 푸는 길로 남는다.
|
|
120
|
+
*
|
|
121
|
+
* 이것은 위 절과 짝이다: 잠금 여부는 `status` 가 정하고, 여기서 `lockedAt` 이 정하는 것은
|
|
122
|
+
* **해제 시각이 있나**뿐이다.
|
|
123
|
+
*/
|
|
124
|
+
for (const lockedAt of [undefined, null, '', 'not-a-date'] as any[]) {
|
|
125
|
+
const facts = locked({ lockedAt })
|
|
126
|
+
|
|
127
|
+
expect(isAccountLocked(facts, minutesAfter(600), MINUTES)).toBe(true)
|
|
128
|
+
expect(isAccountLockExpired(facts, minutesAfter(600), MINUTES)).toBe(false)
|
|
129
|
+
expect(accountLockExpiresAt(facts, MINUTES)).toBeNull()
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('자동해제를 끄는 설정', () => {
|
|
135
|
+
it('0 이하면 풀리지 않는다', () => {
|
|
136
|
+
for (const duration of [LOCK_FOREVER, 0, -1, undefined, null, NaN] as any[]) {
|
|
137
|
+
expect(isAccountLocked(locked(), minutesAfter(60 * 24 * 365), duration)).toBe(true)
|
|
138
|
+
expect(accountLockExpiresAt(locked(), duration)).toBeNull()
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
})
|