Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ [Feature] Google OAuth login #604

Merged
merged 7 commits into from
Aug 21, 2023
Merged
Show file tree
Hide file tree
Changes from 6 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
7 changes: 6 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ FORTYTWO_APP_ID=
# 42인트라넷에서 발급받은 API SECRET KEY
FORTYTWO_APP_SECRET=
# 42인트라넷에서 입력한 callback url
CALLBACK_URL=http://localhost:3000/api/v1/auth/42login/callback
FORTYTWO_CALLBACK_URL=http://localhost:3000/api/v1/auth/callback/ft

# Google OAuth
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=http://localhost:3000/api/v1/auth/callback/google

# JWT token secret 값
USER_JWT_SECRETKEY=
Expand Down
4 changes: 3 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
"@nestjs/swagger": "^6.3.0",
"@nestjs/typeorm": "^9.0.1",
"@nestjs/websockets": "^9.4.0",
"@types/passport-jwt": "^3.0.8",
"bcrypt": "^5.1.0",
"cache-manager": "^5.2.1",
"class-transformer": "^0.5.1",
Expand All @@ -51,6 +50,7 @@
"nodemailer": "^6.9.1",
"passport": "^0.6.0",
"passport-42": "^1.2.6",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"pg": "^8.10.0",
"postgresql": "^0.0.1",
Expand All @@ -74,6 +74,8 @@
"@types/multer": "^1.4.7",
"@types/node": "18.11.18",
"@types/passport": "^1.0.12",
"@types/passport-google-oauth20": "^2.0.11",
"@types/passport-jwt": "^3.0.9",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
Expand Down
64 changes: 33 additions & 31 deletions backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { COOKIE_OPTIONS } from '../common/constant';
import { ExtractUserId } from '../common/decorator/extract-user-id.decorator';
import { ErrorResponseDto } from '../common/dto/error-response.dto';
import { SuccessResponseDto } from '../common/dto/success-response.dto';
import { AppConfigService } from '../config/app/configuration.service';

import { AuthService } from './auth.service';
import { ExtractUser } from './decorator/extract-user.decorator';
Expand All @@ -23,68 +22,75 @@ import { CodeVerificationRequestDto } from './dto/request/code-verification-requ
import { TwoFactorAuthRequestDto } from './dto/request/two-factor-auth-request.dto';
import { TwoFactorAuthResponseDto } from './dto/response/two-factor-auth-response.dto';
import { FtGuard } from './guard/ft.guard';
import { GoogleGuard } from './guard/google.guard';
import { TwoFaGuard } from './guard/two-fa.guard';
import { UserGuard } from './guard/user.guard';
import { LoginInfo } from './type/login-info';
import { SocialResponseOptions } from './type/social-response-options';

@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService, private readonly appConfigService: AppConfigService) {}
constructor(private readonly authService: AuthService) {}

/**
* @summary 로그인
* @description GET /auth/login
* @description GET /auth/login/ft
*/
@ApiOperation({ summary: '42 로그인' })
@SkipUserGuard()
@UseGuards(FtGuard) // strategy.constructor
@Get('42login')
@UseGuards(FtGuard)
@Get('login/ft')
login(): void {
return;
}

/**
* @summary 로그인 callback
* @description GET /auth/login/callback
* @description GET /auth/callback/ft
*/
@ApiOperation({ summary: '42 로그인 callback' })
@SkipUserGuard()
@UseGuards(FtGuard) // strategy.validate() -> return 값 기반으로 request 객체 담아줌
@Get('42login/callback')
@Get('callback/ft')
async callbackLogin(@ExtractUser() user: LoginInfo, @Res() res: Response): Promise<void> {
// 또는 @ReqUser('email') email: string console.log('42 Login Callback!');
const clientUrl = this.appConfigService.clientUrl;
const responseOptions: SocialResponseOptions = await this.authService.socialAuth(user);

if (user.id === null) {
// UNREGSIETERED -> JOIN (sign up)
const token = await this.authService.signUp(user);
res.cookie('jwt-for-unregistered', token, COOKIE_OPTIONS).redirect(`${clientUrl}/auth/register`);
} else {
// REGISTERED -> LOGIN (sign in)
const { twoFa } = await this.authService.getTwoFactorAuth(user.id);
if (twoFa !== null) {
const token = await this.authService.sendAuthCode(user.id, twoFa);
res.cookie('jwt-for-2fa', token, COOKIE_OPTIONS).redirect(`${clientUrl}/auth/2fa`);
} else {
const token = await this.authService.signIn(user.id);
res.redirect(`${clientUrl}/auth?token=${token}`);
}
if (responseOptions.cookieKey !== undefined) {
res.cookie(responseOptions.cookieKey, responseOptions.token, COOKIE_OPTIONS);
}
res.redirect(responseOptions.redirectUrl);
}

@ApiOperation({ summary: 'google 로그인' })
@SkipUserGuard()
@UseGuards(GoogleGuard)
@Get('login/google')
async googleLogin(): Promise<void> {
return;
}

@ApiOperation({ summary: 'google 로그인 callback' })
@SkipUserGuard()
@UseGuards(GoogleGuard)
@Get('callback/google')
async googleCallbackLogin(@ExtractUser() user: LoginInfo, @Res() res: Response): Promise<void> {
return this.callbackLogin(user, res);
}

/**
* @summary 로그인 2단계 인증
* @description POST /auth/42login/2fa
* @description POST /auth/login/2fa
*
* 로그인 시 2fa 설정 되어있는 경우 맞는 인증 코드인지 확인한다.
*/
@ApiOperation({ summary: '42 로그인 2단계 인증' })
@ApiOperation({ summary: '로그인 2단계 인증' })
@ApiHeaders([{ name: 'x-my-id', description: '내 아이디 (임시값)' }])
@ApiForbiddenResponse({ type: ErrorResponseDto, description: '유효하지 않은 인증 코드' })
@ApiBadRequestResponse({ type: ErrorResponseDto, description: '잘못된 인증 코드' })
@SkipUserGuard()
@UseGuards(TwoFaGuard)
@HttpCode(HttpStatus.OK)
@Post('42login/2fa')
@Post('login/2fa')
async twoFactorAuthLogin(
@ExtractUserId() myId: number,
@Body() { code }: CodeVerificationRequestDto,
Expand All @@ -101,7 +107,6 @@ export class AuthController {
@ApiOperation({ summary: '2단계 인증 이메일 가져오기' })
@ApiNotFoundResponse({ type: ErrorResponseDto, description: '유저 없음' })
@ApiHeaders([{ name: 'x-my-id', description: '내 아이디 (임시값)' }])
@UseGuards(UserGuard)
@Get('2fa')
getTwoFactorAuth(@ExtractUserId() myId: number): Promise<TwoFactorAuthResponseDto> {
return this.authService.getTwoFactorAuth(myId);
Expand All @@ -116,7 +121,6 @@ export class AuthController {
@ApiConflictResponse({ type: ErrorResponseDto, description: '중복된 이메일 혹은 이미 인증 완료한 유저' })
@ApiHeaders([{ name: 'x-my-id', description: '내 아이디 (임시값)' }])
@HttpCode(HttpStatus.OK)
@UseGuards(UserGuard)
@Post('2fa')
async twoFactorAuth(
@ExtractUserId() myId: number,
Expand All @@ -138,7 +142,6 @@ export class AuthController {
@ApiBadRequestResponse({ type: ErrorResponseDto, description: '잘못된 2단계 인증 코드' })
@ApiHeaders([{ name: 'x-my-id', description: '내 아이디 (임시값)' }])
@HttpCode(HttpStatus.OK)
@UseGuards(UserGuard)
@Post('2fa/verify')
async updateTwoFactorAuth(
@ExtractUserId() myId: number,
Expand All @@ -156,7 +159,6 @@ export class AuthController {
@ApiOperation({ summary: '2단계 인증 삭제하기' })
@ApiConflictResponse({ type: ErrorResponseDto, description: '2단계 인증하지 않은 유저' })
@ApiHeaders([{ name: 'x-my-id', description: '내 아이디 (임시값)' }])
@UseGuards(UserGuard)
@Delete('2fa')
deleteTwoFactorAuth(@ExtractUserId() myId: number): Promise<SuccessResponseDto> {
return this.authService.deleteTwoFactorAuth(myId);
Expand Down
5 changes: 4 additions & 1 deletion backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { MailerModule } from '@nestjs-modules/mailer';
import { TWO_FA_EXPIRES_IN, TWO_FA_MAX } from '../common/constant';
import { AppConfigModule } from '../config/app/configuration.module';
import { FtAuthConfigModule } from '../config/auth/ft/configuration.module';
import { GoogleAuthConfigModule } from '../config/auth/google/configuration.module';
import { JwtConfigModule } from '../config/auth/jwt/configuration.module';
import { MailerConfigModule } from '../config/auth/mailer/configuration.module';
import { MailerConfigService } from '../config/auth/mailer/configuration.service';
Expand All @@ -16,13 +17,15 @@ import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { UserGuard } from './guard/user.guard';
import { FtStrategy } from './strategy/ft.strategy';
import { GoogleStrategy } from './strategy/google.strategy';
import { UserStrategy } from './strategy/user.strategy';

@Module({
imports: [
TypeOrmModule.forFeature([Auth]),
JwtModule.register({}),
FtAuthConfigModule,
GoogleAuthConfigModule,
JwtConfigModule,
MailerConfigModule,
AppConfigModule,
Expand All @@ -37,7 +40,7 @@ import { UserStrategy } from './strategy/user.strategy';
inject: [MailerConfigService],
}),
],
providers: [AuthService, FtStrategy, UserStrategy, UserGuard],
providers: [AuthService, FtStrategy, UserStrategy, GoogleStrategy, UserGuard],
controllers: [AuthController],
exports: [AuthService],
})
Expand Down
31 changes: 26 additions & 5 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import { Repository } from 'typeorm';

import { AUTH_JWT_EXPIRES_IN, TWO_FA_EXPIRES_IN, TWO_FA_JWT_EXPIRES_IN, USER_JWT_EXPIRES_IN } from '../common/constant';
import { SuccessResponseDto } from '../common/dto/success-response.dto';
import { AppConfigService } from '../config/app/configuration.service';
import { JwtConfigService } from '../config/auth/jwt/configuration.service';
import { Auth } from '../entity/auth.entity';

import { TwoFactorAuthResponseDto } from './dto/response/two-factor-auth-response.dto';
import { LoginInfo } from './type/login-info';
import { SocialResponseOptions } from './type/social-response-options';
import { TwoFactorAuth } from './type/two-factor-auth';

@Injectable()
Expand All @@ -22,6 +24,7 @@ export class AuthService {
private readonly authRepository: Repository<Auth>,
private readonly jwtService: JwtService,
private readonly jwtConfigService: JwtConfigService,
private readonly appConfigService: AppConfigService,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
private readonly mailerService: MailerService,
) {}
Expand All @@ -42,11 +45,7 @@ export class AuthService {
return this.jwtService.sign(payload, signOptions);
}

async signIn(userId: number): Promise<string> {
// CHECK 필요 없을 거 같음 (무조건 user table에 있는 경우에 signIn이 실행됨)
// if ((await this.userRepository.findOneBy({ id: userId })) === null) {
// throw new NotFoundException('[Login Error] 존재하지 않는 유저입니다.');
// }
signIn(userId: number): string {
const payload = { userId };
const signOptions = {
secret: this.jwtConfigService.userSecretKey,
Expand All @@ -55,6 +54,28 @@ export class AuthService {
return this.jwtService.sign(payload, signOptions);
}

async socialAuth(user: LoginInfo): Promise<SocialResponseOptions> {
const auth = await this.authRepository.findOneBy({ email: user.email });
let token = '';
const clientUrl = this.appConfigService.clientUrl;

if (auth === null) {
// unregistered user
token = await this.signUp(user);
return { cookieKey: 'jwt-for-unregistered', token, redirectUrl: `${clientUrl}/auth/register` };
} else {
const userId = auth.id;
const { twoFa } = await this.getTwoFactorAuth(userId);
if (twoFa === null) {
token = this.signIn(userId);
return { token, redirectUrl: `${clientUrl}/auth?token=${token}` };
} else {
token = await this.sendAuthCode(userId, twoFa);
}
return { cookieKey: 'jwt-for-2fa', token, redirectUrl: `${clientUrl}/auth/2fa` };
}
}

async twoFactorAuthSignIn(myId: number, code: string): Promise<string> {
const value: TwoFactorAuth | undefined = await this.cacheManager.get(`${myId}`);
if (value === undefined) {
Expand Down
25 changes: 2 additions & 23 deletions backend/src/auth/guard/ft.guard.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,5 @@
import { Injectable, CanActivate, ExecutionContext, InternalServerErrorException, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';

@Injectable()
export class FtGuard extends AuthGuard('ft') implements CanActivate {
constructor() {
super();
}
logger: Logger = new Logger('FtGuard');

canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
const maxRetries = 5;

for (let retry = 0; retry < maxRetries; ++retry) {
try {
this.logger.log('FtGuard canActivate() try:', retry);
return super.canActivate(context);
} catch (err) {
// do nothing
this.logger.error('FtGuard canActivate() error:', err);
}
}
throw new InternalServerErrorException('Max retries exceeded. Unable to activate guard.');
}
}
export class FtGuard extends AuthGuard('ft') {}
5 changes: 5 additions & 0 deletions backend/src/auth/guard/google.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class GoogleGuard extends AuthGuard('google') {}
4 changes: 2 additions & 2 deletions backend/src/auth/guard/user.guard.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, CanActivate, ExecutionContext, BadRequestException } from '@nestjs/common';
import { Injectable, ExecutionContext, BadRequestException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Request } from 'express';
Expand All @@ -7,7 +7,7 @@ import { Observable } from 'rxjs';
import { AppConfigService } from '../../config/app/configuration.service';

@Injectable()
export class UserGuard extends AuthGuard('user') implements CanActivate {
export class UserGuard extends AuthGuard('user') {
constructor(private reflector: Reflector, private readonly appConfigService: AppConfigService) {
super();
}
Expand Down
25 changes: 6 additions & 19 deletions backend/src/auth/strategy/ft.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
import { Injectable } from '@nestjs/common';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { Strategy } from 'passport-42';
import { Repository } from 'typeorm';

import { FtAuthConfigService } from '../../config/auth/ft/configuration.service';
import { Auth, AuthStatus } from '../../entity/auth.entity';
import { LoginInfo } from '../type/login-info';

@Injectable()
export class FtStrategy extends PassportStrategy(Strategy, 'ft') {
constructor(
private readonly ftAuthConfigService: FtAuthConfigService,
@InjectRepository(Auth)
private readonly authRepository: Repository<Auth>,
) {
constructor(private readonly ftAuthConfigService: FtAuthConfigService) {
super({
clientID: ftAuthConfigService.id,
clientSecret: ftAuthConfigService.secret,
Expand All @@ -34,16 +27,10 @@ export class FtStrategy extends PassportStrategy(Strategy, 'ft') {
* @param cb validate()에서 return한 값이 request 객체에 담김
* @returns validate()에서 return한 값
*/
async validate(accessToken: string, refreshToken: string, profile: LoginInfo) {
const auth = await this.authRepository.findOneBy({ email: profile.email });

if (auth === null || auth.status === AuthStatus.UNREGISTERD) {
profile.id = null;
} else {
// auth.status === REGISTERD 이고, user table에 존재하는 경우
profile.id = auth.id;
async validate(accessToken: string, refreshToken: string, profile: LoginInfo): Promise<LoginInfo> {
if (profile.email === undefined || profile.email === null) {
throw new UnauthorizedException('42 email is empty');
}

return profile;
return { provider: '42', email: profile.email, id: null };
}
}
26 changes: 26 additions & 0 deletions backend/src/auth/strategy/google.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Profile, Strategy } from 'passport-google-oauth20';

import { GoogleAuthConfigService } from '../../config/auth/google/configuration.service';
import { LoginInfo } from '../type/login-info';

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(googleAuthConfigService: GoogleAuthConfigService) {
super({
clientID: googleAuthConfigService.clientId,
clientSecret: googleAuthConfigService.clientSecret,
callbackURL: googleAuthConfigService.callbackUrl,
scope: ['email'],
});
}

async validate(accessToken: string, refreshToken: string, profile: Profile): Promise<LoginInfo> {
const email = profile.emails?.[0].value;
if (email === undefined) {
throw new UnauthorizedException('Google email is empty');
}
return { provider: 'google', email, id: null };
}
}
Loading