본문 바로가기
NestJS

Authentication - 로직 구현

by Programmer.Junny 2025. 2. 26.

1. Auth Resource 생성

nest g resource

// 다음 'Auth' 로 생성

2. JWT 패키지 설치

yarn add @nestjs/jwt bcrypt

// 혹은

npm i @nestjs/jwt bcrypt

jwt와 bcrypt 패키지를 설치한다.

3. Token 발급 메서드

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,
            //seconds
            expiresIn: isRefreshToken ? 3600 : 300,
        })
    }

참고로 auth.service.ts 에 작성해야하며, 해당 로직에는 비밀번호가 포함되지 않아야 한다.

4. loginUser 메서드

loginUser(user: Pick<UsersModel, 'email' | 'id'>) {
        return {
            accessToken: this.signToken(user, false),
            refreshToken: this.signToken(user, true),
        }
    }

loginUser 메서드는 사용자가 로그인을 하거나 회원가입을 할 때, AccessToken과 RefreshToken을 발급받는 공통적인 과정을 하나의 메서드로 작성한 것이다.

5. 회원가입

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

auth.controller.ts 에 위와 같이 라우터를 구현한다.

import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersModel } from './entities/users.entity';

@Module({
  imports: [TypeOrmModule.forFeature([UsersModel])],
  exports: [UsersService],
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}
exports: [UsersService],

우선 로직을 작성하기에 앞서 UsersModel에 접근해야 한다.

그러나 Auth에서 UsersModel에 직접 접근하기보다는 이미 만들어둔 메서드를 사용하기 위해 users.service.ts 에 있는 메서드를 가져오도록 한다.

그러기 위해선 users.module.ts 내에 exports로 UsersService를 등록한다.

@Injectable()
export class AuthService {
    constructor(
        private readonly jwtService: JwtService,
        private readonly usersService: UsersService,
    ) {}

 

이후 auth.service.ts 내의 생성자에 UsersService를 등록하여 사용할 수 있다.

async registerWithEmail(user: Pick<UsersModel, 'nickname' | 'email' | 'password'>) {
        const hash = await bcrypt.hash(
            user.password,
            HASH_ROUNDS,
        );

        const newUser = await this.usersService.createUser({
            ...user,
            password: hash,
        });

        return this.loginUser(newUser);
    }

registerWithEmail 메서드는 클라이언트로부터 받은 nickname, email, password를 받아온 뒤 password와 HASH_ROUNTDS(salt)를 이용해 bcrypt 해쉬를 만들고, 이것을 usersService 내에 만들어져있는 createUser 메서드를 호출하여 유저를 DB에 등록한다.

그리고 loginUser 메서드를 호출하여 AccessToken과 RefreshToken을 생성하여 클라이언트에 반환한다.

6. 이메일 로그인

@Post('login/email')
  postLoginEmail(
    @Headers('authorization') rawToken: string,
  ) {
    // email:password -> base64
    // 디코딩 후 email과 password를 나눠야함
    const token = this.authService.extractTokenFromHeader(rawToken, false);

    const credentials = this.authService.decodeBasicToken(token);

    return this.authService.loginWithEmail(credentials);
  }

auth.controller.ts 에 위와 같이 라우터를 구현한다. rawToken은 클라이언트에서 'email:password' 로 만든 후 base64 인코딩한 문자열이다.

/**
     * Header로 부터 토큰을 받을 때
     * 
     * {authorization: 'Basic {token}'}
     * {authorization: 'Bearer {token}'}
     */
    extractTokenFromHeader(header: string, isBearer: boolean) {
        const splitToken = header.split(' ');

        const prefix = isBearer ? 'Bearer' : 'Basic';

        if(splitToken.length !== 2 || splitToken[0] !== prefix) {
            throw new UnauthorizedException('잘못된 토큰입니다.');
        }

        const token = splitToken[1];

        return token;
    }

extractTokenFromHeader는 클라이언트에서 보낸 헤더를 기반으로 Basic (토큰생성), Bearer (토큰사용)을 구분하여 토큰을 반환한다.

/**
     * Basic asdlkfjadlfkjadflkjasdf
     * 
     * 1) asdlkfjadlfkjadflkjasdf -> email:password (디코딩)
     * 2) email:password -> [email, password]
     * 3) {email: email, password: password}
     */
    decodeBasicToken(base64String: string) {
        const decoded = Buffer.from(base64String, 'base64').toString('utf8');

        const split = decoded.split(':');

        if(split.length !== 2) {
            throw new UnauthorizedException('잘못된 유형의 토큰입니다.');
        }

        const email = split[0];
        const password = split[1];

        return {
            email,
            password,
        }
    }

decodedBasicToken은 받아온 token(email:password)을 디코딩하는 과정이다. 객체로 만들어 email, password을 반환한다.

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

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

        if (!existingUser) {
            throw new UnauthorizedException('존재하지 않는 사용자입니다.');
        }

        /**
         * 파라미터
         * 
         * 1) 입력된 비밀번호
         * 2) 기존 해시(hash) -> 사용자 정보에 저장되어있는 hash
         */
        const passOk = await bcrypt.compare(user.password, existingUser.password);

        if (!passOk) {
            throw new UnauthorizedException('비밀번호가 틀렸습니다.');
        }

        return existingUser;
    }

사용자가 이메일과 비밀번호로 로그인을 진행하면 입력된 이메일로 유저를 DB에서 가져온 뒤, DB에 저장된 패스워드(base64 암호화)입력된 비밀번호를 비교하여 일치하는 경우 유저를 반환하고, 이 유저를 가지고 loginUser 메서드를 실행하여 AccessToken과 RefreshToken을 클라이언트에 반환한다.

7. AccessToken 갱신

@Post('token/access')
  postTokenAccess(
    @Headers('authorization') rawToken: string,
  ) {
    const token = this.authService.extractTokenFromHeader(rawToken, true);

    const newToken = this.authService.rotateToken(token, false);

    /**
     * {accessToken: {token}}
     */
    return {
      accessToken: newToken,
    }
  }

auth.controller.ts 에 postTokenAccess 메서드를 라우팅한다.

/**
     * Header로 부터 토큰을 받을 때
     * 
     * {authorization: 'Basic {token}'}
     * {authorization: 'Bearer {token}'}
     */
    extractTokenFromHeader(header: string, isBearer: boolean) {
        const splitToken = header.split(' ');

        const prefix = isBearer ? 'Bearer' : 'Basic';

        if(splitToken.length !== 2 || splitToken[0] !== prefix) {
            throw new UnauthorizedException('잘못된 토큰입니다.');
        }

        const token = splitToken[1];

        return token;
    }

로그인때와 마찬가지로 클라이언트에서 보낸 헤더를 분해하여 토큰을 반환한다.

rotateToken(token: string, isRefreshToken: boolean) {
        const decoded = this.verifyToken(token);

        /**
         * sub: id
         * email: email,
         * type: 'access' | 'refresh'
         */
        if(decoded.type !== 'refresh') {
            throw new UnauthorizedException('토큰 재발급은 Refresh 토큰으로만 가능합니다!');
        }

        return this.signToken({
            ...decoded
        }, isRefreshToken);
    }

이후 토큰을 검증 후 signToken을 통해 토큰을 재발급한다.

/**
     * 토큰 검증
     */
    verifyToken(token: string) {
        // eslint-disable-next-line @typescript-eslint/no-unsafe-return
        return this.jwtService.verify(token, {
            secret: JWT_SECRET,
        });
    }

verifyTokenjwtService.verify 를 통해 AccessTokenRefreshToken을 검증할 수 있다.

8. RefreshToken 갱신

@Post('token/refresh')
  postTokenRefresh(
    @Headers('authorization') rawToken: string,
  ) {
    const token = this.authService.extractTokenFromHeader(rawToken, true);

    const newToken = this.authService.rotateToken(token, true);

    /**
     * {refreshToken: {token}}
     */
    return {
      refreshToken: newToken,
    }
  }

AccessToken 갱신과 다른 부분은 rotateToken에 두 번째 매개변수 isRefreshToken: boolean차이 뿐이다.

'NestJS' 카테고리의 다른 글

NestJS - Pipe  (0) 2025.02.26
VSC 디버거 사용하기  (0) 2025.02.26
Authentication - Encryption (암호화)  (0) 2025.02.26
Authentication - JWT  (0) 2025.02.26
TypeORM - Repository 메서드 종류  (0) 2025.02.25

최근댓글

최근글

skin by © 2024 ttuttak