@point3/logto-module 1.0.0
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/README.md +224 -0
- package/client/__tests__/m2m-client.spec.ts +60 -0
- package/client/__tests__/oauth-client.spec.ts +43 -0
- package/client/config.ts +79 -0
- package/client/index.ts +5 -0
- package/client/logto-login-session.ts +239 -0
- package/client/m2m-client.ts +428 -0
- package/client/oauth-client.ts +231 -0
- package/client/types.ts +136 -0
- package/dist/client/__tests__/m2m-client.spec.d.ts +1 -0
- package/dist/client/__tests__/m2m-client.spec.js +55 -0
- package/dist/client/__tests__/m2m-client.spec.js.map +1 -0
- package/dist/client/__tests__/oauth-client.spec.d.ts +1 -0
- package/dist/client/__tests__/oauth-client.spec.js +40 -0
- package/dist/client/__tests__/oauth-client.spec.js.map +1 -0
- package/dist/client/config.d.ts +21 -0
- package/dist/client/config.js +16 -0
- package/dist/client/config.js.map +1 -0
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.js +22 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/logto-login-session.d.ts +28 -0
- package/dist/client/logto-login-session.js +128 -0
- package/dist/client/logto-login-session.js.map +1 -0
- package/dist/client/m2m-client.d.ts +34 -0
- package/dist/client/m2m-client.js +201 -0
- package/dist/client/m2m-client.js.map +1 -0
- package/dist/client/oauth-client.d.ts +25 -0
- package/dist/client/oauth-client.js +135 -0
- package/dist/client/oauth-client.js.map +1 -0
- package/dist/client/types.d.ts +45 -0
- package/dist/client/types.js +37 -0
- package/dist/client/types.js.map +1 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.js +62 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/module.d.ts +4 -0
- package/dist/module.js +47 -0
- package/dist/module.js.map +1 -0
- package/dist/stateless/decorator.d.ts +7 -0
- package/dist/stateless/decorator.js +10 -0
- package/dist/stateless/decorator.js.map +1 -0
- package/dist/stateless/guard.d.ts +10 -0
- package/dist/stateless/guard.js +102 -0
- package/dist/stateless/guard.js.map +1 -0
- package/dist/stateless/guard.spec.d.ts +1 -0
- package/dist/stateless/guard.spec.js +210 -0
- package/dist/stateless/guard.spec.js.map +1 -0
- package/dist/stateless/index.d.ts +2 -0
- package/dist/stateless/index.js +19 -0
- package/dist/stateless/index.js.map +1 -0
- package/dist/token/access-token.d.ts +31 -0
- package/dist/token/access-token.js +19 -0
- package/dist/token/access-token.js.map +1 -0
- package/dist/token/index.d.ts +2 -0
- package/dist/token/index.js +19 -0
- package/dist/token/index.js.map +1 -0
- package/dist/token/verifier.d.ts +13 -0
- package/dist/token/verifier.js +66 -0
- package/dist/token/verifier.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/errors.ts +58 -0
- package/index.ts +13 -0
- package/jest.config.js +6 -0
- package/module.ts +85 -0
- package/package.json +33 -0
- package/stateless/decorator.ts +16 -0
- package/stateless/guard.spec.ts +305 -0
- package/stateless/guard.ts +76 -0
- package/stateless/index.ts +2 -0
- package/token/access-token.ts +48 -0
- package/token/index.ts +2 -0
- package/token/verifier.ts +101 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logto M2M(Machine-to-Machine) 클라이언트
|
|
3
|
+
* - Logto API의 M2M 인증 및 사용자/역할 관리 기능 제공
|
|
4
|
+
* - NestJS DI 시스템에 등록됨
|
|
5
|
+
*
|
|
6
|
+
* @author
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
Inject,
|
|
11
|
+
Injectable,
|
|
12
|
+
Global,
|
|
13
|
+
LoggerService,
|
|
14
|
+
} from "@nestjs/common";
|
|
15
|
+
import { ConfigService } from "@nestjs/config";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
LogtoConfig,
|
|
19
|
+
GrantType,
|
|
20
|
+
} from "./config";
|
|
21
|
+
import {
|
|
22
|
+
AccessToken,
|
|
23
|
+
LogtoTokenVerifier,
|
|
24
|
+
LogtoTokenVerifierToken,
|
|
25
|
+
} from "../token";
|
|
26
|
+
import {
|
|
27
|
+
LogtoOAuthRESTTemplate,
|
|
28
|
+
LogtoPasswordAlgorithm,
|
|
29
|
+
LogtoRole,
|
|
30
|
+
LogtoRoleResponse,
|
|
31
|
+
LogtoUser,
|
|
32
|
+
LogtoUserResponse,
|
|
33
|
+
VerificationMethodType,
|
|
34
|
+
LogtoLoggerServiceToken
|
|
35
|
+
} from "./types";
|
|
36
|
+
import { p3Values, axiosAdapter } from "point3-common-tool";
|
|
37
|
+
import {
|
|
38
|
+
UserMissingRequiredFieldsError,
|
|
39
|
+
UserNotFoundError,
|
|
40
|
+
MultipleUsersFoundError,
|
|
41
|
+
} from "../errors";
|
|
42
|
+
|
|
43
|
+
// DI 토큰
|
|
44
|
+
export const LogtoM2MClientToken = Symbol.for("LogtoM2MClient");
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* LogtoM2MClient
|
|
48
|
+
*
|
|
49
|
+
* Logto M2M(Machine-to-Machine) 인증 및 사용자/역할 관리를 위한 클라이언트 서비스입니다.
|
|
50
|
+
* NestJS DI 환경에서 사용되며, 서버 간 통신 및 자동화된 시스템에서 Logto API를 활용할 때 사용합니다.
|
|
51
|
+
*
|
|
52
|
+
* 주요 역할:
|
|
53
|
+
* - M2M 인증을 통한 AccessToken 발급 및 관리
|
|
54
|
+
* - 역할(Role) 생성, 조회, 사용자 역할 할당
|
|
55
|
+
* - 사용자(User) 생성, 조회, 수정, 정지/해제, 삭제 등 관리
|
|
56
|
+
* - 인증코드 발송 및 검증, 비밀번호 변경 등 부가 기능 제공
|
|
57
|
+
*
|
|
58
|
+
* 사용 예시:
|
|
59
|
+
* const client = new LogtoM2MClient(...);
|
|
60
|
+
* await client.fetchAccessToken();
|
|
61
|
+
* const roles = await client.getRoles();
|
|
62
|
+
* const userId = await client.createUser(user);
|
|
63
|
+
* await client.assignRoleToUser(userId, roleId);
|
|
64
|
+
* ...
|
|
65
|
+
*/
|
|
66
|
+
@Global()
|
|
67
|
+
@Injectable()
|
|
68
|
+
export class LogtoM2MClient {
|
|
69
|
+
private logtoConfig: LogtoConfig;
|
|
70
|
+
private accessToken?: AccessToken;
|
|
71
|
+
|
|
72
|
+
// /oidc 엔드포인트용 REST 템플릿
|
|
73
|
+
private readonly authRestTemplate: axiosAdapter.RESTTemplate;
|
|
74
|
+
// /api 엔드포인트용 REST 템플릿
|
|
75
|
+
private readonly apiRestTemplate: axiosAdapter.RESTTemplate;
|
|
76
|
+
|
|
77
|
+
constructor(
|
|
78
|
+
@Inject(ConfigService)
|
|
79
|
+
private readonly configService: ConfigService,
|
|
80
|
+
|
|
81
|
+
@Inject(LogtoTokenVerifierToken)
|
|
82
|
+
private readonly tokenVerifier: LogtoTokenVerifier,
|
|
83
|
+
|
|
84
|
+
@Inject(LogtoLoggerServiceToken)
|
|
85
|
+
private readonly logger: LoggerService,
|
|
86
|
+
) {
|
|
87
|
+
// 환경변수 기반 Logto 설정
|
|
88
|
+
this.logtoConfig = {
|
|
89
|
+
endpoint: this.configService.get<string>('LOGTO_AUTH_ENDPOINT')!,
|
|
90
|
+
appId: this.configService.get<string>('LOGTO_M2M_CLIENT_ID')!,
|
|
91
|
+
appSecret: this.configService.get<string>('LOGTO_M2M_CLIENT_SECRET')!,
|
|
92
|
+
scopes: ['all'],
|
|
93
|
+
resources: [this.configService.get<string>('LOGTO_M2M_RESOURCE')!],
|
|
94
|
+
grantType: GrantType.ClientCredentials,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// 인증용 REST 템플릿 초기화
|
|
98
|
+
this.authRestTemplate = new LogtoOAuthRESTTemplate(
|
|
99
|
+
this.logger,
|
|
100
|
+
this.logtoConfig.endpoint,
|
|
101
|
+
);
|
|
102
|
+
this.authRestTemplate.setBasic(this.logtoConfig.appId, this.logtoConfig.appSecret);
|
|
103
|
+
|
|
104
|
+
// API용 REST 템플릿 초기화
|
|
105
|
+
this.apiRestTemplate = new LogtoOAuthRESTTemplate(
|
|
106
|
+
this.logger,
|
|
107
|
+
this.configService.get<string>('LOGTO_M2M_API_URL'),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// =========================
|
|
112
|
+
// 1. 토큰 관리
|
|
113
|
+
// =========================
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* AccessToken을 발급받아 저장 및 API 템플릿에 Bearer로 설정
|
|
117
|
+
*/
|
|
118
|
+
async fetchAccessToken(): Promise<void> {
|
|
119
|
+
const params = new URLSearchParams();
|
|
120
|
+
params.set('grant_type', this.logtoConfig.grantType);
|
|
121
|
+
params.set('scope', this.logtoConfig.scopes!.join(' '));
|
|
122
|
+
params.set('resource', this.logtoConfig.resources!.join(' '));
|
|
123
|
+
|
|
124
|
+
const response = await this.authRestTemplate.post<{
|
|
125
|
+
access_token: string;
|
|
126
|
+
expires_in: number;
|
|
127
|
+
}>('/token', params.toString(), {
|
|
128
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const { access_token, expires_in } = response.data;
|
|
132
|
+
const payload = await this.tokenVerifier.verifyToken(access_token);
|
|
133
|
+
|
|
134
|
+
this.accessToken = new AccessToken(
|
|
135
|
+
payload.sub,
|
|
136
|
+
access_token,
|
|
137
|
+
expires_in,
|
|
138
|
+
);
|
|
139
|
+
this.apiRestTemplate.setBearer(access_token);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 유효한 AccessToken 반환 (만료 시 자동 갱신)
|
|
144
|
+
* @private
|
|
145
|
+
*/
|
|
146
|
+
private async getAccessToken(): Promise<string> {
|
|
147
|
+
if (!this.accessToken || this.accessToken.isExpired()) {
|
|
148
|
+
await this.fetchAccessToken();
|
|
149
|
+
}
|
|
150
|
+
return this.accessToken!.token;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// =========================
|
|
154
|
+
// 2. 역할(Role) 관리
|
|
155
|
+
// =========================
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 모든 역할 목록 조회
|
|
159
|
+
*/
|
|
160
|
+
async getRoles(): Promise<LogtoRoleResponse[]> {
|
|
161
|
+
await this.getAccessToken();
|
|
162
|
+
const response = await this.apiRestTemplate.get<LogtoRoleResponse[]>('/roles');
|
|
163
|
+
return response.data;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 역할 이름으로 역할 조회
|
|
168
|
+
* @param name 역할 이름
|
|
169
|
+
*/
|
|
170
|
+
async getRoleByName(name: string): Promise<LogtoRoleResponse> {
|
|
171
|
+
await this.getAccessToken();
|
|
172
|
+
const params = new URLSearchParams();
|
|
173
|
+
params.set('search.name', name);
|
|
174
|
+
const response = await this.apiRestTemplate.get<LogtoRoleResponse[]>(
|
|
175
|
+
`/roles?${params.toString()}`,
|
|
176
|
+
);
|
|
177
|
+
return response.data[0];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 역할 생성 (이미 존재하면 기존 역할 반환)
|
|
182
|
+
* @param role 역할 정보
|
|
183
|
+
*/
|
|
184
|
+
async createRole(role: LogtoRole): Promise<LogtoRoleResponse> {
|
|
185
|
+
await this.getAccessToken();
|
|
186
|
+
const body = {
|
|
187
|
+
name: role.name,
|
|
188
|
+
description: role.description,
|
|
189
|
+
type: role.type,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const response = await this.apiRestTemplate.post<LogtoRoleResponse>(
|
|
193
|
+
'/roles',
|
|
194
|
+
body,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
if (response instanceof axiosAdapter.ValidationError) {
|
|
198
|
+
if (response.code === 'role.name_in_use') {
|
|
199
|
+
this.logger.error(
|
|
200
|
+
`이미 존재하는 역할: ${response.code}`,
|
|
201
|
+
this.constructor.name,
|
|
202
|
+
);
|
|
203
|
+
return this.getRoleByName(role.name);
|
|
204
|
+
}
|
|
205
|
+
throw response;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return response.data;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 사용자에게 역할 할당
|
|
213
|
+
* @param userId 사용자 ID
|
|
214
|
+
* @param roleId 역할 ID
|
|
215
|
+
*/
|
|
216
|
+
async assignRoleToUser(userId: string, roleId: string): Promise<void> {
|
|
217
|
+
await this.getAccessToken();
|
|
218
|
+
const body = { roleIds: [roleId] };
|
|
219
|
+
await this.apiRestTemplate.post(`/users/${userId}/roles`, body);
|
|
220
|
+
this.logger.log(
|
|
221
|
+
`사용자에 역할 할당: ${userId}`,
|
|
222
|
+
this.constructor.name,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// =========================
|
|
227
|
+
// 3. 사용자(User) 관리
|
|
228
|
+
// =========================
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 사용자 생성
|
|
232
|
+
* @param user 사용자 정보
|
|
233
|
+
* @returns 생성된 사용자 ID
|
|
234
|
+
*/
|
|
235
|
+
async createUser(user: LogtoUser): Promise<string> {
|
|
236
|
+
await this.getAccessToken();
|
|
237
|
+
|
|
238
|
+
if (user.username && user.primaryEmail && user.password && user.primaryPhone && user.name) {
|
|
239
|
+
user.passwordAlgorithm = user.passwordAlgorithm ?? LogtoPasswordAlgorithm.Argon2i;
|
|
240
|
+
const response = await this.apiRestTemplate.post<{ id: string }>('/users', user);
|
|
241
|
+
return response.data.id;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
this.logger.error(`필수 필드 누락`, this.constructor.name);
|
|
245
|
+
throw new UserMissingRequiredFieldsError();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 사용자 customData.clientId 정보 업데이트
|
|
250
|
+
* @param userId 사용자 ID
|
|
251
|
+
* @param clientId 고객사 ID
|
|
252
|
+
*/
|
|
253
|
+
async updateUserClientInfo(
|
|
254
|
+
userId: string,
|
|
255
|
+
clientId?: string,
|
|
256
|
+
): Promise<void> {
|
|
257
|
+
await this.getAccessToken();
|
|
258
|
+
await this.apiRestTemplate.patch(`/users/${userId}`, {
|
|
259
|
+
customData: { clientId },
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 사용자 ID로 사용자 정보 조회
|
|
265
|
+
* @param id 사용자 ID
|
|
266
|
+
*/
|
|
267
|
+
async getUser(id: string): Promise<LogtoUserResponse> {
|
|
268
|
+
await this.getAccessToken();
|
|
269
|
+
const response = await this.apiRestTemplate.get<LogtoUserResponse>(`/users/${id}`);
|
|
270
|
+
return response.data;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 이메일+휴대폰으로 사용자 단일 조회 (여러명/없음 예외 처리)
|
|
275
|
+
* @param email 이메일
|
|
276
|
+
* @param phone 휴대폰번호
|
|
277
|
+
*/
|
|
278
|
+
async getUsersByEmailAndPhone(
|
|
279
|
+
email: string,
|
|
280
|
+
phone: string,
|
|
281
|
+
): Promise<LogtoUserResponse> {
|
|
282
|
+
await this.getAccessToken();
|
|
283
|
+
|
|
284
|
+
const params = new URLSearchParams();
|
|
285
|
+
params.set('search.primaryEmail', email);
|
|
286
|
+
params.set('search.primaryPhone', generatePhoneNumberWithCountryCode('82', phone));
|
|
287
|
+
params.set('joint', 'and');
|
|
288
|
+
params.set('mode.primaryEmail', 'exact');
|
|
289
|
+
params.set('mode.primaryPhone', 'exact');
|
|
290
|
+
|
|
291
|
+
const response = await this.apiRestTemplate.get<LogtoUserResponse[]>(
|
|
292
|
+
`/users?${params.toString()}`,
|
|
293
|
+
);
|
|
294
|
+
const logtoUsers = response.data;
|
|
295
|
+
|
|
296
|
+
if (logtoUsers.length === 1) {
|
|
297
|
+
return logtoUsers[0];
|
|
298
|
+
}
|
|
299
|
+
if (logtoUsers.length === 0) {
|
|
300
|
+
this.logger.error(`사용자 없음: email=${email}, phone=${phone}`, this.constructor.name);
|
|
301
|
+
throw new UserNotFoundError(email, phone);
|
|
302
|
+
}
|
|
303
|
+
this.logger.error(`여러 사용자 발견: email=${email}, phone=${phone}`, this.constructor.name);
|
|
304
|
+
this.logger.error(JSON.stringify(logtoUsers), this.constructor.name);
|
|
305
|
+
throw new MultipleUsersFoundError(email, phone);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* username으로 사용자 단일 조회
|
|
310
|
+
* @param username 사용자명
|
|
311
|
+
*/
|
|
312
|
+
async getUserByUsername(username: string): Promise<LogtoUserResponse> {
|
|
313
|
+
await this.getAccessToken();
|
|
314
|
+
|
|
315
|
+
const params = new URLSearchParams();
|
|
316
|
+
params.set('search.username', username);
|
|
317
|
+
params.set('mode.username', 'exact');
|
|
318
|
+
|
|
319
|
+
const response = await this.apiRestTemplate.get<LogtoUserResponse[]>(
|
|
320
|
+
`/users?${params.toString()}`,
|
|
321
|
+
);
|
|
322
|
+
return response.data[0];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 사용자 정지
|
|
327
|
+
* @param userId 사용자 ID
|
|
328
|
+
*/
|
|
329
|
+
async suspendUser(userId: string): Promise<LogtoUserResponse> {
|
|
330
|
+
await this.getAccessToken();
|
|
331
|
+
const response = await this.apiRestTemplate.patch<LogtoUserResponse>(
|
|
332
|
+
`/users/${userId}/is-suspended`,
|
|
333
|
+
{ isSuspended: true },
|
|
334
|
+
);
|
|
335
|
+
return response.data;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* 사용자 삭제
|
|
340
|
+
* @param userId 사용자 ID
|
|
341
|
+
*/
|
|
342
|
+
async deleteUser(userId: string): Promise<void> {
|
|
343
|
+
await this.getAccessToken();
|
|
344
|
+
await this.apiRestTemplate.delete(`/users/${userId}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* 사용자 정지 해제
|
|
349
|
+
* @param userId 사용자 ID
|
|
350
|
+
*/
|
|
351
|
+
async unsuspendUser(userId: string): Promise<LogtoUserResponse> {
|
|
352
|
+
await this.getAccessToken();
|
|
353
|
+
const response = await this.apiRestTemplate.patch<LogtoUserResponse>(
|
|
354
|
+
`/users/${userId}/is-suspended`,
|
|
355
|
+
{ isSuspended: false },
|
|
356
|
+
);
|
|
357
|
+
return response.data;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* 인증코드 발송 (이메일/휴대폰)
|
|
362
|
+
* @param identifier 이메일 또는 휴대폰
|
|
363
|
+
*/
|
|
364
|
+
async sendVerificationCode(
|
|
365
|
+
identifier: p3Values.PhoneNumber | p3Values.Email,
|
|
366
|
+
): Promise<void> {
|
|
367
|
+
await this.getAccessToken();
|
|
368
|
+
|
|
369
|
+
// VerificationMethodType.email/phone은 클래스(static)로 정의되어 있음
|
|
370
|
+
const method =
|
|
371
|
+
identifier instanceof VerificationMethodType.email
|
|
372
|
+
? "email"
|
|
373
|
+
: "phone";
|
|
374
|
+
|
|
375
|
+
await this.apiRestTemplate.post('/verification-codes', {
|
|
376
|
+
[method]: identifier.toString(),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* 인증코드 검증
|
|
382
|
+
* @param identifier 이메일 또는 휴대폰
|
|
383
|
+
* @param code 인증코드
|
|
384
|
+
*/
|
|
385
|
+
async verifyCode(
|
|
386
|
+
identifier: p3Values.PhoneNumber | p3Values.Email,
|
|
387
|
+
code: string,
|
|
388
|
+
): Promise<void> {
|
|
389
|
+
await this.getAccessToken();
|
|
390
|
+
|
|
391
|
+
const method =
|
|
392
|
+
identifier instanceof VerificationMethodType.email
|
|
393
|
+
? 'email'
|
|
394
|
+
: 'phone';
|
|
395
|
+
|
|
396
|
+
await this.apiRestTemplate.post(`/verification-codes/verify`, {
|
|
397
|
+
[method]: identifier.toString(),
|
|
398
|
+
verificationCode: code,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* 사용자 비밀번호 변경
|
|
404
|
+
* @param userId 사용자 ID
|
|
405
|
+
* @param password 새 비밀번호
|
|
406
|
+
*/
|
|
407
|
+
async updateUserPassword(userId: string, password: string): Promise<LogtoUserResponse> {
|
|
408
|
+
await this.getAccessToken();
|
|
409
|
+
const response = await this.apiRestTemplate.patch<LogtoUserResponse>(
|
|
410
|
+
`/users/${userId}/password`,
|
|
411
|
+
{ password },
|
|
412
|
+
);
|
|
413
|
+
return response.data;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* 국가번호와 휴대폰번호를 합쳐 국제전화번호 형태로 반환
|
|
419
|
+
* @param countryCode 국가번호 (예: '82')
|
|
420
|
+
* @param phoneNumber 휴대폰번호 (예: '01012345678')
|
|
421
|
+
* @returns 국제전화번호 (예: '821012345678')
|
|
422
|
+
*/
|
|
423
|
+
export function generatePhoneNumberWithCountryCode(countryCode: string, phoneNumber: string): string {
|
|
424
|
+
if (phoneNumber.startsWith('0')) {
|
|
425
|
+
phoneNumber = phoneNumber.slice(1);
|
|
426
|
+
}
|
|
427
|
+
return `${countryCode}${phoneNumber}`;
|
|
428
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { Inject, Injectable, Global, LoggerService } from "@nestjs/common";
|
|
2
|
+
import axios, { AxiosResponse } from "axios";
|
|
3
|
+
import { GrantType, Prompt, LogtoConfig } from "./config";
|
|
4
|
+
import { ConfigService } from "@nestjs/config";
|
|
5
|
+
import { axiosAdapter, p3Values } from "point3-common-tool";
|
|
6
|
+
import {
|
|
7
|
+
TokenRevocationFailedError,
|
|
8
|
+
AuthorizationCodeTokenFetchError,
|
|
9
|
+
SignInUriGenerationError,
|
|
10
|
+
SignOutUriGenerationError,
|
|
11
|
+
} from "../errors";
|
|
12
|
+
import { LogtoLoggerServiceToken, LogtoOAuthRESTTemplate } from "./types";
|
|
13
|
+
|
|
14
|
+
const Gulid = p3Values.Gulid;
|
|
15
|
+
|
|
16
|
+
/** DI 토큰 */
|
|
17
|
+
export const OAuthClientToken = "OAuthClient";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* OAuthClient
|
|
21
|
+
*
|
|
22
|
+
* Logto OAuth 인증을 위한 클라이언트 서비스입니다.
|
|
23
|
+
* 로그인/로그아웃 URI 생성, 토큰 발급 및 해지 등 OAuth 인증 플로우의 핵심 기능을 제공합니다.
|
|
24
|
+
* NestJS DI 환경에서 사용되며, Logto와의 통합 인증 처리를 담당합니다.
|
|
25
|
+
*
|
|
26
|
+
* 주요 역할:
|
|
27
|
+
* - 로그인/로그아웃 URI 생성
|
|
28
|
+
* - 인증 코드로 액세스 토큰 및 ID 토큰 발급
|
|
29
|
+
* - 토큰 해지(로그아웃)
|
|
30
|
+
* - Logto OAuth 관련 예외 및 로깅 처리
|
|
31
|
+
*
|
|
32
|
+
* 사용 예시:
|
|
33
|
+
* const client = new OAuthClient(...);
|
|
34
|
+
* const { uri, state } = client.getSignInURI(SignInType.Admin);
|
|
35
|
+
* const tokens = await client.fetchTokenByAuthorizationCode(code);
|
|
36
|
+
* await client.revokeToken(tokens.accessToken);
|
|
37
|
+
*
|
|
38
|
+
*/
|
|
39
|
+
@Global()
|
|
40
|
+
@Injectable()
|
|
41
|
+
export class OAuthClient {
|
|
42
|
+
/** Logto 설정 정보 */
|
|
43
|
+
private logtoConfig: LogtoConfig;
|
|
44
|
+
/** OAuth REST 템플릿 */
|
|
45
|
+
private logtoRestTemplate: axiosAdapter.RESTTemplate;
|
|
46
|
+
/** 상태값 prefix (CSRF 방지용) */
|
|
47
|
+
static readonly prefix: string = "signin";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 생성자
|
|
51
|
+
* @param configService 환경설정 서비스
|
|
52
|
+
* @param logger 로거 서비스
|
|
53
|
+
*/
|
|
54
|
+
constructor(
|
|
55
|
+
@Inject(ConfigService)
|
|
56
|
+
private configService: ConfigService,
|
|
57
|
+
|
|
58
|
+
@Inject(LogtoLoggerServiceToken)
|
|
59
|
+
private logger: LoggerService
|
|
60
|
+
) {
|
|
61
|
+
// Logto 설정 초기화
|
|
62
|
+
this.logtoConfig = {
|
|
63
|
+
endpoint: this.configService.get<string>("LOGTO_AUTH_ENDPOINT")!,
|
|
64
|
+
appId: this.configService.get<string>("LOGTO_CLIENT_ID")!,
|
|
65
|
+
appSecret: this.configService.get<string>("LOGTO_CLIENT_SECRET")!,
|
|
66
|
+
resources: [this.configService.get<string>("LOGTO_RESOURCES")!],
|
|
67
|
+
scopes: this.configService.get<string>("LOGTO_SCOPES")!.split(","),
|
|
68
|
+
prompt: this.configService.get<string>("LOGTO_PROMPT")! as Prompt,
|
|
69
|
+
redirectUri: this.configService.get<string>("LOGTO_REDIRECT_URI")!,
|
|
70
|
+
grantType: GrantType.AuthorizationCode,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// REST 템플릿 및 Basic Auth 설정
|
|
74
|
+
this.logtoRestTemplate = new LogtoOAuthRESTTemplate(
|
|
75
|
+
logger,
|
|
76
|
+
this.logtoConfig.endpoint
|
|
77
|
+
);
|
|
78
|
+
this.logtoRestTemplate.setBasic(
|
|
79
|
+
this.logtoConfig.appId!,
|
|
80
|
+
this.logtoConfig.appSecret!
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 로그인 URI 생성
|
|
86
|
+
* @param signInType 로그인 타입 (Admin | Dashboard)
|
|
87
|
+
* @returns { uri, state } 로그인 URI와 상태값
|
|
88
|
+
*/
|
|
89
|
+
public getSignInURI(
|
|
90
|
+
signInType: SignInType
|
|
91
|
+
): { uri: string; state: string } {
|
|
92
|
+
try {
|
|
93
|
+
let uri: URL;
|
|
94
|
+
|
|
95
|
+
// 대시보드 로그인일 경우 별도 URI, 실패시 기본 URI로 폴백
|
|
96
|
+
if (signInType === SignInType.Dashboard) {
|
|
97
|
+
try {
|
|
98
|
+
uri = new URL(
|
|
99
|
+
`${this.configService.get<string>(
|
|
100
|
+
"LOGTO_DASHBOARD_SIGN_IN_URI"
|
|
101
|
+
)}/auth`
|
|
102
|
+
);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
this.logger.warn(
|
|
105
|
+
"대시보드 로그인 URI 설정을 찾을 수 없어 기본 URI를 사용합니다.",
|
|
106
|
+
this.constructor.name
|
|
107
|
+
);
|
|
108
|
+
uri = new URL(
|
|
109
|
+
`${this.configService.get<string>("LOGTO_SIGN_IN_URI")}/auth`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
uri = new URL(
|
|
114
|
+
`${this.configService.get<string>("LOGTO_SIGN_IN_URI")}/auth`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 상태값 생성 (CSRF 방지)
|
|
119
|
+
const state = Gulid.create(OAuthClient.prefix);
|
|
120
|
+
|
|
121
|
+
// OAuth 필수 파라미터 설정
|
|
122
|
+
uri.searchParams.set("redirect_uri", this.logtoConfig.redirectUri!);
|
|
123
|
+
uri.searchParams.set("response_type", "code");
|
|
124
|
+
uri.searchParams.set("scope", this.logtoConfig.scopes!.join(" "));
|
|
125
|
+
uri.searchParams.set("prompt", this.logtoConfig.prompt!);
|
|
126
|
+
uri.searchParams.set("client_id", this.logtoConfig.appId!);
|
|
127
|
+
uri.searchParams.set("resource", this.logtoConfig.resources!.join(" "));
|
|
128
|
+
uri.searchParams.set("state", state.toString());
|
|
129
|
+
|
|
130
|
+
return { uri: uri.toString(), state: state.toString() };
|
|
131
|
+
} catch (error) {
|
|
132
|
+
throw new SignInUriGenerationError(signInType);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 로그아웃 URI 생성
|
|
138
|
+
* @returns 로그아웃 URI
|
|
139
|
+
*/
|
|
140
|
+
public async getSignOutURI(): Promise<string> {
|
|
141
|
+
try {
|
|
142
|
+
const uri = new URL(
|
|
143
|
+
`${this.configService.get<string>("LOGTO_SIGN_IN_URI")}/session/end`
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// 로그아웃 후 리다이렉트 URI 및 클라이언트 ID 설정
|
|
147
|
+
uri.searchParams.set("redirect_uri", this.logtoConfig.redirectUri!);
|
|
148
|
+
uri.searchParams.set("client_id", this.logtoConfig.appId!);
|
|
149
|
+
return uri.toString();
|
|
150
|
+
} catch (error) {
|
|
151
|
+
throw new SignOutUriGenerationError();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 인증 코드로 액세스 토큰 및 ID 토큰 발급
|
|
157
|
+
* @param code OAuth 인증 코드
|
|
158
|
+
* @returns { accessToken, idToken } 액세스 토큰과 ID 토큰
|
|
159
|
+
*/
|
|
160
|
+
public async fetchTokenByAuthorizationCode(
|
|
161
|
+
code: string
|
|
162
|
+
): Promise<{ accessToken: string; idToken: string }> {
|
|
163
|
+
try {
|
|
164
|
+
// 토큰 요청 파라미터 설정
|
|
165
|
+
const parameters = new URLSearchParams();
|
|
166
|
+
parameters.set("code", code);
|
|
167
|
+
parameters.set("grant_type", this.logtoConfig.grantType);
|
|
168
|
+
parameters.set("redirect_uri", this.logtoConfig.redirectUri!);
|
|
169
|
+
parameters.set("resource", this.logtoConfig.resources!.join(" "));
|
|
170
|
+
parameters.set("scope", this.logtoConfig.scopes!.join(" "));
|
|
171
|
+
|
|
172
|
+
// 토큰 엔드포인트 호출
|
|
173
|
+
const response = await this.logtoRestTemplate.post<TokenResponse>(
|
|
174
|
+
`${this.logtoConfig.endpoint}/token`,
|
|
175
|
+
parameters.toString()
|
|
176
|
+
);
|
|
177
|
+
return {
|
|
178
|
+
accessToken: response.data.access_token,
|
|
179
|
+
idToken: response.data.id_token,
|
|
180
|
+
};
|
|
181
|
+
} catch (error) {
|
|
182
|
+
throw new AuthorizationCodeTokenFetchError(code);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 토큰 해지
|
|
188
|
+
* @param token 해지할 토큰
|
|
189
|
+
*/
|
|
190
|
+
public async revokeToken(token: string): Promise<void> {
|
|
191
|
+
try {
|
|
192
|
+
const response: AxiosResponse = await axios.post(
|
|
193
|
+
`${this.logtoConfig.endpoint}/token/revoke`,
|
|
194
|
+
new URLSearchParams({
|
|
195
|
+
token: token,
|
|
196
|
+
client_id: this.logtoConfig.appId!,
|
|
197
|
+
}).toString(),
|
|
198
|
+
{
|
|
199
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
200
|
+
}
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
if (response.status === 200) return;
|
|
204
|
+
|
|
205
|
+
throw new TokenRevocationFailedError();
|
|
206
|
+
} catch (error) {
|
|
207
|
+
throw new TokenRevocationFailedError();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 로그인 타입 열거형
|
|
214
|
+
* - Admin: 관리자 로그인
|
|
215
|
+
* - Dashboard: 대시보드 로그인
|
|
216
|
+
*/
|
|
217
|
+
export enum SignInType {
|
|
218
|
+
Admin = "admin",
|
|
219
|
+
Dashboard = "dashboard",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 토큰 응답 타입
|
|
224
|
+
*/
|
|
225
|
+
type TokenResponse = {
|
|
226
|
+
access_token: string; // 액세스 토큰
|
|
227
|
+
refresh_token?: string; // 리프레시 토큰 (선택)
|
|
228
|
+
id_token: string; // ID 토큰
|
|
229
|
+
scope: string; // 부여된 스코프
|
|
230
|
+
expires_in: number; // 토큰 만료 시간(초)
|
|
231
|
+
};
|