@point3/logto-module
Version:
포인트3 내부 logto Authentication 모듈입니다
483 lines (430 loc) • 15.3 kB
text/typescript
/**
* 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);
* ...
*/
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}`;
}