NestJS-Authentication

jaegeunsong97·2023년 11월 20일
0

NestJS

목록 보기
12/37
post-thumbnail
post-custom-banner

🖊️로그인 로직 정리하기

nest g resource -> auth -> RESTapi

인증과 관련된 부분을 위해서 auth 폴더를 생성합니다. 그리고 항상 모듈이 app.module.ts에 들어갔는지 체크해야합니다.

@Module({
  imports: [
    PostsModule,
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: '127.0.0.1',
      port: 5433,
      username: 'postgresql',
      password: 'postgresql',
      database: 'postgresql',
      entities: [
        PostsModel,
        UsersModel,
      ],
      synchronize: true,
    }),
    UsersModule,
    AuthModule // 추가
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

이제 만드려고하는 기능을 나열해봅시다.

* 우리가 만드려는 기능
* 
* 1) registerWithEmail
*   - email, nickname, password를 입력받고 사용자를 생성한다.
*   - 생성이 완료되면 accessToken과 refreshToken을 반환한다. -> 바로 로그인을 진행 해주는 것
* 
* 2) loginWithEmail
*   - email,password를 입력하면 사용자 검증을 진행한다.
*   - 검증이 완료되면 accessToken과 refreshToken을 반환한다.
* 
* 3) loginUser
*   - (1)과 (2)에서 필요한 accessToken과 refreshToken을 반환하는 로직
* 
* 4) signToken
*   - (3)에서 필요한 accessToken과 refreshToken을 sign하는 로직
* 
* 5) authenticateWithEmailAndPassword
*   - (2)에서 로그인을 진행할때 필요한 기본적인 검증 진행
*        1. 사용자가 존재하는지 확인(null)
*        2. 비밀번호가 맞는지 확인
*        3. 모두 통과되면 찾은 사용자 정보 반환
*        4. loginWithEmail에서 반환된 데이터를 기반으로 토큰 생성
*/

🖊️토큰 signing

먼저 jwt 관련 패키지와 Bcrypt를 설치합니다. 그리고 auth.module.ts에 imports를 합니다.

yarn add @nestjs/jwt bcrypt
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtModule } from '@nestjs/jwt';

@Module({
  imports: [
    JwtModule.register({}),
  ],
  controllers: [AuthController],
  providers: [AuthService],
})
export class AuthModule {}

그리고 auth.service.ts에 코드를 작성합니다. 일단 secret키를 넣는 코드가 있어서 const 폴더를 만들고 내부에 auth.const.ts라는 파일에 넣습니다.

  • auth.const.ts
export const JWT_SECRET = 'codefactory';
  • auth.service.ts

여기서는 typescript의 유틸리티 Pick을 이용해서 email과 id만 뽑아옵니다. 또한 jwtService에 sign이라는 메소드를 이용해서 코드를 작성합니다.

@Injectable()
export class AuthService {

     constructor(
          private readonly jwtService: JwtService,
     ) {}

     /**
      * Payload에 들어갈 정보
      * 
      * 1) email
      * 2) sub = id
      * 3) type = 'access' | 'refresh'
      */
     signToken(user: Pick<UsersModel, 'email' | 'id'>) { // 유틸리티 사용, UsersModel에서 전부 X 골라서 O
 	 	  const payload = {
               email: user.email,
               sub: user.id,
               type: isRefreshToken ? 'refresh' : 'access',
          }
          return this.jwtService.sign(payload, {
               secret: JWT_SECRET,
               expiresIn: isRefreshToken ? 3600 : 300, // seconds
          });
     }
}

🖊️의존성 에러

nest.js를 하면 자주 보는 에러를 보겠습니다. 의미는 AuthService 0번째 요소인 jwtService가 존재하는지 AuthModule에서 확인을 해달라고 하는 것입니다.

즉, 에러가 발생한 곳으로 가서 의존성을 부분을 체크(Module)하면 해결할 수 있습니다.


🖊️loginUser() 작업

loginUser에서 필요한 함수가 signToken이기 때문에 코드를 작성하겠습니다.

@Injectable()
export class AuthService {

     constructor(
          private readonly jwtService: JwtService,
     ) {}
  
     signToken(user: Pick<UsersModel, 'email' | 'id'>, isRefreshToken: boolean) {
          const payload = {
               email: user.email,
               sub: user.id,
               type: isRefreshToken ? 'refresh' : 'access',
          }
          return this.jwtService.sign(payload, {
               secret: JWT_SECRET,
               expiresIn: isRefreshToken ? 3600 : 300, // seconds
          });
     }
	 
  	 // 추가
     loginUser(user: Pick<UsersModel, 'email' | 'id'>) {
          return {
               accessToken: this.signToken(user, false),
               refreshToken: this.signToken(user, true),
          }
     }
}

🖊️authenticateWithEmailAndPassword() 작업

* 1. 사용자가 존재하는지 확인 (email) -> userService에다가 기능을 만들기
* 2. 비밀번호가 맞는지 확인
* 3. 모두 통과되면 찾은 사용자 정보 반환
  • auth.service.ts
import * as bcrypt from 'bcrypt'; // 추가 필요!!
.
.
constructor(
  	private readonly jwtService: JwtService,
  	private readonly usersService: UsersService, // 추가
) {}
.
.
async authenticateWithEmailAndPassword(user: Pick<UsersModel, 'email' | 'password'>) {
  	/**
     * 1. 사용자가 존재하는지 확인 (email) -> userService에다가 기능을 만들기
     * 2. 비밀번호가 맞는지 확인
     * 3. 모두 통과되면 찾은 사용자 정보 반환
     */
  	const existingUser = await this.usersService.getUserByEmail(user.email); // 사용자관련 메소드는 해당 모듈에서 관리하자
  	if (!existingUser) throw new UnauthorizedException('존재하지 않는 사용자입니다.');

  	/**
     * bcrypt.compare(1, 2) 파라미터
     * 
     * 1. 입력된 비밀번호
     * 2. 기존 해시 (hash) -> 사용자 정보에 저장된 hash
     */
  	const passOK = await bcrypt.compare(user.password, existingUser.password); // return true | false
  	if (!passOK) throw new UnauthorizedException('비밀번호가 틀렸습니다.');
  	return existingUser;
}

그리고 users.service.ts에 getUsersByEmail을 가지고오자. 되도록 사용자 관련 메소드는 해당 모듈에 작업하는 것이 좋습니다. 또한 UsersService를 사용하려면 module에서 작업을 좀 해야합니다.

  • users.service.ts
async getUserByEmail(email: string) {
  	return this.usersRepository.findOne({
      where: {
        	email,
      },
  	});
}
  • users.module.ts

exports를 해야한다는 것 잊지말자. exports된 UsersService를 다른 곳에서도 사용이 가능하게 된다.

@Module({
  imports: [
    TypeOrmModule.forFeature([
      UsersModel,
    ])
  ],
  exports: [UsersService],
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}
  • auth.module.ts
@Module({
  imports: [
    JwtModule.register({}),
    UsersModule,
  ],
  controllers: [AuthController],
  providers: [AuthService],
})
export class AuthModule {}

🖊️loginWithEmail() 작업

async loginWithEmail(user: Pick<UsersModel, 'email' | 'password'>) {
  	const existingUser = await this.authenticateWithEmailAndPassword(user);
  	return this.loginUser(existingUser);
}

🖊️registerWithEmail() 작업

HASH_ROUND 관련 문서

https://www.npmjs.com/package/bcrypt

  • auth.const.ts
export const JWT_SECRET = 'codefactory';
export const HASH_ROUNDS = 10; // 추가
  • auth.service.ts
async registerWithEmail(user: Pick<UsersModel, 'nickname' | 'email' | 'password'>) {
    const hash = await bcrypt.hash( // 내부에 salt가 내장
        user.password,
        HASH_ROUNDS, // user.password를 몇번 해싱할 것인지
    );

    const newUser = await this.usersService.createUser({
        ...user,
        password: hash,
    });
    return this.loginUser(newUser);
}
  • users.service.ts
async createUser(user: Pick<UsersModel, 'email' | 'nickname' | 'password'>) {
    // 1) nickname 중복이 없는지 확인
    //   exist() -> 만약에 조건에 해당되는 값이 있으면 true
    const nicknameExists = await this.usersRepository.exists({
        where: {
          	nickname: user.nickname,
        },
    });
    if (nicknameExists) throw new BadRequestException('이미 존재하는 nickname 입니다.!')
    const emailExists = await this.usersRepository.exists({
        where: {
          	email: user.email,
        },
    });
    if (emailExists) throw new BadRequestException('이미 가입한 email 입니다.!')

    const userObject = this.usersRepository.create({
        // Object 형태로 넣기
        nickname: user.nickname,
        email: user.email,
        password: user.password,
    });
    const newUser = await this.usersRepository.save(userObject);
    return newUser;
}

🖊️회원가입,로그인

  • auth.controller.ts
@Post('login/email')
postLoginEmail(
    @Body('email') email: string,
    @Body('password') password: string,
) {
    return this.authService.loginWithEmail({
      	email,
      	password,
	});
}

@Post('register/email')
postRegisterEmail(
  	@Body('nickname') nickname: string,
  	@Body('email') email: string,
  	@Body('password') password: string,
) {
    return this.authService.registerWithEmail({
        nickname,
        email,
        password,
    });
}

회원가입 테스트

{
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5QGdtYWlsLmNvbSIsInN1YiI6MSwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTcwNTg0MjU1MiwiZXhwIjoxNzA1ODQyODUyfQ.k6JtM6_XecS7wd4LNBJCWxbvg8HCPtYs7iAjNuU0_Fw",
    "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5QGdtYWlsLmNvbSIsInN1YiI6MSwidHlwZSI6InJlZnJlc2giLCJpYXQiOjE3MDU4NDI1NTIsImV4cCI6MTcwNTg0NjE1Mn0.dy5K2KdWdNYL2RDKwPGiLPAZZRCE4VAvwo0X6s_rxDM"
}

로그인 테스트

{
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5QGdtYWlsLmNvbSIsInN1YiI6MSwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTcwNTg0MjY2NywiZXhwIjoxNzA1ODQyOTY3fQ.5f6WD4iGKBlChhP_lAOJg4MK8uWy1vjxhn_nDxZMWlo",
    "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5QGdtYWlsLmNvbSIsInN1YiI6MSwidHlwZSI6InJlZnJlc2giLCJpYXQiOjE3MDU4NDI2NjcsImV4cCI6MTcwNTg0NjI2N30.FPekKWWg_s0CIXTRYf4XkOGBSjJzfgo78CY-7-6lIO0"
}

🖊️Token Refresh 기능 정리

  • 토큰을 사용하게 되는 방식
  • 1) 사용자가 로그인 또는 회원가입을 진행하면 accessToken과 refreshToken을 발급받는다.
  • 2) 로그인 할때는 Basic 토큰과 함께 요청을 보낸다. Basic 토큰은 '이매일:비밀번호' Base64로 인코딩한 형태이다.
  • ex) {authorization: 'Basic {token}'}
  • 3) 아무나 접근 할 수 없는 정보 (private route)를 접근 할때는 accessToken을 Header에 추가해서 요청과 함께 보낸다.
  • ex) {authorization: 'Bearer {token}'}
  • 4) 토큰과 요청을 함께 받은 서버는 토큰 검증을 통해 현재 요청을 보낸 사용자가 누구인지 알 수 있다.
  • 예를들어, 현재 로그인한 사용자가 작성한 포스트만 가져오려면 토큰의 sub 값에 입력돼있는 사용자의 포스트만 따로 필터링 할 수 있다.
  • 특정 사용자의 토큰이 없다면 다른 사용자의 데이터를 접근 못한다.
  • 5) 모든 토큰은 만료기한이 있다. 지나면 새로 발급받아야 한다. 그렇지 않으면 jwtService.verify에러 인증이 안된다.
  • 따라서 access와 refresh를 새로 발급받을 수 있는 /auth/token/refresh, /auth/token/access가 필요하다.
  • 6) 토큰이 만료되면 각각의 토큰을 새로 발급 받을 수 있는 엔드포인트에 요청해서 새로 발급 받고, private route에 접근한다.

🖊️헤더값으로부터 토큰 추출하는 로직 작성

컨트롤러에서 헤더를 받고 authorization 부분만 추출 후, 서비스 로직으로 넘기는 과정입니다.

  • auth.service.ts
/**
* Header로부터 토큰을 받을 때
* 
* {authorization: 'Basic {token}'}
* {authorization: 'Bearer {token}'}
*/
async extractTokenFromHeader(header: string, isBearer: boolean) {
    const splitToken = header.split(' '); // 'Basic {token}' -> [Basic, {token}] / 'Bearer {token}' -> [Bearer, {token}]
    const prefix = isBearer ? 'Bearer' : 'Basic';
    if (splitToken.length !== 2 || splitToken[0] !== prefix) throw new UnauthorizedException('잘못된 토큰입니다.');     
    const token = splitToken[1];
}

🖊️토큰 시스템을 사용하도록 엔드포인트 변경

기존의 auth.controller.ts를 변경할 것입니다.

  • auth.controller.ts
@Post('login/email')
postLoginEmail(
    @Body('email') email: string,
    @Body('password') password: string,
) {
    return this.authService.loginWithEmail({
      	email,
      	password,
    });
}
.
.
변경
.
.
import { Body, Controller, Post, Headers } from '@nestjs/common';
@Post('login/email')
postLoginEmail(
  	@Headers('authorization') rawToken: string, // {authorization: 'Bearer {token}'} -> 'Bearer {token}' 가져옴
) {
    // email:password -> base64 -> asdasdkjb12kjebdkas -> email:password
    const token = this.authService.extractTokenFromHeader(rawToken, false); // basic 토큰이기 때문에 false
    const credentials = this.authService.decodeBasicToken(token);
    return this.authService.loginWithEmail(credentials);
}

이후에 auth.service.ts에 로직을 작성합니다.

  • auth.service.ts
extractTokenFromHeader(header: string, isBearer: boolean) {
    const splitToken = header.split(' '); // 'Basic {token}' -> [Basic, {token}] / 'Bearer {token}' -> [Bearer, {token}]
    const prefix = isBearer ? 'Bearer' : 'Basic';
    if (splitToken.length !== 2 || splitToken[0] !== prefix) throw new UnauthorizedException('잘못된 토큰입니다.');     
    const token = splitToken[1];
    return token;
}

/**
* Basic: asdljn1n2l1k2n213l1j23n
* 
* 1) asdljn1n2l1k2n213l1j23n -> email:password
* 2) email:password -> [email, password]
* 3) {email: email, password: password}
*/
decodeBasicToken(base64String: string) {
    const decoded = Buffer.from(base64String, 'base64').toString('utf8'); // Node.js에서 제공해주는 기능
    const split = decoded.split(':');
    if (split.length !== 2) throw new UnauthorizedException('잘못된 유형의 토큰입니다.');
    const email = split[0];
    const password = split[1];
    return {
        email,
        password,
    }
}

완성 후 테스트를 합니다. https://www.base64decode.org/ko/
인코딩도 가능하고 디코딩도 가능하다.

포스트맨으로 테스트를 합시다. 디코딩된 문자열을 로그인하는 엔드포인트 헤더에 Authorization으로 넣고 요청을 보내봅시다. 그러면 올바르게 토큰이 발급이 됩니다.

{
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5MTBAY29kZWZhY3RvcnkuYWkiLCJzdWIiOjIsInR5cGUiOiJhY2Nlc3MiLCJpYXQiOjE3MDU4Nzc0NDcsImV4cCI6MTcwNTg3Nzc0N30.JD0W9CRh84A87NWcP3rGefcLg3eYFkdrPxsl-8JrlSI",
    "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5MTBAY29kZWZhY3RvcnkuYWkiLCJzdWIiOjIsInR5cGUiOiJyZWZyZXNoIiwiaWF0IjoxNzA1ODc3NDQ3LCJleHAiOjE3MDU4ODEwNDd9.5w174IrNn28U0u42gkwBTzMY6ngYdVSks9RhvNxmvfY"
}

하지만 이제는 email과 password를 인코딩하지 않고 요청을 보내게 되면 500 에러가 나옵니다.

{
    "statusCode": 500,
    "message": "Internal server error"
}

만약 token의 일부를 조금만 삭제하고 요청을 하게 되면 어떻게 될까?

{
    "message": "잘못된 유형의 토큰입니다.",
    "error": "Unauthorized",
    "statusCode": 401
}

🖊️토큰 재발급 로직

토큰 재발급을 위해서 auth.service.ts에 코드를 작성합니다.

/**
* 토큰 검증
*/
verifyToken(token: string) {
    // verify는 jwt 패키지에 존재
    return this.jwtService.verify(token, { 
      	secret: JWT_SECRET,
    }); 
}

rotateToken(token: string, isRefreshToken: boolean) {
    const decoded = this.jwtService.verify(token, {
      	secret: JWT_SECRET,
    });
  	/**
    * sub: id
    * email: email
    * type: 'access' | 'refresh'
    */
    if (decoded.type !== 'refresh') throw new UnauthorizedException('토큰 재발급은 refresh 토큰으로만 가능합니다.');
    return this.signToken({ // 토큰 발급받기
      	...decoded,
    }, isRefreshToken);
}
  • auth.controller.ts

코드의 일반화를 잘했기 때문에, 1~2줄로 간결하게 만들어 졌습니다.

@Post('token/access')
postTokenAccess(
  	@Headers('authorization') rawToken: string
) {
    const token = this.authService.extractTokenFromHeader(rawToken, true);
    const newToken = this.authService.rotateToken(token, false);
    return {
        // 반환 형태 -> {accessToken: {token}}
        accessToken: newToken,
    }
}

@Post('token/refresh')
postTokenRefresh(
  	@Headers('authorization') rawToken: string
) {
    const token = this.authService.extractTokenFromHeader(rawToken, true);
    const newToken = this.authService.rotateToken(token, true);
    return {
        // 반환 형태 -> {refreshToken: {token}}
        refreshToken: newToken,
    }
}

포스트맨으로 확인을 합시다.

{
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5MTBAY29kZWZhY3RvcnkuYWkiLCJzdWIiOjIsInR5cGUiOiJhY2Nlc3MiLCJpYXQiOjE3MDU4NzkzMjYsImV4cCI6MTcwNTg3OTYyNn0.nlNECqQMOUHkVUjPA1BVJHtic2IKcgC1_AzTSav99-U",
    "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5MTBAY29kZWZhY3RvcnkuYWkiLCJzdWIiOjIsInR5cGUiOiJyZWZyZXNoIiwiaWF0IjoxNzA1ODc5MzI2LCJleHAiOjE3MDU4ODI5MjZ9.a2hSFdLOaE9Xunckyee6bNS70ndxWBplLOw8wt_jNnc"
}

refresh 토큰을 가지고 access 토큰을 재발급 합시다.

{
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5MTBAY29kZWZhY3RvcnkuYWkiLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzA1ODc5MzU5LCJleHAiOjE3MDU4Nzk2NTl9.fSaYQlxTrC4LvwnJsEUmWfiIToR3JvAjOKGEeAVYnUA"
}

만약 accessToken으로 Header의 Authorization에 넣어서 재발급 받으면 다음과 같은 에러가 나옵니다.

{
    "message": "토큰 재발급은 refresh 토큰으로만 가능합니다.",
    "error": "Unauthorized",
    "statusCode": 401
}

마찬가지로 refresh 토큰을 재발급 받아봅시다.

{
    "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImNvZGVmYWN0b3J5MTBAY29kZWZhY3RvcnkuYWkiLCJ0eXBlIjoicmVmcmVzaCIsImlhdCI6MTcwNTg3OTU4NywiZXhwIjoxNzA1ODgzMTg3fQ.IN_gQ1wduGs2QydjHj4YGM1jV_4t1mOKiEn3EsdM3BM"
}

동일하게, 만약 accessToken으로 Header의 Authorization에 넣어서 재발급 받으면 다음과 같은 에러가 나옵니다.

{
    "message": "토큰 재발급은 refresh 토큰으로만 가능합니다.",
    "error": "Unauthorized",
    "statusCode": 401
}
profile
블로그 이전 : https://medium.com/@jaegeunsong97
post-custom-banner

0개의 댓글