generated from ttipo/project-base
feat(backend): 최고관리자가 만드는 업체 계정과 업체 실적 화면 API
관리자 화면에는 env 계정 하나만 있어 업체가 자기 실적을 볼 방법이 없었다. env 계정은 최고관리자로 그대로 두고, 최고관리자가 업체마다 로그인 계정을 만든다. - admin_accounts(업체 계정) · admin_sessions.account_id — 비밀번호는 bcrypt 해시만 - 토큰에 역할(super·partner)을 싣고, 계정 지문으로 비밀번호 변경·초기화 시 다른 기기를 끊는다. 중지된 계정은 즉시 거부한다 - 가드 분리: AdminAuthGuard 는 최고관리자 전용(기존 관리 API 전부, 업체 토큰은 AUTH_003), PartnerAuthGuard 는 업체 계정 전용(임시 비밀번호면 ACCOUNT_003), AccountAuthGuard 는 공통 - 업체 계정 관리: 만들기·사용/중지·비밀번호 초기화 — 임시 비밀번호는 응답에서 한 번만 - 업체 화면 /api/partner-portal: 요약·링크·신청·구매. 업체 ID 는 로그인 정보에서만 가져오고 신청·구매자는 가린 이름만 내려준다 (연락처·이메일·아이디·주문번호 없음) - 없는 아이디도 bcrypt 비교를 거쳐 응답 시간으로 아이디 존재를 알 수 없게 한다. bcryptjs(순수 JS)라 비용은 10 — 무차별 대입은 로그인 요청 제한이 함께 막는다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+10
@@ -20,6 +20,7 @@
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cache-manager": "^6.4.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.4",
|
||||
@@ -5494,6 +5495,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-version": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-version/-/binary-version-7.1.0.tgz",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cache-manager": "^6.4.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.4",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ExecutionContext, createParamDecorator } from '@nestjs/common';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { AdminRequest } from '@/common/guards/admin-auth.guard';
|
||||
import type { AdminResponseDto } from '@/modules/admin-auth/dto/admin-response.dto';
|
||||
import type { VerifiedAccount } from '@/modules/admin-auth/admin-auth.service';
|
||||
|
||||
/**
|
||||
* AdminAuthGuard 가 검증한 관리자를 핸들러 인자로 받는다.
|
||||
@@ -15,3 +16,15 @@ export const CurrentAdmin = createParamDecorator(
|
||||
return request.admin;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 가드가 검증한 계정(역할·업체 포함)을 핸들러 인자로 받는다.
|
||||
* 업체 화면은 반드시 이 값의 partnerId 로 데이터를 거른다 — 요청값의 업체 ID 를 믿지 않는다.
|
||||
*/
|
||||
export const CurrentAccount = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): VerifiedAccount => {
|
||||
const request = context.switchToHttp().getRequest<AdminRequest>();
|
||||
if (!request.account) throw new BusinessException('AUTH_001');
|
||||
return request.account;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
AdminAuthService,
|
||||
VerifiedAccount,
|
||||
} from '@/modules/admin-auth/admin-auth.service';
|
||||
import {
|
||||
AccountAuthGuard,
|
||||
AdminAuthGuard,
|
||||
AdminRequest,
|
||||
PartnerAuthGuard,
|
||||
} from './admin-auth.guard';
|
||||
|
||||
const SUPER: VerifiedAccount = {
|
||||
admin: {
|
||||
username: 'admin',
|
||||
role: 'super',
|
||||
partner: null,
|
||||
mustChangePassword: false,
|
||||
},
|
||||
sessionId: 's-super',
|
||||
role: 'super',
|
||||
accountId: null,
|
||||
partnerId: null,
|
||||
};
|
||||
|
||||
function partner(mustChangePassword = false): VerifiedAccount {
|
||||
return {
|
||||
admin: {
|
||||
username: 'moneytube',
|
||||
role: 'partner',
|
||||
partner: { id: 'p-1', name: '머니튜브' },
|
||||
mustChangePassword,
|
||||
},
|
||||
sessionId: 's-partner',
|
||||
role: 'partner',
|
||||
accountId: 'a-1',
|
||||
partnerId: 'p-1',
|
||||
};
|
||||
}
|
||||
|
||||
function run(
|
||||
Guard:
|
||||
| typeof AdminAuthGuard
|
||||
| typeof PartnerAuthGuard
|
||||
| typeof AccountAuthGuard,
|
||||
verified: VerifiedAccount,
|
||||
) {
|
||||
const request = {
|
||||
headers: { authorization: 'Bearer token' },
|
||||
} as AdminRequest;
|
||||
const context = {
|
||||
switchToHttp: () => ({ getRequest: () => request }),
|
||||
} as unknown as ExecutionContext;
|
||||
const auth = {
|
||||
verifyAccessToken: jest.fn(() => Promise.resolve(verified)),
|
||||
} as unknown as AdminAuthService;
|
||||
return { result: new Guard(auth).canActivate(context), request };
|
||||
}
|
||||
|
||||
describe('계정 가드 (역할별 접근)', () => {
|
||||
it('기존 관리 기능(AdminAuthGuard)은 최고관리자만 — 업체 계정은 AUTH_003', async () => {
|
||||
await expect(run(AdminAuthGuard, SUPER).result).resolves.toBe(true);
|
||||
await expect(run(AdminAuthGuard, partner()).result).rejects.toMatchObject({
|
||||
code: 'AUTH_003',
|
||||
});
|
||||
});
|
||||
|
||||
it('업체 화면(PartnerAuthGuard)은 업체 계정만 — 최고관리자는 AUTH_003', async () => {
|
||||
const { result, request } = run(PartnerAuthGuard, partner());
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(request.account?.partnerId).toBe('p-1');
|
||||
await expect(run(PartnerAuthGuard, SUPER).result).rejects.toMatchObject({
|
||||
code: 'AUTH_003',
|
||||
});
|
||||
});
|
||||
|
||||
it('임시 비밀번호를 바꾸기 전에는 업체 화면을 ACCOUNT_003 으로 막는다', async () => {
|
||||
await expect(
|
||||
run(PartnerAuthGuard, partner(true)).result,
|
||||
).rejects.toMatchObject({ code: 'ACCOUNT_003' });
|
||||
});
|
||||
|
||||
it('계정 공통(AccountAuthGuard)은 두 역할 모두 통과한다 (임시 비밀번호여도 — 비밀번호 변경용)', async () => {
|
||||
await expect(run(AccountAuthGuard, SUPER).result).resolves.toBe(true);
|
||||
await expect(run(AccountAuthGuard, partner(true)).result).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,88 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { AdminAuthService } from '@/modules/admin-auth/admin-auth.service';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { AccountRole } from '@/modules/admin-auth/admin-auth.constants';
|
||||
import {
|
||||
AdminAuthService,
|
||||
VerifiedAccount,
|
||||
} from '@/modules/admin-auth/admin-auth.service';
|
||||
import { AdminResponseDto } from '@/modules/admin-auth/dto/admin-response.dto';
|
||||
import { readBearerToken } from '@/shared/utils/bearer';
|
||||
|
||||
/** 가드가 검증한 관리자를 요청 객체에 실어 둔다 */
|
||||
/** 가드가 검증한 계정을 요청 객체에 실어 둔다 */
|
||||
export interface AdminRequest extends Request {
|
||||
admin?: AdminResponseDto;
|
||||
/** 이 요청의 로그인 세션 — 로그아웃할 때 이 세션만 끊는다 */
|
||||
adminSessionId?: string;
|
||||
/** 역할·업체까지 포함한 검증 결과 — 업체 화면은 여기 partnerId 로만 데이터를 거른다 */
|
||||
account?: VerifiedAccount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 전용 라우트 보호 — `Authorization: Bearer` 의 액세스 토큰을 검증한다.
|
||||
* 실패하면 AUTH_001(401). 화면은 401 을 받으면 리프레시 토큰으로 갱신을 한 번 시도한다.
|
||||
* 토큰을 검증하고 허용된 역할인지 확인한다.
|
||||
* - 토큰이 없거나 무효: AUTH_001(401) — 화면은 리프레시 토큰으로 갱신을 한 번 시도한다
|
||||
* - 역할이 다르면: AUTH_003(403)
|
||||
*/
|
||||
async function authenticate(
|
||||
context: ExecutionContext,
|
||||
auth: AdminAuthService,
|
||||
roles: readonly AccountRole[],
|
||||
): Promise<VerifiedAccount> {
|
||||
const request = context.switchToHttp().getRequest<AdminRequest>();
|
||||
const token = readBearerToken(request.headers.authorization);
|
||||
const verified = await auth.verifyAccessToken(token);
|
||||
if (!roles.includes(verified.role)) {
|
||||
throw new BusinessException('AUTH_003', {
|
||||
context: { reason: 'role_not_allowed', role: verified.role },
|
||||
});
|
||||
}
|
||||
request.admin = verified.admin;
|
||||
request.adminSessionId = verified.sessionId;
|
||||
request.account = verified;
|
||||
return verified;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최고관리자 전용 — 기존 관리 기능(회원·신청·구매·업체 링크·연동)은 모두 이 가드를 쓴다.
|
||||
* 업체 계정 토큰은 AUTH_003 으로 막는다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminAuthGuard implements CanActivate {
|
||||
constructor(private readonly auth: AdminAuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<AdminRequest>();
|
||||
const token = readBearerToken(request.headers.authorization);
|
||||
const verified = await this.auth.verifyAccessToken(token);
|
||||
request.admin = verified.admin;
|
||||
request.adminSessionId = verified.sessionId;
|
||||
await authenticate(context, this.auth, ['super']);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 업체 계정 전용 — 업체 화면(partner-portal). 임시 비밀번호를 바꾸기 전에는 ACCOUNT_003(403).
|
||||
* 최고관리자 토큰은 AUTH_003 — 업체 화면은 업체 ID 가 있어야 의미가 있다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PartnerAuthGuard implements CanActivate {
|
||||
constructor(private readonly auth: AdminAuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const verified = await authenticate(context, this.auth, ['partner']);
|
||||
if (verified.admin.mustChangePassword) {
|
||||
throw new BusinessException('ACCOUNT_003', {
|
||||
context: { accountId: verified.accountId },
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** 역할 무관 — 로그인한 계정이면 된다 (현재 계정 조회·로그아웃·비밀번호 변경) */
|
||||
@Injectable()
|
||||
export class AccountAuthGuard implements CanActivate {
|
||||
constructor(private readonly auth: AdminAuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
await authenticate(context, this.auth, ['super', 'partner']);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddPartnerAccounts1791269588289 implements MigrationInterface {
|
||||
name = 'AddPartnerAccounts1791269588289';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "admin_accounts" ("id" uuid NOT NULL, "username" character varying(50) NOT NULL, "password_hash" character varying(100) NOT NULL, "partner_id" uuid NOT NULL, "is_active" boolean NOT NULL DEFAULT true, "must_change_password" boolean NOT NULL DEFAULT true, "last_login_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_79fb3604ed15538685183d8df5f" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "uq_admin_accounts_username" ON "admin_accounts" ("username") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "idx_admin_accounts_partner" ON "admin_accounts" ("partner_id", "created_at") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "admin_sessions" ADD "account_id" uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_a0e3bf56d35eae8a062e2757cc" ON "admin_sessions" ("account_id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "admin_accounts" ADD CONSTRAINT "FK_14a57a0f0e7a2b771e39dcb32f3" FOREIGN KEY ("partner_id") REFERENCES "partners"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "admin_sessions" ADD CONSTRAINT "FK_a0e3bf56d35eae8a062e2757cc3" FOREIGN KEY ("account_id") REFERENCES "admin_accounts"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "admin_sessions" DROP CONSTRAINT "FK_a0e3bf56d35eae8a062e2757cc3"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "admin_accounts" DROP CONSTRAINT "FK_14a57a0f0e7a2b771e39dcb32f3"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_a0e3bf56d35eae8a062e2757cc"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "admin_sessions" DROP COLUMN "account_id"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "public"."idx_admin_accounts_partner"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."uq_admin_accounts_username"`);
|
||||
await queryRunner.query(`DROP TABLE "admin_accounts"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { Partner } from '@/modules/partners/entities/partner.entity';
|
||||
|
||||
/**
|
||||
* 업체 계정 — 최고관리자가 업체마다 만들어 주는 로그인 계정.
|
||||
*
|
||||
* 최고관리자는 DB 계정이 아니라 env(ADMIN_USERNAME / ADMIN_PASSWORD) 계정이다.
|
||||
* 업체 계정은 자기 업체의 링크·실적만 볼 수 있다 (PartnerAuthGuard).
|
||||
* 지우지 않고 isActive=false 로 중지한다 — 업체를 지우면 그 업체의 계정도 함께 지워진다.
|
||||
*/
|
||||
@Entity({ name: 'admin_accounts' })
|
||||
@Index('idx_admin_accounts_partner', ['partnerId', 'createdAt'])
|
||||
export class AdminAccount {
|
||||
/** 앱에서 crypto.randomUUID() 로 만든다 */
|
||||
@PrimaryColumn({ name: 'id', type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
/** 로그인 아이디 — 소문자로 저장한다 (대소문자 구분 없이 로그인) */
|
||||
@Index('uq_admin_accounts_username', { unique: true })
|
||||
@Column({ name: 'username', type: 'varchar', length: 50 })
|
||||
username!: string;
|
||||
|
||||
/** bcrypt 해시 — 평문은 어디에도 남기지 않는다 */
|
||||
@Column({ name: 'password_hash', type: 'varchar', length: 100 })
|
||||
passwordHash!: string;
|
||||
|
||||
@Column({ name: 'partner_id', type: 'uuid' })
|
||||
partnerId!: string;
|
||||
|
||||
@ManyToOne(() => Partner, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'partner_id' })
|
||||
partner?: Partner;
|
||||
|
||||
/** 사용 여부 — 중지하면 로그인할 수 없고 열려 있던 세션도 끊긴다 */
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
/** 서버가 만든 임시 비밀번호인가 — 바꾸기 전에는 실적을 볼 수 없다 (ACCOUNT_003) */
|
||||
@Column({ name: 'must_change_password', type: 'boolean', default: true })
|
||||
mustChangePassword!: boolean;
|
||||
|
||||
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
||||
lastLoginAt!: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -28,16 +28,35 @@ export const ADMIN_PASSWORD_MIN_LENGTH = 12;
|
||||
/** 로그인 시도 제한 — 1분에 5회 (브루트포스 방지, 전역 한도보다 엄격하게) */
|
||||
export const LOGIN_THROTTLE = { default: { ttl: 60_000, limit: 5 } };
|
||||
|
||||
/**
|
||||
* 계정 역할.
|
||||
* - super : 최고관리자 — env 계정. 모든 관리 기능 + 업체 계정 관리
|
||||
* - partner : 업체 계정 — DB(admin_accounts). 자기 업체의 링크·실적만
|
||||
*/
|
||||
export const ACCOUNT_ROLES = ['super', 'partner'] as const;
|
||||
export type AccountRole = (typeof ACCOUNT_ROLES)[number];
|
||||
|
||||
/** 업체 계정 비밀번호 — 최소 길이 (bcrypt 가 72바이트까지만 쓰므로 최대는 72) */
|
||||
export const ACCOUNT_PASSWORD_MIN_LENGTH = 10;
|
||||
export const ACCOUNT_PASSWORD_MAX_LENGTH = 72;
|
||||
/**
|
||||
* bcrypt 비용 — OWASP 최소 권장값. 순수 JS 구현(bcryptjs)이라 12 면 한 번에 1초 가까이 걸려
|
||||
* 로그인 실패마다 서버 CPU 를 오래 쓴다. 무차별 대입은 로그인 요청 제한(LOGIN_THROTTLE)이 함께 막는다
|
||||
*/
|
||||
export const BCRYPT_ROUNDS = 10;
|
||||
|
||||
/** Swagger 의 Bearer 인증 이름 (main.ts 의 addBearerAuth 기본값) */
|
||||
export const ADMIN_BEARER_AUTH = 'bearer';
|
||||
|
||||
export type AdminTokenType = 'access' | 'refresh';
|
||||
|
||||
export interface AdminTokenPayload {
|
||||
/** 관리자 아이디 (env ADMIN_USERNAME) */
|
||||
/** 최고관리자는 env 아이디, 업체 계정은 계정 ID(admin_accounts.id) */
|
||||
sub: string;
|
||||
typ: AdminTokenType;
|
||||
/** 계정 지문 — env 아이디·비밀번호가 바뀌면 달라져 모든 토큰이 무효가 된다 */
|
||||
/** 역할 — 예전 토큰(역할 없음)은 최고관리자 토큰이다 */
|
||||
role?: AccountRole;
|
||||
/** 계정 지문 — 아이디·비밀번호가 바뀌면 달라져 그 계정의 모든 토큰이 무효가 된다 */
|
||||
cred: string;
|
||||
/** AdminSession.id — 로그아웃은 이 세션만 끊는다 (공유 계정의 다른 기기는 유지) */
|
||||
sid: string;
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { CurrentAdmin } from '@/common/decorators/current-admin.decorator';
|
||||
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
|
||||
import { AccountAuthGuard } from '@/common/guards/admin-auth.guard';
|
||||
import type { AdminRequest } from '@/common/guards/admin-auth.guard';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { ADMIN_BEARER_AUTH, LOGIN_THROTTLE } from './admin-auth.constants';
|
||||
@@ -29,7 +29,7 @@ import { AdminSessionDto, RenewSessionDto } from './dto/admin-session.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
|
||||
/**
|
||||
* 관리자 세션 — 로그인·갱신·로그아웃.
|
||||
* 로그인 세션 — 최고관리자(env 계정)·업체 계정 공통. 로그인·갱신·로그아웃.
|
||||
* 관리자 화면과 API 가 다른 사이트라 쿠키를 쓸 수 없어 토큰을 본문으로 내려준다
|
||||
* (이유와 보완책은 admin-auth.constants.ts 참조).
|
||||
*/
|
||||
@@ -40,7 +40,10 @@ export class AdminAuthController {
|
||||
|
||||
@Post()
|
||||
@Throttle(LOGIN_THROTTLE)
|
||||
@ApiOperation({ summary: '관리자 로그인 — 액세스·리프레시 토큰 발급' })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'로그인 — 최고관리자(env 계정) 또는 업체 계정. 액세스·리프레시 토큰 발급',
|
||||
})
|
||||
@ApiStandardResponse(AdminSessionDto, {
|
||||
status: 201,
|
||||
description: '로그인 성공',
|
||||
@@ -66,9 +69,12 @@ export class AdminAuthController {
|
||||
}
|
||||
|
||||
@Get('current')
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@UseGuards(AccountAuthGuard)
|
||||
@ApiBearerAuth(ADMIN_BEARER_AUTH)
|
||||
@ApiOperation({ summary: '현재 로그인한 관리자' })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'현재 로그인한 계정 — 역할(최고관리자·업체)과 업체를 함께 돌려준다',
|
||||
})
|
||||
@ApiStandardResponse(AdminResponseDto, { description: '조회 성공' })
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
current(@CurrentAdmin() admin: AdminResponseDto): AdminResponseDto {
|
||||
@@ -76,7 +82,7 @@ export class AdminAuthController {
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@UseGuards(AccountAuthGuard)
|
||||
@ApiBearerAuth(ADMIN_BEARER_AUTH)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@SkipTransform()
|
||||
|
||||
@@ -3,14 +3,28 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AdminAuthGuard } from '@/common/guards/admin-auth.guard';
|
||||
import {
|
||||
AccountAuthGuard,
|
||||
AdminAuthGuard,
|
||||
PartnerAuthGuard,
|
||||
} from '@/common/guards/admin-auth.guard';
|
||||
import { Partner } from '@/modules/partners/entities/partner.entity';
|
||||
import { AdminAccount } from './admin-account.entity';
|
||||
import { AdminAuthController } from './admin-auth.controller';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
import { AdminSession } from './admin-session.entity';
|
||||
import {
|
||||
AccountPasswordController,
|
||||
PartnerAccountsController,
|
||||
} from './partner-accounts.controller';
|
||||
import { PartnerAccountsService } from './partner-accounts.service';
|
||||
|
||||
const GUARDS = [AdminAuthGuard, PartnerAuthGuard, AccountAuthGuard];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AdminSession]),
|
||||
// 업체 계정이 업체에 속하는지 확인한다 (PartnersModule 을 가져오면 순환 의존이 되어 엔티티만 쓴다)
|
||||
TypeOrmModule.forFeature([AdminSession, AdminAccount, Partner]),
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
// 운영에서는 env 검증이 JWT_SECRET 을 필수로 요구한다.
|
||||
@@ -20,9 +34,13 @@ import { AdminSession } from './admin-session.entity';
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AdminAuthController],
|
||||
providers: [AdminAuthService, AdminAuthGuard],
|
||||
// 다른 모듈의 컨트롤러가 @UseGuards(AdminAuthGuard) 를 쓰려면 서비스와 가드가 필요하다
|
||||
exports: [AdminAuthService, AdminAuthGuard],
|
||||
controllers: [
|
||||
AdminAuthController,
|
||||
PartnerAccountsController,
|
||||
AccountPasswordController,
|
||||
],
|
||||
providers: [AdminAuthService, PartnerAccountsService, ...GUARDS],
|
||||
// 다른 모듈의 컨트롤러가 @UseGuards(...) 를 쓰려면 서비스와 가드가 필요하다
|
||||
exports: [AdminAuthService, ...GUARDS],
|
||||
})
|
||||
export class AdminAuthModule {}
|
||||
|
||||
@@ -1,16 +1,56 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { hashSync } from 'bcryptjs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { FindOperator } from 'typeorm';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import type { Partner } from '@/modules/partners/entities/partner.entity';
|
||||
import { AdminAccount } from './admin-account.entity';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
import { AdminSession } from './admin-session.entity';
|
||||
|
||||
const USERNAME = 'moneyguider';
|
||||
const PASSWORD = 'correct-horse-battery';
|
||||
const SECRET = 'test-secret';
|
||||
/** 테스트 픽스처는 bcrypt 비용을 낮춰 빠르게 만든다 (비교는 비용과 무관하게 동작한다) */
|
||||
const FIXTURE_ROUNDS = 4;
|
||||
const PARTNER = { id: 'partner-1', name: '머니튜브' } as Partner;
|
||||
const PARTNER_USERNAME = 'moneytube';
|
||||
const PARTNER_PASSWORD = 'temporary-pass-1';
|
||||
|
||||
function setup(env: Record<string, string> = {}) {
|
||||
function partnerAccount(overrides: Partial<AdminAccount> = {}): AdminAccount {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
username: PARTNER_USERNAME,
|
||||
passwordHash: hashSync(PARTNER_PASSWORD, FIXTURE_ROUNDS),
|
||||
partnerId: PARTNER.id,
|
||||
partner: PARTNER,
|
||||
isActive: true,
|
||||
mustChangePassword: true,
|
||||
lastLoginAt: null,
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** update 조건 흉내 — id(값 또는 Not), accountId 로 고른다 */
|
||||
function matches(row: AdminSession, where: Record<string, unknown>): boolean {
|
||||
const { id, accountId } = where;
|
||||
if (typeof id === 'string' && row.id !== id) return false;
|
||||
if (id instanceof FindOperator && id.type === 'not' && row.id === id.value) {
|
||||
return false;
|
||||
}
|
||||
if (typeof accountId === 'string' && row.accountId !== accountId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function setup(
|
||||
env: Record<string, string> = {},
|
||||
accountRows: AdminAccount[] = [],
|
||||
) {
|
||||
// env 는 테스트 중에 바꿀 수 있게 객체로 둔다 (비밀번호 변경 시나리오)
|
||||
const values: Record<string, string> = {
|
||||
ADMIN_USERNAME: USERNAME,
|
||||
@@ -32,20 +72,46 @@ function setup(env: Record<string, string> = {}) {
|
||||
findOne: jest.fn(({ where }: { where: { id: string } }) =>
|
||||
Promise.resolve(store.get(where.id) ?? null),
|
||||
),
|
||||
update: jest.fn(({ id }: { id: string }, patch: Partial<AdminSession>) => {
|
||||
const row = store.get(id);
|
||||
update: jest.fn(
|
||||
(where: Record<string, unknown>, patch: Partial<AdminSession>) => {
|
||||
for (const row of store.values()) {
|
||||
if (matches(row, where)) Object.assign(row, patch);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
),
|
||||
delete: jest.fn(() => Promise.resolve({ affected: 0 })),
|
||||
};
|
||||
|
||||
// 업체 계정 저장소 — 메모리
|
||||
const accountStore = new Map(accountRows.map((row) => [row.id, row]));
|
||||
const accounts = {
|
||||
findOne: jest.fn(
|
||||
({ where }: { where: { id?: string; username?: string } }) =>
|
||||
Promise.resolve(
|
||||
[...accountStore.values()].find((row) =>
|
||||
where.id ? row.id === where.id : row.username === where.username,
|
||||
) ?? null,
|
||||
),
|
||||
),
|
||||
update: jest.fn((id: string, patch: Partial<AdminAccount>) => {
|
||||
const row = accountStore.get(id);
|
||||
if (row) Object.assign(row, patch);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
delete: jest.fn(() => Promise.resolve({ affected: 0 })),
|
||||
save: jest.fn((row: AdminAccount) => {
|
||||
accountStore.set(row.id, row);
|
||||
return Promise.resolve(row);
|
||||
}),
|
||||
};
|
||||
|
||||
const service = new AdminAuthService(
|
||||
sessions as unknown as Repository<AdminSession>,
|
||||
accounts as unknown as Repository<AdminAccount>,
|
||||
new JwtService({ secret: SECRET }),
|
||||
config as unknown as ConfigService,
|
||||
);
|
||||
return { service, store, values };
|
||||
return { service, store, values, accountStore };
|
||||
}
|
||||
|
||||
describe('AdminAuthService (env 단일 계정)', () => {
|
||||
@@ -54,7 +120,12 @@ describe('AdminAuthService (env 단일 계정)', () => {
|
||||
|
||||
const session = await service.login(USERNAME, PASSWORD, 'Mozilla/5.0');
|
||||
|
||||
expect(session.admin).toEqual({ username: USERNAME });
|
||||
expect(session.admin).toEqual({
|
||||
username: USERNAME,
|
||||
role: 'super',
|
||||
partner: null,
|
||||
mustChangePassword: false,
|
||||
});
|
||||
expect(Date.parse(session.refreshTokenExpiresAt)).toBeGreaterThan(
|
||||
Date.parse(session.accessTokenExpiresAt),
|
||||
);
|
||||
@@ -63,8 +134,11 @@ describe('AdminAuthService (env 단일 계정)', () => {
|
||||
|
||||
const verified = await service.verifyAccessToken(session.accessToken);
|
||||
expect(verified).toEqual({
|
||||
admin: { username: USERNAME },
|
||||
admin: session.admin,
|
||||
sessionId: [...store.keys()][0],
|
||||
role: 'super',
|
||||
accountId: null,
|
||||
partnerId: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -206,3 +280,121 @@ describe('AdminAuthService (env 단일 계정)', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminAuthService (업체 계정)', () => {
|
||||
it('업체 계정으로 로그인하면 업체 역할·업체·임시 비밀번호 여부를 돌려준다 (아이디 대소문자 무시)', async () => {
|
||||
const account = partnerAccount();
|
||||
const { service, store } = setup({}, [account]);
|
||||
|
||||
const session = await service.login('MoneyTube', PARTNER_PASSWORD);
|
||||
|
||||
expect(session.admin).toEqual({
|
||||
username: PARTNER_USERNAME,
|
||||
role: 'partner',
|
||||
partner: { id: PARTNER.id, name: PARTNER.name },
|
||||
mustChangePassword: true,
|
||||
});
|
||||
expect([...store.values()][0].accountId).toBe(account.id);
|
||||
expect(account.lastLoginAt).toBeInstanceOf(Date);
|
||||
await expect(
|
||||
service.verifyAccessToken(session.accessToken),
|
||||
).resolves.toMatchObject({
|
||||
role: 'partner',
|
||||
accountId: account.id,
|
||||
partnerId: PARTNER.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('비밀번호가 틀리거나 중지된 계정·없는 아이디는 같은 AUTH_001, 세션을 만들지 않는다', async () => {
|
||||
const inactive = partnerAccount({ username: 'stopped', isActive: false });
|
||||
const { service, store } = setup({}, [partnerAccount(), inactive]);
|
||||
|
||||
for (const [username, password] of [
|
||||
[PARTNER_USERNAME, 'wrong-password'],
|
||||
['stopped', PARTNER_PASSWORD],
|
||||
['nobody', PARTNER_PASSWORD],
|
||||
]) {
|
||||
await expect(service.login(username, password)).rejects.toMatchObject({
|
||||
code: 'AUTH_001',
|
||||
});
|
||||
}
|
||||
expect(store.size).toBe(0);
|
||||
});
|
||||
|
||||
it('계정을 중지하면 이미 발급한 토큰도 바로 거부한다', async () => {
|
||||
const account = partnerAccount();
|
||||
const { service } = setup({}, [account]);
|
||||
const session = await service.login(PARTNER_USERNAME, PARTNER_PASSWORD);
|
||||
|
||||
account.isActive = false;
|
||||
|
||||
await expect(
|
||||
service.verifyAccessToken(session.accessToken),
|
||||
).rejects.toMatchObject({ code: 'AUTH_001' });
|
||||
await expect(service.renew(session.refreshToken)).rejects.toMatchObject({
|
||||
code: 'AUTH_001',
|
||||
});
|
||||
});
|
||||
|
||||
it('비밀번호를 바꾸면 이 기기는 새 토큰으로 이어 쓰고 다른 기기는 끊긴다', async () => {
|
||||
const account = partnerAccount();
|
||||
const { service } = setup({}, [account]);
|
||||
const deviceA = await service.login(PARTNER_USERNAME, PARTNER_PASSWORD);
|
||||
const deviceB = await service.login(PARTNER_USERNAME, PARTNER_PASSWORD);
|
||||
const verifiedA = await service.verifyAccessToken(deviceA.accessToken);
|
||||
|
||||
const renewed = await service.changePassword(
|
||||
verifiedA,
|
||||
PARTNER_PASSWORD,
|
||||
'my-own-new-password',
|
||||
);
|
||||
|
||||
expect(renewed.admin.mustChangePassword).toBe(false);
|
||||
await expect(
|
||||
service.verifyAccessToken(renewed.accessToken),
|
||||
).resolves.toMatchObject({ sessionId: verifiedA.sessionId });
|
||||
await expect(
|
||||
service.verifyAccessToken(deviceB.accessToken),
|
||||
).rejects.toMatchObject({ code: 'AUTH_001' });
|
||||
await expect(
|
||||
service.login(PARTNER_USERNAME, 'my-own-new-password'),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('현재 비밀번호가 틀리면 ACCOUNT_002, 최고관리자는 AUTH_003 (env 에서 바꾼다)', async () => {
|
||||
const { service } = setup({}, [partnerAccount()]);
|
||||
const partner = await service.verifyAccessToken(
|
||||
(await service.login(PARTNER_USERNAME, PARTNER_PASSWORD)).accessToken,
|
||||
);
|
||||
const admin = await service.verifyAccessToken(
|
||||
(await service.login(USERNAME, PASSWORD)).accessToken,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.changePassword(partner, 'wrong-current', 'my-own-new-password'),
|
||||
).rejects.toMatchObject({ code: 'ACCOUNT_002' });
|
||||
await expect(
|
||||
service.changePassword(admin, PASSWORD, 'my-own-new-password'),
|
||||
).rejects.toMatchObject({ code: 'AUTH_003' });
|
||||
});
|
||||
|
||||
it('업체 토큰의 역할을 최고관리자로 바꿔 쓸 수 없다', async () => {
|
||||
const { service } = setup({}, [partnerAccount()]);
|
||||
const session = await service.login(PARTNER_USERNAME, PARTNER_PASSWORD);
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(session.accessToken.split('.')[1], 'base64url').toString(),
|
||||
) as Record<string, unknown>;
|
||||
|
||||
// 서명키를 아는 상황을 가정해도, 업체 지문으로는 최고관리자 확인을 통과하지 못한다
|
||||
const forged = new JwtService({ secret: SECRET }).sign({
|
||||
sub: USERNAME,
|
||||
typ: 'access',
|
||||
role: 'super',
|
||||
cred: payload.cred,
|
||||
sid: payload.sid,
|
||||
});
|
||||
await expect(service.verifyAccessToken(forged)).rejects.toMatchObject({
|
||||
code: 'AUTH_001',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,16 +2,20 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { compare, hash } from 'bcryptjs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { LessThan, Repository } from 'typeorm';
|
||||
import { IsNull, LessThan, Not, Repository } from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { credentialFingerprint, safeEqual } from '@/shared/utils/credential';
|
||||
import { AdminAccount } from './admin-account.entity';
|
||||
import {
|
||||
ADMIN_PASSWORD_KEY,
|
||||
ADMIN_USERNAME_KEY,
|
||||
AccountRole,
|
||||
AdminTokenPayload,
|
||||
AdminTokenType,
|
||||
BCRYPT_ROUNDS,
|
||||
accessTokenTtlMs,
|
||||
refreshTokenTtlMs,
|
||||
} from './admin-auth.constants';
|
||||
@@ -25,54 +29,61 @@ const USER_AGENT_MAX_LENGTH = 200;
|
||||
const ENDED_SESSION_RETENTION_MS = 7 * 86_400_000;
|
||||
|
||||
/** 가드가 확인한 요청의 주인 */
|
||||
export interface VerifiedAdmin {
|
||||
export interface VerifiedAccount {
|
||||
admin: AdminResponseDto;
|
||||
sessionId: string;
|
||||
role: AccountRole;
|
||||
/** [업체 계정] 계정 ID — 최고관리자면 null */
|
||||
accountId: string | null;
|
||||
/** [업체 계정] 업체 ID — 최고관리자면 null. 업체 화면은 이 값으로만 데이터를 거른다 */
|
||||
partnerId: string | null;
|
||||
}
|
||||
|
||||
interface AdminAccount {
|
||||
username: string;
|
||||
password: string;
|
||||
/** 현재 env 계정의 지문 — 토큰의 cred 와 같아야 유효하다 */
|
||||
fingerprint: string;
|
||||
/** 토큰의 주인 — 최고관리자(env) 또는 업체 계정(DB) */
|
||||
type Principal =
|
||||
| { role: 'super'; username: string; fingerprint: string }
|
||||
| { role: 'partner'; account: AdminAccount; fingerprint: string };
|
||||
|
||||
/** 아이디는 대소문자를 가리지 않는다 — 저장·조회 모두 소문자로 */
|
||||
export function normalizeUsername(username: string): string {
|
||||
return username.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 인증 — env(ADMIN_USERNAME / ADMIN_PASSWORD)에 둔 단일 계정.
|
||||
* 계정 인증 — 최고관리자(env ADMIN_USERNAME / ADMIN_PASSWORD)와 업체 계정(admin_accounts).
|
||||
*
|
||||
* 계정 하나를 여러 사람이 함께 쓰는 것을 전제로, 로그인한 기기마다 세션을 따로 둔다.
|
||||
* 최고관리자 계정 하나를 여러 사람이 함께 쓰는 것을 전제로, 로그인한 기기마다 세션을 따로 둔다.
|
||||
* - 로그아웃: 그 기기의 세션만 끊는다
|
||||
* - env 비밀번호를 바꾸고 재시작: 계정 지문이 달라져 모든 기기가 끊긴다
|
||||
* - 비밀번호 변경(env 는 재시작): 계정 지문이 달라져 그 계정의 모든 기기가 끊긴다
|
||||
* - 업체 계정 중지: 그 계정의 세션을 모두 끊는다
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminAuthService {
|
||||
private readonly logger = new Logger(AdminAuthService.name);
|
||||
/** 없는 아이디도 bcrypt 비교를 거치게 하는 해시 — 응답 시간으로 아이디 존재를 알 수 없게 */
|
||||
private dummyHash: Promise<string> | null = null;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AdminSession)
|
||||
private readonly sessions: Repository<AdminSession>,
|
||||
@InjectRepository(AdminAccount)
|
||||
private readonly accounts: Repository<AdminAccount>,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
/** 아이디·비밀번호를 env 계정과 비교하고 새 세션을 연다. 실패 원인은 구분하지 않는다. */
|
||||
/**
|
||||
* 아이디·비밀번호로 새 세션을 연다 — env 최고관리자 계정을 먼저 보고, 아니면 업체 계정.
|
||||
* 실패 원인(아이디 없음·비밀번호 틀림·중지됨)은 구분하지 않는다.
|
||||
*/
|
||||
async login(
|
||||
username: string,
|
||||
password: string,
|
||||
userAgent?: string,
|
||||
): Promise<AdminSessionDto> {
|
||||
const account = this.account();
|
||||
// 둘 다 항상 비교해 어느 쪽이 틀렸는지 응답 시간으로 드러나지 않게 한다
|
||||
const usernameMatched = safeEqual(username, account?.username ?? '');
|
||||
const passwordMatched = safeEqual(password, account?.password ?? '');
|
||||
|
||||
if (!account || !usernameMatched || !passwordMatched) {
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: {
|
||||
reason: account ? 'invalid_credentials' : 'admin_not_configured',
|
||||
},
|
||||
});
|
||||
}
|
||||
const principal =
|
||||
this.matchSuperAdmin(username, password) ??
|
||||
(await this.matchPartnerAccount(username, password));
|
||||
|
||||
await this.removeEndedSessions();
|
||||
|
||||
@@ -80,52 +91,172 @@ export class AdminAuthService {
|
||||
const session = await this.sessions.save(
|
||||
this.sessions.create({
|
||||
id: randomUUID(),
|
||||
accountId: principal.role === 'partner' ? principal.account.id : null,
|
||||
userAgent: userAgent?.slice(0, USER_AGENT_MAX_LENGTH) || null,
|
||||
lastUsedAt: new Date(now),
|
||||
expiresAt: new Date(now + refreshTokenTtlMs()),
|
||||
revokedAt: null,
|
||||
}),
|
||||
);
|
||||
this.logger.log(`관리자 로그인: session=${session.id}`);
|
||||
if (principal.role === 'partner') {
|
||||
await this.accounts.update(principal.account.id, {
|
||||
lastLoginAt: new Date(now),
|
||||
});
|
||||
this.logger.log(
|
||||
`업체 계정 로그인: account=${principal.account.id} session=${session.id}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.log(`관리자 로그인: session=${session.id}`);
|
||||
}
|
||||
|
||||
return this.issue(account, session.id, now);
|
||||
return this.issue(principal, session.id, now);
|
||||
}
|
||||
|
||||
/** 리프레시 토큰으로 같은 세션의 새 토큰 쌍을 발급하고 세션 만료를 늦춘다. */
|
||||
async renew(refreshToken: string): Promise<AdminSessionDto> {
|
||||
const payload = await this.verify(refreshToken, 'refresh');
|
||||
const { account, session } = await this.findActive(payload);
|
||||
const { principal, session } = await this.findActive(payload);
|
||||
|
||||
const now = Date.now();
|
||||
session.lastUsedAt = new Date(now);
|
||||
session.expiresAt = new Date(now + refreshTokenTtlMs());
|
||||
await this.sessions.save(session);
|
||||
|
||||
return this.issue(account, session.id, now);
|
||||
return this.issue(principal, session.id, now);
|
||||
}
|
||||
|
||||
/** 로그아웃 — 이 세션만 끊는다. 같은 계정으로 로그인한 다른 기기는 그대로다. */
|
||||
async logout(sessionId: string): Promise<void> {
|
||||
await this.sessions.update({ id: sessionId }, { revokedAt: new Date() });
|
||||
this.logger.log(`관리자 로그아웃: session=${sessionId}`);
|
||||
this.logger.log(`로그아웃: session=${sessionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 액세스 토큰을 검증한다 — 가드에서 쓴다.
|
||||
* 서명·만료뿐 아니라 세션과 계정 지문도 확인해, 로그아웃·비밀번호 변경이 바로 반영된다.
|
||||
* 서명·만료뿐 아니라 세션과 계정 지문·계정 상태도 확인해, 로그아웃·비밀번호 변경·중지가 바로 반영된다.
|
||||
* (토큰이 페이지 스크립트에 노출되는 Bearer 방식이라 즉시 무효화가 필요하다)
|
||||
*/
|
||||
async verifyAccessToken(token: string | null): Promise<VerifiedAdmin> {
|
||||
async verifyAccessToken(token: string | null): Promise<VerifiedAccount> {
|
||||
const payload = await this.verify(token, 'access');
|
||||
const { account, session } = await this.findActive(payload);
|
||||
return { admin: { username: account.username }, sessionId: session.id };
|
||||
const { principal, session } = await this.findActive(payload);
|
||||
return {
|
||||
admin: this.toResponse(principal),
|
||||
sessionId: session.id,
|
||||
role: principal.role,
|
||||
accountId: principal.role === 'partner' ? principal.account.id : null,
|
||||
partnerId:
|
||||
principal.role === 'partner' ? principal.account.partnerId : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** env 계정. 아이디·비밀번호가 비어 있으면 null — 아무도 로그인할 수 없다. */
|
||||
private account(): AdminAccount | null {
|
||||
/**
|
||||
* 업체 계정 비밀번호 변경. 바꾸면 계정 지문이 달라져 다른 기기의 세션은 끊기고,
|
||||
* 지금 세션에는 새 토큰을 발급해 그대로 이어서 쓰게 한다.
|
||||
*/
|
||||
async changePassword(
|
||||
verified: VerifiedAccount,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<AdminSessionDto> {
|
||||
if (verified.role !== 'partner' || !verified.accountId) {
|
||||
// 최고관리자 비밀번호는 env 에서 바꾼다
|
||||
throw new BusinessException('AUTH_003', {
|
||||
context: { reason: 'super_admin_password_in_env' },
|
||||
});
|
||||
}
|
||||
const account = await this.accounts.findOne({
|
||||
where: { id: verified.accountId },
|
||||
relations: { partner: true },
|
||||
});
|
||||
if (!account) throw this.rejected('account_not_found');
|
||||
if (!(await compare(currentPassword, account.passwordHash))) {
|
||||
throw new BusinessException('ACCOUNT_002', {
|
||||
context: { accountId: account.id },
|
||||
});
|
||||
}
|
||||
|
||||
account.passwordHash = await hash(newPassword, BCRYPT_ROUNDS);
|
||||
account.mustChangePassword = false;
|
||||
await this.accounts.save(account);
|
||||
// 다른 기기의 세션은 지문이 달라져 어차피 무효다 — 기록도 끊긴 것으로 남긴다
|
||||
await this.sessions.update(
|
||||
{
|
||||
accountId: account.id,
|
||||
id: Not(verified.sessionId),
|
||||
revokedAt: IsNull(),
|
||||
},
|
||||
{ revokedAt: new Date() },
|
||||
);
|
||||
this.logger.log(`업체 계정 비밀번호 변경: account=${account.id}`);
|
||||
|
||||
return this.issue(
|
||||
{ role: 'partner', account, fingerprint: this.fingerprintOf(account) },
|
||||
verified.sessionId,
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/** 업체 계정의 열린 세션을 모두 끊는다 — 계정 중지·비밀번호 초기화 때 */
|
||||
async revokeAccountSessions(accountId: string): Promise<void> {
|
||||
await this.sessions.update(
|
||||
{ accountId, revokedAt: IsNull() },
|
||||
{ revokedAt: new Date() },
|
||||
);
|
||||
}
|
||||
|
||||
/** env 최고관리자 계정과 맞으면 그 주인. 어느 쪽이 틀렸는지 응답 시간으로 드러나지 않게 둘 다 비교한다 */
|
||||
private matchSuperAdmin(
|
||||
username: string,
|
||||
password: string,
|
||||
): Principal | null {
|
||||
const account = this.superAccount();
|
||||
const usernameMatched = safeEqual(username, account?.username ?? '');
|
||||
const passwordMatched = safeEqual(password, account?.password ?? '');
|
||||
if (!account || !usernameMatched || !passwordMatched) return null;
|
||||
return {
|
||||
role: 'super',
|
||||
username: account.username,
|
||||
fingerprint: account.fingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
/** 업체 계정과 맞으면 그 주인, 아니면 AUTH_001. 없는 아이디도 bcrypt 비교를 거친다 */
|
||||
private async matchPartnerAccount(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<Principal> {
|
||||
const account = await this.accounts.findOne({
|
||||
where: { username: normalizeUsername(username) },
|
||||
relations: { partner: true },
|
||||
});
|
||||
const matched = await compare(
|
||||
password,
|
||||
account?.passwordHash ?? (await this.getDummyHash()),
|
||||
);
|
||||
if (!account || !matched || !account.isActive || !account.partner) {
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: {
|
||||
reason:
|
||||
account && matched ? 'account_inactive' : 'invalid_credentials',
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
role: 'partner',
|
||||
account,
|
||||
fingerprint: this.fingerprintOf(account),
|
||||
};
|
||||
}
|
||||
|
||||
/** env 최고관리자 계정. 아이디·비밀번호·서명키 중 하나라도 비어 있으면 null — 최고관리자 로그인 불가 */
|
||||
private superAccount(): {
|
||||
username: string;
|
||||
password: string;
|
||||
fingerprint: string;
|
||||
} | null {
|
||||
const username = (this.config.get<string>(ADMIN_USERNAME_KEY) ?? '').trim();
|
||||
const password = this.config.get<string>(ADMIN_PASSWORD_KEY) ?? '';
|
||||
const secret = this.config.get<string>('JWT_SECRET') ?? '';
|
||||
const secret = this.secret();
|
||||
if (!username || !password || !secret) {
|
||||
this.logger.warn(
|
||||
`관리자 계정이 설정되지 않았습니다 — backend/.env 의 ${ADMIN_USERNAME_KEY}·${ADMIN_PASSWORD_KEY}·JWT_SECRET 을 확인하세요`,
|
||||
@@ -139,29 +270,84 @@ export class AdminAuthService {
|
||||
};
|
||||
}
|
||||
|
||||
/** 업체 계정 지문 — 비밀번호(해시)가 바뀌면 달라진다. 비밀키 HMAC 이라 토큰으로 해시를 알 수 없다 */
|
||||
private fingerprintOf(account: AdminAccount): string {
|
||||
return credentialFingerprint(
|
||||
account.id,
|
||||
account.passwordHash,
|
||||
this.secret(),
|
||||
);
|
||||
}
|
||||
|
||||
private secret(): string {
|
||||
return this.config.get<string>('JWT_SECRET') ?? '';
|
||||
}
|
||||
|
||||
private getDummyHash(): Promise<string> {
|
||||
this.dummyHash ??= hash(randomUUID(), BCRYPT_ROUNDS);
|
||||
return this.dummyHash;
|
||||
}
|
||||
|
||||
/** 토큰이 가리키는 세션과 계정이 아직 유효한지 확인한다. */
|
||||
private async findActive(
|
||||
payload: AdminTokenPayload,
|
||||
): Promise<{ account: AdminAccount; session: AdminSession }> {
|
||||
const account = this.account();
|
||||
// env 아이디·비밀번호가 바뀌었으면 이전에 발급한 모든 토큰이 무효다
|
||||
): Promise<{ principal: Principal; session: AdminSession }> {
|
||||
if (typeof payload.cred !== 'string') {
|
||||
throw this.rejected('credential_changed');
|
||||
}
|
||||
const principal =
|
||||
(payload.role ?? 'super') === 'super'
|
||||
? this.activeSuperAdmin(payload)
|
||||
: await this.activePartnerAccount(payload);
|
||||
|
||||
if (!payload.sid) throw this.rejected('no_session');
|
||||
const session = await this.sessions.findOne({ where: { id: payload.sid } });
|
||||
if (!session) throw this.rejected('no_session');
|
||||
// 다른 계정의 세션을 빌려 쓰지 못하게 한다
|
||||
const expectedAccountId =
|
||||
principal.role === 'partner' ? principal.account.id : null;
|
||||
if (session.accountId !== expectedAccountId) {
|
||||
throw this.rejected('session_account_mismatch');
|
||||
}
|
||||
if (session.revokedAt) throw this.rejected('logged_out');
|
||||
if (session.expiresAt.getTime() <= Date.now()) {
|
||||
throw this.rejected('session_expired');
|
||||
}
|
||||
return { principal, session };
|
||||
}
|
||||
|
||||
/** env 아이디·비밀번호가 바뀌었으면 이전에 발급한 모든 토큰이 무효다 */
|
||||
private activeSuperAdmin(payload: AdminTokenPayload): Principal {
|
||||
const account = this.superAccount();
|
||||
if (
|
||||
!account ||
|
||||
typeof payload.cred !== 'string' ||
|
||||
payload.sub !== account.username ||
|
||||
!safeEqual(payload.cred, account.fingerprint)
|
||||
) {
|
||||
throw this.rejected('credential_changed');
|
||||
}
|
||||
if (!payload.sid) throw this.rejected('no_session');
|
||||
return {
|
||||
role: 'super',
|
||||
username: account.username,
|
||||
fingerprint: account.fingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
const session = await this.sessions.findOne({ where: { id: payload.sid } });
|
||||
if (!session) throw this.rejected('no_session');
|
||||
if (session.revokedAt) throw this.rejected('logged_out');
|
||||
if (session.expiresAt.getTime() <= Date.now()) {
|
||||
throw this.rejected('session_expired');
|
||||
/** 계정이 중지·삭제됐거나 비밀번호가 바뀌었으면 무효다 */
|
||||
private async activePartnerAccount(
|
||||
payload: AdminTokenPayload,
|
||||
): Promise<Principal> {
|
||||
const account = await this.accounts.findOne({
|
||||
where: { id: payload.sub },
|
||||
relations: { partner: true },
|
||||
});
|
||||
if (!account || !account.partner) throw this.rejected('account_not_found');
|
||||
if (!account.isActive) throw this.rejected('account_inactive');
|
||||
const fingerprint = this.fingerprintOf(account);
|
||||
if (!safeEqual(payload.cred, fingerprint)) {
|
||||
throw this.rejected('credential_changed');
|
||||
}
|
||||
return { account, session };
|
||||
return { role: 'partner', account, fingerprint };
|
||||
}
|
||||
|
||||
private rejected(reason: string): BusinessException {
|
||||
@@ -175,14 +361,36 @@ export class AdminAuthService {
|
||||
await this.sessions.delete({ revokedAt: LessThan(cutoff) });
|
||||
}
|
||||
|
||||
private toResponse(principal: Principal): AdminResponseDto {
|
||||
if (principal.role === 'super') {
|
||||
return {
|
||||
username: principal.username,
|
||||
role: 'super',
|
||||
partner: null,
|
||||
mustChangePassword: false,
|
||||
};
|
||||
}
|
||||
const { account } = principal;
|
||||
return {
|
||||
username: account.username,
|
||||
role: 'partner',
|
||||
partner: account.partner
|
||||
? { id: account.partner.id, name: account.partner.name }
|
||||
: null,
|
||||
mustChangePassword: account.mustChangePassword,
|
||||
};
|
||||
}
|
||||
|
||||
private async issue(
|
||||
account: AdminAccount,
|
||||
principal: Principal,
|
||||
sessionId: string,
|
||||
now: number,
|
||||
): Promise<AdminSessionDto> {
|
||||
const base = {
|
||||
sub: account.username,
|
||||
cred: account.fingerprint,
|
||||
sub:
|
||||
principal.role === 'super' ? principal.username : principal.account.id,
|
||||
role: principal.role,
|
||||
cred: principal.fingerprint,
|
||||
sid: sessionId,
|
||||
};
|
||||
const accessTtlMs = accessTokenTtlMs();
|
||||
@@ -199,7 +407,7 @@ export class AdminAuthService {
|
||||
),
|
||||
]);
|
||||
return {
|
||||
admin: { username: account.username },
|
||||
admin: this.toResponse(principal),
|
||||
accessToken,
|
||||
accessTokenExpiresAt: new Date(now + accessTtlMs).toISOString(),
|
||||
refreshToken,
|
||||
|
||||
@@ -3,15 +3,20 @@ import {
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { AdminAccount } from './admin-account.entity';
|
||||
|
||||
/**
|
||||
* 관리자 로그인 세션 — 로그인한 기기(브라우저)마다 한 행.
|
||||
* 로그인 세션 — 로그인한 기기(브라우저)마다 한 행.
|
||||
*
|
||||
* 관리자 계정(env 단일 계정) 하나를 여러 사람이 함께 쓰므로, 로그아웃은 그 기기의 세션만 끊어야 한다.
|
||||
* 최고관리자(env 단일 계정)는 여러 사람이 함께 쓰므로, 로그아웃은 그 기기의 세션만 끊어야 한다.
|
||||
* (계정 단위로 끊으면 한 사람이 로그아웃할 때 모두가 함께 로그아웃된다)
|
||||
* 모든 세션을 한 번에 끊는 것은 env 비밀번호 변경(토큰의 계정 지문)이 맡는다.
|
||||
* 모든 세션을 한 번에 끊는 것은 비밀번호 변경(토큰의 계정 지문)이 맡는다.
|
||||
* 업체 계정 세션은 accountId 로 계정과 잇는다 — 계정을 중지하면 그 계정의 세션을 모두 끊는다.
|
||||
*/
|
||||
@Entity({ name: 'admin_sessions' })
|
||||
export class AdminSession {
|
||||
@@ -19,6 +24,15 @@ export class AdminSession {
|
||||
@PrimaryColumn({ name: 'id', type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
/** 업체 계정 — 최고관리자(env 계정) 세션이면 null */
|
||||
@Index()
|
||||
@Column({ name: 'account_id', type: 'uuid', nullable: true })
|
||||
accountId!: string | null;
|
||||
|
||||
@ManyToOne(() => AdminAccount, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'account_id' })
|
||||
account?: AdminAccount;
|
||||
|
||||
/** 어느 브라우저에서 로그인했는지 — 확인·감사용 (개인정보 아님, 길이 제한) */
|
||||
@Column({ name: 'user_agent', type: 'varchar', length: 200, nullable: true })
|
||||
userAgent!: string | null;
|
||||
|
||||
@@ -1,10 +1,43 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/** 로그인한 관리자 */
|
||||
import { ACCOUNT_ROLES } from '../admin-auth.constants';
|
||||
import type { AccountRole } from '../admin-auth.constants';
|
||||
|
||||
/** 업체 계정이 속한 업체 */
|
||||
export class AdminPartnerDto {
|
||||
@ApiProperty({ description: '업체 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '업체 이름', example: '머니튜브' })
|
||||
name!: string;
|
||||
}
|
||||
|
||||
/** 로그인한 계정 */
|
||||
export class AdminResponseDto {
|
||||
@ApiProperty({
|
||||
description: '관리자 아이디 (env ADMIN_USERNAME)',
|
||||
description: '아이디 (최고관리자는 env ADMIN_USERNAME)',
|
||||
example: 'admin',
|
||||
})
|
||||
username!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '역할 — super(최고관리자) · partner(업체 계정)',
|
||||
enum: ACCOUNT_ROLES,
|
||||
example: 'super',
|
||||
})
|
||||
role!: AccountRole;
|
||||
|
||||
@ApiProperty({
|
||||
description: '[업체 계정] 속한 업체 — 최고관리자면 null',
|
||||
type: AdminPartnerDto,
|
||||
nullable: true,
|
||||
})
|
||||
partner!: AdminPartnerDto | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'[업체 계정] 임시 비밀번호를 바꿔야 하는가 — 바꾸기 전에는 실적을 볼 수 없다',
|
||||
example: false,
|
||||
})
|
||||
mustChangePassword!: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import {
|
||||
ACCOUNT_PASSWORD_MAX_LENGTH,
|
||||
ACCOUNT_PASSWORD_MIN_LENGTH,
|
||||
} from '../admin-auth.constants';
|
||||
|
||||
/** 영문·숫자로 시작하고 영문·숫자·. _ - 만, 4~30자 (저장은 소문자) */
|
||||
const USERNAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{3,29}$/;
|
||||
|
||||
/** 업체 계정 목록 조회 */
|
||||
export class PartnerAccountListQueryDto extends CursorPaginationQueryDto {}
|
||||
|
||||
/** 업체 계정 만들기 — 비밀번호는 서버가 만들어 한 번만 돌려준다 */
|
||||
export class CreatePartnerAccountDto {
|
||||
@ApiProperty({
|
||||
description: '로그인 아이디 — 영문·숫자·. _ - 4~30자 (대소문자 구분 없음)',
|
||||
example: 'moneytube',
|
||||
})
|
||||
@IsString()
|
||||
@Matches(USERNAME_PATTERN, {
|
||||
message:
|
||||
'아이디는 영문·숫자로 시작하는 4~30자(영문·숫자·. _ -)로 입력해 주세요.',
|
||||
})
|
||||
username!: string;
|
||||
}
|
||||
|
||||
/** 업체 계정 수정 — 지금은 사용/중지만 */
|
||||
export class UpdatePartnerAccountDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'사용 여부 — false 로 바꾸면 그 계정의 모든 기기가 로그아웃된다',
|
||||
example: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: '사용 여부 값이 올바르지 않습니다.' })
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/** 업체 계정 한 개 — 비밀번호(해시 포함)는 내려주지 않는다 */
|
||||
export class PartnerAccountResponseDto {
|
||||
@ApiProperty({ description: '계정 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '로그인 아이디 (소문자)', example: 'moneytube' })
|
||||
username!: string;
|
||||
|
||||
@ApiProperty({ description: '업체 ID' })
|
||||
partnerId!: string;
|
||||
|
||||
@ApiProperty({ description: '사용 여부', example: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '임시 비밀번호를 아직 바꾸지 않았는가',
|
||||
example: true,
|
||||
})
|
||||
mustChangePassword!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '마지막 로그인 시각 — 한 번도 안 했으면 null',
|
||||
nullable: true,
|
||||
example: '2026-10-06T05:00:00.000Z',
|
||||
})
|
||||
lastLoginAt!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '만든 시각',
|
||||
example: '2026-10-06T05:00:00.000Z',
|
||||
})
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
/** 만들기·초기화 결과 — 임시 비밀번호는 이 응답에서 한 번만 볼 수 있다 */
|
||||
export class PartnerAccountCredentialDto {
|
||||
@ApiProperty({ description: '업체 계정', type: PartnerAccountResponseDto })
|
||||
account!: PartnerAccountResponseDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'임시 비밀번호 — 다시 볼 수 없다. 업체가 첫 로그인 때 직접 바꿔야 실적을 볼 수 있다',
|
||||
example: 'Kp7mXz3qRt9wHc',
|
||||
})
|
||||
temporaryPassword!: string;
|
||||
}
|
||||
|
||||
/** 비밀번호 변경 (업체 계정 본인) */
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty({ description: '현재 비밀번호 (임시 비밀번호 포함)' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '현재 비밀번호를 입력해 주세요.' })
|
||||
@MaxLength(ACCOUNT_PASSWORD_MAX_LENGTH, {
|
||||
message: '비밀번호가 너무 깁니다.',
|
||||
})
|
||||
currentPassword!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: `새 비밀번호 — ${ACCOUNT_PASSWORD_MIN_LENGTH}자 이상`,
|
||||
example: 'a-new-long-password',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(ACCOUNT_PASSWORD_MIN_LENGTH, {
|
||||
message: `새 비밀번호는 ${ACCOUNT_PASSWORD_MIN_LENGTH}자 이상이어야 합니다.`,
|
||||
})
|
||||
@MaxLength(ACCOUNT_PASSWORD_MAX_LENGTH, {
|
||||
message: '비밀번호가 너무 깁니다.',
|
||||
})
|
||||
newPassword!: string;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { CurrentAccount } from '@/common/decorators/current-admin.decorator';
|
||||
import { PaginatedResult } from '@/common/dto/pagination.dto';
|
||||
import {
|
||||
AccountAuthGuard,
|
||||
AdminAuthGuard,
|
||||
} from '@/common/guards/admin-auth.guard';
|
||||
import { ADMIN_BEARER_AUTH, LOGIN_THROTTLE } from './admin-auth.constants';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
import type { VerifiedAccount } from './admin-auth.service';
|
||||
import { AdminSessionDto } from './dto/admin-session.dto';
|
||||
import {
|
||||
ChangePasswordDto,
|
||||
CreatePartnerAccountDto,
|
||||
PartnerAccountCredentialDto,
|
||||
PartnerAccountListQueryDto,
|
||||
PartnerAccountResponseDto,
|
||||
UpdatePartnerAccountDto,
|
||||
} from './dto/partner-account.dto';
|
||||
import { PartnerAccountsService } from './partner-accounts.service';
|
||||
|
||||
/** 업체 계정 관리 — 최고관리자 전용 */
|
||||
@ApiTags('Admin Partner Accounts')
|
||||
@ApiBearerAuth(ADMIN_BEARER_AUTH)
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Controller('admin')
|
||||
export class PartnerAccountsController {
|
||||
constructor(private readonly accounts: PartnerAccountsService) {}
|
||||
|
||||
@Get('partners/:partnerId/accounts')
|
||||
@ApiOperation({ summary: '업체 계정 목록 (최근 만든 순)' })
|
||||
@ApiStandardResponse(PartnerAccountResponseDto, {
|
||||
description: '조회 성공 — data 는 { items, pageInfo }',
|
||||
isArray: true,
|
||||
})
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('AUTH_003', '최고관리자가 아님')
|
||||
@ApiErrorResponse('RES_001', '없는 업체')
|
||||
findMany(
|
||||
@Param('partnerId', new ParseUUIDPipe()) partnerId: string,
|
||||
@Query() query: PartnerAccountListQueryDto,
|
||||
): Promise<PaginatedResult<PartnerAccountResponseDto>> {
|
||||
return this.accounts.findMany(partnerId, query);
|
||||
}
|
||||
|
||||
@Post('partners/:partnerId/accounts')
|
||||
@ApiOperation({
|
||||
summary: '업체 계정 만들기 — 임시 비밀번호는 이 응답에서만 볼 수 있다',
|
||||
})
|
||||
@ApiStandardResponse(PartnerAccountCredentialDto, {
|
||||
status: 201,
|
||||
description: '생성 성공',
|
||||
})
|
||||
@ApiErrorResponse('AUTH_003', '최고관리자가 아님')
|
||||
@ApiErrorResponse('RES_001', '없는 업체')
|
||||
@ApiErrorResponse('ACCOUNT_001', '이미 쓰는 아이디')
|
||||
@ApiErrorResponse('VAL_001', '아이디 형식 오류')
|
||||
create(
|
||||
@Param('partnerId', new ParseUUIDPipe()) partnerId: string,
|
||||
@Body() dto: CreatePartnerAccountDto,
|
||||
): Promise<PartnerAccountCredentialDto> {
|
||||
return this.accounts.create(partnerId, dto);
|
||||
}
|
||||
|
||||
@Patch('partner-accounts/:id')
|
||||
@ApiOperation({
|
||||
summary: '업체 계정 사용/중지 — 중지하면 모든 기기 로그아웃',
|
||||
})
|
||||
@ApiStandardResponse(PartnerAccountResponseDto, { description: '수정 성공' })
|
||||
@ApiErrorResponse('AUTH_003', '최고관리자가 아님')
|
||||
@ApiErrorResponse('RES_001', '없는 계정')
|
||||
update(
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
@Body() dto: UpdatePartnerAccountDto,
|
||||
): Promise<PartnerAccountResponseDto> {
|
||||
return this.accounts.update(id, dto);
|
||||
}
|
||||
|
||||
@Post('partner-accounts/:id/password-resets')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'업체 계정 비밀번호 초기화 — 새 임시 비밀번호를 한 번만 돌려주고 모든 기기 로그아웃',
|
||||
})
|
||||
@ApiStandardResponse(PartnerAccountCredentialDto, {
|
||||
description: '초기화 성공',
|
||||
})
|
||||
@ApiErrorResponse('AUTH_003', '최고관리자가 아님')
|
||||
@ApiErrorResponse('RES_001', '없는 계정')
|
||||
resetPassword(
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
): Promise<PartnerAccountCredentialDto> {
|
||||
return this.accounts.resetPassword(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** 로그인한 계정 본인 — 비밀번호 변경 (업체 계정만. 최고관리자는 env 에서 바꾼다) */
|
||||
@ApiTags('Admin Session')
|
||||
@ApiBearerAuth(ADMIN_BEARER_AUTH)
|
||||
@UseGuards(AccountAuthGuard)
|
||||
@Controller('admin/accounts/current')
|
||||
export class AccountPasswordController {
|
||||
constructor(private readonly auth: AdminAuthService) {}
|
||||
|
||||
@Put('password')
|
||||
@Throttle(LOGIN_THROTTLE)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'비밀번호 변경 — 다른 기기는 로그아웃되고, 이 기기에는 새 토큰을 돌려준다',
|
||||
})
|
||||
@ApiStandardResponse(AdminSessionDto, { description: '변경 성공' })
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('AUTH_003', '최고관리자 (env 에서 바꾼다)')
|
||||
@ApiErrorResponse('ACCOUNT_002', '현재 비밀번호 불일치')
|
||||
@ApiErrorResponse('VAL_001', '새 비밀번호 형식 오류')
|
||||
@ApiErrorResponse('REQ_001', '시도 횟수 초과 (1분 5회)')
|
||||
changePassword(
|
||||
@CurrentAccount() account: VerifiedAccount,
|
||||
@Body() dto: ChangePasswordDto,
|
||||
): Promise<AdminSessionDto> {
|
||||
return this.auth.changePassword(
|
||||
account,
|
||||
dto.currentPassword,
|
||||
dto.newPassword,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import { compareSync } from 'bcryptjs';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import type { Partner } from '@/modules/partners/entities/partner.entity';
|
||||
import { AdminAccount } from './admin-account.entity';
|
||||
import type { AdminAuthService } from './admin-auth.service';
|
||||
import { PartnerAccountsService } from './partner-accounts.service';
|
||||
|
||||
const PARTNER_ID = '11111111-1111-4111-8111-111111111111';
|
||||
|
||||
function setup(options: { partnerExists?: boolean } = {}) {
|
||||
const inserted: AdminAccount[] = [];
|
||||
const store = new Map<string, AdminAccount>();
|
||||
const accounts = {
|
||||
create: jest.fn((row: Partial<AdminAccount>) => row as AdminAccount),
|
||||
insert: jest.fn((row: AdminAccount) => {
|
||||
inserted.push(row);
|
||||
store.set(row.id, { ...row, createdAt: new Date() });
|
||||
return Promise.resolve();
|
||||
}),
|
||||
findOne: jest.fn(({ where }: { where: { id: string } }) =>
|
||||
Promise.resolve(store.get(where.id) ?? null),
|
||||
),
|
||||
save: jest.fn((row: AdminAccount) => {
|
||||
store.set(row.id, row);
|
||||
return Promise.resolve(row);
|
||||
}),
|
||||
};
|
||||
const partners = {
|
||||
exists: jest.fn(() => Promise.resolve(options.partnerExists ?? true)),
|
||||
};
|
||||
const auth = { revokeAccountSessions: jest.fn(() => Promise.resolve()) };
|
||||
const config = {
|
||||
get: (key: string) => (key === 'ADMIN_USERNAME' ? 'Admin' : ''),
|
||||
};
|
||||
|
||||
const service = new PartnerAccountsService(
|
||||
accounts as unknown as Repository<AdminAccount>,
|
||||
partners as unknown as Repository<Partner>,
|
||||
auth as unknown as AdminAuthService,
|
||||
config as unknown as ConfigService,
|
||||
);
|
||||
return { service, accounts, inserted, store, auth };
|
||||
}
|
||||
|
||||
describe('PartnerAccountsService', () => {
|
||||
it('계정을 만들면 임시 비밀번호를 한 번 돌려주고 해시만 저장한다 (아이디는 소문자)', async () => {
|
||||
const { service, inserted } = setup();
|
||||
|
||||
const result = await service.create(PARTNER_ID, { username: 'MoneyTube' });
|
||||
|
||||
expect(result.account).toMatchObject({
|
||||
username: 'moneytube',
|
||||
partnerId: PARTNER_ID,
|
||||
isActive: true,
|
||||
mustChangePassword: true,
|
||||
});
|
||||
expect(result.temporaryPassword).toHaveLength(14);
|
||||
expect(inserted[0].passwordHash).not.toContain(result.temporaryPassword);
|
||||
expect(
|
||||
compareSync(result.temporaryPassword, inserted[0].passwordHash),
|
||||
).toBe(true);
|
||||
expect(JSON.stringify(result.account)).not.toContain('passwordHash');
|
||||
});
|
||||
|
||||
it('최고관리자 아이디·이미 있는 아이디는 ACCOUNT_001, 없는 업체는 RES_001', async () => {
|
||||
const { service, accounts } = setup();
|
||||
await expect(
|
||||
service.create(PARTNER_ID, { username: 'admin' }),
|
||||
).rejects.toMatchObject({ code: 'ACCOUNT_001' });
|
||||
|
||||
accounts.insert.mockRejectedValueOnce(
|
||||
new QueryFailedError('INSERT', [], { code: '23505' } as never),
|
||||
);
|
||||
await expect(
|
||||
service.create(PARTNER_ID, { username: 'taken' }),
|
||||
).rejects.toMatchObject({ code: 'ACCOUNT_001' });
|
||||
|
||||
const missing = setup({ partnerExists: false });
|
||||
await expect(
|
||||
missing.service.create(PARTNER_ID, { username: 'someone' }),
|
||||
).rejects.toMatchObject({ code: 'RES_001' });
|
||||
});
|
||||
|
||||
it('계정을 중지하면 그 계정의 세션을 모두 끊는다', async () => {
|
||||
const { service, auth } = setup();
|
||||
const { account } = await service.create(PARTNER_ID, {
|
||||
username: 'moneytube',
|
||||
});
|
||||
|
||||
const updated = await service.update(account.id, { isActive: false });
|
||||
|
||||
expect(updated.isActive).toBe(false);
|
||||
expect(auth.revokeAccountSessions).toHaveBeenCalledWith(account.id);
|
||||
});
|
||||
|
||||
it('비밀번호를 초기화하면 새 임시 비밀번호로 바뀌고 다시 변경을 요구하며 세션을 끊는다', async () => {
|
||||
const { service, auth, store } = setup();
|
||||
const created = await service.create(PARTNER_ID, { username: 'moneytube' });
|
||||
store.get(created.account.id)!.mustChangePassword = false;
|
||||
|
||||
const reset = await service.resetPassword(created.account.id);
|
||||
|
||||
const row = store.get(created.account.id)!;
|
||||
expect(reset.temporaryPassword).not.toBe(created.temporaryPassword);
|
||||
expect(compareSync(reset.temporaryPassword, row.passwordHash)).toBe(true);
|
||||
expect(compareSync(created.temporaryPassword, row.passwordHash)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(row.mustChangePassword).toBe(true);
|
||||
expect(auth.revokeAccountSessions).toHaveBeenCalledWith(created.account.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { hash } from 'bcryptjs';
|
||||
import { randomInt, randomUUID } from 'crypto';
|
||||
import { QueryFailedError, Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { Partner } from '@/modules/partners/entities/partner.entity';
|
||||
import { generateTemporaryPassword } from '@/shared/utils/temporary-password';
|
||||
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
|
||||
import { AdminAccount } from './admin-account.entity';
|
||||
import { ADMIN_USERNAME_KEY, BCRYPT_ROUNDS } from './admin-auth.constants';
|
||||
import { AdminAuthService, normalizeUsername } from './admin-auth.service';
|
||||
import {
|
||||
CreatePartnerAccountDto,
|
||||
PartnerAccountCredentialDto,
|
||||
PartnerAccountListQueryDto,
|
||||
PartnerAccountResponseDto,
|
||||
UpdatePartnerAccountDto,
|
||||
} from './dto/partner-account.dto';
|
||||
|
||||
const ALIAS = 'account';
|
||||
/** Postgres 유일 제약 위반 — 같은 아이디가 동시에 만들어진 경우 */
|
||||
const PG_UNIQUE_VIOLATION = '23505';
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof QueryFailedError &&
|
||||
(error.driverError as { code?: string } | undefined)?.code ===
|
||||
PG_UNIQUE_VIOLATION
|
||||
);
|
||||
}
|
||||
|
||||
function toResponse(row: AdminAccount): PartnerAccountResponseDto {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
partnerId: row.partnerId,
|
||||
isActive: row.isActive,
|
||||
mustChangePassword: row.mustChangePassword,
|
||||
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 업체 계정 관리 — 최고관리자만 (AdminAuthGuard).
|
||||
*
|
||||
* 비밀번호는 서버가 만들어 응답으로 한 번만 돌려주고 해시만 남긴다.
|
||||
* 업체는 첫 로그인 때 직접 바꿔야 실적을 볼 수 있다 (mustChangePassword → ACCOUNT_003).
|
||||
*/
|
||||
@Injectable()
|
||||
export class PartnerAccountsService {
|
||||
private readonly logger = new Logger(PartnerAccountsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AdminAccount)
|
||||
private readonly accounts: Repository<AdminAccount>,
|
||||
@InjectRepository(Partner)
|
||||
private readonly partners: Repository<Partner>,
|
||||
private readonly auth: AdminAuthService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
/** 업체의 계정 목록 — 최근 만든 순 */
|
||||
async findMany(
|
||||
partnerId: string,
|
||||
query: PartnerAccountListQueryDto,
|
||||
): Promise<PaginatedResult<PartnerAccountResponseDto>> {
|
||||
await this.requirePartner(partnerId);
|
||||
const base = this.accounts
|
||||
.createQueryBuilder(ALIAS)
|
||||
.where(`${ALIAS}.partnerId = :partnerId`, { partnerId });
|
||||
|
||||
const total = await base.clone().getCount();
|
||||
const page = base
|
||||
.clone()
|
||||
.orderBy(`${ALIAS}.createdAt`, 'DESC')
|
||||
.addOrderBy(`${ALIAS}.id`, 'DESC')
|
||||
.take(query.limit + 1);
|
||||
if (query.cursor) {
|
||||
const cursor = decodeTimeCursor(query.cursor);
|
||||
if (!cursor) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'invalid_cursor' },
|
||||
});
|
||||
}
|
||||
page.andWhere(
|
||||
`(${ALIAS}.createdAt, ${ALIAS}.id) < (:cursorTime, :cursorId)`,
|
||||
{ cursorTime: cursor.time, cursorId: cursor.key },
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await page.getMany();
|
||||
const hasNext = rows.length > query.limit;
|
||||
const items = rows.slice(0, query.limit);
|
||||
const last = items.at(-1);
|
||||
return toPaginatedResult(items.map(toResponse), {
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && last
|
||||
? encodeTimeCursor({
|
||||
time: last.createdAt.toISOString(),
|
||||
key: last.id,
|
||||
})
|
||||
: null,
|
||||
total,
|
||||
});
|
||||
}
|
||||
|
||||
/** 업체 계정 만들기 — 임시 비밀번호를 한 번만 돌려준다 */
|
||||
async create(
|
||||
partnerId: string,
|
||||
dto: CreatePartnerAccountDto,
|
||||
): Promise<PartnerAccountCredentialDto> {
|
||||
await this.requirePartner(partnerId);
|
||||
const username = normalizeUsername(dto.username);
|
||||
// 최고관리자(env) 아이디와 같으면 로그인 때 최고관리자로 먼저 맞춰 보므로 쓸 수 없다
|
||||
const superUsername = normalizeUsername(
|
||||
this.config.get<string>(ADMIN_USERNAME_KEY) ?? '',
|
||||
);
|
||||
if (username === superUsername) {
|
||||
throw new BusinessException('ACCOUNT_001', {
|
||||
context: { reason: 'super_admin_username' },
|
||||
});
|
||||
}
|
||||
|
||||
const temporaryPassword = generateTemporaryPassword(randomInt);
|
||||
const row = this.accounts.create({
|
||||
id: randomUUID(),
|
||||
username,
|
||||
passwordHash: await hash(temporaryPassword, BCRYPT_ROUNDS),
|
||||
partnerId,
|
||||
isActive: true,
|
||||
mustChangePassword: true,
|
||||
lastLoginAt: null,
|
||||
});
|
||||
try {
|
||||
await this.accounts.insert(row);
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new BusinessException('ACCOUNT_001', { context: { username } });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.logger.log(`업체 계정 생성: account=${row.id} partner=${partnerId}`);
|
||||
return {
|
||||
account: toResponse(await this.require(row.id)),
|
||||
temporaryPassword,
|
||||
};
|
||||
}
|
||||
|
||||
/** 사용/중지 — 중지하면 그 계정의 모든 기기가 로그아웃된다 */
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdatePartnerAccountDto,
|
||||
): Promise<PartnerAccountResponseDto> {
|
||||
const row = await this.require(id);
|
||||
if (dto.isActive !== undefined && dto.isActive !== row.isActive) {
|
||||
row.isActive = dto.isActive;
|
||||
await this.accounts.save(row);
|
||||
if (!row.isActive) await this.auth.revokeAccountSessions(row.id);
|
||||
this.logger.log(
|
||||
`업체 계정 ${row.isActive ? '사용' : '중지'}: account=${row.id}`,
|
||||
);
|
||||
}
|
||||
return toResponse(row);
|
||||
}
|
||||
|
||||
/** 비밀번호 초기화 — 새 임시 비밀번호를 한 번만 돌려주고 열린 세션을 모두 끊는다 */
|
||||
async resetPassword(id: string): Promise<PartnerAccountCredentialDto> {
|
||||
const row = await this.require(id);
|
||||
const temporaryPassword = generateTemporaryPassword(randomInt);
|
||||
row.passwordHash = await hash(temporaryPassword, BCRYPT_ROUNDS);
|
||||
row.mustChangePassword = true;
|
||||
await this.accounts.save(row);
|
||||
await this.auth.revokeAccountSessions(row.id);
|
||||
this.logger.log(`업체 계정 비밀번호 초기화: account=${row.id}`);
|
||||
return { account: toResponse(row), temporaryPassword };
|
||||
}
|
||||
|
||||
private async require(id: string): Promise<AdminAccount> {
|
||||
const row = await this.accounts.findOne({ where: { id } });
|
||||
if (!row) {
|
||||
throw new BusinessException('RES_001', { context: { accountId: id } });
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private async requirePartner(partnerId: string): Promise<void> {
|
||||
const exists = await this.partners.exists({ where: { id: partnerId } });
|
||||
if (!exists) {
|
||||
throw new BusinessException('RES_001', { context: { partnerId } });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsEnum, IsOptional } from 'class-validator';
|
||||
|
||||
import { ConversionType } from '../entities/partner-conversion.entity';
|
||||
import { PartnerLinkProductDto } from './partner-link.dto';
|
||||
import { PartnerPeriodQueryDto, PartnerStatsDto } from './partner.dto';
|
||||
|
||||
/** 업체 화면 요약 기간 — 없으면 전체 */
|
||||
export class PortalPeriodQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '집계 시작 (이상, ISO 8601)',
|
||||
example: '2026-09-30T15:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '집계 시작일 형식이 올바르지 않습니다.' })
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '집계 끝 (미만, ISO 8601)',
|
||||
example: '2026-10-31T15:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '집계 종료일 형식이 올바르지 않습니다.' })
|
||||
to?: string;
|
||||
}
|
||||
|
||||
/** 업체 화면 실적 목록 — 신청·구매 시각 기준 기간·종류로 거른다 */
|
||||
export class PortalConversionQueryDto extends PartnerPeriodQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '실적 종류 — application(신청) · purchase(구매)',
|
||||
enum: ConversionType,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(ConversionType, { message: '실적 종류 값이 올바르지 않습니다.' })
|
||||
type?: ConversionType;
|
||||
}
|
||||
|
||||
/** 업체 화면 요약 — 로그인한 업체 계정의 업체 */
|
||||
export class PortalSummaryDto {
|
||||
@ApiProperty({ description: '업체 ID' })
|
||||
partnerId!: string;
|
||||
|
||||
@ApiProperty({ description: '업체 이름', example: '머니튜브' })
|
||||
partnerName!: string;
|
||||
|
||||
@ApiProperty({ description: '발급된 링크 수', example: 3 })
|
||||
linkCount!: number;
|
||||
|
||||
@ApiProperty({ description: '기간 내 실적', type: PartnerStatsDto })
|
||||
stats!: PartnerStatsDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 업체 화면 실적 한 건 — 누가 왔는지는 가린 이름만.
|
||||
* 연락처·이메일·아이디·회원 코드·주문번호는 내려주지 않는다 (개인정보 제3자 제공 방지).
|
||||
*/
|
||||
export class PortalConversionDto {
|
||||
@ApiProperty({ description: '실적 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '실적 종류', enum: ConversionType })
|
||||
type!: ConversionType;
|
||||
|
||||
@ApiProperty({
|
||||
description: '신청·주문 시각',
|
||||
example: '2026-10-05T05:00:00.000Z',
|
||||
})
|
||||
convertedAt!: string;
|
||||
|
||||
@ApiProperty({ description: '상품', type: PartnerLinkProductDto })
|
||||
product!: PartnerLinkProductDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: '가린 이름 (예: 홍*동) — 모르면 null',
|
||||
nullable: true,
|
||||
example: '홍*동',
|
||||
})
|
||||
maskedName!: string | null;
|
||||
|
||||
@ApiProperty({ description: '회원인가 (비회원 구매면 false)', example: true })
|
||||
isMember!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '실적에 들어가는가 — 구매가 환불·취소되면 false',
|
||||
example: true,
|
||||
})
|
||||
isValid!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { CurrentAccount } from '@/common/decorators/current-admin.decorator';
|
||||
import { PaginatedResult } from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { PartnerAuthGuard } from '@/common/guards/admin-auth.guard';
|
||||
import { ADMIN_BEARER_AUTH } from '@/modules/admin-auth/admin-auth.constants';
|
||||
import type { VerifiedAccount } from '@/modules/admin-auth/admin-auth.service';
|
||||
import { PartnerLinkResponseDto } from './dto/partner-link.dto';
|
||||
import {
|
||||
PortalConversionDto,
|
||||
PortalConversionQueryDto,
|
||||
PortalPeriodQueryDto,
|
||||
PortalSummaryDto,
|
||||
} from './dto/partner-portal.dto';
|
||||
import { PartnerPeriodQueryDto } from './dto/partner.dto';
|
||||
import { PartnerPortalService } from './partner-portal.service';
|
||||
|
||||
/** 가드가 업체 계정만 통과시키지만, 업체 ID 가 없으면 아무것도 보여주지 않는다 */
|
||||
function partnerIdOf(account: VerifiedAccount): string {
|
||||
if (!account.partnerId) {
|
||||
throw new BusinessException('AUTH_003', {
|
||||
context: { reason: 'no_partner' },
|
||||
});
|
||||
}
|
||||
return account.partnerId;
|
||||
}
|
||||
|
||||
/** 업체 화면 — 업체 계정 전용. 업체 ID 는 로그인한 계정에서만 가져온다 */
|
||||
@ApiTags('Partner Portal')
|
||||
@ApiBearerAuth(ADMIN_BEARER_AUTH)
|
||||
@UseGuards(PartnerAuthGuard)
|
||||
@Controller('partner-portal')
|
||||
export class PartnerPortalController {
|
||||
constructor(private readonly portal: PartnerPortalService) {}
|
||||
|
||||
@Get('summary')
|
||||
@ApiOperation({ summary: '내 업체 요약 — 링크 수·기간 실적' })
|
||||
@ApiStandardResponse(PortalSummaryDto, { description: '조회 성공' })
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@ApiErrorResponse('AUTH_003', '업체 계정이 아님')
|
||||
@ApiErrorResponse('ACCOUNT_003', '임시 비밀번호 변경 필요')
|
||||
summary(
|
||||
@CurrentAccount() account: VerifiedAccount,
|
||||
@Query() query: PortalPeriodQueryDto,
|
||||
): Promise<PortalSummaryDto> {
|
||||
return this.portal.summary(partnerIdOf(account), query);
|
||||
}
|
||||
|
||||
@Get('links')
|
||||
@ApiOperation({ summary: '내 업체 링크 — 업체에 줄 링크와 기간 실적' })
|
||||
@ApiStandardResponse(PartnerLinkResponseDto, {
|
||||
description: '조회 성공 — data 는 { items, pageInfo }',
|
||||
isArray: true,
|
||||
})
|
||||
@ApiErrorResponse('AUTH_003', '업체 계정이 아님')
|
||||
@ApiErrorResponse('ACCOUNT_003', '임시 비밀번호 변경 필요')
|
||||
findLinks(
|
||||
@CurrentAccount() account: VerifiedAccount,
|
||||
@Query() query: PartnerPeriodQueryDto,
|
||||
): Promise<PaginatedResult<PartnerLinkResponseDto>> {
|
||||
return this.portal.findLinks(partnerIdOf(account), query);
|
||||
}
|
||||
|
||||
@Get('conversions')
|
||||
@ApiOperation({
|
||||
summary: '내 업체 신청·구매 — 최근순, 이름은 가려서 (연락처 없음)',
|
||||
})
|
||||
@ApiStandardResponse(PortalConversionDto, {
|
||||
description: '조회 성공 — data 는 { items, pageInfo }',
|
||||
isArray: true,
|
||||
})
|
||||
@ApiErrorResponse('AUTH_003', '업체 계정이 아님')
|
||||
@ApiErrorResponse('ACCOUNT_003', '임시 비밀번호 변경 필요')
|
||||
@ApiErrorResponse('VAL_001', '필터·커서 형식 오류')
|
||||
findConversions(
|
||||
@CurrentAccount() account: VerifiedAccount,
|
||||
@Query() query: PortalConversionQueryDto,
|
||||
): Promise<PaginatedResult<PortalConversionDto>> {
|
||||
return this.portal.findConversions(partnerIdOf(account), query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import type { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
|
||||
import type { ImwebProduct } from '@/modules/imweb/entities/imweb-product.entity';
|
||||
import type { PurchasesService } from '@/modules/purchases/purchases.service';
|
||||
import {
|
||||
ConversionType,
|
||||
PartnerConversion,
|
||||
} from './entities/partner-conversion.entity';
|
||||
import type { PartnerLinksService } from './partner-links.service';
|
||||
import { PartnerPortalService } from './partner-portal.service';
|
||||
import type { PartnerStatsService } from './partner-stats.service';
|
||||
import type { PartnersService } from './partners.service';
|
||||
|
||||
const PARTNER_ID = 'partner-1';
|
||||
|
||||
function conversion(overrides: Partial<PartnerConversion>): PartnerConversion {
|
||||
return {
|
||||
id: 'c-1',
|
||||
type: ConversionType.APPLICATION,
|
||||
linkId: 'link-1',
|
||||
partnerId: PARTNER_ID,
|
||||
prodNo: 52,
|
||||
memberCode: 'm-1',
|
||||
applicationId: 'app-1',
|
||||
orderNo: null,
|
||||
isValid: true,
|
||||
touchedAt: new Date('2026-10-01T00:00:00Z'),
|
||||
convertedAt: new Date('2026-10-02T00:00:00Z'),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** 조회 쿼리 빌더 흉내 — where 조건을 기록하고 정해 둔 행을 돌려준다 */
|
||||
interface QueryMock {
|
||||
where: jest.Mock<QueryMock, unknown[]>;
|
||||
andWhere: jest.Mock<QueryMock, unknown[]>;
|
||||
orderBy: jest.Mock<QueryMock, unknown[]>;
|
||||
addOrderBy: jest.Mock<QueryMock, unknown[]>;
|
||||
take: jest.Mock<QueryMock, unknown[]>;
|
||||
clone: jest.Mock<QueryMock, []>;
|
||||
getCount: jest.Mock<Promise<number>, []>;
|
||||
getMany: jest.Mock<Promise<PartnerConversion[]>, []>;
|
||||
}
|
||||
|
||||
function setup(rows: PartnerConversion[]) {
|
||||
const query: QueryMock = {
|
||||
where: jest.fn(() => query),
|
||||
andWhere: jest.fn(() => query),
|
||||
orderBy: jest.fn(() => query),
|
||||
addOrderBy: jest.fn(() => query),
|
||||
take: jest.fn(() => query),
|
||||
clone: jest.fn(() => query),
|
||||
getCount: jest.fn(() => Promise.resolve(rows.length)),
|
||||
getMany: jest.fn(() => Promise.resolve(rows)),
|
||||
};
|
||||
const conversions = { createQueryBuilder: jest.fn(() => query) };
|
||||
const members = {
|
||||
find: jest.fn(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
memberCode: 'm-1',
|
||||
name: '홍길동',
|
||||
callnum: '010-1234-5678',
|
||||
email: 'hong@test.dev',
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
const products = {
|
||||
find: jest.fn(() => Promise.resolve([{ prodNo: 52, name: 'AI 강의' }])),
|
||||
};
|
||||
const purchases = {
|
||||
ordererNames: jest.fn(() =>
|
||||
Promise.resolve(new Map([['202610060001', '김비회']])),
|
||||
),
|
||||
};
|
||||
const service = new PartnerPortalService(
|
||||
conversions as unknown as Repository<PartnerConversion>,
|
||||
members as unknown as Repository<ImwebMember>,
|
||||
products as unknown as Repository<ImwebProduct>,
|
||||
{} as PartnersService,
|
||||
{} as PartnerLinksService,
|
||||
{} as PartnerStatsService,
|
||||
purchases as unknown as PurchasesService,
|
||||
);
|
||||
return { service, query };
|
||||
}
|
||||
|
||||
describe('PartnerPortalService.findConversions', () => {
|
||||
it('로그인한 업체의 실적만 조회한다', async () => {
|
||||
const { service, query } = setup([]);
|
||||
await service.findConversions(PARTNER_ID, { limit: 20 });
|
||||
expect(query.where).toHaveBeenCalledWith(
|
||||
expect.stringContaining('partnerId'),
|
||||
{ partnerId: PARTNER_ID },
|
||||
);
|
||||
});
|
||||
|
||||
it('신청·구매자는 가린 이름만 — 연락처·이메일·회원 코드·주문번호는 내려주지 않는다', async () => {
|
||||
const { service } = setup([
|
||||
conversion({}),
|
||||
conversion({
|
||||
id: 'c-2',
|
||||
type: ConversionType.PURCHASE,
|
||||
memberCode: null,
|
||||
applicationId: null,
|
||||
orderNo: '202610060001',
|
||||
}),
|
||||
]);
|
||||
|
||||
const page = await service.findConversions(PARTNER_ID, { limit: 20 });
|
||||
|
||||
expect(page.items).toEqual([
|
||||
expect.objectContaining({
|
||||
maskedName: '홍*동',
|
||||
isMember: true,
|
||||
product: { prodNo: 52, name: 'AI 강의' },
|
||||
}),
|
||||
expect.objectContaining({ maskedName: '김*회', isMember: false }),
|
||||
]);
|
||||
const body = JSON.stringify(page);
|
||||
for (const secret of [
|
||||
'010-1234-5678',
|
||||
'hong@test.dev',
|
||||
'm-1',
|
||||
'202610060001',
|
||||
'홍길동',
|
||||
]) {
|
||||
expect(body).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { ImwebMember } from '@/modules/imweb/entities/imweb-member.entity';
|
||||
import { ImwebProduct } from '@/modules/imweb/entities/imweb-product.entity';
|
||||
import { PurchasesService } from '@/modules/purchases/purchases.service';
|
||||
import { maskName } from '@/shared/utils/mask-name';
|
||||
import { decodeTimeCursor, encodeTimeCursor } from '@/shared/utils/time-cursor';
|
||||
import { PartnerLinkResponseDto } from './dto/partner-link.dto';
|
||||
import {
|
||||
PortalConversionDto,
|
||||
PortalConversionQueryDto,
|
||||
PortalPeriodQueryDto,
|
||||
PortalSummaryDto,
|
||||
} from './dto/partner-portal.dto';
|
||||
import { PartnerPeriodQueryDto } from './dto/partner.dto';
|
||||
import { PartnerConversion } from './entities/partner-conversion.entity';
|
||||
import { PartnerLinksService } from './partner-links.service';
|
||||
import { PartnerStatsService } from './partner-stats.service';
|
||||
import { PartnersService } from './partners.service';
|
||||
|
||||
const ALIAS = 'v';
|
||||
/** 커서의 고유값은 실적 ID(UUID)다 — 형식이 다르면 DB 오류 대신 VAL_001 */
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* 업체 화면 — 업체 계정이 자기 업체의 링크·실적을 본다 (PartnerAuthGuard).
|
||||
*
|
||||
* 업체 ID 는 항상 로그인한 계정에서 받는다 — 요청값으로 다른 업체를 볼 수 없다.
|
||||
* 신청·구매자는 가린 이름만 보여주고 연락처·이메일·아이디·주문번호는 내려주지 않는다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PartnerPortalService {
|
||||
constructor(
|
||||
@InjectRepository(PartnerConversion)
|
||||
private readonly conversions: Repository<PartnerConversion>,
|
||||
@InjectRepository(ImwebMember)
|
||||
private readonly members: Repository<ImwebMember>,
|
||||
@InjectRepository(ImwebProduct)
|
||||
private readonly products: Repository<ImwebProduct>,
|
||||
private readonly partners: PartnersService,
|
||||
private readonly links: PartnerLinksService,
|
||||
private readonly stats: PartnerStatsService,
|
||||
private readonly purchases: PurchasesService,
|
||||
) {}
|
||||
|
||||
async summary(
|
||||
partnerId: string,
|
||||
query: PortalPeriodQueryDto,
|
||||
): Promise<PortalSummaryDto> {
|
||||
const partner = await this.partners.require(partnerId);
|
||||
const [statsById, linkCounts] = await Promise.all([
|
||||
this.stats.byPartners([partnerId], query),
|
||||
this.stats.linkCounts([partnerId]),
|
||||
]);
|
||||
return {
|
||||
partnerId,
|
||||
partnerName: partner.name,
|
||||
linkCount: linkCounts.get(partnerId) ?? 0,
|
||||
stats: statsById.get(partnerId) ?? {
|
||||
clickCount: 0,
|
||||
applicationCount: 0,
|
||||
purchaseCount: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 자기 업체의 링크 — 관리자 화면과 같은 응답 (업체에 줄 링크·기간 실적) */
|
||||
findLinks(
|
||||
partnerId: string,
|
||||
query: PartnerPeriodQueryDto,
|
||||
): Promise<PaginatedResult<PartnerLinkResponseDto>> {
|
||||
return this.links.findMany({
|
||||
cursor: query.cursor,
|
||||
limit: query.limit,
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
partnerId,
|
||||
});
|
||||
}
|
||||
|
||||
/** 자기 업체 실적 목록 — 최근순, 가린 이름만 */
|
||||
async findConversions(
|
||||
partnerId: string,
|
||||
query: PortalConversionQueryDto,
|
||||
): Promise<PaginatedResult<PortalConversionDto>> {
|
||||
const base = this.conversions
|
||||
.createQueryBuilder(ALIAS)
|
||||
.where(`${ALIAS}.partnerId = :partnerId`, { partnerId });
|
||||
if (query.type) {
|
||||
base.andWhere(`${ALIAS}.type = :type`, { type: query.type });
|
||||
}
|
||||
if (query.from) {
|
||||
base.andWhere(`${ALIAS}.convertedAt >= :from`, { from: query.from });
|
||||
}
|
||||
if (query.to) {
|
||||
base.andWhere(`${ALIAS}.convertedAt < :to`, { to: query.to });
|
||||
}
|
||||
|
||||
const total = await base.clone().getCount();
|
||||
const page = base
|
||||
.clone()
|
||||
.orderBy(`${ALIAS}.convertedAt`, 'DESC')
|
||||
.addOrderBy(`${ALIAS}.id`, 'DESC')
|
||||
.take(query.limit + 1);
|
||||
if (query.cursor) {
|
||||
const cursor = decodeTimeCursor(query.cursor);
|
||||
if (!cursor || !UUID_PATTERN.test(cursor.key)) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'invalid_cursor' },
|
||||
});
|
||||
}
|
||||
page.andWhere(
|
||||
`(${ALIAS}.convertedAt, ${ALIAS}.id) < (:cursorTime, :cursorId)`,
|
||||
{ cursorTime: cursor.time, cursorId: cursor.key },
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await page.getMany();
|
||||
const hasNext = rows.length > query.limit;
|
||||
const items = rows.slice(0, query.limit);
|
||||
const last = items.at(-1);
|
||||
const names = await this.namesOf(items);
|
||||
const productNames = await this.productNamesOf(items);
|
||||
|
||||
return toPaginatedResult(
|
||||
items.map(
|
||||
(row): PortalConversionDto => ({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
convertedAt: row.convertedAt.toISOString(),
|
||||
product: {
|
||||
prodNo: row.prodNo,
|
||||
name: productNames.get(row.prodNo) ?? null,
|
||||
},
|
||||
maskedName: maskName(names.get(row.id)),
|
||||
isMember: !!row.memberCode,
|
||||
isValid: row.isValid,
|
||||
}),
|
||||
),
|
||||
{
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && last
|
||||
? encodeTimeCursor({
|
||||
time: last.convertedAt.toISOString(),
|
||||
key: last.id,
|
||||
})
|
||||
: null,
|
||||
total,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 실적 ID → 이름 (가리기 전) — 회원은 회원 이름, 비회원 구매는 주문자 이름 */
|
||||
private async namesOf(
|
||||
rows: PartnerConversion[],
|
||||
): Promise<Map<string, string>> {
|
||||
const memberCodes = [
|
||||
...new Set(
|
||||
rows
|
||||
.map((row) => row.memberCode)
|
||||
.filter((code): code is string => !!code),
|
||||
),
|
||||
];
|
||||
const guestOrderNos = rows
|
||||
.filter((row) => !row.memberCode && row.orderNo)
|
||||
.map((row) => row.orderNo as string);
|
||||
|
||||
const [memberRows, ordererNames] = await Promise.all([
|
||||
memberCodes.length > 0
|
||||
? this.members.find({
|
||||
select: { memberCode: true, name: true },
|
||||
where: { memberCode: In(memberCodes) },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
this.purchases.ordererNames(guestOrderNos),
|
||||
]);
|
||||
const memberNames = new Map(memberRows.map((m) => [m.memberCode, m.name]));
|
||||
|
||||
const names = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
const name = row.memberCode
|
||||
? memberNames.get(row.memberCode)
|
||||
: row.orderNo
|
||||
? ordererNames.get(row.orderNo)
|
||||
: undefined;
|
||||
if (name) names.set(row.id, name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private async productNamesOf(
|
||||
rows: PartnerConversion[],
|
||||
): Promise<Map<number, string>> {
|
||||
const prodNos = [...new Set(rows.map((row) => row.prodNo))];
|
||||
if (prodNos.length === 0) return new Map();
|
||||
const products = await this.products.find({
|
||||
select: { prodNo: true, name: true },
|
||||
where: { prodNo: In(prodNos) },
|
||||
});
|
||||
return new Map(products.map((p) => [p.prodNo, p.name]));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
} from './partner-links.controller';
|
||||
import { PartnerLinksService } from './partner-links.service';
|
||||
import { PartnerOrderSyncService } from './partner-order-sync.service';
|
||||
import { PartnerPortalController } from './partner-portal.controller';
|
||||
import { PartnerPortalService } from './partner-portal.service';
|
||||
import {
|
||||
PartnerRedirectController,
|
||||
PartnerTouchesController,
|
||||
@@ -29,6 +31,7 @@ import { PartnersService } from './partners.service';
|
||||
|
||||
/**
|
||||
* 업체 링크 — 업체·상품별 단축 링크를 발급하고, 그 링크로 들어온 회원의 신청·구매를 센다.
|
||||
* 업체 계정은 업체 화면(PartnerPortalController)에서 자기 업체의 링크·실적만 본다.
|
||||
* 신청은 ApplicationsModule 이 PartnerAttributionService 를 불러 귀속하고,
|
||||
* 구매는 PartnerOrderSyncService 가 아임웹 주문을 주기적으로 훑어 찾는다
|
||||
* (같은 동기화가 업체와 무관한 구매 내역도 PurchasesModule 에 남긴다).
|
||||
@@ -55,8 +58,10 @@ import { PartnersService } from './partners.service';
|
||||
PartnerOrderSyncController,
|
||||
PartnerRedirectController,
|
||||
PartnerTouchesController,
|
||||
PartnerPortalController,
|
||||
],
|
||||
providers: [
|
||||
PartnerPortalService,
|
||||
PartnersService,
|
||||
PartnerLinksService,
|
||||
PartnerStatsService,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { maskName } from './mask-name';
|
||||
|
||||
describe('maskName', () => {
|
||||
it('세 글자 이상이면 첫 글자와 마지막 글자만 남긴다', () => {
|
||||
expect(maskName('홍길동')).toBe('홍*동');
|
||||
expect(maskName('남궁민수')).toBe('남**수');
|
||||
});
|
||||
|
||||
it('두 글자면 첫 글자만, 한 글자면 모두 가린다', () => {
|
||||
expect(maskName('김철')).toBe('김*');
|
||||
expect(maskName('이')).toBe('*');
|
||||
});
|
||||
|
||||
it('값이 없으면 null 이다', () => {
|
||||
expect(maskName(null)).toBeNull();
|
||||
expect(maskName(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('공백·영문도 글자 수대로 가린다 (길이로 이름을 추측하기 어렵게 앞뒤만 남긴다)', () => {
|
||||
expect(maskName(' John Smith ')).toBe('J********h');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 이름 가리기 (순수 함수) — 업체 계정 화면에 신청·구매자를 보여줄 때 쓴다.
|
||||
*
|
||||
* 업체는 자기 링크로 누가 왔는지 대략 확인할 수 있어야 하지만, 개인을 특정할 수 있으면 안 된다.
|
||||
* 연락처·이메일은 아예 내려주지 않고, 이름은 첫 글자와 마지막 글자만 남긴다.
|
||||
* 홍길동 → 홍*동 · 김철 → 김* · 남궁민수 → 남**수 · 이 → *
|
||||
*/
|
||||
const MASK = '*';
|
||||
|
||||
export function maskName(name: string | null | undefined): string | null {
|
||||
const trimmed = name?.trim();
|
||||
if (!trimmed) return null;
|
||||
// 한글·이모지도 한 글자로 센다 (UTF-16 길이가 아니라 코드 포인트 기준)
|
||||
const chars = [...trimmed.replace(/\s+/g, ' ')];
|
||||
if (chars.length === 1) return MASK;
|
||||
if (chars.length === 2) return `${chars[0]}${MASK}`;
|
||||
return `${chars[0]}${MASK.repeat(chars.length - 2)}${chars.at(-1)}`;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { randomInt } from 'crypto';
|
||||
|
||||
import {
|
||||
TEMPORARY_PASSWORD_LENGTH,
|
||||
generateTemporaryPassword,
|
||||
} from './temporary-password';
|
||||
|
||||
describe('generateTemporaryPassword', () => {
|
||||
it('정한 길이만큼 헷갈리는 글자 없이 만든다', () => {
|
||||
const password = generateTemporaryPassword(randomInt);
|
||||
expect(password).toHaveLength(TEMPORARY_PASSWORD_LENGTH);
|
||||
expect(password).toMatch(/^[A-HJ-NP-Za-km-z2-9]+$/);
|
||||
});
|
||||
|
||||
it('난수 함수가 고른 글자를 쓴다 (결정적)', () => {
|
||||
expect(generateTemporaryPassword(() => 0, 4)).toBe('AAAA');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 임시 비밀번호 생성 (순수 함수 — 난수 함수를 받는다).
|
||||
*
|
||||
* 최고관리자가 업체 계정을 만들거나 비밀번호를 초기화하면 서버가 만들어 화면에 한 번만 보여준다.
|
||||
* 전화·메신저로 불러 주기 쉽게 헷갈리는 글자(0·O·1·l·I)를 뺐다.
|
||||
*/
|
||||
const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789';
|
||||
export const TEMPORARY_PASSWORD_LENGTH = 14;
|
||||
|
||||
/** @param randomInt 0 이상 max 미만의 정수를 돌려주는 함수 (운영: crypto.randomInt) */
|
||||
export function generateTemporaryPassword(
|
||||
randomInt: (max: number) => number,
|
||||
length: number = TEMPORARY_PASSWORD_LENGTH,
|
||||
): string {
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
result += ALPHABET[randomInt(ALPHABET.length)];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user