Skip to content

Commit

Permalink
feat/#108: add checking for social users
Browse files Browse the repository at this point in the history
  • Loading branch information
ohchanghoon committed Jan 15, 2024
1 parent ed390d5 commit fba1876
Show file tree
Hide file tree
Showing 6 changed files with 326 additions and 0 deletions.
94 changes: 94 additions & 0 deletions src/apis/auth/social/controllers/auth-social.swagger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { ApiOperator } from "@src/types/type";
import { AuthSocialController } from "./auth-social.controller";
import { OperationObject } from "@nestjs/swagger/dist/interfaces/open-api-spec.interface";
import { HttpStatus, applyDecorators } from "@nestjs/common";
import { ApiCreatedResponse, ApiOperation } from "@nestjs/swagger";
import { DetailResponseDto } from "@src/interceptors/success-interceptor/dto/detail-response.dto";
import { UserDto } from "@src/apis/users/dto/user.dto";
import { ValidationError } from "class-validator";
import { HttpException } from "@src/http-exceptions/exceptions/http.exception";
import { COMMON_ERROR_CODE } from "@src/constants/error/common/common-error-code.constant";
import { USER_ERROR_CODE } from "@src/constants/error/users/user-error-code.constant";
import { AUTH_ERROR_CODE } from "@src/constants/error/auth/auth-error-code.constant";

export const ApiAuthSocial: ApiOperator<keyof AuthSocialController> = {
CheckRegistration: (
apiOperationOptions: Required<Pick<Partial<OperationObject>, 'summary'>> &
Partial<OperationObject>,
): PropertyDecorator => {
return applyDecorators(
ApiOperation({
operationId: 'CheckRegistration',
...apiOperationOptions,
}),
ApiCreatedResponse({
type: Boolean
}),
)
},
SignUp: (
apiOperationOptions: Required<Pick<Partial<OperationObject>, 'summary'>> &
Partial<OperationObject>,
): PropertyDecorator => {
return applyDecorators(
ApiOperation({
operationId: 'Signup',
...apiOperationOptions,
}),
DetailResponseDto.swaggerBuilder(HttpStatus.CREATED, 'user', UserDto),
HttpException.swaggerBuilder(
HttpStatus.BAD_REQUEST,
[COMMON_ERROR_CODE.INVALID_REQUEST_PARAMETER],
{
description:
'해당 필드는 request parameter 가 잘못된 경우에만 리턴됩니다.',
type: ValidationError,
},
),
HttpException.swaggerBuilder(HttpStatus.CONFLICT, [
USER_ERROR_CODE.ALREADY_EXIST_USER_EMAIL,
USER_ERROR_CODE.ALREADY_EXIST_USER_PHONE_NUMBER,
]),
HttpException.swaggerBuilder(HttpStatus.INTERNAL_SERVER_ERROR, [
COMMON_ERROR_CODE.SERVER_ERROR,
]),
);
},
SignIn: (
apiOperationOptions: Required<Pick<Partial<OperationObject>, 'summary'>> &
Partial<OperationObject>,
): PropertyDecorator => {
return applyDecorators(
ApiOperation({
operationId: 'AuthSignIn',
...apiOperationOptions,
}),
ApiCreatedResponse({
schema: {
properties: {
accessToken: {
description: 'access token',
type: 'string',
},
},
},
}),
HttpException.swaggerBuilder(
HttpStatus.BAD_REQUEST,
[
COMMON_ERROR_CODE.INVALID_REQUEST_PARAMETER,
AUTH_ERROR_CODE.ACCOUNT_NOT_FOUND,
AUTH_ERROR_CODE.DIFFERENT_ACCOUNT_INFORMATION,
],
{
description:
'해당 필드는 request parameter 가 잘못된 경우에만 리턴됩니다.',
type: ValidationError,
},
),
HttpException.swaggerBuilder(HttpStatus.INTERNAL_SERVER_ERROR, [
COMMON_ERROR_CODE.SERVER_ERROR,
]),
);
},
}
17 changes: 17 additions & 0 deletions src/apis/auth/social/dto/auth-registration.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ApiProperty } from "@nestjs/swagger";
import { UserLoginType } from "@src/apis/users/constants/user.enum";
import { IsEnum, IsString } from "class-validator";

export class CheckRegistrationRequestBodyDto {
@ApiProperty({
description: '로그인 타입',
enum: UserLoginType,
enumName: 'UserLoginType'
})
@IsEnum(UserLoginType)
loginType: UserLoginType;

@ApiProperty({ description: 'SNS 토큰' })
@IsString()
snsToken: string;
}
25 changes: 25 additions & 0 deletions src/apis/auth/social/service/auth-registration.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Injectable } from "@nestjs/common";
import { UsersService } from "@src/apis/users/services/users.service";
import { getSnsProfile } from "../../util/getSnsProfile";
import { CheckRegistrationRequestBodyDto } from "../dto/auth-registration.dto";

@Injectable()
export class AuthRegistrationService {
constructor(private readonly usersService: UsersService) { }

async checkUserRegistered(checkRegistrationRequestBodyDto: CheckRegistrationRequestBodyDto): Promise<boolean> {
const { loginType, snsToken } = checkRegistrationRequestBodyDto;
const snsProfile = await getSnsProfile(loginType, snsToken);

if (snsProfile) {
const user = await this.usersService.findOneBy({
loginType: checkRegistrationRequestBodyDto.loginType,
snsId: snsProfile.sns_id
});

return !!user;
}

return false;
}
}
91 changes: 91 additions & 0 deletions src/apis/auth/social/service/auth-social.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { Injectable } from "@nestjs/common";
import { SignInRequestBodyDto, SignUpRequestBodyDto } from "../dto/auth-social.dto";
import { UsersService } from "@src/apis/users/services/users.service";
import { HttpBadRequestException } from "@src/http-exceptions/exceptions/http-bad-request.exception";
import { HttpConflictException } from "@src/http-exceptions/exceptions/http-conflict.exception";
import { USER_ERROR_CODE } from "@src/constants/error/users/user-error-code.constant";
import { getSnsProfile } from "../../util/getSnsProfile";
import { HttpInternalServerErrorException } from "@src/http-exceptions/exceptions/http-internal-server-error.exception";
import { COMMON_ERROR_CODE } from "@src/constants/error/common/common-error-code.constant";
import { AUTH_ERROR_CODE } from "@src/constants/error/auth/auth-error-code.constant";
import { AuthService } from "../../services/auth.service";

@Injectable()
export class AuthSocialService {
constructor(
private readonly usersService: UsersService,
private readonly authService: AuthService,
) { }

async signUp(signUpRequestBodyDto: SignUpRequestBodyDto) {
const {
loginType,
snsToken,
email,
phoneNumber,
} = signUpRequestBodyDto;

const snsProfile = await getSnsProfile(loginType, snsToken);
if (!snsProfile.sns_id) {
throw new HttpInternalServerErrorException({
code: COMMON_ERROR_CODE.SERVER_ERROR,
ctx: '소셜 프로필 조회 중 알 수 없는 에러',
});
}

const existUser = await this.usersService.findOneBy({
loginType,
email: email,
phoneNumber: phoneNumber,
});

if (existUser) {
if (existUser.email.toLowerCase() === email.toLowerCase()) {
throw new HttpConflictException({
code: USER_ERROR_CODE.ALREADY_EXIST_USER_EMAIL,
});
}
if (existUser.phoneNumber === phoneNumber) {
throw new HttpConflictException({
code: USER_ERROR_CODE.ALREADY_EXIST_USER_PHONE_NUMBER,
});
}
}

const user = this.usersService.create({
...signUpRequestBodyDto,
snsId: snsProfile.sns_id,
password: null
})

return user;
}

async signIn(signInRequestBodyDto: SignInRequestBodyDto) {
const { loginType, snsToken } = signInRequestBodyDto;

const snsProfile = await getSnsProfile(loginType, snsToken);

if (!snsProfile.sns_id) {
throw new HttpInternalServerErrorException({
code: COMMON_ERROR_CODE.SERVER_ERROR,
ctx: '소셜 프로필 조회 중 알 수 없는 에러',
});
}

const existUser = await this.usersService.findOneBy({
loginType,
snsId: snsProfile.sns_id
});

if (!existUser) {
throw new HttpBadRequestException({
code: AUTH_ERROR_CODE.ACCOUNT_NOT_FOUND,
});
}

const accessToken = this.authService.generateToken({ id: existUser.id });
return { accessToken };

}
}
26 changes: 26 additions & 0 deletions src/apis/auth/social/types/auth-social.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { UserLoginType } from "@src/apis/users/constants/user.enum";

export interface SnsProfileBase {
sns_id: string;
}

export interface SnsProfile extends SnsProfileBase {
loginType: UserLoginType;
snsToken: string;
}

export interface KakaoUserResponse {
id: string;
}

export interface GoogleUserResponse {
sub: string;
}

export interface NaverUserResponse {
resultcode: string;
message: string;
response?: {
id: string;
};
}
73 changes: 73 additions & 0 deletions src/apis/auth/util/getSnsProfile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import fetch from 'node-fetch';
import { GoogleUserResponse, KakaoUserResponse, NaverUserResponse, SnsProfileBase } from '../social/types/auth-social.type';
import { HttpBadRequestException } from '@src/http-exceptions/exceptions/http-bad-request.exception';
import { ERROR_CODE } from '@src/constants/error/error-code.constant';
import { HttpInternalServerErrorException } from '@src/http-exceptions/exceptions/http-internal-server-error.exception';
import { COMMON_ERROR_CODE } from '@src/constants/error/common/common-error-code.constant';

/**
* SNS에서 사용자 프로필 정보를 가져온다.
* @param {string} loginType 'GOOGLE','KAKAO','NAVER'
* @param {string} snsToken SNS에서 발급한 token
* @returns {Promise<SnsProfileBase>} SNS profile 데이터
*/
export async function getSnsProfile(loginType: string, snsToken: string): Promise<SnsProfileBase> {
try {
let result: SnsProfileBase;

switch (loginType) {
case 'KAKAO': {
const response = await fetch('https://kapi.kakao.com/v2/user/me', {
method: 'GET',
headers: { Authorization: `Bearer ${snsToken}` },
});

const { id: kakaoId } = (await response.json()) as KakaoUserResponse;

result = { sns_id: kakaoId };
break;
}

case 'GOOGLE': {
const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
method: 'GET',
headers: { Authorization: `Bearer ${snsToken}` },
});

const { sub: googleSub } = (await response.json()) as GoogleUserResponse;

result = { sns_id: googleSub };
break;
}

case 'NAVER': {
const response = await fetch('https://openapi.naver.com/v1/nid/me', {
method: 'GET',
headers: { Authorization: `Bearer ${snsToken}` },
});

const { resultcode, message, response: naverResponse } = (await response.json()) as NaverUserResponse;

if (resultcode !== '00') {
throw new HttpInternalServerErrorException({
code: COMMON_ERROR_CODE.SERVER_ERROR,
ctx: `네이버 서버 에러 ${message}`
})
}

result = { sns_id: naverResponse?.id || '' };
break;
}

default:
throw new HttpBadRequestException({
code: ERROR_CODE.INVALID_REQUEST_PARAMETER
});
}

return result;
} catch (error) {
console.error('Error fetching user information:', error.message);
return null;
}
}

0 comments on commit fba1876

Please sign in to comment.