Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/features/auth/helpers/initial-nickname.helper.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { buildInitialNickname } from '@/features/auth/helpers/initial-nickname.helper';

// 앱 nickname 정책(영문/숫자/한글/_) — 생성 결과가 항상 이를 만족해야 한다.
const NICKNAME_REGEX = /^[A-Za-z0-9가-힣_]+$/;

describe('buildInitialNickname', () => {
it('공백 포함 이름은 공백을 제거하고 accountId suffix 를 붙인다', () => {
expect(buildInitialNickname(5n, 'John Doe')).toBe('JohnDoe_5');
});

it('특수문자는 제거한다', () => {
expect(buildInitialNickname(7n, 'user@123!')).toBe('user123_7');
});

it('한글 이름은 유지한다', () => {
expect(buildInitialNickname(9n, '김 철수')).toBe('김철수_9');
});

it('표시 이름이 비거나 모두 특수문자면 이메일 local part 를 정제해 사용한다', () => {
expect(buildInitialNickname(3n, '!!!', 'john.doe@example.com')).toBe(
'johndoe_3',
);
});

it('이름·이메일이 모두 없거나 무효면 user 로 폴백한다', () => {
expect(buildInitialNickname(11n)).toBe('user_11');
expect(buildInitialNickname(12n, ' ', '@@@')).toBe('user_12');
});

it('VarChar(50) 한도 내로 clamp 하되 accountId suffix 는 보존한다', () => {
const longName = 'a'.repeat(100);
const result = buildInitialNickname(123n, longName);
expect(result.length).toBeLessThanOrEqual(50);
expect(result.endsWith('_123')).toBe(true);
});

it('생성 결과는 항상 nickname 정책(regex)을 만족한다', () => {
const cases: [bigint, string | undefined, string | undefined][] = [
[1n, 'John Doe', undefined],
[2n, 'user@!#$', 'a.b+c@x.com'],
[3n, '김 철수 ', undefined],
[4n, undefined, undefined],
];
for (const [id, name, email] of cases) {
expect(buildInitialNickname(id, name, email)).toMatch(NICKNAME_REGEX);
}
});

it('서로 다른 accountId 는 서로 다른 nickname 을 만든다 (유니크 보장)', () => {
expect(buildInitialNickname(1n, '철수')).not.toBe(
buildInitialNickname(2n, '철수'),
);
});
});
36 changes: 36 additions & 0 deletions src/features/auth/helpers/initial-nickname.helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// UserProfile.nickname 컬럼 한도 (prisma schema: VarChar(50)).
const MAX_NICKNAME_LENGTH = 50;

// 앱 nickname 정책과 동일한 허용 문자 집합(영문/숫자/한글/_) 외 문자.
const DISALLOWED_CHARS = /[^A-Za-z0-9가-힣_]/g;

/**
* OIDC 최초 가입 시 사용할 임시 nickname 을 생성한다.
* (사용자는 온보딩에서 본인 nickname 으로 교체한다.)
*
* provider 가 주는 이름/이메일에는 공백·특수문자가 있거나, 길이가 길거나, 흔한 이름이
* 겹칠 수 있다. 이를 그대로 쓰면 (a) nickname 정책 위반 값 저장, (b) VarChar(50) 초과
* insert 실패, (c) unique 제약 충돌로 가입 실패가 발생한다. 이를 모두 방지한다:
* - 허용 문자만 남기고 제거(공백/특수문자 제거)
* - `_{accountId}`(PK) suffix 로 유일성 보장
* - VarChar(50) 한도 내로 clamp (suffix 는 보존)
*
* @param accountId 생성된 계정 PK (유일성 보장에 사용)
* @param displayName provider 표시 이름 (kakao nickname / google name 등)
* @param email provider 이메일 (표시 이름이 없을 때 local part 사용)
*/
export function buildInitialNickname(
accountId: bigint,
displayName?: string,
email?: string,
): string {
const sanitize = (raw: string): string => raw.replace(DISALLOWED_CHARS, '');

const fromName = sanitize(displayName?.trim() ?? '');
const fromEmail = email ? sanitize(email.split('@')[0]) : '';
const base = fromName || fromEmail || 'user';

const suffix = `_${accountId.toString()}`;
const baseMaxLength = Math.max(0, MAX_NICKNAME_LENGTH - suffix.length);
return `${base.slice(0, baseMaxLength)}${suffix}`;
}
13 changes: 10 additions & 3 deletions src/features/auth/repositories/account.repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,10 @@ describe('AccountRepository (real DB)', () => {
expect(result.account).not.toBeNull();
expect(result.account!.email).toBe('new@example.com');
expect(result.account!.user_profile).not.toBeNull();
expect(result.account!.user_profile!.nickname).toBe('New User');
// 공백 제거 + accountId suffix (이전엔 'New User' 가 공백째 저장되던 회귀)
expect(result.account!.user_profile!.nickname).toBe(
`NewUser_${result.account!.id}`,
);
});

it('기존 Identity가 있으면 업데이트한다', async () => {
Expand Down Expand Up @@ -142,7 +145,9 @@ describe('AccountRepository (real DB)', () => {
emailVerified: true,
});

expect(result.account!.user_profile!.nickname).toBe('john');
expect(result.account!.user_profile!.nickname).toBe(
`john_${result.account!.id}`,
);
});

it('displayName/email 모두 없으면 nickname이 "user"로 생성된다', async () => {
Expand All @@ -152,7 +157,9 @@ describe('AccountRepository (real DB)', () => {
emailVerified: false,
});

expect(result.account!.user_profile!.nickname).toBe('user');
expect(result.account!.user_profile!.nickname).toBe(
`user_${result.account!.id}`,
);
});

it('기존 Identity + account email이 null + user_profile 없는 경우: profile 신규 생성 + email 주입', async () => {
Expand Down
4 changes: 2 additions & 2 deletions src/features/auth/repositories/account.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { AccountType, type IdentityProvider } from '@prisma/client';

import { ClockService } from '@/common/providers/clock.service';
import { buildInitialNickname } from '@/features/auth/helpers/initial-nickname.helper';
import type {
AccountForJwt,
AccountIdentityWithAccount,
Expand Down Expand Up @@ -224,8 +225,7 @@ export class AccountRepository implements IAccountRepository {
email?: string,
profileImageUrl?: string,
): Promise<void> {
const nickname =
displayName?.trim() || (email ? email.split('@')[0] : 'user');
const nickname = buildInitialNickname(accountId, displayName, email);

await tx.userProfile.create({
data: {
Expand Down
47 changes: 47 additions & 0 deletions src/features/auth/services/oidc-client.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,53 @@ describe('OidcClientService', () => {
code_challenge_method: 'S256',
});
});

it('Kakao는 카카오 동의항목 scope를 사용해야 한다 (표준 email/profile 금지 — KOE205 방지)', async () => {
mockConfig.get.mockImplementation((key: string) => {
const config: Record<string, string> = {
OIDC_KAKAO_ISSUER_URL: 'https://kauth.kakao.com',
OIDC_KAKAO_CLIENT_ID: 'kakao-client-id',
OIDC_KAKAO_CLIENT_SECRET: 'kakao-client-secret',
BACKEND_BASE_URL: 'http://localhost:4000',
};
return config[key];
});
mockClient.authorizationUrl.mockReturnValue(
'https://kauth.kakao.com/...',
);

await service.buildAuthorizationUrl('kakao');

expect(mockClient.authorizationUrl).toHaveBeenCalledWith(
expect.objectContaining({
scope: 'openid account_email profile_nickname profile_image',
}),
);
});

it('OIDC_KAKAO_SCOPE env 로 scope 를 덮어쓸 수 있어야 한다 (콘솔 동의항목과 정합)', async () => {
mockConfig.get.mockImplementation((key: string) => {
const config: Record<string, string> = {
OIDC_KAKAO_ISSUER_URL: 'https://kauth.kakao.com',
OIDC_KAKAO_CLIENT_ID: 'kakao-client-id',
OIDC_KAKAO_CLIENT_SECRET: 'kakao-client-secret',
BACKEND_BASE_URL: 'http://localhost:4000',
OIDC_KAKAO_SCOPE: 'openid profile_nickname profile_image',
};
return config[key];
});
mockClient.authorizationUrl.mockReturnValue(
'https://kauth.kakao.com/...',
);

await service.buildAuthorizationUrl('kakao');

expect(mockClient.authorizationUrl).toHaveBeenCalledWith(
expect.objectContaining({
scope: 'openid profile_nickname profile_image',
}),
);
});
});

describe('exchangeCode', () => {
Expand Down
26 changes: 25 additions & 1 deletion src/features/auth/services/oidc-client.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export class OidcClientService {
const codeChallenge = generators.codeChallenge(codeVerifier);

const authorizationUrl = client.authorizationUrl({
scope: 'openid email profile',
scope: this.getScope(provider),
state,
nonce,
code_challenge: codeChallenge,
Expand All @@ -72,6 +72,30 @@ export class OidcClientService {
return { authorizationUrl, state, nonce, codeVerifier };
}

/**
* provider별 OIDC scope를 반환한다.
*
* 카카오는 표준 `email`/`profile`이 아니라 자체 동의항목 ID
* (`account_email`/`profile_nickname`/`profile_image`)를 사용한다. 표준 scope를 보내면
* KOE205(invalid_scope)가 발생하므로 provider별로 분리한다.
* 콘솔에 활성화한 동의항목과 정확히 맞추도록 env(`OIDC_GOOGLE_SCOPE`/`OIDC_KAKAO_SCOPE`)로
* 덮어쓸 수 있다(예: account_email 미승인 시 카카오 scope에서 제외).
*
* @param provider provider
*/
private getScope(provider: OidcProvider): string {
if (provider === 'google') {
return (
this.config.get<string>('OIDC_GOOGLE_SCOPE')?.trim() ||
'openid email profile'
);
}
return (
this.config.get<string>('OIDC_KAKAO_SCOPE')?.trim() ||
'openid account_email profile_nickname profile_image'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Delimit Kakao consent scopes with commas

For Kakao authorization requests that explicitly ask for additional consent items, Kakao's REST API docs show the scope value as comma-delimited (and note OIDC requests must include openid when scope is present), while openid-client will send this string unchanged as scope=openid%20account_email.... In that Kakao login path this can still be parsed as an invalid/single consent scope and reproduce the KOE205 failure this change is trying to fix; the Kakao default (and example override) should use commas, e.g. openid,account_email,profile_nickname,profile_image.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

false positive. 카카오 OIDC scope는 OIDC 표준대로 공백 구분이 맞음(RFC 6749 + 카카오 OIDC 문서 'space-delimited' + 토큰 응답 scope도 공백: 'profile_image openid profile_nickname' + next-auth/openid-client 동일 사용). 콤마로 주면 openid-client가 %2C로 인코딩→카카오가 단일 무효 scope로 파싱→오히려 KOE205. 콤마 표기는 카카오 레거시 REST API 한정.

);
}

/**
* OIDC callback 처리 후 TokenSet을 반환한다.
*
Expand Down
16 changes: 15 additions & 1 deletion src/features/user/services/user-profile.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('UserProfileService (real DB)', () => {
beforeAll(async () => {
s3Service = {
createUploadUrl: jest.fn(),
isOwnedProfileImageUrl: jest.fn(),
} as unknown as jest.Mocked<S3Service>;

const { module, prisma: p } = await createTestingModuleWithRealDb({
Expand Down Expand Up @@ -412,9 +413,10 @@ describe('UserProfileService (real DB)', () => {

// ─── updateMyProfileImage ───
describe('updateMyProfileImage', () => {
it('유효한 URL이면 프로필 이미지를 업데이트한다', async () => {
it('발급된(소유) URL이면 프로필 이미지를 업데이트한다', async () => {
const account = await createAccount(prisma, { account_type: 'USER' });
await createUserProfile(prisma, { account_id: account.id });
s3Service.isOwnedProfileImageUrl.mockReturnValue(true);

const result = await service.updateMyProfileImage(account.id, {
profileImageUrl: 'https://s3.example.com/profile.jpg',
Expand All @@ -425,6 +427,18 @@ describe('UserProfileService (real DB)', () => {
);
});

it('발급되지 않은(소유 아님) URL이면 BadRequest 로 거절한다', async () => {
const account = await createAccount(prisma, { account_type: 'USER' });
await createUserProfile(prisma, { account_id: account.id });
s3Service.isOwnedProfileImageUrl.mockReturnValue(false);

await expect(
service.updateMyProfileImage(account.id, {
profileImageUrl: 'https://evil.example.com/someone-else.jpg',
}),
).rejects.toThrow(BadRequestException);
});

// profileImageUrl 형식·길이 검증은 DTO (UpdateMyProfileImageInput) 로 이전됨.
});

Expand Down
6 changes: 6 additions & 0 deletions src/features/user/services/user-profile.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ export class UserProfileService extends UserBaseService {
// DTO 의 @Transform 이 trim, @MinLength/@MaxLength 가 길이를 보장.
const profileImageUrl = input.profileImageUrl;

// 우리가 발급한 presigned URL(이 버킷·해당 계정 prefix)인지 검증 —
// 클라이언트가 임의 URL 을 프로필 이미지로 저장하는 것을 방지한다.
if (!this.s3Service.isOwnedProfileImageUrl(profileImageUrl, accountId)) {
throw new BadRequestException('Invalid profile image URL.');
}

await this.repo.updateProfileImage({
accountId,
profileImageUrl,
Expand Down
53 changes: 53 additions & 0 deletions src/global/storage/s3.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,59 @@ describe('S3Service', () => {
expect(lastCallArgs?.credentials).toBeUndefined();
});
});

describe('isOwnedProfileImageUrl', () => {
it('이 버킷·해당 계정 prefix 의 URL 이면 true', () => {
const url =
'https://caquick-media-test.s3.ap-northeast-2.amazonaws.com/profile-images/1/2026-06-10/abc.jpg';
expect(service.isOwnedProfileImageUrl(url, BigInt(1))).toBe(true);
});

it('다른 계정 prefix 면 false', () => {
const url =
'https://caquick-media-test.s3.ap-northeast-2.amazonaws.com/profile-images/2/2026-06-10/abc.jpg';
expect(service.isOwnedProfileImageUrl(url, BigInt(1))).toBe(false);
});

it('다른 버킷/외부 도메인 URL 이면 false', () => {
expect(
service.isOwnedProfileImageUrl(
'https://evil.example.com/profile-images/1/x.jpg',
BigInt(1),
),
).toBe(false);
});

it('review-media 등 다른 prefix 면 false', () => {
const url =
'https://caquick-media-test.s3.ap-northeast-2.amazonaws.com/review-media/images/1/x.jpg';
expect(service.isOwnedProfileImageUrl(url, BigInt(1))).toBe(false);
});

it('path traversal(../)로 타 계정 key 를 가리키면 false (정규화 후 검증)', () => {
const url =
'https://caquick-media-test.s3.ap-northeast-2.amazonaws.com/profile-images/1/../2/2026-06-10/x.jpg';
expect(service.isOwnedProfileImageUrl(url, BigInt(1))).toBe(false);
});

it('인코딩된 dot(%2e)이 포함되면 false', () => {
const url =
'https://caquick-media-test.s3.ap-northeast-2.amazonaws.com/profile-images/1/%2e%2e/2/x.jpg';
expect(service.isOwnedProfileImageUrl(url, BigInt(1))).toBe(false);
});

it('http(비 https)면 false', () => {
const url =
'http://caquick-media-test.s3.ap-northeast-2.amazonaws.com/profile-images/1/x.jpg';
expect(service.isOwnedProfileImageUrl(url, BigInt(1))).toBe(false);
});

it('URL 형식이 아니면 false', () => {
expect(service.isOwnedProfileImageUrl('not a url', BigInt(1))).toBe(
false,
);
});
});
});

// Jest 커스텀 매처
Expand Down
33 changes: 33 additions & 0 deletions src/global/storage/s3.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,39 @@ export class S3Service {
}
}

/**
* 주어진 URL 이 이 버킷에 발급된 "해당 계정의 프로필 이미지" URL 인지 검증한다.
* 클라이언트가 임의 URL(외부 링크·타인 key)을 프로필 이미지로 저장하는 것을 막는다.
*
* raw `startsWith` 비교는 path traversal(`/profile-images/1/../2/...`)로 우회 가능하므로
* URL 을 파싱해 host·protocol 과 **정규화된 pathname** 을 검증하고, dot segment(`.`/`..`)와
* 인코딩된 dot(`%2e`)은 거절한다.
*
* @param url 저장하려는 URL
* @param accountId 소유 계정
*/
isOwnedProfileImageUrl(url: string, accountId: bigint): boolean {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}

// 인코딩된 path traversal(%2e) 차단 — URL 파서가 정규화하지 않는 케이스 방어.
// (literal `../`·`./` 는 new URL 이 정규화하므로, 아래 정규화된 pathname prefix 검사로 자동 차단됨)
if (/%2e/i.test(parsed.pathname)) return false;

const expectedHost = `${this.bucket}.s3.${this.region}.amazonaws.com`;
const expectedPathPrefix = `/${UPLOAD_POLICIES.PROFILE_IMAGE.keyPrefix}/${accountId.toString()}/`;

return (
parsed.protocol === 'https:' &&
parsed.host === expectedHost &&
parsed.pathname.startsWith(expectedPathPrefix)
);
}

private validateContentType(
contentType: string,
allowedTypes: readonly string[],
Expand Down
Loading