본문으로 건너뛰기
Advertisement

14.4 NestJS 인증 — JWT와 Passport 통합

JWT 인증 설정

npm install @nestjs/jwt @nestjs/passport passport passport-jwt
npm install --save-dev @types/passport-jwt

AuthModule 구성

// auth/auth.module.ts
import { Module } from '@nestjs/common'
import { JwtModule } from '@nestjs/jwt'
import { PassportModule } from '@nestjs/passport'
import { ConfigModule, ConfigService } from '@nestjs/config'
import { AuthService } from './auth.service'
import { AuthController } from './auth.controller'
import { JwtStrategy } from './strategies/jwt.strategy'
import { UsersModule } from '../users/users.module'

@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: { expiresIn: '1h' },
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}

JWT Strategy

// auth/strategies/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { ExtractJwt, Strategy } from 'passport-jwt'
import { ConfigService } from '@nestjs/config'
import { UsersService } from '../../users/users.service'

interface JwtPayload {
sub: string // 사용자 ID
email: string
role: string
iat: number
exp: number
}

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
configService: ConfigService,
private usersService: UsersService
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET')!,
})
}

async validate(payload: JwtPayload) {
const user = await this.usersService.findOne(payload.sub)
if (!user) {
throw new UnauthorizedException('사용자를 찾을 수 없습니다.')
}
return { id: payload.sub, email: payload.email, role: payload.role }
}
}

AuthService

// auth/auth.service.ts
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import * as bcrypt from 'bcrypt'
import { UsersService } from '../users/users.service'
import { LoginDto } from './dto/login.dto'
import { RegisterDto } from './dto/register.dto'

export interface AuthTokens {
accessToken: string
refreshToken: string
}

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

async register(registerDto: RegisterDto) {
// 이메일 중복 확인
const exists = await this.usersService.findByEmail(registerDto.email)
if (exists) {
throw new ConflictException('이미 사용 중인 이메일입니다.')
}

// 비밀번호 해시
const hashedPassword = await bcrypt.hash(registerDto.password, 10)

const user = await this.usersService.create({
...registerDto,
password: hashedPassword,
})

return this.generateTokens(user)
}

async login(loginDto: LoginDto): Promise<AuthTokens> {
const user = await this.usersService.findByEmail(loginDto.email)

if (!user || !(await bcrypt.compare(loginDto.password, user.password))) {
throw new UnauthorizedException('이메일 또는 비밀번호가 올바르지 않습니다.')
}

return this.generateTokens(user)
}

async refreshTokens(refreshToken: string): Promise<AuthTokens> {
try {
const payload = this.jwtService.verify(refreshToken, {
secret: process.env.REFRESH_TOKEN_SECRET,
})
const user = await this.usersService.findOne(payload.sub)
return this.generateTokens(user)
} catch {
throw new UnauthorizedException('유효하지 않은 리프레시 토큰입니다.')
}
}

private generateTokens(user: { id: string; email: string; role: string }): AuthTokens {
const payload = { sub: user.id, email: user.email, role: user.role }

return {
accessToken: this.jwtService.sign(payload, { expiresIn: '1h' }),
refreshToken: this.jwtService.sign(payload, {
secret: process.env.REFRESH_TOKEN_SECRET,
expiresIn: '7d',
}),
}
}
}

AuthController

// auth/auth.controller.ts
import {
Controller,
Post,
Body,
HttpCode,
HttpStatus,
UseGuards,
Get,
} from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'
import { AuthService } from './auth.service'
import { LoginDto } from './dto/login.dto'
import { RegisterDto } from './dto/register.dto'
import { CurrentUser } from '../common/decorators/current-user.decorator'

@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}

@Post('register')
@HttpCode(HttpStatus.CREATED)
register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto)
}

@Post('login')
@HttpCode(HttpStatus.OK)
login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto)
}

@Post('refresh')
@HttpCode(HttpStatus.OK)
refresh(@Body('refreshToken') refreshToken: string) {
return this.authService.refreshTokens(refreshToken)
}

@Get('me')
@UseGuards(AuthGuard('jwt'))
getMe(@CurrentUser() user: AuthUser) {
return user
}
}

역할 기반 접근 제어 (RBAC)

// auth/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'
import { Reflector } from '@nestjs/core'

@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}

canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
context.getHandler(),
context.getClass(),
])

if (!requiredRoles || requiredRoles.length === 0) {
return true // 역할 제한 없음
}

const { user } = context.switchToHttp().getRequest()

if (!requiredRoles.includes(user.role)) {
throw new ForbiddenException('접근 권한이 없습니다.')
}

return true
}
}

// 사용 예시
@Get('admin-data')
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles('admin')
getAdminData() {
return { secret: 'admin only data' }
}

고수 팁

전역 인증 가드 적용

// app.module.ts
import { APP_GUARD } from '@nestjs/core'

@Module({
providers: [
{
provide: APP_GUARD,
useClass: AuthGuard, // 모든 엔드포인트에 인증 적용
},
{
provide: APP_GUARD,
useClass: RolesGuard,
},
],
})
export class AppModule {}

// 인증 필요 없는 엔드포인트에는 @Public() 데코레이터 사용
@Get('public')
@Public()
getPublicData() {
return 'public data'
}
Advertisement