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
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ export class AuthorizeOAuthUseCase {
constructor(private readonly oAuthProviderFactory: OAuthProviderFactory) {}

execute(requestDto: AuthorizeOAuthRequestDto): AuthorizeOAuthResponseDto {
const { oAuthProviderType } = requestDto;
const { oAuthProviderType, redirectUrl } = requestDto;
const provider = this.oAuthProviderFactory.getProvider(oAuthProviderType);
const authUrl = provider.getAuthorizationUrl();

const encodedState = encodeURIComponent(redirectUrl || '');

const authUrl = provider.getAuthorizationUrl(encodedState);

return { authUrl };
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { IsEnum, IsNotEmpty } from 'class-validator';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { OAuthProviderType } from 'src/auth/domain/value-object/oauth-provider.enum';

export class AuthorizeOAuthRequestDto {
@IsEnum(OAuthProviderType)
@IsNotEmpty()
oAuthProviderType: OAuthProviderType;

@IsString()
redirectUrl?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,11 @@ export class OAuthLoginResponseDto {
@IsString()
@IsNotEmpty()
refreshToken: string;

@IsString()
@IsNotEmpty()
userId: string;

@IsString()
redirectUrl: string;
}
1 change: 1 addition & 0 deletions src/auth/application/oauth-login/oauth-login.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ export class OAuthLoginCommand implements ICommand {
constructor(
public readonly oAuthProviderType: OAuthProviderType,
public readonly code: string,
public readonly state?: string,
) {}
}
12 changes: 11 additions & 1 deletion src/auth/application/oauth-login/oauth-login.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ export class OAuthLoginUseCase {
const { oauthId, provider, email } = await this.getOAuthUserInfo(oAuthProviderType, code);
const auth = await this.findOrCreateAuth(oauthId, provider, email);
const { accessToken, refreshToken } = await this.generateAndSaveTokens(auth);
const redirectUrl = this.decodeRedirectUrl(command.state);

return { accessToken, refreshToken };
return { accessToken, refreshToken, userId: auth.userId.value, redirectUrl };
}

// 소셜로그인 유저저 정보 가져오기
Expand Down Expand Up @@ -85,4 +86,13 @@ export class OAuthLoginUseCase {

return { accessToken, refreshToken };
}

// 리다이렉트 url 복호화 및 반환
private decodeRedirectUrl(state?: string): string {
if (!state) return '';

const decodedState = decodeURIComponent(state);

return decodedState;
}
}
4 changes: 4 additions & 0 deletions src/auth/domain/entity/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ export class Auth extends AggregateRoot<AuthProps> {
return auth;
}

public static of(props: AuthProps): Auth {
return new Auth(props);
}

public validate(): void {
if (!this.props.oauthId) {
throw new CustomException(CustomExceptionCode.AUTH_OAUTH_ID_EMPTY);
Expand Down
2 changes: 1 addition & 1 deletion src/auth/infrastructure/mapper/auth.mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { UserEntity } from 'src/user/command/infrastructure/user.entity';

export class AuthMapper {
static toDomain(entity: AuthEntity): Auth {
return Auth.create({
return Auth.of({
id: Identifier.from(entity.id),
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
Expand Down
27 changes: 17 additions & 10 deletions src/auth/presentation/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,30 +25,37 @@ export class AuthController {

@Get('oauth/authorization')
@AuthDocs('oauthAuthorization')
authorizeOAuth(@Res() res: Response) {
const { authUrl } = this.authorizeOAuthUseCase.execute({ oAuthProviderType: OAuthProviderType.KAKAO });
authorizeOAuth(@Query('returnPath') returnPath?: string) {
const { authUrl } = this.authorizeOAuthUseCase.execute({
oAuthProviderType: OAuthProviderType.KAKAO,
redirectUrl: returnPath,
});

res.redirect(authUrl);
return { authUrl, returnPath };
}

@Get('login/oauth/callback')
@Post('login/oauth/callback')
Copy link
Contributor

@sunwoo611 sunwoo611 Oct 30, 2025

Choose a reason for hiding this comment

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

POST로 바꾸면 provider의 리디렉션이 컨트롤러에 도달하지 않아서 GET으로 유지해야 한다는데, 저도 확실치가 않아서 확인 한 번만 해주세요.
추가적으로 클라이언트가 제공하는 redirectUrl을 그대로 사용하기 보다는 이것에 대한 whitelist 또는 도메인 검사로 검증해서 보안을 좀 강화시키는 것도 좋을 것 같아요.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

  1. 로그인 트리거(/oauth/authorize)와 콜백(login/oauth/callback)을 분리해서 프론트에서 직접 콜백을 요청하는 방식으로 변경해서 문제 없이 진행돼요.
  2. 로직 추가하겠니다

@AuthDocs('oauthCallback')
async oAuthLogin(@Res() res: Response, @Query('code') code: string, @Query('error') error?: string) {
async oAuthLogin(
@Res() res: Response,
@Query('code') code: string,
@Query('error') error?: string,
@Query('state') state?: string,
) {
if (error && error === 'access_denied') {
return res.redirect(`${this.configService.getOrThrow('frontend.url')}/login?error=cancelled`);
return res.status(HttpStatus.UNAUTHORIZED).json({ message: 'Access denied' });
}

const { accessToken, refreshToken } = await this.oAuthLoginUseCase.execute({
const { accessToken, refreshToken, userId, redirectUrl } = await this.oAuthLoginUseCase.execute({
oAuthProviderType: OAuthProviderType.KAKAO,
code,
state,
});

res.cookie('accessToken', accessToken, accessTokenCookieOptions);
res.cookie('refreshToken', refreshToken, refreshTokenCookieOptions);

res.redirect(
`${this.configService.getOrThrow('frontend.url')}/${this.configService.getOrThrow('frontend.loginRedirectPath')}`,
);
res.json({ userId, redirectUrl });
}

@Get('refresh')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { OAuthProviderType } from 'src/auth/domain/value-object/oauth-provider.e
export interface BaseOAuthProvider {
getToken(code: string): Promise<string>;
getUserInfo(token: string): Promise<OAuthUser>;
getAuthorizationUrl(): string;
getAuthorizationUrl(state?: string): string;
unlinkAccount(userId: string): Promise<void>;
}

Expand Down
5 changes: 4 additions & 1 deletion src/shared/core/infrastructure/oauth/kakao.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export class KakaoOAuthProvider implements BaseOAuthProvider {
return res.data.access_token;
}

getAuthorizationUrl(): string {
getAuthorizationUrl(state?: string): string {
const authorizeUrl = 'https://kauth.kakao.com/oauth/authorize';
const clientId = this.configService.get<string>('kakao.clientId');
const redirectUri = this.configService.get<string>('kakao.redirectUri');
Expand All @@ -84,6 +84,9 @@ export class KakaoOAuthProvider implements BaseOAuthProvider {
params.append('response_type', 'code');
params.append('client_id', clientId);
params.append('redirect_uri', redirectUri);
if (state) {
params.append('state', state);
}

return `${authorizeUrl}?${params.toString()}`;
}
Expand Down