Nest.js - JWT & Passport 인증

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

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개의 댓글