Nest.js - JWT & Passport 인증

맛없는콩두유·2025년 5월 23일

NestJS 인증 기능 구현 가이드

1. User 모듈 설정

설명

User 모듈은 사용자 관리의 기본 단위입니다. 사용자의 등록, 조회, 수정, 삭제 등의 기능을 담당하며, 인증의 기초가 되는 모듈입니다.

구현 경로

  • src/user/user.module.ts: 사용자 관련 모듈 설정
  • src/user/user.controller.ts: 사용자 관련 API 엔드포인트 정의
  • src/user/user.service.ts: 사용자 관련 비즈니스 로직 구현
  • src/user/user.entity.ts: 사용자 데이터 모델 정의

모듈 생성 명령어

nest g mo user
nest g co user
nest g s user

2. Virtual Column 속성

설명

Virtual Column은 실제 데이터베이스에는 존재하지 않지만, TypeORM에서 필요한 계산된 값이나 관계를 표현할 때 사용됩니다. 예를 들어, 사용자가 작성한 게시글 수를 조회할 때 유용합니다.

구현 경로

src/user/entities/user.entity.ts

Entity에서 Virtual Column 설정

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  username: string;

  @OneToMany(() => Board, (board) => board.user)
  boards: Board[];

  @Column({ select: false }) // 기본 조회 시 비밀번호 필드 제외
  password: string;
}

3. 게시글 수 조회 기능

설명

사용자가 작성한 게시글의 수를 효율적으로 조회하기 위한 기능입니다. TypeORM의 QueryBuilder를 사용하여 최적화된 쿼리를 구현합니다.

구현 경로

  • src/user/entities/user.entity.ts: Virtual Column 정의
  • src/user/user.service.ts: 조회 로직 구현

구현 예시

@Entity()
export class User {
  // ... 기존 컬럼들 ...

  @VirtualColumn({
    query: (alias) => `
      SELECT COUNT(*)
      FROM board
      WHERE board.userId = ${alias}.id
    `
  })
  boardCount: number;
}

// user.service.ts
async getUserWithBoardCount(userId: number) {
  return this.userRepository
    .createQueryBuilder("user")
    .loadRelationCountAndMap(
      "user.boardCount",
      "user.boards"
    )
    .where("user.id = :userId", { userId })
    .getOne();
}

4. 비밀번호 암호화

MiniBlog 프로젝트에서는 사용자의 비밀번호 보안을 위해 bcrypt 라이브러리를 사용하여 암호화를 구현했습니다.

4.1 회원가입 시 비밀번호 암호화

파일 경로: src/user/user.service.ts

회원가입 시 사용자가 입력한 평문 비밀번호를 bcrypt를 사용하여 해시화합니다:

import { hash } from 'bcrypt';

async signup(body: any) {
  const { username, email, password } = body;
  const encryptedPassword = await this.encryptPassword(password);
  
  // 중복 사용자 체크 로직...
  
  const newUser = this.userRepository.create({
    username,
    email,
    password: encryptedPassword, // 암호화된 비밀번호 저장
  });
  return this.userRepository.save(newUser);
}

async encryptPassword(password: string) {
  const DEFAULT_SALT = 11;
  return hash(password, DEFAULT_SALT);
}

4.2 로그인 시 비밀번호 검증

파일 경로: src/user/user.service.ts

로그인 시 입력된 평문 비밀번호와 데이터베이스에 저장된 해시된 비밀번호를 bcrypt의 compare 함수로 비교합니다:

import { compare } from 'bcrypt';

async login(body: LoginUserDto) {
  const { email, password } = body;
  const user = await this.userRepository.findOne({
    where: { email },
    select: ['id', 'email', 'username', 'password'], // password 필드 명시적 선택
  });

  if (!user) {
    throw new HttpException('User not found', HttpStatus.NOT_FOUND);
  }

  const isValid = await compare(password, user.password);

  if (!isValid) {
    throw new HttpException('UNAUTHORIZED', HttpStatus.UNAUTHORIZED);
  }
  
  // 비밀번호 정보 제외하고 응답
  return plainToInstance(UserResponseDto, user, {
    excludeExtraneousValues: true,
  });
}

4.3 응답 시 비밀번호 정보 숨기기

UserResponseDto 생성

파일 경로: src/user/dto/user-response.dto.ts

로그인 성공 시 비밀번호 정보를 제외한 사용자 정보만 반환하기 위해 DTO를 생성합니다:

import { Expose } from 'class-transformer';

export class UserResponseDto {
  @Expose()
  id: number;

  @Expose()
  username: string;

  @Expose()
  email: string;
  
  // password 필드는 @Expose() 데코레이터가 없어 응답에서 제외됨
}

plainToInstance 사용

파일 경로: src/user/user.service.ts

class-transformerplainToInstance 함수를 사용하여 응답 데이터를 변환합니다:

import { plainToInstance } from 'class-transformer';

return plainToInstance(UserResponseDto, user, {
  excludeExtraneousValues: true, // @Expose()가 없는 필드는 제외
});

4.4 전역 직렬화 설정

파일 경로: src/main.ts

애플리케이션 전역에서 ClassSerializerInterceptor를 설정하여 응답 데이터의 직렬화를 자동으로 처리합니다:

import { ClassSerializerInterceptor } from '@nestjs/common';
import { NestFactory, Reflector } from '@nestjs/core';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  // 전역 직렬화 인터셉터 설정
  app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
  
  // 기타 설정...
}

4.5 데이터베이스 레벨 보안

파일 경로: src/entity/user.entity.ts

User 엔티티에서 password 필드에 select: false 옵션을 설정하여 기본 조회 시 비밀번호가 포함되지 않도록 합니다:

@Entity()
export class User {
  // 기타 필드...
  
  @Column({ select: false }) // 기본 조회 시 제외
  password: string;
  
  // 기타 필드...
}

이러한 다층 보안 구조를 통해 사용자의 비밀번호가 안전하게 보호되며, API 응답에서도 노출되지 않도록 보장합니다.

5. JWT 설정

필요한 패키지 설치

npm install @nestjs/jwt @nestjs/passport passport passport-local
npm install -D @types/passport-local

JWT 모듈 설정 - auth.module.ts

@Module({
  imports: [
    PassportModule,
    TypeOrmModule.forFeature([User]),
    JwtModule.register({
      secret: 'secret_key',        // JWT 서명/검증용 비밀키
      signOptions: {
        expiresIn: '1h',          // 토큰 만료 시간 설정
      },
    }),
  ],
  providers: [AuthService, LocalStrategy, UserService, JwtStrategy],
  exports: [AuthService],
})

역할:

  • JWT 토큰 생성과 검증을 위한 기본 설정
  • secret_key: 토큰 서명 및 검증에 사용하는 비밀키
  • expiresIn: 보안을 위한 토큰 만료 시간 (1시간)

6. Passport 로그인 설정

필요한 패키지 설치

npm install @nestjs/passport passport passport-local
npm install -D @types/passport-local

LocalStrategy 구현 - auth.strategy.ts

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super(
	      usernameField: 'email',
);  // passport-local 기본 설정 (username, password 필드)
  }

  async validate(username: string, password: string) {
    const user = await this.authService.validateUser(username, password);

    if (!user) {
      throw new UnauthorizedException();
    }

    return user;  // 성공 시 user 객체를 req.user에 저장
  }
}

LocalAuthGuard 구현 - local-auth.guard.ts

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

역할:

  • Passport의 Local Strategy를 NestJS에서 사용할 수 있게 래핑
  • 로그인 요청 시 자동으로 username/password 추출하여 인증 처리

7. 로그인 시 사용자 인증

필요한 패키지 설치

npm install bcrypt
npm install -D @types/bcrypt

실제 인증 로직 - auth.service.ts

async validateUser(username: string, password: string) {
  const user = await this.userService.getUserByUsername(username);

  if (user) {
    const match = await compare(password, user.password);  // bcrypt로 비밀번호 비교
    if (match) {
      return user;  // 인증 성공
    } else {
      return null;  // 비밀번호 틀림
    }
  }

  return null;  // 사용자 없음
}

로그인 엔드포인트 - app.controller.ts

@UseGuards(LocalAuthGuard)  // LocalStrategy.validate() 실행
@Post('login')
async login(@Request() req) {
  return this.authService.login(req.user);  // 인증된 사용자로 JWT 생성
}

흐름:

POST /login → LocalAuthGuard → LocalStrategy.validate() → AuthService.validateUser() → DB 조회 + bcrypt 비교 → 성공 시 req.user 저장

8. 토큰 검증 및 JWT Passport

필요한 패키지 설치

npm install passport-jwt
npm install -D @types/passport-jwt

JWT 토큰 생성 - auth.service.ts

async login(user: User) {
  const payload = {
    id: user.id,
    username: user.username,
    name: user.name,
  };

  return {
    accessToken: this.jwtService.sign(payload),  // JWT 토큰 생성
  };
}

JWT Strategy 구현 - jwt.strategy.ts

export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),  // Authorization: Bearer 헤더에서 추출
      ignoreExpiration: false,  // 만료된 토큰 거부
      secretOrKey: 'secret_key',  // 토큰 검증용 비밀키
    });
  }
  async validate(payload: { id: number; username: string; name: string }) {
    return payload;  // 검증된 payload를 req.user에 저장
  }
}

JWT Guard 구현 - jwt-auth.guard.ts

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

9. 보호된 리소스 접근

JWT 인증이 필요한 엔드포인트 - app.controller.ts

@UseGuards(JwtAuthGuard)  // JWT 토큰 검증
@Get('me')
async me(@Request() req) {
  return req.user;  // JWT에서 추출된 사용자 정보 반환
}

게시판에서 JWT 사용 예시 - board.controller.ts

@Post()
@UseGuards(JwtAuthGuard)
create(@UserInfo() userInfo, @Body('contents') contents: string) {
  if (!userInfo) throw new UnauthorizedException();
  console.log('userInfo', userInfo.id);
  return this.boardService.create({ userId: userInfo.id, contents });
}

토큰 검증 흐름:

GET /me (Authorization: Bearer token) → JwtAuthGuard → JwtStrategy.validate() → 토큰 검증 → payload 추출 → req.user 저장

10. 사용자 정보 추출 데코레이터

UserInfo 데코레이터 - user-info.decorator.ts

import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const UserInfo = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    return request.user;  // JWT에서 추출된 사용자 정보 반환
  },
);

사용 예시:

@UseGuards(JwtAuthGuard)
@Post()
create(@UserInfo() userInfo) {  // JWT payload가 userInfo에 자동 주입
  console.log(userInfo.id);     // { id: 1, username: "admin", name: "홍길동" }
}

11. 전체 인증 흐름 요약

로그인 과정:

1. POST /login { username, password }
2. LocalAuthGuard 실행
3. LocalStrategy.validate() → AuthService.validateUser()
4. DB 조회 + bcrypt 비교
5. 성공 시 AuthService.login() → JWT 토큰 생성
6. 클라이언트에 accessToken 반환

보호된 리소스 접근:

1. GET /me (Authorization: Bearer token)
2. JwtAuthGuard 실행
3. JwtStrategy.validate() → 토큰 검증
4. payload 추출하여 req.user에 저장
5. 컨트롤러 실행

+ refresToken

기존 Access Token만 사용할 때의 문제점

❌ Access Token 만료 시간이 길면 (1시간~1일)
   → 토큰 탈취 시 오랫동안 악용 가능
   → 보안 위험 높음

❌ Access Token 만료 시간이 짧으면 (5~15분)
   → 사용자가 자주 재로그인 해야 함
   → 사용자 경험 나쁨

전체 인증 흐름

1. 회원가입/로그인 과정

[클라이언트] → POST /auth/login (email, password)
     ↓
[서버] validateUser() → 사용자 인증
     ↓
[서버] login() 메서드 실행:
     ├─ accessToken 생성 (15분 만료)
     ├─ refreshToken 생성 (7일 만료)
     └─ refreshToken을 DB에 저장
     ↓
[클라이언트] ← { accessToken, refreshToken }
     ↓
[클라이언트] localStorage에 두 토큰 저장
  • src/routes/auth/auth.service.ts
async login(user: UserResponseDto) {
  const payload = {
    id: user.id,
    email: user.email,
    username: user.username,
  };

  // 1. accessToken 생성 (기본 15분 만료)
  const accessToken = this.jwtService.sign(payload);
  
  // 2. refreshToken 생성 (7일 만료)
  const refreshToken = await this.generateRefreshToken(user.id);

  // 3. DB에 refreshToken 저장
  await this.userRepository.update(user.id, { refreshToken });

  // 4. 클라이언트에게 두 토큰 모두 반환
  return {
    accessToken,
    refreshToken,
  };
}
  • src/routes/auth/auth.service.ts
async generateRefreshToken(userId: number): Promise<string> {
  const payload = { userId, type: 'refresh' };
  return this.jwtService.sign(payload, {
    secret: 'refresh_secret_key', // accessToken과 다른 시크릿 키
    expiresIn: '7d', // 7일 만료
  });
}

2. API 요청 과정

[클라이언트] → API 요청 (Authorization: Bearer accessToken)
     ↓
[서버] JwtAuthGuard → accessToken 검증
     ↓
✅ 유효하면 → API 응답
❌ 만료되면 → 401 Unauthorized
     ↓
[클라이언트] 401 받으면 자동으로 토큰 갱신 시도

3. 토큰 갱신 과정

[클라이언트] → POST /auth/refresh { refreshToken }
     ↓
[서버] refresh() 메서드 실행:
     ├─ refreshToken JWT 검증
     ├─ DB에서 해당 refreshToken 존재 확인
     └─ 새로운 accessToken 생성
     ↓
[클라이언트] ← { accessToken }
     ↓
[클라이언트] 새 accessToken으로 원래 API 재요청
  • src/routes/auth/auth.service.ts
async generateRefreshToken(userId: number): Promise<string> {
  const payload = { userId, type: 'refresh' };
  return this.jwtService.sign(payload, {
    secret: 'refresh_secret_key', // accessToken과 다른 시크릿 키
    expiresIn: '7d', // 7일 만료
  });
}

특징:

  • userId와 type: 'refresh' 포함
  • 다른 시크릿 키 사용 (보안 강화)
  • 7일 만료 (긴 유효기간)
async refresh(refreshToken: string) {
  try {
    // 1. refreshToken JWT 검증
    const payload = this.jwtService.verify(refreshToken, {
      secret: 'refresh_secret_key', // 생성 시와 같은 시크릿 키
    });

    // 2. DB에서 사용자와 refreshToken 일치 확인
    const user = await this.userRepository.findOne({
      where: { id: payload.userId, refreshToken }, // 🔑 핵심: DB의 토큰과 비교
      select: ['id', 'email', 'username', 'refreshToken'],
    });

    if (!user) {
      throw new HttpException('Invalid refresh token', HttpStatus.UNAUTHORIZED);
    }

    // 3. 새로운 accessToken 발급
    const newAccessToken = this.jwtService.sign({
      id: user.id,
      email: user.email,
      username: user.username,
    });

    return { accessToken: newAccessToken };
  } catch (error) {
    throw new HttpException('Invalid refresh token', HttpStatus.UNAUTHORIZED);
  }
}

보안 검증 단계:

  • JWT 서명 검증 (변조 여부 확인)
  • 만료 시간 검증 (7일 초과 여부)
  • DB 저장값과 비교 (탈취된 토큰 방지)

4. 로그아웃 과정

[클라이언트] → POST /auth/logout (Authorization: Bearer accessToken)
     ↓
[서버] logout() 메서드 실행:
     └─ DB에서 refreshToken 삭제 (null로 설정)
     ↓
[클라이언트] ← { message: "로그아웃 성공" }
     ↓
[클라이언트] localStorage에서 두 토큰 삭제
  • refreshToken 무효화
async logout(userId: number) {
  // DB에서 refreshToken 삭제 (null로 설정)
  await this.userRepository.update(userId, { refreshToken: null });
  return {
    message: 'Logout successful. Refresh token has been invalidated.',
  };
}
  • src/routes/auth/auth.controller.ts
// 로그인 (accessToken + refreshToken 발급)
@UseGuards(LocalAuthGuard)
@Post('login')
async login(@Request() req: any) {
  return this.authService.login(req.user);
}

// 토큰 갱신 (Guard 없음 - Body로 받음)
@Post('refresh')
async refresh(@Body() body: { refreshToken: string }) {
  return this.authService.refresh(body.refreshToken);
}

// 로그아웃 (refreshToken 무효화)
@UseGuards(JwtAuthGuard)
@Post('logout')
async logout(@Request() req: any) {
  return this.authService.logout(req.user.id);
}
profile
하루하루 기록하기!

0개의 댓글