From 97780eb4b6825b20bf70cbb4f6c05631ac57fcd8 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 6 Oct 2026 18:22:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=EC=B5=9C=EA=B3=A0=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=EA=B0=80=20=EB=A7=8C=EB=93=9C=EB=8A=94=20?= =?UTF-8?q?=EC=97=85=EC=B2=B4=20=EA=B3=84=EC=A0=95=EA=B3=BC=20=EC=97=85?= =?UTF-8?q?=EC=B2=B4=20=EC=8B=A4=EC=A0=81=20=ED=99=94=EB=A9=B4=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 화면에는 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 --- backend/package-lock.json | 10 + backend/package.json | 1 + .../decorators/current-admin.decorator.ts | 13 + .../common/guards/admin-auth.guard.spec.ts | 90 +++++ backend/src/common/guards/admin-auth.guard.ts | 75 ++++- .../1791269588289-AddPartnerAccounts.ts | 47 +++ .../admin-auth/admin-account.entity.ts | 56 ++++ .../admin-auth/admin-auth.constants.ts | 23 +- .../admin-auth/admin-auth.controller.ts | 18 +- .../modules/admin-auth/admin-auth.module.ts | 30 +- .../admin-auth/admin-auth.service.spec.ts | 206 +++++++++++- .../modules/admin-auth/admin-auth.service.ts | 308 +++++++++++++++--- .../admin-auth/admin-session.entity.ts | 20 +- .../admin-auth/dto/admin-response.dto.ts | 37 ++- .../admin-auth/dto/partner-account.dto.ts | 119 +++++++ .../admin-auth/partner-accounts.controller.ts | 147 +++++++++ .../partner-accounts.service.spec.ts | 115 +++++++ .../admin-auth/partner-accounts.service.ts | 201 ++++++++++++ .../partners/dto/partner-portal.dto.ts | 88 +++++ .../partners/partner-portal.controller.ts | 87 +++++ .../partners/partner-portal.service.spec.ts | 134 ++++++++ .../partners/partner-portal.service.ts | 211 ++++++++++++ .../src/modules/partners/partners.module.ts | 5 + backend/src/shared/utils/mask-name.spec.ts | 22 ++ backend/src/shared/utils/mask-name.ts | 18 + .../shared/utils/temporary-password.spec.ts | 18 + .../src/shared/utils/temporary-password.ts | 20 ++ 27 files changed, 2034 insertions(+), 85 deletions(-) create mode 100644 backend/src/common/guards/admin-auth.guard.spec.ts create mode 100644 backend/src/migrations/1791269588289-AddPartnerAccounts.ts create mode 100644 backend/src/modules/admin-auth/admin-account.entity.ts create mode 100644 backend/src/modules/admin-auth/dto/partner-account.dto.ts create mode 100644 backend/src/modules/admin-auth/partner-accounts.controller.ts create mode 100644 backend/src/modules/admin-auth/partner-accounts.service.spec.ts create mode 100644 backend/src/modules/admin-auth/partner-accounts.service.ts create mode 100644 backend/src/modules/partners/dto/partner-portal.dto.ts create mode 100644 backend/src/modules/partners/partner-portal.controller.ts create mode 100644 backend/src/modules/partners/partner-portal.service.spec.ts create mode 100644 backend/src/modules/partners/partner-portal.service.ts create mode 100644 backend/src/shared/utils/mask-name.spec.ts create mode 100644 backend/src/shared/utils/mask-name.ts create mode 100644 backend/src/shared/utils/temporary-password.spec.ts create mode 100644 backend/src/shared/utils/temporary-password.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 93fe10d..0cfac43 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 9b2fabe..c093dcd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/common/decorators/current-admin.decorator.ts b/backend/src/common/decorators/current-admin.decorator.ts index 3465234..6258062 100644 --- a/backend/src/common/decorators/current-admin.decorator.ts +++ b/backend/src/common/decorators/current-admin.decorator.ts @@ -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(); + if (!request.account) throw new BusinessException('AUTH_001'); + return request.account; + }, +); diff --git a/backend/src/common/guards/admin-auth.guard.spec.ts b/backend/src/common/guards/admin-auth.guard.spec.ts new file mode 100644 index 0000000..5bab1a7 --- /dev/null +++ b/backend/src/common/guards/admin-auth.guard.spec.ts @@ -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, + ); + }); +}); diff --git a/backend/src/common/guards/admin-auth.guard.ts b/backend/src/common/guards/admin-auth.guard.ts index 1e71c07..9ccb619 100644 --- a/backend/src/common/guards/admin-auth.guard.ts +++ b/backend/src/common/guards/admin-auth.guard.ts @@ -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 { + const request = context.switchToHttp().getRequest(); + 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 { - const request = context.switchToHttp().getRequest(); - 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 { + 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 { + await authenticate(context, this.auth, ['super', 'partner']); return true; } } diff --git a/backend/src/migrations/1791269588289-AddPartnerAccounts.ts b/backend/src/migrations/1791269588289-AddPartnerAccounts.ts new file mode 100644 index 0000000..d903f1f --- /dev/null +++ b/backend/src/migrations/1791269588289-AddPartnerAccounts.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPartnerAccounts1791269588289 implements MigrationInterface { + name = 'AddPartnerAccounts1791269588289'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } +} diff --git a/backend/src/modules/admin-auth/admin-account.entity.ts b/backend/src/modules/admin-auth/admin-account.entity.ts new file mode 100644 index 0000000..ba98c20 --- /dev/null +++ b/backend/src/modules/admin-auth/admin-account.entity.ts @@ -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; +} diff --git a/backend/src/modules/admin-auth/admin-auth.constants.ts b/backend/src/modules/admin-auth/admin-auth.constants.ts index 83a7ae7..81a0324 100644 --- a/backend/src/modules/admin-auth/admin-auth.constants.ts +++ b/backend/src/modules/admin-auth/admin-auth.constants.ts @@ -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; diff --git a/backend/src/modules/admin-auth/admin-auth.controller.ts b/backend/src/modules/admin-auth/admin-auth.controller.ts index d3e0b17..338d8d9 100644 --- a/backend/src/modules/admin-auth/admin-auth.controller.ts +++ b/backend/src/modules/admin-auth/admin-auth.controller.ts @@ -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() diff --git a/backend/src/modules/admin-auth/admin-auth.module.ts b/backend/src/modules/admin-auth/admin-auth.module.ts index fd7fec8..86ef413 100644 --- a/backend/src/modules/admin-auth/admin-auth.module.ts +++ b/backend/src/modules/admin-auth/admin-auth.module.ts @@ -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 {} diff --git a/backend/src/modules/admin-auth/admin-auth.service.spec.ts b/backend/src/modules/admin-auth/admin-auth.service.spec.ts index e4ef278..5e5affb 100644 --- a/backend/src/modules/admin-auth/admin-auth.service.spec.ts +++ b/backend/src/modules/admin-auth/admin-auth.service.spec.ts @@ -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 = {}) { +function partnerAccount(overrides: Partial = {}): 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): 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 = {}, + accountRows: AdminAccount[] = [], +) { // env 는 테스트 중에 바꿀 수 있게 객체로 둔다 (비밀번호 변경 시나리오) const values: Record = { ADMIN_USERNAME: USERNAME, @@ -32,20 +72,46 @@ function setup(env: Record = {}) { findOne: jest.fn(({ where }: { where: { id: string } }) => Promise.resolve(store.get(where.id) ?? null), ), - update: jest.fn(({ id }: { id: string }, patch: Partial) => { - const row = store.get(id); + update: jest.fn( + (where: Record, patch: Partial) => { + 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) => { + 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, + accounts as unknown as Repository, 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; + + // 서명키를 아는 상황을 가정해도, 업체 지문으로는 최고관리자 확인을 통과하지 못한다 + 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', + }); + }); +}); diff --git a/backend/src/modules/admin-auth/admin-auth.service.ts b/backend/src/modules/admin-auth/admin-auth.service.ts index 4ee719c..3aac3e2 100644 --- a/backend/src/modules/admin-auth/admin-auth.service.ts +++ b/backend/src/modules/admin-auth/admin-auth.service.ts @@ -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 | null = null; constructor( @InjectRepository(AdminSession) private readonly sessions: Repository, + @InjectRepository(AdminAccount) + private readonly accounts: Repository, private readonly jwt: JwtService, private readonly config: ConfigService, ) {} - /** 아이디·비밀번호를 env 계정과 비교하고 새 세션을 연다. 실패 원인은 구분하지 않는다. */ + /** + * 아이디·비밀번호로 새 세션을 연다 — env 최고관리자 계정을 먼저 보고, 아니면 업체 계정. + * 실패 원인(아이디 없음·비밀번호 틀림·중지됨)은 구분하지 않는다. + */ async login( username: string, password: string, userAgent?: string, ): Promise { - 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 { 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 { 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 { + async verifyAccessToken(token: string | null): Promise { 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 { + 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 { + 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 { + 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(ADMIN_USERNAME_KEY) ?? '').trim(); const password = this.config.get(ADMIN_PASSWORD_KEY) ?? ''; - const secret = this.config.get('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('JWT_SECRET') ?? ''; + } + + private getDummyHash(): Promise { + 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 { + 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 { 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, diff --git a/backend/src/modules/admin-auth/admin-session.entity.ts b/backend/src/modules/admin-auth/admin-session.entity.ts index 3d3b591..af77253 100644 --- a/backend/src/modules/admin-auth/admin-session.entity.ts +++ b/backend/src/modules/admin-auth/admin-session.entity.ts @@ -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; diff --git a/backend/src/modules/admin-auth/dto/admin-response.dto.ts b/backend/src/modules/admin-auth/dto/admin-response.dto.ts index eddaac4..2902af1 100644 --- a/backend/src/modules/admin-auth/dto/admin-response.dto.ts +++ b/backend/src/modules/admin-auth/dto/admin-response.dto.ts @@ -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; } diff --git a/backend/src/modules/admin-auth/dto/partner-account.dto.ts b/backend/src/modules/admin-auth/dto/partner-account.dto.ts new file mode 100644 index 0000000..33bbc42 --- /dev/null +++ b/backend/src/modules/admin-auth/dto/partner-account.dto.ts @@ -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; +} diff --git a/backend/src/modules/admin-auth/partner-accounts.controller.ts b/backend/src/modules/admin-auth/partner-accounts.controller.ts new file mode 100644 index 0000000..7ee27ba --- /dev/null +++ b/backend/src/modules/admin-auth/partner-accounts.controller.ts @@ -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> { + 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 { + 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 { + 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 { + 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 { + return this.auth.changePassword( + account, + dto.currentPassword, + dto.newPassword, + ); + } +} diff --git a/backend/src/modules/admin-auth/partner-accounts.service.spec.ts b/backend/src/modules/admin-auth/partner-accounts.service.spec.ts new file mode 100644 index 0000000..89e1945 --- /dev/null +++ b/backend/src/modules/admin-auth/partner-accounts.service.spec.ts @@ -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(); + const accounts = { + create: jest.fn((row: Partial) => 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, + partners as unknown as Repository, + 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); + }); +}); diff --git a/backend/src/modules/admin-auth/partner-accounts.service.ts b/backend/src/modules/admin-auth/partner-accounts.service.ts new file mode 100644 index 0000000..fa99711 --- /dev/null +++ b/backend/src/modules/admin-auth/partner-accounts.service.ts @@ -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, + @InjectRepository(Partner) + private readonly partners: Repository, + private readonly auth: AdminAuthService, + private readonly config: ConfigService, + ) {} + + /** 업체의 계정 목록 — 최근 만든 순 */ + async findMany( + partnerId: string, + query: PartnerAccountListQueryDto, + ): Promise> { + 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 { + await this.requirePartner(partnerId); + const username = normalizeUsername(dto.username); + // 최고관리자(env) 아이디와 같으면 로그인 때 최고관리자로 먼저 맞춰 보므로 쓸 수 없다 + const superUsername = normalizeUsername( + this.config.get(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 { + 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 { + 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 { + 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 { + const exists = await this.partners.exists({ where: { id: partnerId } }); + if (!exists) { + throw new BusinessException('RES_001', { context: { partnerId } }); + } + } +} diff --git a/backend/src/modules/partners/dto/partner-portal.dto.ts b/backend/src/modules/partners/dto/partner-portal.dto.ts new file mode 100644 index 0000000..b40a240 --- /dev/null +++ b/backend/src/modules/partners/dto/partner-portal.dto.ts @@ -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; +} diff --git a/backend/src/modules/partners/partner-portal.controller.ts b/backend/src/modules/partners/partner-portal.controller.ts new file mode 100644 index 0000000..ef85fbe --- /dev/null +++ b/backend/src/modules/partners/partner-portal.controller.ts @@ -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 { + 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> { + 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> { + return this.portal.findConversions(partnerIdOf(account), query); + } +} diff --git a/backend/src/modules/partners/partner-portal.service.spec.ts b/backend/src/modules/partners/partner-portal.service.spec.ts new file mode 100644 index 0000000..28d59a7 --- /dev/null +++ b/backend/src/modules/partners/partner-portal.service.spec.ts @@ -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 { + 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; + andWhere: jest.Mock; + orderBy: jest.Mock; + addOrderBy: jest.Mock; + take: jest.Mock; + clone: jest.Mock; + getCount: jest.Mock, []>; + getMany: jest.Mock, []>; +} + +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, + members as unknown as Repository, + products as unknown as Repository, + {} 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); + } + }); +}); diff --git a/backend/src/modules/partners/partner-portal.service.ts b/backend/src/modules/partners/partner-portal.service.ts new file mode 100644 index 0000000..b490d66 --- /dev/null +++ b/backend/src/modules/partners/partner-portal.service.ts @@ -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, + @InjectRepository(ImwebMember) + private readonly members: Repository, + @InjectRepository(ImwebProduct) + private readonly products: Repository, + private readonly partners: PartnersService, + private readonly links: PartnerLinksService, + private readonly stats: PartnerStatsService, + private readonly purchases: PurchasesService, + ) {} + + async summary( + partnerId: string, + query: PortalPeriodQueryDto, + ): Promise { + 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> { + return this.links.findMany({ + cursor: query.cursor, + limit: query.limit, + from: query.from, + to: query.to, + partnerId, + }); + } + + /** 자기 업체 실적 목록 — 최근순, 가린 이름만 */ + async findConversions( + partnerId: string, + query: PortalConversionQueryDto, + ): Promise> { + 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> { + 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(); + 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> { + 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])); + } +} diff --git a/backend/src/modules/partners/partners.module.ts b/backend/src/modules/partners/partners.module.ts index 3e75f76..b8a6d1f 100644 --- a/backend/src/modules/partners/partners.module.ts +++ b/backend/src/modules/partners/partners.module.ts @@ -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, diff --git a/backend/src/shared/utils/mask-name.spec.ts b/backend/src/shared/utils/mask-name.spec.ts new file mode 100644 index 0000000..d10acb8 --- /dev/null +++ b/backend/src/shared/utils/mask-name.spec.ts @@ -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'); + }); +}); diff --git a/backend/src/shared/utils/mask-name.ts b/backend/src/shared/utils/mask-name.ts new file mode 100644 index 0000000..d1b80d9 --- /dev/null +++ b/backend/src/shared/utils/mask-name.ts @@ -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)}`; +} diff --git a/backend/src/shared/utils/temporary-password.spec.ts b/backend/src/shared/utils/temporary-password.spec.ts new file mode 100644 index 0000000..56badaf --- /dev/null +++ b/backend/src/shared/utils/temporary-password.spec.ts @@ -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'); + }); +}); diff --git a/backend/src/shared/utils/temporary-password.ts b/backend/src/shared/utils/temporary-password.ts new file mode 100644 index 0000000..76b5602 --- /dev/null +++ b/backend/src/shared/utils/temporary-password.ts @@ -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; +}