diff --git a/packages/server/package.json b/packages/server/package.json index 11c044d..d3b1e84 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -23,6 +23,7 @@ "@aws-sdk/client-s3": "^3.682.0", "@aws-sdk/s3-request-presigner": "^3.682.0", "@imgly/background-removal-node": "^1.4.5", + "@nestjs/axios": "^3.1.3", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.0.0", @@ -35,6 +36,7 @@ "class-validator": "^0.14.1", "dotenv": "^16.4.5", "ioredis": "^5.4.1", + "jsonwebtoken": "^9.0.2", "mysql2": "^3.11.3", "node-imap": "^0.9.6", "passport": "^0.7.0", @@ -52,6 +54,7 @@ "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", + "@types/jsonwebtoken": "^9", "@types/multer": "^1.4.12", "@types/node": "^20.3.1", "@types/node-imap": "^0", @@ -62,7 +65,7 @@ "@types/uuid": "^10", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", - "axios": "^1.7.7", + "axios": "^1.7.9", "eslint": "^8.42.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", diff --git a/packages/server/src/app.module.ts b/packages/server/src/app.module.ts index 77845e2..edba386 100644 --- a/packages/server/src/app.module.ts +++ b/packages/server/src/app.module.ts @@ -35,7 +35,7 @@ export const modules = [ const errors = validateSync(validatedConfig); if (errors.length > 0) { errors.map((err) => { - console.error(err); + console.error('필수 환경 변수가 없습니다 :', err.constraints); }); } diff --git a/packages/server/src/domain/auth/auth.controller.ts b/packages/server/src/domain/auth/auth.controller.ts index 14732dc..d4e875d 100644 --- a/packages/server/src/domain/auth/auth.controller.ts +++ b/packages/server/src/domain/auth/auth.controller.ts @@ -38,7 +38,7 @@ export class AuthController { @Body() dto: SignRequest, @Res({ passthrough: true }) res: Response, ) { - const data = await this.auth.signUp(dto.phone, dto.password); + const data = await this.auth.signUp(dto.email, dto.password); res.cookie('accessToken', data.access, { httpOnly: true, }); @@ -57,7 +57,28 @@ export class AuthController { @Body() dto: SignRequest, @Res({ passthrough: true }) res: Response, ) { - const data = await this.auth.signIn(dto.phone, dto.password); + const data = await this.auth.signIn(dto.email, dto.password); + res.cookie('accessToken', data.access, { + httpOnly: true, + }); + res.cookie('refreshToken', data.refresh, { httpOnly: true }); + return { + result: true, + data, + }; + } + + @ApiOperation({ + summary : '구글 이메일 로그인', + description : '기존 회원이 아닐 경우 회원 가입 처리' + }) + @Post('signin/google') + @UseGuards() + async signInWithGoogle( + @Body() body : {code : string}, + @Res({ passthrough: true }) res: Response, + ) { + const data = await this.auth.signInWithGoogle(body.code) res.cookie('accessToken', data.access, { httpOnly: true, }); diff --git a/packages/server/src/domain/auth/auth.facade.ts b/packages/server/src/domain/auth/auth.facade.ts index 0f74086..feb5378 100644 --- a/packages/server/src/domain/auth/auth.facade.ts +++ b/packages/server/src/domain/auth/auth.facade.ts @@ -8,6 +8,8 @@ import { AuthService } from './service/auth.service'; import { MailService } from './service/mail.service'; import { SessionService } from './service/session.service'; import { UserService } from '../user/user.service'; +import { randomString } from '@app/util/random'; +import { GoogleService } from '../google/google.service'; @Injectable() export class AuthFacade { @@ -17,6 +19,7 @@ export class AuthFacade { private readonly authService: AuthService, private readonly mailService: MailService, private readonly sessionService: SessionService, + private readonly googleService: GoogleService, ) {} async prepareSignUp(phone: string) { @@ -38,10 +41,10 @@ export class AuthFacade { return await this.authService.resign(refreshToken); } - async signIn(phone: string, pwd: string) { + async signIn(email: string, pwd: string) { //TODO: 이미 로그인되었을 경우 기존 로그인 세션 제거 const { userId, access, refresh } = await this.authService.signIn( - phone, + email, pwd, ); await this.sessionService.setSignInSession(userId, access, refresh); @@ -51,11 +54,14 @@ export class AuthFacade { }; } - async signUpWithGoogle(mail: string) { - const user = await this.userService.getGoogleUser(mail).catch((err) => { + async signInWithGoogle(authCode: string) { + const email = await this.googleService.getEmailFromCode(authCode); + const user = await this.userService.getUser({email}).catch( async (err) => { if (err instanceof UnauthorizedException) { + return await this.userService.saveUser(email,randomString(10)) } throw err; }); + return this.signIn(user.email,user.password) } } diff --git a/packages/server/src/domain/auth/auth.google.controller.ts b/packages/server/src/domain/auth/auth.google.controller.ts deleted file mode 100644 index 0349962..0000000 --- a/packages/server/src/domain/auth/auth.google.controller.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - Body, - Controller, - Get, - Post, - Request, - Res, - UseGuards, -} from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; -import { ApiOperation } from '@nestjs/swagger'; -import { GoogleSignRequest, SignRequest } from './dto/sign'; -import { AuthFacade } from './auth.facade'; -import { Response } from 'express'; - -@Controller('auth/google') -export class AuthGoogleController { - constructor(private readonly auth: AuthFacade) {} - - @Get() - @UseGuards(AuthGuard('google')) - async googleAuth() {} - - @Get('redirect') - @UseGuards(AuthGuard('google')) - async googleAuthRedirect() { - return { - result: true, - }; - } -} diff --git a/packages/server/src/domain/auth/auth.module.ts b/packages/server/src/domain/auth/auth.module.ts index 08e1447..0a77207 100644 --- a/packages/server/src/domain/auth/auth.module.ts +++ b/packages/server/src/domain/auth/auth.module.ts @@ -10,7 +10,6 @@ import { UserRefreshStrategy } from '@app/jwt/strategy/user.refresh.strategy'; import { AuthFacade } from './auth.facade'; import { MailService } from './service/mail.service'; import { SessionService } from './service/session.service'; -import { AuthGoogleController } from './auth.google.controller'; import { GoogleStrategy } from '@app/jwt/strategy/google.strategy'; interface AuthModuleAsyncOptions { @@ -42,7 +41,7 @@ export class AuthModule { inject: options.inject, }), ], - controllers: [AuthController, AuthGoogleController], + controllers: [AuthController], providers: [ { provide: UserAccessStrategy, diff --git a/packages/server/src/domain/auth/dto/sign.ts b/packages/server/src/domain/auth/dto/sign.ts index 08cb902..85572fb 100644 --- a/packages/server/src/domain/auth/dto/sign.ts +++ b/packages/server/src/domain/auth/dto/sign.ts @@ -1,20 +1,14 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsNotEmpty, IsPhoneNumber, IsString } from 'class-validator'; +import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; export class SignRequest { - @ApiProperty({ description: '휴대폰 번호', example: '01012341234' }) + @ApiProperty({ description: '이메일 주소', example: 'fog0510@gmail.com' }) @IsNotEmpty() - @IsPhoneNumber('KR') - phone: string; + @IsEmail() + email: string; @ApiProperty({ description: '패스워드' }) @IsNotEmpty() @IsString() password: string; -} - -export class GoogleSignRequest extends SignRequest{ - @IsNotEmpty() - @IsEmail() - mail : string; } \ No newline at end of file diff --git a/packages/server/src/domain/auth/service/auth.service.ts b/packages/server/src/domain/auth/service/auth.service.ts index 23da4d2..c1702e2 100644 --- a/packages/server/src/domain/auth/service/auth.service.ts +++ b/packages/server/src/domain/auth/service/auth.service.ts @@ -11,8 +11,8 @@ export class AuthService { private readonly jwtService: JwtService, ) {} - async signIn(phone: string, pwd: string) { - const { userId, password } = await this.userService.getUser({ phone }); + async signIn(email: string, pwd: string) { + const { userId, password } = await this.userService.getUser({ email }); if (password !== pwd) throw new UnauthorizedException('잘못된 비밀번호 입니다.'); const refresh = this._generateRefreshToken(userId); @@ -24,8 +24,8 @@ export class AuthService { }; } - async signUp(phone: string, pwd: string) { - const user = await this.userService.saveUser(phone, pwd); + async signUp(email: string, pwd: string) { + const user = await this.userService.saveUser(email, pwd); const access = this._generateAccessToken(user.userId); const refresh = this._generateRefreshToken(user.userId); await this.userService.updateRefresh(user.userId, refresh); diff --git a/packages/server/src/domain/dto/env.ts b/packages/server/src/domain/dto/env.ts index 7a8c74d..c3fcfc3 100644 --- a/packages/server/src/domain/dto/env.ts +++ b/packages/server/src/domain/dto/env.ts @@ -89,4 +89,16 @@ export class Enviroments { @IsNotEmpty() @IsString() JWT_REFRESH_EXPIRES: string; + + @IsNotEmpty() + @IsString() + GOOGLE_CALLBACK_URL : string; + + @IsNotEmpty() + @IsString() + GOOGLE_CLIENT_ID : string; + + @IsNotEmpty() + @IsString() + GOOGLE_CLIENT_SECRET : string; } diff --git a/packages/server/src/domain/google/google.service.ts b/packages/server/src/domain/google/google.service.ts new file mode 100644 index 0000000..1f171e8 --- /dev/null +++ b/packages/server/src/domain/google/google.service.ts @@ -0,0 +1,49 @@ +import { HttpService } from "@nestjs/axios"; +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { verify } from 'jsonwebtoken' +import { firstValueFrom } from "rxjs"; +@Injectable() +export class GoogleService { + + private GOOGLE_OAUTH_URL = 'https://oauth2.googleapis.com/token' + private GOOGLE_CLIENT_ID : string; + private GOOGLE_CLIENT_SECRET : string; + private GOOGLE_CALLBACK_URL : string; + + constructor( + private readonly http : HttpService, + private readonly config: ConfigService + ) { + this.GOOGLE_CLIENT_ID = config.get('GOOGLE_CLIENT_ID'); + this.GOOGLE_CLIENT_SECRET = config.get('GOOGLE_CLIENT_SECRET') + this.GOOGLE_CALLBACK_URL = config.get('GOOGLE_CALLBACK_URL') + } + + /** + * 구글 OAuth 로 authCode 를 보내 idToken 획득 후 email 추출 + * @param code + * @returns + */ + async getEmailFromCode(code: string) : Promise { + if(!code) throw new UnauthorizedException('AuhorizationCode required') + const { data } = await firstValueFrom(this.http.post<{ + id_token : string, + }>(this.GOOGLE_OAUTH_URL,{ + code, + client_id : this.GOOGLE_CLIENT_ID, + client_secret : this.GOOGLE_CLIENT_SECRET, + redirect_uri : this.GOOGLE_CALLBACK_URL, + grant_type : 'authorization_code' + })) + const { id_token : idToken } = data; + const decoded = verify(idToken, '', { complete: true }) as { + payload: { email?: string }; + }; + const email = decoded?.payload?.email; + if (!email) { + throw new Error('Email not found in id_token'); + } + return email; + } +} \ No newline at end of file diff --git a/packages/server/src/domain/user/user.service.ts b/packages/server/src/domain/user/user.service.ts index 04b7680..ca17f5a 100644 --- a/packages/server/src/domain/user/user.service.ts +++ b/packages/server/src/domain/user/user.service.ts @@ -20,12 +20,6 @@ export class UserService { return user; } - async getGoogleUser(email: string): Promise { - const user = await this.userRepository.selectUserFromEmail({ email }); - if (!user) throw new UnauthorizedException('존재하지 않는 회원입니다.'); - return user; - } - async validateRefresh(userId: string, refreshToken: string) { const user = await this.getUser({ userId }); if (user.refreshToken !== refreshToken) diff --git a/packages/server/src/util/random.ts b/packages/server/src/util/random.ts index 9d4299e..7ff613a 100644 --- a/packages/server/src/util/random.ts +++ b/packages/server/src/util/random.ts @@ -1,3 +1,8 @@ +/** + * 랜덤한 문자열을 반환한다. ( 기본 길이 10 ) + * @param length + * @returns string + */ export const randomString = (length: number = 10): string => { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; diff --git a/yarn.lock b/yarn.lock index c3f291a..8cf85b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2218,6 +2218,17 @@ __metadata: languageName: node linkType: hard +"@nestjs/axios@npm:^3.1.3": + version: 3.1.3 + resolution: "@nestjs/axios@npm:3.1.3" + peerDependencies: + "@nestjs/common": ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + axios: ^1.3.1 + rxjs: ^6.0.0 || ^7.0.0 + checksum: 10c0/3b3f5ecc9a17317daafbf6ffe0cb792c5bd44d9fe7c6e2bda5d87163b9c2ed05a71a49d4e2d810c455eaa94f25e85e63673da21d674917bd9c16dfb937771109 + languageName: node + linkType: hard + "@nestjs/cli@npm:^10.0.0": version: 10.4.5 resolution: "@nestjs/cli@npm:10.4.5" @@ -3553,7 +3564,7 @@ __metadata: languageName: node linkType: hard -"@types/jsonwebtoken@npm:*": +"@types/jsonwebtoken@npm:*, @types/jsonwebtoken@npm:^9": version: 9.0.7 resolution: "@types/jsonwebtoken@npm:9.0.7" dependencies: @@ -4711,14 +4722,14 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.7.7": - version: 1.7.7 - resolution: "axios@npm:1.7.7" +"axios@npm:^1.7.9": + version: 1.7.9 + resolution: "axios@npm:1.7.9" dependencies: follow-redirects: "npm:^1.15.6" form-data: "npm:^4.0.0" proxy-from-env: "npm:^1.1.0" - checksum: 10c0/4499efc89e86b0b49ffddc018798de05fab26e3bf57913818266be73279a6418c3ce8f9e934c7d2d707ab8c095e837fc6c90608fb7715b94d357720b5f568af7 + checksum: 10c0/b7a41e24b59fee5f0f26c1fc844b45b17442832eb3a0fb42dd4f1430eb4abc571fe168e67913e8a1d91c993232bd1d1ab03e20e4d1fee8c6147649b576fc1b0b languageName: node linkType: hard @@ -8730,7 +8741,7 @@ __metadata: languageName: node linkType: hard -"jsonwebtoken@npm:9.0.2, jsonwebtoken@npm:^9.0.0": +"jsonwebtoken@npm:9.0.2, jsonwebtoken@npm:^9.0.0, jsonwebtoken@npm:^9.0.2": version: 9.0.2 resolution: "jsonwebtoken@npm:9.0.2" dependencies: @@ -11081,6 +11092,7 @@ __metadata: "@aws-sdk/client-s3": "npm:^3.682.0" "@aws-sdk/s3-request-presigner": "npm:^3.682.0" "@imgly/background-removal-node": "npm:^1.4.5" + "@nestjs/axios": "npm:^3.1.3" "@nestjs/cli": "npm:^10.0.0" "@nestjs/common": "npm:^10.0.0" "@nestjs/config": "npm:^3.3.0" @@ -11094,6 +11106,7 @@ __metadata: "@nestjs/typeorm": "npm:^10.0.2" "@types/express": "npm:^4.17.17" "@types/jest": "npm:^29.5.2" + "@types/jsonwebtoken": "npm:^9" "@types/multer": "npm:^1.4.12" "@types/node": "npm:^20.3.1" "@types/node-imap": "npm:^0" @@ -11104,7 +11117,7 @@ __metadata: "@types/uuid": "npm:^10" "@typescript-eslint/eslint-plugin": "npm:^6.0.0" "@typescript-eslint/parser": "npm:^6.0.0" - axios: "npm:^1.7.7" + axios: "npm:^1.7.9" class-transformer: "npm:^0.5.1" class-validator: "npm:^0.14.1" dotenv: "npm:^16.4.5" @@ -11114,6 +11127,7 @@ __metadata: eslint-plugin-unused-imports: "npm:^4.1.4" ioredis: "npm:^5.4.1" jest: "npm:^29.5.0" + jsonwebtoken: "npm:^9.0.2" mysql2: "npm:^3.11.3" node-imap: "npm:^0.9.6" passport: "npm:^0.7.0"