UNPKG

@point3/logto-module

Version:

포인트3 내부 logto Authentication 모듈입니다

483 lines (430 loc) 15.3 kB
/** * Logto M2M(Machine-to-Machine) 클라이언트 * - Logto API의 M2M 인증 및 사용자/역할 관리 기능 제공 * - NestJS DI 시스템에 등록됨 * * @author */ import { Injectable, Global, LoggerService, } from "@nestjs/common"; import { LogtoConfig, LogtoM2MConfig, GrantType, } from "./config"; import { AccessToken, LogtoTokenVerifier, } from "../token"; import { LogtoOAuthRESTTemplate, LogtoPasswordAlgorithm, LogtoPersonalAccessTokenResponse, LogtoRole, LogtoRoleResponse, LogtoUser, LogtoUserResponse, VerificationMethodType, } from "./types"; import { p3Values, axiosAdapter } from "point3-common-tool"; import { UserMissingRequiredFieldsError, PersonalAccessTokenFetchError, } from "../errors"; // DI 토큰 export const LogtoM2MClientToken = Symbol.for("LogtoM2MClient"); /** * LogtoM2MClient * * Logto M2M(Machine-to-Machine) 인증 및 사용자/역할 관리를 위한 클라이언트 서비스입니다. * NestJS DI 환경에서 사용되며, 서버 간 통신 및 자동화된 시스템에서 Logto API를 활용할 때 사용합니다. * * 주요 역할: * - M2M 인증을 통한 AccessToken 발급 및 관리 * - 역할(Role) 생성, 조회, 사용자 역할 할당 * - 사용자(User) 생성, 조회, 수정, 정지/해제, 삭제 등 관리 * - 인증코드 발송 및 검증, 비밀번호 변경 등 부가 기능 제공 * * 사용 예시: * const client = new LogtoM2MClient(...); * await client.fetchAccessToken(); * const roles = await client.getRoles(); * const userId = await client.createUser(user); * await client.assignRoleToUser(userId, roleId); * ... */ @Global() @Injectable() export class LogtoM2MClient { private logtoConfig: LogtoConfig; private accessToken?: AccessToken; // /oidc 엔드포인트용 REST 템플릿 private readonly authRestTemplate: axiosAdapter.RESTTemplate; // /api 엔드포인트용 REST 템플릿 private readonly apiRestTemplate: axiosAdapter.RESTTemplate; constructor( private readonly config: LogtoM2MConfig, private readonly tokenVerifier: LogtoTokenVerifier, private readonly logger: LoggerService, ) { // config 기반 Logto 설정 this.logtoConfig = { endpoint: config.endpoint, appId: config.clientId, appSecret: config.clientSecret, scopes: config.scopes, resources: [config.resource], grantType: GrantType.ClientCredentials, }; // 인증용 REST 템플릿 초기화 this.authRestTemplate = new LogtoOAuthRESTTemplate( this.logger, this.logtoConfig.endpoint, ); this.authRestTemplate.setBasic(this.logtoConfig.appId, this.logtoConfig.appSecret); // API용 REST 템플릿 초기화 this.apiRestTemplate = new LogtoOAuthRESTTemplate( this.logger, config.apiUrl, ); } // ========================= // 1. 토큰 관리 // ========================= /** * AccessToken을 발급받아 저장 및 API 템플릿에 Bearer로 설정 */ async fetchAccessToken(): Promise<void> { const params = new URLSearchParams(); params.set('grant_type', this.logtoConfig.grantType); params.set('scope', this.logtoConfig.scopes!.join(' ')); params.set('resource', this.logtoConfig.resources!.join(' ')); const response = await this.authRestTemplate.post<{ access_token: string; expires_in: number; }>('/token', params.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); const { access_token, expires_in } = response.data; const payload = await this.tokenVerifier.verifyToken(access_token); this.accessToken = new AccessToken( payload.sub, access_token, expires_in, ); this.apiRestTemplate.setBearer(access_token); } /** * PAT 토큰을 이용해 AccessToken 발급 * @param pat Personal Access Token * @returns { accessToken } 액세스 토큰 */ public async fetchAccessTokenByPAT(pat: string): Promise<{ accessToken: string }> { try { const parameters = new URLSearchParams(); parameters.set("client_id", this.logtoConfig.appId!); parameters.set("grant_type", 'urn:ietf:params:oauth:grant-type:token-exchange'); parameters.set("resource", this.logtoConfig.resources!.join(" ")); parameters.set("scope", this.logtoConfig.scopes!.join(" ")); parameters.set("subject_token", pat); parameters.set("subject_token_type", 'urn:logto:token-type:personal_access_token'); const response = await this.authRestTemplate.post< { access_token: string; } >( `${this.logtoConfig.endpoint}/token`, parameters.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, } ); return { accessToken: response.data.access_token }; } catch (error) { this.logger.error(`PAT를 이용한 AccessToken 발급 실패: ${error.message}`, error.stack, this.constructor.name); throw new PersonalAccessTokenFetchError(); } } /** * 유효한 AccessToken 반환 (만료 시 자동 갱신) * @private */ private async getAccessToken(): Promise<string> { if (!this.accessToken || this.accessToken.isExpired()) { await this.fetchAccessToken(); } return this.accessToken!.token; } // ========================= // 2. 역할(Role) 관리 // ========================= /** * 모든 역할 목록 조회 */ async getRoles(): Promise<LogtoRoleResponse[]> { await this.getAccessToken(); const response = await this.apiRestTemplate.get<LogtoRoleResponse[]>('/roles'); return response.data; } /** * 역할 이름으로 역할 조회 * @param name 역할 이름 */ async getRoleByName(name: string): Promise<LogtoRoleResponse> { await this.getAccessToken(); const params = new URLSearchParams(); params.set('search.name', name); const response = await this.apiRestTemplate.get<LogtoRoleResponse[]>( `/roles?${params.toString()}`, ); return response.data[0]; } /** * 역할 생성 (이미 존재하면 기존 역할 반환) * @param role 역할 정보 */ async createRole(role: LogtoRole): Promise<LogtoRoleResponse> { await this.getAccessToken(); const body = { name: role.name, description: role.description, type: role.type, }; const response = await this.apiRestTemplate.post<LogtoRoleResponse>( '/roles', body, ); if (response instanceof axiosAdapter.ValidationError) { if (response.code === 'role.name_in_use') { this.logger.error( `이미 존재하는 역할: ${response.code}`, this.constructor.name, ); return this.getRoleByName(role.name); } throw response; } return response.data; } /** * 사용자에게 역할 할당 * @param userId 사용자 ID * @param roleId 역할 ID */ async assignRoleToUser(userId: string, roleId: string): Promise<void> { await this.getAccessToken(); const body = { roleIds: [roleId] }; await this.apiRestTemplate.post(`/users/${userId}/roles`, body); this.logger.log( `사용자에 역할 할당: ${userId}`, this.constructor.name, ); } // ========================= // 3. 사용자(User) 관리 // ========================= /** * 사용자 생성 * @param user 사용자 정보 * @returns 생성된 사용자 ID */ async createUser(user: LogtoUser): Promise<string> { await this.getAccessToken(); if (user.username && user.primaryEmail && user.password && user.name) { user.passwordAlgorithm = user.passwordAlgorithm ?? LogtoPasswordAlgorithm.Argon2i; const response = await this.apiRestTemplate.post<{ id: string }>('/users', user); return response.data.id; } this.logger.error(`필수 필드 누락`, this.constructor.name); throw new UserMissingRequiredFieldsError(); } /** * 사용자 customData.clientId 정보 업데이트 * @param userId 사용자 ID * @param clientId 고객사 ID */ async updateUserClientInfo( userId: string, clientId?: string, ): Promise<void> { await this.getAccessToken(); await this.apiRestTemplate.patch(`/users/${userId}`, { customData: { clientId }, }); } /** * 사용자 ID로 사용자 정보 조회 * @param id 사용자 ID */ async getUser(id: string): Promise<LogtoUserResponse> { await this.getAccessToken(); const response = await this.apiRestTemplate.get<LogtoUserResponse>(`/users/${id}`); return response.data; } /** * username으로 사용자 단일 조회 * @param username 사용자명 */ async getUserByUsername(username: string): Promise<LogtoUserResponse> { await this.getAccessToken(); const params = new URLSearchParams(); params.set('search.username', username); params.set('mode.username', 'exact'); const response = await this.apiRestTemplate.get<LogtoUserResponse[]>( `/users?${params.toString()}`, ); return response.data[0]; } /** * 사용자 정지 * @param userId 사용자 ID */ async suspendUser(userId: string): Promise<LogtoUserResponse> { await this.getAccessToken(); const response = await this.apiRestTemplate.patch<LogtoUserResponse>( `/users/${userId}/is-suspended`, { isSuspended: true }, ); return response.data; } /** * 사용자 삭제 * @param userId 사용자 ID */ async deleteUser(userId: string): Promise<void> { await this.getAccessToken(); await this.apiRestTemplate.delete(`/users/${userId}`); } /** * 사용자 역할 삭제 * @param userId 사용자 ID * @param roleId 역할 ID */ async deleteUserRole(userId: string, roleId: string): Promise<void> { await this.getAccessToken(); await this.apiRestTemplate.delete(`/roles/${roleId}/users/${userId}`); } /** * 사용자 정지 해제 * @param userId 사용자 ID */ async unsuspendUser(userId: string): Promise<LogtoUserResponse> { await this.getAccessToken(); const response = await this.apiRestTemplate.patch<LogtoUserResponse>( `/users/${userId}/is-suspended`, { isSuspended: false }, ); return response.data; } /** * 인증코드 발송 (이메일/휴대폰) * @param identifier 이메일 또는 휴대폰 */ async sendVerificationCode( identifier: p3Values.PhoneNumber | p3Values.Email, ): Promise<void> { await this.getAccessToken(); // VerificationMethodType.email/phone은 클래스(static)로 정의되어 있음 const method = identifier instanceof VerificationMethodType.email ? "email" : "phone"; await this.apiRestTemplate.post('/verification-codes', { [method]: identifier.toString(), }); } /** * 인증코드 검증 * @param identifier 이메일 또는 휴대폰 * @param code 인증코드 */ async verifyCode( identifier: p3Values.PhoneNumber | p3Values.Email, code: string, ): Promise<void> { await this.getAccessToken(); const method = identifier instanceof VerificationMethodType.email ? 'email' : 'phone'; await this.apiRestTemplate.post(`/verification-codes/verify`, { [method]: identifier.toString(), verificationCode: code, }); } /** * 사용자 비밀번호 변경 * @param userId 사용자 ID * @param password 새 비밀번호 */ async updateUserPassword(userId: string, password: string): Promise<LogtoUserResponse> { await this.getAccessToken(); const response = await this.apiRestTemplate.patch<LogtoUserResponse>( `/users/${userId}/password`, { password }, ); return response.data; } /** * 사용자 Personal Access Token 발급 * @param userId 사용자 ID * @param name 발급할 토큰의 고유 이름 * @param expiresIn 만료 시간(초). 지정하지 않으면 만료되지 않음 */ async addPersonalAccessToken( userId: string, name: string, expiresIn?: number ): Promise<LogtoPersonalAccessTokenResponse> { await this.getAccessToken(); const body: Record<string, any> = { name }; if (expiresIn !== undefined && expiresIn !== null) { // expiresIn is in seconds, expiresAt requires epoch time in milliseconds body.expiresAt = Date.now() + expiresIn * 1000; } const response = await this.apiRestTemplate.post<LogtoPersonalAccessTokenResponse>( `/users/${userId}/personal-access-tokens`, body ); return response.data; } /** * 사용자 Personal Access Token 삭제 * @param userId 사용자 ID * @param name 삭제할 토큰 이름 */ async deletePersonalAccessToken(userId: string, name: string): Promise<void> { await this.getAccessToken(); await this.apiRestTemplate.delete( `/users/${userId}/personal-access-tokens/${name}` ); } /** * 사용자의 모든 Personal Access Token 조회 * @param userId 사용자 ID */ async getPersonalAccessTokens(userId: string): Promise<LogtoPersonalAccessTokenResponse[]> { await this.getAccessToken(); const response = await this.apiRestTemplate.get<LogtoPersonalAccessTokenResponse[]>( `/users/${userId}/personal-access-tokens` ); return response.data; } } /** * 국가번호와 휴대폰번호를 합쳐 국제전화번호 형태로 반환 * @param countryCode 국가번호 (예: '82') * @param phoneNumber 휴대폰번호 (예: '01012345678') * @returns 국제전화번호 (예: '821012345678') */ export function generatePhoneNumberWithCountryCode(countryCode: string, phoneNumber: string): string { if (phoneNumber.startsWith('0')) { phoneNumber = phoneNumber.slice(1); } return `${countryCode}${phoneNumber}`; }