-
Notifications
You must be signed in to change notification settings - Fork 4
JWT 쿠키로 사용하기
@Get('naver/callback')
@UseGuards(AuthGuard('naver'))
async naverCallback(@Req() req) {
// 네이버 인증 후 사용자 정보 반환
const user = req.user;
// TODO: 후에 권한 (workspace 조회, 편집 기능)도 payload에 추가
const payload = { sub: user.id, provider: user.provider };
const accessToken = this.jwtService.sign(payload, { expiresIn: '1h' });
const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
return {
message: '네이버 로그인 성공',
user,
accessToken,
refreshToken,
};
}
- 현재 백엔드에서는 생성한 access, refresh token을 response의 Body에 담아서 알아서 보관하고, 매 요청때마다 자동으로 헤더에 담아서 request를 보내게 구현할 예정이었다
- 그런데 클라이언트의 입장에서 해당 방법으로 구현할 경우, 로그인 성공 후
리디렉션을 할 수 없는 문제 (CORS)
보안상 토큰 탈취의 가능성
이 두 가지의 이유로 HttpOnly 쿠키 형태로 두가지 토큰을 담아, 전달하는 것으로 인증 방식을 변경하기로 하였다
async naverCallback(@Req() req, @Res() res: Response) {
// 네이버 인증 후 사용자 정보 반환
const user = req.user;
// TODO: 후에 권한 (workspace 조회, 편집 기능)도 payload에 추가
const payload = { sub: user.id, provider: user.provider };
const accessToken = this.jwtService.sign(payload, { expiresIn: '1h' });
const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
// 토큰을 쿠키에 담아서 메인 페이지로 리디렉션
res.cookie('accessToken', accessToken, { httpOnly: true, maxAge: HOUR });
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
maxAge: WEEK,
});
res.redirect(302, '/');
}
- oAuth 인증 후 서버는 토큰을 생성한 후, response에 쿠키로 첨부하여 302 상태코드를 전송한다
- 일단은 jwt 토큰의 유효기간과 쿠키의 유효기간을 똑같이 맞춘다
- 문제는 쿠키는 유효시간이 제대로 설정되지 않는다면 브라우저 종료시, 바로 소멸될 가능성이 있다는 것이다.
- 가장 중요한 것은 브라우저 별로 다르지만, 쿠키의
maxAge
와expire
을 둘 다 설정해야 해단 기간동안 쿠키가 소멸되지않고 안전히 보관된다는 것이다 - 또한,
쿠키가 존재하는 기간이 토큰의 유효기간보다 길게 하여
서버에서 해당 토큰을 직접 체크하고 관리하기로 하였다. HttpOnly 쿠키이기 때문에 클라이언트 코드는 해당 쿠키에 접근할 수 없기 때문이다. 앞으로 서버에서 유효하지않은 토큰을 확인하고 해당 쿠키를 직접 없애면서 로그인, 로그아웃 상태를 관리할 것이다. -
Access Token(JWT)
의 유효기간:1시간
-
Refresh Token(JWT)
의 유효기간:5개월
- Access Token, Refresh Token을 담은
쿠키
의 수명:6개월
const HOUR = 60 * 60;
const DAY = 24 * 60 * 60;
const FIVE_MONTHS = 5 * 30 * 24 * 60 * 60;
const MS_HALF_YEAR = 6 * 30 * 24 * 60 * 60 * 1000;
@Injectable()
export class TokenService {
constructor(
private readonly jwtService: JwtService,
private readonly userRepository: UserRepository,
) {}
generateAccessToken(userId: number): string {
const payload = { sub: userId };
return this.jwtService.sign(payload, {
expiresIn: HOUR,
});
}
async generateRefreshToken(userId: number): Promise<string> {
const payload = { sub: userId};
const refreshToken = this.jwtService.sign(payload, {
expiresIn: FIVE_MONTHS,
});
await this.updateRefreshToken(userId, refreshToken);
return refreshToken;
}
setAccessTokenCookie(response: Response, accessToken: string): void {
response.cookie('accessToken', accessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: MS_HALF_YEAR,
expires: new Date(Date.now() + MS_HALF_YEAR),
});
}
setRefreshTokenCookie(response: Response, refreshToken: string): void {
response.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: MS_HALF_YEAR,
expires: new Date(Date.now() + MS_HALF_YEAR),
});
}
- 이제 토큰, 쿠키 관련 로직은 이제 token.service.ts에서 관리하기에 authController에서 token module을 import해서 사용하면 된다
-
이제 클라이언트에서 토큰에 접근을 할 수 없기에,
- (1) access token이 만료의 이유로 유효하지 않을 시
- (2) refresh token을 검증
- (3) access token을 해당 사용자 정보를 바탕으로 새로 발급
⇒ 해당 로직을 서버에서 직접 실행해야한다
마찬가지로 tokenService에서 해당 역할을 하는 함수를 만든다
...
async refreshAccessToken(refreshToken: string): Promise<string> {
// refreshToken 1차 검증
const decoded = this.jwtService.verify(refreshToken, {
secret: process.env.JWT_SECRET,
});
// 검증된 토큰에서 사용자 ID 추출
const userId = decoded.sub;
// refreshToken 2차 검증
// DB에 저장된 refreshToken과 비교
const isValid = await this.compareStoredRefreshToken(userId, refreshToken);
if (!isValid) {
throw new InvalidTokenException();
}
// 새로운 accessToken을 발급한다
return this.generateAccessToken(decoded.sub);
}
- 그렇다면 쿠키는 어떤 케이스에서 삭제해야할까? jwt-auth guard(로그인을 요구로하는 api에서 데코레이터 형태로 사용할 Guard),에서 로그인한 사용자의 인증을 검사하고 인가할 때
- access token, refresh token 둘 다 있지 않을 경우
- 둘의 검증 과정 중
- access token이 만료 외의 이유로 유효하지 않을 경우
- refresh token이 어떠한 이유로든 유효하지 않은 경우
⇒ 모두 request에 담겨있는 쿠키를 삭제
하고 (정확히 같은 특성의 쿠키를 삭제하도록 코드를 써야 해당 쿠키를 인식하고 삭제한다) 다시 로그인 절차를 진행하도록 클라이언트 측에서 안내한다
clearCookies(response: Response) {
response.clearCookie('accessToken', {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
response.clearCookie('refreshToken', {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
}
- token service에 쿠키를 모두 삭제하는 함수를 추가하고
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse<Response>();
const cookies = request.cookies; // 쿠키에서 가져오기
// 쿠키가 아예 없는 경우는 로그인 안 된 상태로 간주
if (!cookies || !cookies.accessToken || !cookies.refreshToken) {
// 관련된 쿠키 비워주기
this.tokenService.clearCookies(response);
throw new LoginRequiredException();
}
const { accessToken, refreshToken } = cookies;
try {
// JWT 검증
const decodedToken = this.jwtService.verify(accessToken, {
secret: process.env.JWT_SECRET,
});
// 유효한 토큰이면 요청 객체에 사용자 정보를 추가
request.user = decodedToken;
return true;
} catch (error) {
// accessToken이 만료된 경우
if (error instanceof TokenExpiredError) {
try {
// 새로운 accessToken 발급받기
const newAccessToken =
await this.tokenService.refreshAccessToken(refreshToken);
// 쿠키 업데이트
this.tokenService.setAccessTokenCookie(response, newAccessToken);
// 요청 객체에 사용자 정보 추가
const decodedNewToken = this.jwtService.verify(newAccessToken, {
secret: process.env.JWT_SECRET,
});
request.user = decodedNewToken;
return true;
} catch (refreshError) {
// refreshToken 디코딩 실패 시 처리 쿠키 비워줌
this.tokenService.clearCookies(response);
throw new InvalidTokenException();
}
} else {
// accessToken 디코딩(만료가 아닌 이유로) 실패 시 처리 쿠키 비워줌
this.tokenService.clearCookies(response);
throw new InvalidTokenException();
}
}
- 위의 auth-guard 코드에서 token 서비스 계층의 함수들을 사용하여 쿠키를 관리한다
⇒ 이 구현 후 흐름을 정리하자면
- 로그인을 필요로 하며 + 로그인 한 사용자의 정보(Id)를 요구하는 api에서는
- auth-guard를 사용하면
- api 도달 전 auth-guard에서 request의 쿠키를 검증하여
- 토큰을 검증한다
- access token이 유효할 시 바로 사용자 정보를 얻어서 전달한다
- access token이 만료되었지만 refresh token은 유효할 시, 해당 토큰에서 얻은 사용자 정보로 access token을 다시 발급하고, 사용자 정보를 전달한다
- 그 밖의 경우는 사용자에게 다시 로그인하도록 redirection으로 안내한다
- 이제 api 코드에서는 request안에서 사용자 정보를 얻을 수 있다!!
async createWorkspace(
@Request() req,
@Body() createWorkspaceDto: CreateWorkspaceDto,
) {
const userId = req.user.sub; // 인증된 사용자의 ID
→ 이런 식으로!
⚓️ 사용자 피드백과 버그 기록
👷🏻 기술적 도전
📖 위키와 학습정리
✏️ 에디터
Novel이란?
Novel 스타일링 문제
에디터 저장 및 고려 사항들
📠 실시간 협업, 통신
Yorkie와 Novel editor 연동
YJS, Websocket, React-Flow
YJS, Socket.io
WebSocket과 Socket.io에 대해 간단히 알아보기
YJS 가이드 근데 이제 Socket.io를 곁들인
🏗️ 인프라와 CI/CD
NCloud CI CD 구축
BE 개발 스택과 기술적 고민
private key로 원격 서버 접근
nCloud 서버, VPC 만들고 설정
monorepo로 변경
⌛ 캐시, 최적화
rabbit mq 사용법
🔑 인증, 인가, 보안
passport로 oAuth 로그인 회원가입 구현
FE 로그인 기능 구현
JWT로 인증 인가 구현
JWT 쿠키로 사용하기
refresh token 보완하기
🧸 팀원 소개
⛺️ 그라운드 룰
🍞 커밋 컨벤션
🧈 이슈, PR 컨벤션
🥞 브랜치 전략
🌤️ 데일리 스크럼
📑 회의록
1️⃣ 1주차
킥오프(10/25)
2일차(10/29)
3일차(10/30)
4일차(10/31)
2️⃣ 2주차
8일차(11/04)
9일차(11/05)
11일차(11/07)
13일차(11/09)
3️⃣ 3주차
3주차 주간계획(11/11)
16일차(11/12)
18일차(11/14)
4️⃣ 4주차
4주차 주간계획(11/18)
23일차(11/19)
24일차(11/20)
25일차(11/21)
5️⃣ 5주차
5주차 주간계획(11/25)
29일차(11/25)
32일차(11/28)
34일차(11/30)
6️⃣ 6주차
6주차 주간계획(12/2)
37일차(12/3)