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] Local login/signup API #624

Merged
merged 11 commits into from
Aug 24, 2023
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.10.0",
"postgresql": "^0.0.1",
"reflect-metadata": "^0.1.13",
Expand All @@ -82,6 +83,7 @@
"@types/passport-github2": "^1.2.5",
"@types/passport-google-oauth20": "^2.0.11",
"@types/passport-jwt": "^3.0.9",
"@types/passport-local": "^1.0.35",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
Expand Down
42 changes: 37 additions & 5 deletions backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Res, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import {
ApiBadRequestResponse,
ApiConflictResponse,
Expand All @@ -19,12 +20,14 @@ import { AuthService } from './auth.service';
import { ExtractUser } from './decorator/extract-user.decorator';
import { SkipUserGuard } from './decorator/skip-user-guard.decorator';
import { CodeVerificationRequestDto } from './dto/request/code-verification-request.dto';
import { LocalLoginRequestDto } from './dto/request/local-login-request.dto';
import { LocalSignUpRequestDto } from './dto/request/local-signup-request.dto';
import { TwoFactorAuthRequestDto } from './dto/request/two-factor-auth-request.dto';
import { TwoFactorAuthResponseDto } from './dto/response/two-factor-auth-response.dto';
import { SocialGuard } from './guard/social.guard';
import { TwoFaGuard } from './guard/two-fa.guard';
import { LoginInfo } from './type/login-info';
import { SocialResponseOptions } from './type/social-response-options';
import { LoginResponseOptions } from './type/login-response-options';

@ApiTags('auth')
@Controller('auth')
Expand All @@ -39,7 +42,7 @@ export class AuthController {
@SkipUserGuard()
@UseGuards(SocialGuard)
@Get('login/:provider')
login(): void {
socialLogin(): void {
return;
}

Expand All @@ -51,15 +54,44 @@ export class AuthController {
@SkipUserGuard()
@UseGuards(SocialGuard) // strategy.validate() -> return 값 기반으로 request 객체 담아줌
@Get('callback/:provider')
async callbackLogin(@ExtractUser() user: LoginInfo, @Res() res: Response): Promise<void> {
const responseOptions: SocialResponseOptions = await this.authService.socialAuth(user);

async callbackSocialLogin(@ExtractUser() user: LoginInfo, @Res() res: Response): Promise<void> {
const responseOptions: LoginResponseOptions = await this.authService.socialAuth(user);
if (responseOptions.cookieKey !== undefined) {
res.cookie(responseOptions.cookieKey, responseOptions.token, COOKIE_OPTIONS);
}
res.redirect(responseOptions.redirectUrl);
}

/**
* @summary Local 로그인
* @description POST /auth/login/local
*/
@ApiOperation({ summary: 'local 로그인' })
@ApiNotFoundResponse({ type: ErrorResponseDto, description: '이메일 없음' })
@ApiBadRequestResponse({ type: ErrorResponseDto, description: '잘못된 비밀번호' })
@SkipUserGuard()
@UseGuards(AuthGuard('local'))
@HttpCode(HttpStatus.OK)
@Post('login/local')
async localLogin(@ExtractUser() user: LocalLoginRequestDto, @Res() res: Response): Promise<void> {
const token = await this.authService.localLogin(user);
res.send({ token });
}

/**
* @summary Local 회원가입
* @description POST /auth/signup/local
*/
@ApiOperation({ summary: 'local 회원가입' })
@SkipUserGuard()
@Post('signup/local')
async localSignUp(@Body() signUpInfo: LocalSignUpRequestDto): Promise<SuccessResponseDto> {
await this.authService.localSignUp(signUpInfo);
return {
message: '회원가입이 완료되었습니다.',
};
}

/**
* @summary 로그인 2단계 인증
* @description POST /auth/login/2fa
Expand Down
6 changes: 4 additions & 2 deletions backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,20 @@ import { JwtConfigModule } from '../config/auth/jwt/configuration.module';
import { MailerConfigModule } from '../config/auth/mailer/configuration.module';
import { MailerConfigService } from '../config/auth/mailer/configuration.service';
import { Auth } from '../entity/auth.entity';
import { User } from '../entity/user.entity';

import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { UserGuard } from './guard/user.guard';
import { FtStrategy } from './strategy/ft.strategy';
import { GithubStrategy } from './strategy/github.strategy';
import { GoogleStrategy } from './strategy/google.strategy';
import { LocalStrategy } from './strategy/local.strategy';
import { UserStrategy } from './strategy/user.strategy';

@Module({
imports: [
TypeOrmModule.forFeature([Auth]),
TypeOrmModule.forFeature([Auth, User]),
JwtModule.register({}),
FtAuthConfigModule,
GoogleAuthConfigModule,
Expand All @@ -43,7 +45,7 @@ import { UserStrategy } from './strategy/user.strategy';
inject: [MailerConfigService],
}),
],
providers: [AuthService, FtStrategy, UserStrategy, GoogleStrategy, GithubStrategy, UserGuard],
providers: [AuthService, FtStrategy, UserStrategy, GoogleStrategy, GithubStrategy, LocalStrategy, UserGuard],
controllers: [AuthController],
exports: [AuthService],
})
Expand Down
97 changes: 81 additions & 16 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,42 @@
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { BadRequestException, ConflictException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { MailerService } from '@nestjs-modules/mailer';
import { compare, hash } from 'bcrypt';
import { Cache } from 'cache-manager';
import { Repository } from 'typeorm';
import { nanoid } from 'nanoid';
import { EntityManager, 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, AuthStatus } from '../entity/auth.entity';
import { UserRecord } from '../entity/user-record.entity';
import { User } from '../entity/user.entity';

import { LocalLoginRequestDto } from './dto/request/local-login-request.dto';
import { LocalSignUpRequestDto } from './dto/request/local-signup-request.dto';
import { TwoFactorAuthResponseDto } from './dto/response/two-factor-auth-response.dto';
import { LoginInfo } from './type/login-info';
import { SocialResponseOptions } from './type/social-response-options';
import { LoginResponseOptions } from './type/login-response-options';
import { TwoFactorAuth } from './type/two-factor-auth';

@Injectable()
export class AuthService {
constructor(
@InjectRepository(Auth)
private readonly authRepository: Repository<Auth>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly jwtService: JwtService,
private readonly jwtConfigService: JwtConfigService,
private readonly appConfigService: AppConfigService,
Expand All @@ -32,8 +47,7 @@ export class AuthService {
async signUp(auth: Auth | null, loginInfo: LoginInfo): Promise<string> {
let authId: number;
if (auth === null) {
authId = (await this.authRepository.insert({ email: loginInfo.email, accountId: loginInfo.id })).identifiers[0]
.id;
authId = await this.createAuth(loginInfo.email, null, loginInfo.id);
} else {
authId = auth.id;
}
Expand All @@ -54,7 +68,7 @@ export class AuthService {
return this.jwtService.sign(payload, signOptions);
}

async socialAuth(loginInfo: LoginInfo): Promise<SocialResponseOptions> {
async socialAuth(loginInfo: LoginInfo): Promise<LoginResponseOptions> {
let token = '';
const clientUrl = this.appConfigService.clientUrl;

Expand All @@ -63,17 +77,42 @@ export class AuthService {
// unregistered users
token = await this.signUp(auth, loginInfo);
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` };
}
return await this.checkTwoFactorAuth(auth.id);
}

async localLogin(loginInfo: LocalLoginRequestDto): Promise<string> {
const auth = await this.authRepository.findOneBy({ email: loginInfo.email });
if (auth === null || auth.password === null) {
throw new NotFoundException('이메일 또는 비밀번호를 확인해주세요.');
}
if ((await compare(loginInfo.password, auth.password)) === false) {
throw new BadRequestException('비밀번호를 확인해주세요 .');
}
return (await this.checkTwoFactorAuth(auth.id)).token;
}

async localSignUp(signUpInfo: LocalSignUpRequestDto): Promise<void> {
const email = signUpInfo.email;
const nickname = signUpInfo.nickname;
const password = await hash(signUpInfo.password, 5);
const accountId = 'local-' + nanoid();

if (await this.authRepository.findOneBy({ email })) {
throw new ConflictException('이미 존재하는 이메일입니다.');
}
if (await this.userRepository.findOneBy({ nickname })) {
throw new ConflictException('중복된 닉네임입니다.');
}
// create auth
const authId = await this.createAuth(email, password, accountId);

// create user
await this.userRepository.manager.transaction(async (manager: EntityManager) => {
await manager.insert(User, { id: authId, nickname: nickname });
await manager.insert(UserRecord, { id: authId });
await manager.update(Auth, { id: authId }, { status: AuthStatus.REGISTERD });
});
}

async twoFactorAuthSignIn(myId: number, code: string): Promise<string> {
Expand Down Expand Up @@ -149,13 +188,39 @@ export class AuthService {
}

// SECTION private
private async createAuth(email: string | null, password: string | null, accountId: string): Promise<number> {
const authId = (
await this.authRepository.insert({
email,
password,
accountId,
})
).identifiers[0].id;
return authId;
}

private async verifyTwoFactorAuth(myId: number, code: string, successCode: string) {
if (code !== successCode) {
throw new BadRequestException('잘못된 인증 코드입니다.');
}
await this.cacheManager.del(`${myId}`);
}

// 2fa 인증 유저인지 확인 후 로그인
private async checkTwoFactorAuth(userId: number): Promise<LoginResponseOptions> {
const clientUrl = this.appConfigService.clientUrl;
let token = '';

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` };
}

private getEmailTemplate(code: string): string {
return `
<!DOCTYPE html>
Expand Down
19 changes: 19 additions & 0 deletions backend/src/auth/dto/request/local-login-request.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { IsEmail, IsString } from 'class-validator';

import { LocalLoginRequest } from '@/types/auth/request';

export class LocalLoginRequestDto implements LocalLoginRequest {
/**
* 이메일
* @example 'sample@sample.com'
*/
@IsEmail()
email: string;

/**
* 비밀번호
* @example 'sample1234'
*/
@IsString()
password: string;
}
19 changes: 19 additions & 0 deletions backend/src/auth/dto/request/local-signup-request.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { IsEmail, IsString, Matches } from 'class-validator';

import { LocalSignUpRequest } from '@/types/auth/request';

export class LocalSignUpRequestDto implements LocalSignUpRequest {
@IsEmail()
email: string;

// TODO @IsStrongPassword()
@IsString()
password: string;

/**
* nickname
* @example 'san1'
*/
@Matches(/^[가-힣a-zA-Z0-9]{1,8}$/, { message: '유효하지 않은 닉네임 입니다.' })
nickname: string;
}
20 changes: 20 additions & 0 deletions backend/src/auth/strategy/local.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';

import { LocalLoginRequestDto } from '../dto/request/local-login-request.dto';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
constructor() {
super({
usernameField: 'email',
passwordField: 'password',
passReqToCallback: false,
});
}

async validate(email: string, password: string): Promise<LocalLoginRequestDto> {
return { email, password };
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type SocialResponseOptions = {
export type LoginResponseOptions = {
cookieKey?: string;
token: string;
redirectUrl: string;
Expand Down
16 changes: 16 additions & 0 deletions backend/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1294,6 +1294,15 @@
"@types/jsonwebtoken" "*"
"@types/passport-strategy" "*"

"@types/passport-local@^1.0.35":
version "1.0.35"
resolved "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.35.tgz#233d370431b3f93bb43cf59154fb7519314156d9"
integrity sha512-K4eLTJ8R0yYW8TvCqkjB0pTKoqfUSdl5PfZdidTjV2ETV3604fQxtY6BHKjQWAx50WUS0lqzBvKv3LoI1ZBPeA==
dependencies:
"@types/express" "*"
"@types/passport" "*"
"@types/passport-strategy" "*"

"@types/passport-oauth2@*":
version "1.4.12"
resolved "https://registry.yarnpkg.com/@types/passport-oauth2/-/passport-oauth2-1.4.12.tgz#c2ae0ee3b16646188d8b0b6cdbc6880a0247dc5f"
Expand Down Expand Up @@ -6242,6 +6251,13 @@ passport-jwt@^4.0.1:
jsonwebtoken "^9.0.0"
passport-strategy "^1.0.0"

passport-local@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz#1fe63268c92e75606626437e3b906662c15ba6ee"
integrity sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==
dependencies:
passport-strategy "1.x.x"

passport-oauth2@1.x.x, passport-oauth2@^1.4.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.7.0.tgz#5c4766c8531ac45ffe9ec2c09de9809e2c841fc4"
Expand Down
Loading