From fba18767e2797c1793c8cb6f8bf2ac494da8ebd4 Mon Sep 17 00:00:00 2001 From: ChangHoonOh Date: Tue, 16 Jan 2024 00:02:21 +0900 Subject: [PATCH] feat/#108: add checking for social users --- .../social/controllers/auth-social.swagger.ts | 94 +++++++++++++++++++ .../auth/social/dto/auth-registration.dto.ts | 17 ++++ .../service/auth-registration.service.ts | 25 +++++ .../social/service/auth-social.service.ts | 91 ++++++++++++++++++ .../auth/social/types/auth-social.type.ts | 26 +++++ src/apis/auth/util/getSnsProfile.ts | 73 ++++++++++++++ 6 files changed, 326 insertions(+) create mode 100644 src/apis/auth/social/controllers/auth-social.swagger.ts create mode 100644 src/apis/auth/social/dto/auth-registration.dto.ts create mode 100644 src/apis/auth/social/service/auth-registration.service.ts create mode 100644 src/apis/auth/social/service/auth-social.service.ts create mode 100644 src/apis/auth/social/types/auth-social.type.ts create mode 100644 src/apis/auth/util/getSnsProfile.ts diff --git a/src/apis/auth/social/controllers/auth-social.swagger.ts b/src/apis/auth/social/controllers/auth-social.swagger.ts new file mode 100644 index 00000000..a265beb1 --- /dev/null +++ b/src/apis/auth/social/controllers/auth-social.swagger.ts @@ -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 = { + CheckRegistration: ( + apiOperationOptions: Required, 'summary'>> & + Partial, + ): PropertyDecorator => { + return applyDecorators( + ApiOperation({ + operationId: 'CheckRegistration', + ...apiOperationOptions, + }), + ApiCreatedResponse({ + type: Boolean + }), + ) + }, + SignUp: ( + apiOperationOptions: Required, 'summary'>> & + Partial, + ): 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, 'summary'>> & + Partial, + ): 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, + ]), + ); + }, +} \ No newline at end of file diff --git a/src/apis/auth/social/dto/auth-registration.dto.ts b/src/apis/auth/social/dto/auth-registration.dto.ts new file mode 100644 index 00000000..f7b4cf74 --- /dev/null +++ b/src/apis/auth/social/dto/auth-registration.dto.ts @@ -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; +} \ No newline at end of file diff --git a/src/apis/auth/social/service/auth-registration.service.ts b/src/apis/auth/social/service/auth-registration.service.ts new file mode 100644 index 00000000..28c9f4b2 --- /dev/null +++ b/src/apis/auth/social/service/auth-registration.service.ts @@ -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 { + 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; + } +} diff --git a/src/apis/auth/social/service/auth-social.service.ts b/src/apis/auth/social/service/auth-social.service.ts new file mode 100644 index 00000000..5a7e2039 --- /dev/null +++ b/src/apis/auth/social/service/auth-social.service.ts @@ -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 }; + + } +} \ No newline at end of file diff --git a/src/apis/auth/social/types/auth-social.type.ts b/src/apis/auth/social/types/auth-social.type.ts new file mode 100644 index 00000000..89fda89d --- /dev/null +++ b/src/apis/auth/social/types/auth-social.type.ts @@ -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; + }; +} diff --git a/src/apis/auth/util/getSnsProfile.ts b/src/apis/auth/util/getSnsProfile.ts new file mode 100644 index 00000000..a5c613d8 --- /dev/null +++ b/src/apis/auth/util/getSnsProfile.ts @@ -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} SNS profile 데이터 + */ +export async function getSnsProfile(loginType: string, snsToken: string): Promise { + 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; + } +}