-
JWT 발급 및 검증 기능 제공
generateTokens
,verifyToken
,decodeToken
메서드로 JWT 관련 작업을 효과적으로 처리할 수 있습니다.- 액세스 토큰과 리프레시 토큰을 분리하여 보안과 갱신 흐름을 명확히 관리합니다.
-
JWT 생성
generateTokens
는access
와refresh
토큰을 동시에 생성하며, 각각의 만료 시간을 설정합니다.- 토큰 데이터에
type
을 추가해, 액세스와 리프레시 토큰을 구분합니다. 이는 토큰 오용 방지에 효과적입니다.
-
토큰 상태 관리
isTokenExpired
와getTokenTimeRemaining
은 토큰이 유효한지, 만료까지 얼마나 남았는지를 확인합니다.
// src/auth/jwt/jwt.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../../users/users.service';
export interface JwtPayload {
sub: string; // 사용자 ID email?: string; // 이메일 (선택)
provider: string; // OAuth 제공자
type: 'access' | 'refresh'; // 토큰 타입
iat?: number; // 발급 시간
exp?: number; // 만료 시간
}
export interface TokenResponse {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
@Injectable()
export class AuthJwtService {
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
/**
* 액세스 토큰과 리프레시 토큰 생성
*/
async generateTokens(
payload: Omit<JwtPayload, 'type' | 'iat' | 'exp'>,
): Promise<TokenResponse> {
const accessTokenExpiresIn = this.configService.get<number>(
'auth.jwt.accessExpiresIn',
900,
); // 15분
const refreshTokenExpiresIn = this.configService.get<number>(
'auth.jwt.refreshExpiresIn',
604800,
); // 7일
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(
{
...payload,
type: 'access',
},
{
expiresIn: accessTokenExpiresIn,
},
),
this.jwtService.signAsync(
{
...payload,
type: 'refresh',
},
{
expiresIn: refreshTokenExpiresIn,
},
),
]);
return {
accessToken,
refreshToken,
expiresIn: accessTokenExpiresIn,
};
}
/**
* 토큰 검증
*/
async verifyToken(token: string): Promise<JwtPayload> {
try {
return await this.jwtService.verifyAsync<JwtPayload>(token);
} catch (error) {
throw new Error(`Token verification failed: ${error.message}`);
}
}
/**
* 토큰 디코딩 (검증 없이)
*/ decodeToken(token: string): JwtPayload {
try {
return this.jwtService.decode(token) as JwtPayload;
} catch (error) {
throw new Error(`Token decoding failed: ${error.message}`);
}
}
/**
* 토큰 만료 시간 확인
*/
isTokenExpired(token: string): boolean {
try {
const decoded = this.decodeToken(token);
if (!decoded.exp) return true;
return decoded.exp * 1000 < Date.now();
} catch {
return true;
}
}
/**
* 토큰 남은 시간 확인 (초 단위)
*/ getTokenTimeRemaining(token: string): number {
try {
const decoded = this.decodeToken(token);
if (!decoded.exp) return 0;
return Math.max(0, decoded.exp - Math.floor(Date.now() / 1000));
} catch {
return 0;
}
}
}
// JWT 서비스 사용 예시
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: AuthJwtService,
private readonly usersService: UsersService,
) {}
/**
* OAuth 로그인 처리
*/
async handleOAuthLogin(
provider: string,
profile: any,
): Promise<TokenResponse> {
// 1. 사용자 찾기 또는 생성
const user = await this.usersService.findOrCreateByOAuth(provider, profile);
// 2. JWT 토큰 생성
return this.jwtService.generateTokens({
sub: user.id,
email: user.email,
provider,
});
}
/**
* 토큰 갱신
*/
async refreshToken(refreshToken: string): Promise<TokenResponse> {
try {
// 1. 리프레시 토큰 검증
const payload = await this.jwtService.verifyToken(refreshToken);
// 2. 리프레시 토큰 타입 확인
if (payload.type !== 'refresh') {
throw new Error('Invalid token type');
}
// 3. 사용자 확인
const user = await this.usersService.findById(payload.sub);
if (!user) {
throw new Error('User not found');
}
// 4. 새 토큰 발급
return this.jwtService.generateTokens({
sub: user.id,
email: user.email,
provider: payload.provider,
});
} catch (error) {
throw new Error(`Token refresh failed: ${error.message}`);
}
}
/**
* 토큰으로 사용자 인증
*/
async validateToken(token: string): Promise<any> {
try {
// 1. 토큰 검증
const payload = await this.jwtService.verifyToken(token);
// 2. 액세스 토큰 타입 확인
if (payload.type !== 'access') {
throw new Error('Invalid token type');
}
// 3. 만료 확인
if (this.jwtService.isTokenExpired(token)) {
throw new Error('Token has expired');
}
// 4. 사용자 조회
return this.usersService.findById(payload.sub);
} catch (error) {
throw new Error(`Token validation failed: ${error.message}`);
}
}
}
사용예시
// JWT 서비스 사용 예시
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: AuthJwtService,
private readonly usersService: UsersService,
) {}
/**
* OAuth 로그인 처리
*/
async handleOAuthLogin(
provider: string,
profile: any,
): Promise<TokenResponse> {
// 1. 사용자 찾기 또는 생성
const user = await this.usersService.findOrCreateByOAuth(provider, profile);
// 2. JWT 토큰 생성
return this.jwtService.generateTokens({
sub: user.id,
email: user.email,
provider,
});
}
/**
* 토큰 갱신
*/
async refreshToken(refreshToken: string): Promise<TokenResponse> {
try {
// 1. 리프레시 토큰 검증
const payload = await this.jwtService.verifyToken(refreshToken);
// 2. 리프레시 토큰 타입 확인
if (payload.type !== 'refresh') {
throw new Error('Invalid token type');
}
// 3. 사용자 확인
const user = await this.usersService.findById(payload.sub);
if (!user) {
throw new Error('User not found');
}
// 4. 새 토큰 발급
return this.jwtService.generateTokens({
sub: user.id,
email: user.email,
provider: payload.provider,
});
} catch (error) {
throw new Error(`Token refresh failed: ${error.message}`);
}
}
/**
* 토큰으로 사용자 인증
*/
async validateToken(token: string): Promise<any> {
try {
// 1. 토큰 검증
const payload = await this.jwtService.verifyToken(token);
// 2. 액세스 토큰 타입 확인
if (payload.type !== 'access') {
throw new Error('Invalid token type');
}
// 3. 만료 확인
if (this.jwtService.isTokenExpired(token)) {
throw new Error('Token has expired');
}
// 4. 사용자 조회
return this.usersService.findById(payload.sub);
} catch (error) {
throw new Error(`Token validation failed: ${error.message}`);
}
}
}
-
OAuth 로그인 처리 (
handleOAuthLogin
)- 사용자를 조회하거나 없으면 생성(
findOrCreateByOAuth
) 후 JWT 토큰을 발급합니다. - OAuth 인증 과정에서 사용되는 일반적인 흐름을 잘 반영합니다.
- 사용자를 조회하거나 없으면 생성(
-
리프레시 토큰 갱신 (
refreshToken
)- 리프레시 토큰 검증 후, 새 JWT 토큰 세트를 발급합니다.
- 토큰 타입(
type
)과 만료 상태를 철저히 검증하여 보안을 강화했습니다.
-
토큰 인증 (
validateToken
)- 액세스 토큰 검증 후, 만료 확인 및 사용자 조회까지 처리합니다.
- 토큰의 타입(
access
)과 만료 여부를 확인해 오용 가능성을 줄였습니다.