JavaScript/NextJS
HttpOnly 쿠키를 활용한 JWT 로그인 구현
제이널
2023. 2. 4. 00:00
배경
JWT를 구현하는 방법은 요청 헤더에 토큰을 포함해서 전송하는 방법이 있지만, 어차피 쿠키나 localstorage에 토큰을 저장해야 하므로, 좀 더 안전한 방법인 httponly가 적용된 쿠키를 활용해 구현해보려 합니다. 또한, Safari에선 HttpOnly 옵션을 적용하지 않으면 쿠키를 사용할 수 없었기 때문에 반 강제(?)로 이 방법을 사용하게 되었습니다.
HttpOnly?
쿠키에 javascript로 직접 접근할 수 없도록 하는 옵션입니다. 따라서 XSS(Cross Site Script) 공격에 대한 방어 수단이 될 수 있습니다. 이 옵션을 쿠키에 적용하려면 백엔드에서 응답할 때 이 옵션을 적용한 쿠키를 만들어서 응답해야 합니다. 이 쿠키에는 개발자도 접근할 수 없으므로, 백엔드 서버로 전송 목적으로만 사용됩니다. Secure 옵션을 사용하면 좀 더 안전합니다.
Secure?
HTTPS 환경에서만 쿠키를 사용하도록 설정합니다.
JWT 구조
header, payload, verify signature로 이루어진 JWT의 구조를 다시 상기해주세요.
개발 환경
- 프런트엔드: NextJS, React-Query, Axios
- 백엔드: NestJS, TypeORM
- 데이터베이스: PostgreSQL
백엔드 준비
1. 패키지 설치
시작하기 전에, 필요한 패키지들을 먼저 설치해 주세요.
npm i @nestjs/jwt @nestjs/passport passport passport-custom passport-jwt @types/passport-jwt cookie-parser @types/cookie-parser rand-token --save
2. main.ts에 cookie-parser 적용
main.ts
import { NestFactory } from '@nestjs/core';
import * as cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(cookieParser());
await app.listen(8080);
}
bootstrap();
3. Auth 모듈 생성
nest g module auth
nest g controller auth --no-spec
nest g service auth --no-spec
4. 엑세스 토큰, 갱신 토큰 유효성 검사를 위한 Strategy 파일 생성
먼저 엑세스 토큰의 유효성을 검사하기 위한 전략 파일을 생성해줍니다.
src/auth/jwt.strategy.ts
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { InjectDataSource } from '@nestjs/typeorm';
import { Request } from 'express';
import * as jwt from 'jsonwebtoken';
import { Strategy } from 'passport-custom';
import { Users } from 'src/user/user.entity';
import { DataSource } from 'typeorm';
import { jwtConstants } from 'src/constans/jwtConstants';
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(@InjectDataSource() private readonly datasource: DataSource) {
super();
}
async validate(req: Request) {
try {
const token: string = req.cookies['auth-cookie']?.token;
if (!token) {
return true;
// throw new BadRequestException('There is no access token in cookie');
}
jwt.verify(token, jwtConstants.secret);
const payload = jwt.decode(token);
const manager = this.datasource.manager;
const query = { where: { email: payload['email'] } };
const user: Users = await manager.getRepository(Users).findOne(query);
if (!user) {
throw new BadRequestException('Not Allowed');
}
return user;
} catch (e) {
if (e instanceof TokenExpiredError) {
// 토큰 만료
throw new UnauthorizedException('Token is expired');
}
if (e instanceof SyntaxError) {
// payload가 잘 못 되었을 때 (base64 decode가 안되는 경우 등)
throw new BadRequestException('Invalid JSON object');
}
if (e instanceof JsonWebTokenError) {
// JwtWebTokenError should be later than TokenExpiredError
// invalid signature | invalid token (header 깨졌을 때)
throw new BadRequestException(e.message);
}
if (e instanceof JwtTypeError) {
throw new BadRequestException('Token is not access token');
}
throw e;
}
}
}
class JwtTypeError extends Error {
constructor(message: string) {
super(message);
}
}
엑세스 토큰을 갱신하기 위해선 엑세스 토큰이 만료되었다는 걸 프런트엔드 측이 알아야 합니다. 하지만 기본 Passport Strategy를 사용하면 항상 401 에러로 응답하기 때문에, 프런트엔드 측에서는 엑세스 토큰이 없어서 오류가 발생했는지 만료되어서 오류가 발생했는지를 구분하기 어렵습니다. 그렇기 때문에 상황에 따라 다른 에러 응답을 줄 수 있도록 passport-custom의 Strategy를 사용했습니다.
또한, 엑세스 토큰이 없는 인증 요청은 에러를 응답해주는 것이 원칙이지만, 저는 Next에서 페이지를 이동할 때마다 인증 상태를 검사할 목적이기 때문에, 페이지를 이동할 때마다 브라우저 콘솔과 Nest 콘솔에 에러가 많이 찍히는 것이 부담스러워서 'return true'로 통과시켜 주었습니다. 로그인이 안 된 상태의 유저도 웹 페이지를 둘러볼 수 있기 때문입니다. 물론 이런 로직은 에러 처리나 응답 데이터 처리에 대한 부작용이 있습니다.
엑세스 토큰은 헤더가 아닌 쿠키에서 가져옵니다. 이 글의 목적이기도 하죠.
쿠키의 내용은 아래와 같이 객체 형태로 되어 있습니다.
'auth-cookie': {
accessToken: ...,
refreshToken: ...
}
다음으로 엑세스 토큰을 갱신할 때, 갱신 토큰의 유효성을 검사하기 위한 전략 파일을 만들어 줍니다.
src/auth/refresh.strategy.ts
import { BadRequestException, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { InjectDataSource } from '@nestjs/typeorm';
import { Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { jwtConstants } from 'src/constans/jwtConstants';
import { Users } from 'src/user/user.entity';
import { getDateTime } from 'src/utils/common';
import { DataSource } from 'typeorm';
@Injectable()
export class RefreshStrategy extends PassportStrategy(Strategy, 'refresh') {
constructor(@InjectDataSource() private readonly datasource: DataSource) {
super({
ignoreExpiration: true,
passReqToCallback: true,
secretOrKey: jwtConstants.secret,
jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => {
const data = request?.cookies['auth-cookie'];
if (!data) {
return null;
}
return data.token;
},
]),
});
}
async validate(req: Request, payload: any) {
const manager = this.datasource.manager;
const data = req?.cookies['auth-cookie'];
if (!data?.refreshToken) {
// throw new BadRequestException('invalid refresh token');
return true;
}
const user = await manager.getRepository(Users).findOne({
where: {
email: payload.email,
refresh_token: data.refreshToken,
},
});
if (user) {
const now = getDateTime();
const exp = user.refresh_token_exp;
if (now > exp) {
throw new BadRequestException('token expired');
}
} else {
throw new BadRequestException('token expired');
}
return user;
}
}
갱신 토큰의 유효성은 에러 응답을 구분해주지 않고 기본 Strategy를 사용했습니다.
제가 생각한 엑세스 토큰 갱신에 대한 프로세스는 아래와 같습니다.
- 엑세스 토큰 없음 / 갱신 토큰 없음 --> 새로 로그인
- 엑세스 토큰 없음 / 갱신 토큰 유효 --> payload가 없으므로 유저 정보 확인 불가, 새로 로그인
- 엑세스 토큰 있음 / 갱신 토큰 만료 --> 갱신 불가, 새로운 갱신 토큰 발급이 필요하므로 새로 로그인
- 엑세스 토큰 만료 / 갱신 토큰 유효 --> 엑세스 토큰 갱신
결국엔 갱신 토큰이 유효하지 않은 경우엔 모두 로그인이 필요하기 때문에 에러를 구분할 필요가 없었습니다.
코드의 extends PassportStrategy(Strategy, 'refresh') 부분에서 'refresh'는 추후 Authgurad에서 사용할 키값입니다. 이 문자열을 통해 특정 전략을 지정해서 사용할 수 있습니다.
엑세스 토큰 검사와 마찬가지로 갱신 토큰이 요청값에 없으면 에러를 발생시켜주는 것이 원칙이지만, 동일한 이유로 그냥 통과시켜서 에러를 띄우지 않도록 했습니다.
5. Auth 모듈에 Passport, JwtModule, Strategy 추가
src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { jwtConstants } from 'src/constans/jwtConstants';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { RefreshStrategy } from './refresh.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({
secret: jwtConstants.secret,
signOptions: {
expiresIn: 60 * 60, // 한 시간 뒤 토큰 만료
// expiresIn: '1s', // 5초
},
}),
],
exports: [PassportModule, JwtStrategy],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, RefreshStrategy],
})
export class AuthModule {}
- secret: 토큰의 Verify Signature 부분을 만들고 검사할 때 사용되는 비밀 문자입니다. 값을 상수로 관리하면 유지보수에 유리합니다.
- signOptions.expresIn: 토큰 만료 시간을 정의합니다. 짧을 수록 보안에 유리합니다.
- exports: auth 모듈이 아닌 모듈에서 Strategy와 Passport를 사용하려면 export에 추가해줍니다.
- providers: auth 모듈에 주입할 Strategy를 추가합니다.
6. 서비스 구현
import {
Injectable,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { SignInDTO } from './dto/signin.dto';
import { Users } from 'src/user/user.entity';
import { getDateTime } from 'src/utils/common';
import * as crypto from 'crypto';
import * as randomToken from 'rand-token';
interface IJwtToken {
accessToken: string;
refreshToken: string;
}
@Injectable()
export class AuthService {
constructor(
@InjectDataSource() private readonly datasource: DataSource,
private jwtService: JwtService,
) {}
// 인증(로그인)
async signIn(signInDTO: SignInDTO): Promise<IJwtToken> {
const manager = this.datasource.manager;
// 비밀번호 md5 암호화
signInDTO.passwd = crypto
.createHash('md5')
.update(signInDTO.passwd)
.digest('hex');
const query = {
where: {
email: signInDTO.email,
passwd: signInDTO.passwd,
},
};
const user = await manager.getRepository(Users).findOne(query);
if (!user) {
throw new UnauthorizedException();
}
const refreshToken = await this.cretaeRefreshToken(user.user_id);
if (!refreshToken) {
throw new ServiceUnavailableException();
} else {
const payload = { email: signInDTO.email };
const accessToken = await this.jwtService.sign(payload);
return { accessToken, refreshToken };
}
}
// 엑세스 토큰 갱신
async refreshToken(user: Users): Promise<IJwtToken> {
const payload = { email: user.email };
const accessToken = await this.jwtService.sign(payload);
return { accessToken, refreshToken: user.refresh_token };
}
// 갱신 토큰 (재)발급
async cretaeRefreshToken(userId: number): Promise<string> {
const manager = this.datasource.manager;
const refresh_token = randomToken.generate(16);
const refresh_token_exp = getDateTime(3); // 만료기한: 3일
await manager
.getRepository(Users)
.createQueryBuilder()
.update(Users)
.set({
refresh_token,
refresh_token_exp,
})
.where(`user_id = ${userId}`)
.execute();
return refresh_token;
}
// 갱신 토큰 만료 계산에 사용(return: '2023-01-23 12:34:56')
getDateTime(days?: number): string {
const date = new Date();
if (days) {
date.setDate(date.getDate() + days);
}
date.setHours(date.getHours() + 9);
return date.toISOString().replace('T', ' ').substring(0, 19);
}
}
7. 컨트롤러 구현
src/auth/auth.controller.ts
import {
Body,
Controller,
Delete,
Get,
Patch,
Post,
Res,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Users } from 'src/user/user.entity';
import { AuthService } from './auth.service';
import { SignInDTO } from './dto/signin.dto';
import { GetUser } from './get-user.decorator';
@Controller('/api/auth')
export class AuthController {
constructor(private authService: AuthService) {}
// 전역 쿠키 설정
private readonly cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'local' ? false : true,
sameSite: process.env.NODE_ENV === 'local' ? '' : 'none',
path: '/',
// maxAge: 60 * 60 * 24 * 3,
};
// 인증(로그인)
@Post()
@UsePipes(ValidationPipe)
async signIn(@Body() signInDTO: SignInDTO, @Res({ passthrough: true }) res) {
const tokens = await this.authService.signIn(signInDTO);
res.cookie(
'auth-cookie',
{ token: tokens.accessToken, refreshToken: tokens.refreshToken },
this.cookieOptions,
);
}
// 인가
@Get()
@UseGuards(AuthGuard()) // jwt.strategy.ts에서 엑세스 토큰 검사
authorize(@GetUser() user: Users): Users {
return user.user_id ? user : null;
}
// 엑세스 토큰 만료 시 갱신
@Patch()
@UseGuards(AuthGuard('refresh')) // refresh.strategy.ts에서 갱신 토큰 검사
async refreshToken(@GetUser() user: Users, @Res({ passthrough: true }) res) {
const tokens = await this.authService.refreshToken(user);
res.cookie(
'auth-cookie',
{ token: tokens.accessToken, refreshToken: tokens.refreshToken },
this.cookieOptions,
);
}
// 로그아웃
@Delete()
signOut(@Res({ passthrough: true }) res) {
res.cookie('auth-cookie', '');
}
}
- @GetUser(): JwtStrategy에서 반환된 결과값을 변환(transform)합니다.(선택)
백엔드에서의 준비는 끝났습니다.
이제 프런트엔드에서 요청해보면 되겠네요.
프런트엔드 준비
1. axios.common 파일 생성
axios의 요청이나 응답에 대해 전역적으로 컨트롤하고 싶을 때 아래와 같이 client를 따로 만들어서 사용할 수 있습니다. 개인적으론 useQuery로 인가를 구현할 때 onError로 401 에러를 캐치하면 많은 엑세스 토큰 재발급 요청이 발생돼서 이 방법을 택하게 되었습니다.
/utils/axios-common.ts
import axios, { HeadersDefaults } from 'axios';
const axiosClient = axios.create();
axiosClient.interceptors.response.use(
res => {
return res;
},
async err => {
const originalConfig = err.config;
// 엑세스 토큰 만료
if (err.response.status === 401 && !originalConfig._retry) {
originalConfig._retry = true;
try {
const rs = await axios.patch('/api/auth');
return axiosClient(originalConfig);
} catch (_error) {
return Promise.reject(_error);
}
}
return Promise.reject(err);
}
);
export default axiosClient;
2. QueryClient 설정
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 20,// 전역 캐싱 타임: 20초
refetchOnWindowFocus: false, // 탭 바뀌거나 포커스되면 refetch
retry: false, // 재요청 비활성화
notifyOnChangeProps: 'tracked', // 속성의 변화가 있을 때만 리렌더링, 속성 추적에 따른 오버헤드 발생 주의
},
}
});
3. 인증(로그인) 커스텀 훅 생성
/hooks/useSignIn.ts
import { moveUrl, setCookie } from "@/utils/common";
import axios, { AxiosError } from "axios";
import { useMutation } from "react-query";
interface ISignInReqData {
email: string;
passwd: string;
}
export function useSignIn(data?: ISignInReqData) {
const fetcher = (data: ISignInReqData) => axios.post('/api/auth', data);
return useMutation(fetcher, {
onSuccess: (res) => {
moveUrl('/');
},
onError: (err: AxiosError) => {
if (err.response!.status >= 500) {
alert('서버 연결에 실패했습니다. 잠시 후 다시 시도해 주세요.')
}
}
}
)
}
4. 인가 커스텀 훅 생성
/hooks/useAuth.ts
import { queryKeys } from "@/constant/queryKeys";
import { useQuery, useQueryClient } from "react-query";
import request from '@/utils/axios-common';
export function useAuth() {
const fetcher = () => request.get('/api/auth');
return useQuery(queryKeys.AUTH_USER, fetcher, {
select: (res): IUser => ({
user_id: res.data.user_id,
type_cd: res.data.type_cd,
email: res.data.email,
})
});
}
export interface IUser {
user_id: string;
type_cd: string;
email: string;
}
- 여러 컴포넌트에서 사용될 인가 훅은 useQuery를 사용해 중복 요청을 하지 않도록 했습니다.
- request: 401에러 핸들링을 위해 axios client를 import해서 사용합니다.
- queryKeys: 쿼리 키를 상수로 관리하면 유지보수에 유리합니다.
- select: 응답 결과를 변환(transform)해줄 수 있습니다.
5. 로그아웃 커스텀 훅 생성
/hooks/useSignOut.ts
import axios from "axios";
import { useMutation, useQueryClient } from "react-query";
export function useSignOut() {
const queryClient = useQueryClient();
return useMutation(() => axios.delete('/api/auth'), {
onSuccess: (res)=> {
// queryClient.invalidateQueries({ queryKey: [queryKeys.AUTH_USER] });
location.href='/';
}
});
}
- 캐시 데이터만 최신 상태로 바꾸려면 queryClient.invalidateQueries를 사용해 주세요.
이제 훅을 사용해서 로그인 > 로그인 중 인가/갱신 > 갱신 토큰 만료 > 로그아웃 순으로 테스트 해주시면 됩니다.
엑세스 토큰 시간을 1초로 설정하면 갱신 테스트에 도움이 됩니다.
- 로그인 테스트
- 'auth-cookie'라는 이름의 쿠키가 생성되는지 확인
- 인가 테스트
- 엑세스 토큰이 있을 때 로그인 상태가 유지되는지 확인
- 엑세스 토큰이 만료되었을 때 자동으로 갱신되는지 확인
- 갱신 토큰 만료 테스트
- 갱신 토큰이 만료되었을 때, 엑세스 토큰 재발급이 실패하는지 확인
- 로그아웃 테스트
- 'auth-cookie'라는 키의 쿠키 값이 사라졌는지 확인
참고
- https://www.youtube.com/watch?v=3JminDpCJNE&t=19851s&ab_channel=JohnAhn
- https://v3.leedo.me/b3a73fa7-5dc5-44a7-99e7-bc8a715e42b9
- https://www.learmoreseekmore.com/2021/05/nestjs-jwt-auth-cookie-series-part3-refresh-token.html