From 888399637faf0a11a77090fb14004e4ebf9da875 Mon Sep 17 00:00:00 2001 From: xet-a Date: Sat, 24 Aug 2024 15:47:44 +0900 Subject: [PATCH 01/16] Implement refresh token in backend --- backend/.env.development | 5 +- backend/src/auth/auth.controller.ts | 36 +++++++------- backend/src/auth/auth.module.ts | 17 ++++--- backend/src/auth/auth.service.spec.ts | 46 +++++++++++++++++- backend/src/auth/auth.service.ts | 48 ++++++++++++++++++- backend/src/auth/jwt-refresh.strategy.ts | 28 +++++++++++ backend/src/auth/jwt.strategy.ts | 10 ++-- backend/src/auth/types/login-response.type.ts | 1 + .../auth/types/refresh-token-request.type.ts | 6 +++ 9 files changed, 167 insertions(+), 30 deletions(-) create mode 100644 backend/src/auth/jwt-refresh.strategy.ts create mode 100644 backend/src/auth/types/refresh-token-request.type.ts diff --git a/backend/.env.development b/backend/.env.development index 8c8d82a1..f69cbda4 100644 --- a/backend/.env.development +++ b/backend/.env.development @@ -16,7 +16,10 @@ GITHUB_CALLBACK_URL=http://localhost:3000/auth/login/github # JWT_AUTH_SECRET: Secret key for JWT authentication. # This key is used to sign and verify JWT tokens. -JWT_AUTH_SECRET=you_should_change_this_secret_key_in_production +JWT_ACCESS_TOKEN_SECRET=you_should_change_this_secret_key_in_production +JWT_ACCESS_TOKEN_EXPIRATION_TIME=86400 +JWT_REFRESH_TOKEN_SECRET=you_should_change_this_secret_key_in_production +JWT_REFRESH_TOKEN_EXPIRATION_TIME=604800 # FRONTEND_BASE_URL: Base URL of the frontend application. # This URL is used for redirecting after authentication, etc. diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 9bfc0215..36c3ea99 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,20 +1,19 @@ -import { Controller, Get, HttpRedirectResponse, Redirect, Req, UseGuards } from "@nestjs/common"; +import { Body, Controller, Get, HttpRedirectResponse, Post, Redirect, Req, UseGuards } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { AuthGuard } from "@nestjs/passport"; +import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { Public } from "src/utils/decorators/auth.decorator"; +import { AuthService } from "./auth.service"; import { LoginRequest } from "./types/login-request.type"; -import { JwtService } from "@nestjs/jwt"; import { LoginResponse } from "./types/login-response.type"; -import { UsersService } from "src/users/users.service"; -import { Public } from "src/utils/decorators/auth.decorator"; -import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; -import { ConfigService } from "@nestjs/config"; +import { RefreshTokenRequest } from "./types/refresh-token-request.type"; @ApiTags("Auth") @Controller("auth") export class AuthController { constructor( - private configService: ConfigService, - private jwtService: JwtService, - private usersService: UsersService + private readonly authService: AuthService, + private configService: ConfigService ) {} @Public() @@ -28,16 +27,21 @@ export class AuthController { }) @ApiResponse({ type: LoginResponse }) async login(@Req() req: LoginRequest): Promise { - const user = await this.usersService.findOrCreate( - req.user.socialProvider, - req.user.socialUid - ); - - const accessToken = this.jwtService.sign({ sub: user.id, nickname: user.nickname }); + const { accessToken, refreshToken } = await this.authService.loginWithGithub(req); return { - url: `${this.configService.get("FRONTEND_BASE_URL")}/auth/callback?token=${accessToken}`, + url: `${this.configService.get("FRONTEND_BASE_URL")}/auth/callback?accessToken=${accessToken}&refreshToken=${refreshToken}`, statusCode: 302, }; } + + @Public() + @Post("refresh") + @UseGuards(AuthGuard("refresh")) + @ApiOperation({ summary: 'Refresh Access Token' }) + @ApiResponse({ type: LoginResponse }) + async refresh(@Body() body: RefreshTokenRequest): Promise<{ accessToken: string }> { + const accessToken = await this.authService.getNewAccessToken(body.refreshToken); + return { accessToken }; + } } diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index a7902981..2b80d736 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -1,10 +1,11 @@ import { Module } from "@nestjs/common"; -import { AuthService } from "./auth.service"; +import { ConfigService } from "@nestjs/config"; +import { JwtModule } from "@nestjs/jwt"; import { UsersModule } from "src/users/users.module"; import { AuthController } from "./auth.controller"; +import { AuthService } from "./auth.service"; import { GithubStrategy } from "./github.strategy"; -import { ConfigService } from "@nestjs/config"; -import { JwtModule } from "@nestjs/jwt"; +import { JwtRefreshStrategy } from "./jwt-refresh.strategy"; import { JwtStrategy } from "./jwt.strategy"; @Module({ @@ -14,14 +15,18 @@ import { JwtStrategy } from "./jwt.strategy"; useFactory: async (configService: ConfigService) => { return { global: true, - signOptions: { expiresIn: "24h" }, - secret: configService.get("JWT_AUTH_SECRET"), + signOptions: { + expiresIn: `${configService.get( + 'JWT_ACCESS_TOKEN_EXPIRATION_TIME', + )}s`, + }, + secret: configService.get("JWT_ACCESS_TOKEN_SECRET"), }; }, inject: [ConfigService], }), ], - providers: [AuthService, GithubStrategy, JwtStrategy], + providers: [AuthService, GithubStrategy, JwtStrategy, JwtRefreshStrategy], controllers: [AuthController], }) export class AuthModule {} diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index 5430748f..92bb5cc7 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -1,18 +1,62 @@ +import { ConfigModule } from "@nestjs/config"; +import { JwtService } from "@nestjs/jwt"; import { Test, TestingModule } from "@nestjs/testing"; +import { UsersService } from "../users/users.service"; import { AuthService } from "./auth.service"; describe("AuthService", () => { let service: AuthService; + let usersService: UsersService; + let jwtService: JwtService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [AuthService], + imports: [ConfigModule.forRoot()], + providers: [ + AuthService, + { + provide: UsersService, + useValue: { + findOrCreate: jest.fn().mockResolvedValue({ id: '123', nickname: 'testuser' }), + }, + }, + { + provide: JwtService, + useValue: { + sign: jest.fn().mockReturnValue('signedToken'), + verify: jest.fn().mockReturnValue({ sub: '123', nickname: 'testuser' }), + }, + }, + ], }).compile(); service = module.get(AuthService); + jwtService = module.get(JwtService); + usersService = module.get(UsersService); }); it("should be defined", () => { expect(service).toBeDefined(); }); + + describe("getNewAccessToken", () => { + it("should generate a new access token using refresh token", async () => { + const newToken = await service.getNewAccessToken('refreshToken'); + + expect(newToken).toBe('signedToken'); + expect(jwtService.verify).toHaveBeenCalledWith('refreshToken'); + expect(jwtService.sign).toHaveBeenCalledWith( + { sub: '123', nickname: 'testuser' }, + expect.any(Object), + ); + }); + + it("should throw an error if refresh token is invalid", async () => { + jwtService.verify = jest.fn().mockImplementation(() => { + throw new Error("Invalid token"); + }); + + await expect(service.getNewAccessToken('invalidToken')).rejects.toThrow("Invalid token"); + }); + }); }); diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index beddfe5b..8679dd40 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,7 +1,51 @@ import { Injectable } from "@nestjs/common"; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from "@nestjs/jwt"; import { UsersService } from "src/users/users.service"; +import { LoginRequest } from "./types/login-request.type"; +import { LoginResponse } from "./types/login-response.type"; @Injectable() export class AuthService { - constructor(private usersService: UsersService) {} -} + constructor( + private readonly usersService: UsersService, + private readonly jwtService: JwtService, + private readonly configService: ConfigService + ) {} + + async loginWithGithub(req: LoginRequest): Promise { + const user = await this.usersService.findOrCreate( + req.user.socialProvider, + req.user.socialUid, + ); + + const accessToken = this.jwtService.sign( + { sub: user.id, nickname: user.nickname }, + { expiresIn: `${this.configService.get( + 'JWT_ACCESS_TOKEN_EXPIRATION_TIME', + )}s`, }, + ); + + const refreshToken = this.jwtService.sign( + { sub: user.id }, + { expiresIn: `${this.configService.get( + 'JWT_REFRESH_TOKEN_EXPIRATION_TIME', + )}s`, }, + ); + + return {accessToken, refreshToken}; + } + + async getNewAccessToken(refreshToken: string) { + const payload = this.jwtService.verify(refreshToken); + + const newAccessToken = this.jwtService.sign( + { sub: payload.sub, nickname: payload.nickname }, + { expiresIn: `${this.configService.get( + 'JWT_ACCESS_TOKEN_EXPIRATION_TIME', + )}s`, }, + ); + + return newAccessToken; + } +} \ No newline at end of file diff --git a/backend/src/auth/jwt-refresh.strategy.ts b/backend/src/auth/jwt-refresh.strategy.ts new file mode 100644 index 00000000..19befde3 --- /dev/null +++ b/backend/src/auth/jwt-refresh.strategy.ts @@ -0,0 +1,28 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PassportStrategy } from "@nestjs/passport"; +import { Strategy as PassportJwtStrategy } from "passport-jwt"; +import { JwtPayload } from "src/utils/types/jwt.type"; +import { AuthorizedUser } from "src/utils/types/req.type"; + +@Injectable() +export class JwtRefreshStrategy extends PassportStrategy( + PassportJwtStrategy, "refresh" +) { + constructor(configService: ConfigService) { + super({ + jwtFromRequest: (req: Request) => { + if (req && (req.body as any).refresh_token) { + return (req.body as any).refresh_token; + } + return null; + }, + ignoreExpiration: false, + secretOrKey: configService.get("JWT_REFRESH_TOKEN_SECRET"), + }); + } + + async validate(payload: JwtPayload): Promise { + return { id: payload.sub, nickname: payload.nickname }; + } +} diff --git a/backend/src/auth/jwt.strategy.ts b/backend/src/auth/jwt.strategy.ts index 7c823730..fd0a2706 100644 --- a/backend/src/auth/jwt.strategy.ts +++ b/backend/src/auth/jwt.strategy.ts @@ -1,17 +1,19 @@ -import { ExtractJwt, Strategy as PassportJwtStrategy } from "passport-jwt"; -import { ConfigService } from "@nestjs/config"; import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { PassportStrategy } from "@nestjs/passport"; +import { ExtractJwt, Strategy as PassportJwtStrategy } from "passport-jwt"; import { JwtPayload } from "src/utils/types/jwt.type"; import { AuthorizedUser } from "src/utils/types/req.type"; @Injectable() -export class JwtStrategy extends PassportStrategy(PassportJwtStrategy) { +export class JwtStrategy extends PassportStrategy( + PassportJwtStrategy, "jwt" +) { constructor(configService: ConfigService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: configService.get("JWT_AUTH_SECRET"), + secretOrKey: configService.get("JWT_ACCESS_TOKEN_SECRET"), }); } diff --git a/backend/src/auth/types/login-response.type.ts b/backend/src/auth/types/login-response.type.ts index 99542f23..829c3df9 100644 --- a/backend/src/auth/types/login-response.type.ts +++ b/backend/src/auth/types/login-response.type.ts @@ -3,4 +3,5 @@ import { ApiProperty } from "@nestjs/swagger"; export class LoginResponse { @ApiProperty({ type: String, description: "Access token for CodePair" }) accessToken: string; + refreshToken: string; } diff --git a/backend/src/auth/types/refresh-token-request.type.ts b/backend/src/auth/types/refresh-token-request.type.ts new file mode 100644 index 00000000..dd9a187b --- /dev/null +++ b/backend/src/auth/types/refresh-token-request.type.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class RefreshTokenRequest { + @IsString() + refreshToken: string; +} \ No newline at end of file From b5f4be5aa49c0767a8b299526a096a7b5362c449 Mon Sep 17 00:00:00 2001 From: xet-a Date: Sat, 24 Aug 2024 15:50:05 +0900 Subject: [PATCH 02/16] Implement token refresh with axios interceptor --- frontend/src/App.tsx | 59 ++++++++++++++++++---- frontend/src/pages/auth/callback/Index.tsx | 11 ++-- frontend/src/store/authSlice.ts | 9 +++- 3 files changed, 63 insertions(+), 16 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c8e6c966..59448186 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,9 +2,12 @@ import "@fontsource/roboto/300.css"; import "@fontsource/roboto/400.css"; import "@fontsource/roboto/500.css"; import "@fontsource/roboto/700.css"; -import "./App.css"; import { Box, CssBaseline, ThemeProvider, createTheme, useMediaQuery } from "@mui/material"; -import { useSelector } from "react-redux"; +import * as Sentry from "@sentry/react"; +import { QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import axios from "axios"; +import { useEffect, useMemo } from "react"; +import { useDispatch, useSelector } from "react-redux"; import { RouterProvider, createBrowserRouter, @@ -13,15 +16,15 @@ import { useLocation, useNavigationType, } from "react-router-dom"; -import { useEffect, useMemo } from "react"; -import { selectConfig } from "./store/configSlice"; -import axios from "axios"; -import { routes } from "./routes"; -import { QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import AuthProvider from "./providers/AuthProvider"; -import { useErrorHandler } from "./hooks/useErrorHandler"; -import * as Sentry from "@sentry/react"; +import "./App.css"; import { useGetSettingsQuery } from "./hooks/api/settings"; +import { useErrorHandler } from "./hooks/useErrorHandler"; +import AuthProvider from "./providers/AuthProvider"; +import { routes } from "./routes"; +import { setAccessToken, setRefreshToken } from "./store/authSlice"; +import { selectConfig } from "./store/configSlice"; +import { store } from "./store/store"; +import { setUserData } from "./store/userSlice"; import { isAxios404Error, isAxios500Error } from "./utils/axios.default"; if (import.meta.env.PROD) { @@ -49,6 +52,42 @@ if (import.meta.env.PROD) { const router = createBrowserRouter(routes); +axios.interceptors.response.use( + res => { + console.log("inspector test: " + res.data.json); + return res; + }, + async error => { + const state = store.getState(); + const dispatch = useDispatch(); + const originalRequest = error.config; + + if (error.response.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + const refreshToken = state.auth.refreshToken; + const response = await axios.post('/auth/refresh', { refreshToken }); + + if (response.status === 200) { + const newAccessToken = response.data.accessToken; + dispatch(setAccessToken(newAccessToken)); + + axios.defaults.headers.common['Authorization'] = `Bearer ${newAccessToken}`; + originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`; + + return axios(originalRequest); + } else { + dispatch(setAccessToken(null)); + dispatch(setRefreshToken(null)); + dispatch(setUserData(null)); + } + } + + return Promise.reject(error); + } +); + + axios.defaults.baseURL = import.meta.env.VITE_API_ADDR; function SettingLoader() { diff --git a/frontend/src/pages/auth/callback/Index.tsx b/frontend/src/pages/auth/callback/Index.tsx index 77d363e2..d1c338eb 100644 --- a/frontend/src/pages/auth/callback/Index.tsx +++ b/frontend/src/pages/auth/callback/Index.tsx @@ -2,7 +2,7 @@ import { Box } from "@mui/material"; import { useEffect } from "react"; import { useDispatch } from "react-redux"; import { useNavigate, useSearchParams } from "react-router-dom"; -import { setAccessToken } from "../../../store/authSlice"; +import { setAccessToken, setRefreshToken } from "../../../store/authSlice"; function CallbackIndex() { const dispatch = useDispatch(); @@ -10,14 +10,17 @@ function CallbackIndex() { const [searchParams] = useSearchParams(); useEffect(() => { - const token = searchParams.get("token"); + const accessToken = searchParams.get("accessToken"); + const refreshToken = searchParams.get("refreshToken"); - if (!token) { + if (!accessToken || !refreshToken) { navigate("/"); return; } - dispatch(setAccessToken(token)); + dispatch(setAccessToken(accessToken)); + dispatch(setRefreshToken(refreshToken)); + }, [dispatch, navigate, searchParams]); return ; diff --git a/frontend/src/store/authSlice.ts b/frontend/src/store/authSlice.ts index 4ecf3371..7e0d0cea 100644 --- a/frontend/src/store/authSlice.ts +++ b/frontend/src/store/authSlice.ts @@ -1,13 +1,15 @@ -import { createSlice } from "@reduxjs/toolkit"; import type { PayloadAction } from "@reduxjs/toolkit"; +import { createSlice } from "@reduxjs/toolkit"; import { RootState } from "./store"; export interface AuthState { accessToken: string | null; + refreshToken: string | null; } const initialState: AuthState = { accessToken: null, + refreshToken: null, }; export const authSlice = createSlice({ @@ -17,10 +19,13 @@ export const authSlice = createSlice({ setAccessToken: (state, action: PayloadAction) => { state.accessToken = action.payload; }, + setRefreshToken(state, action: PayloadAction) { + state.refreshToken = action.payload; + }, }, }); -export const { setAccessToken } = authSlice.actions; +export const { setAccessToken, setRefreshToken } = authSlice.actions; export const selectAuth = (state: RootState) => state.auth; From 1a657bbbd7d158723a0347f3f6b6ca9963944fc0 Mon Sep 17 00:00:00 2001 From: xet-a Date: Sun, 25 Aug 2024 11:48:06 +0900 Subject: [PATCH 03/16] Fix typo --- backend/src/auth/auth.service.spec.ts | 24 +++++++++++++----------- backend/src/auth/jwt-refresh.strategy.ts | 10 ++++------ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index 92bb5cc7..53df34b3 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -6,7 +6,6 @@ import { AuthService } from "./auth.service"; describe("AuthService", () => { let service: AuthService; - let usersService: UsersService; let jwtService: JwtService; beforeEach(async () => { @@ -17,14 +16,16 @@ describe("AuthService", () => { { provide: UsersService, useValue: { - findOrCreate: jest.fn().mockResolvedValue({ id: '123', nickname: 'testuser' }), + findOrCreate: jest + .fn() + .mockResolvedValue({ id: "123", nickname: "testuser" }), }, }, { provide: JwtService, useValue: { - sign: jest.fn().mockReturnValue('signedToken'), - verify: jest.fn().mockReturnValue({ sub: '123', nickname: 'testuser' }), + sign: jest.fn().mockReturnValue("signedToken"), + verify: jest.fn().mockReturnValue({ sub: "123", nickname: "testuser" }), }, }, ], @@ -32,7 +33,6 @@ describe("AuthService", () => { service = module.get(AuthService); jwtService = module.get(JwtService); - usersService = module.get(UsersService); }); it("should be defined", () => { @@ -41,13 +41,13 @@ describe("AuthService", () => { describe("getNewAccessToken", () => { it("should generate a new access token using refresh token", async () => { - const newToken = await service.getNewAccessToken('refreshToken'); + const newToken = await service.getNewAccessToken("refreshToken"); - expect(newToken).toBe('signedToken'); - expect(jwtService.verify).toHaveBeenCalledWith('refreshToken'); + expect(newToken).toBe("signedToken"); + expect(jwtService.verify).toHaveBeenCalledWith("refreshToken"); expect(jwtService.sign).toHaveBeenCalledWith( - { sub: '123', nickname: 'testuser' }, - expect.any(Object), + { sub: "123", nickname: "testuser" }, + expect.any(Object) ); }); @@ -56,7 +56,9 @@ describe("AuthService", () => { throw new Error("Invalid token"); }); - await expect(service.getNewAccessToken('invalidToken')).rejects.toThrow("Invalid token"); + await expect(service.getNewAccessToken("invalidToken")).rejects.toThrow( + "Invalid token" + ); }); }); }); diff --git a/backend/src/auth/jwt-refresh.strategy.ts b/backend/src/auth/jwt-refresh.strategy.ts index 19befde3..13840b56 100644 --- a/backend/src/auth/jwt-refresh.strategy.ts +++ b/backend/src/auth/jwt-refresh.strategy.ts @@ -6,14 +6,12 @@ import { JwtPayload } from "src/utils/types/jwt.type"; import { AuthorizedUser } from "src/utils/types/req.type"; @Injectable() -export class JwtRefreshStrategy extends PassportStrategy( - PassportJwtStrategy, "refresh" -) { +export class JwtRefreshStrategy extends PassportStrategy(PassportJwtStrategy, "refresh") { constructor(configService: ConfigService) { super({ - jwtFromRequest: (req: Request) => { - if (req && (req.body as any).refresh_token) { - return (req.body as any).refresh_token; + jwtFromRequest: (req) => { + if (req && req.body.refreshToken) { + return req.body.refreshToken; } return null; }, From a2a80ab7c878d0bb6f41e758119d234c44f44c8c Mon Sep 17 00:00:00 2001 From: xet-a Date: Sun, 25 Aug 2024 11:49:27 +0900 Subject: [PATCH 04/16] Apply lint --- backend/src/auth/auth.controller.ts | 13 ++++++++++-- backend/src/auth/auth.module.ts | 4 +--- backend/src/auth/auth.service.ts | 20 +++++++------------ backend/src/auth/jwt.strategy.ts | 4 +--- .../auth/types/refresh-token-request.type.ts | 8 ++++---- 5 files changed, 24 insertions(+), 25 deletions(-) diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 36c3ea99..cdadbfa0 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,4 +1,13 @@ -import { Body, Controller, Get, HttpRedirectResponse, Post, Redirect, Req, UseGuards } from "@nestjs/common"; +import { + Body, + Controller, + Get, + HttpRedirectResponse, + Post, + Redirect, + Req, + UseGuards, +} from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { AuthGuard } from "@nestjs/passport"; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; @@ -38,7 +47,7 @@ export class AuthController { @Public() @Post("refresh") @UseGuards(AuthGuard("refresh")) - @ApiOperation({ summary: 'Refresh Access Token' }) + @ApiOperation({ summary: "Refresh Access Token" }) @ApiResponse({ type: LoginResponse }) async refresh(@Body() body: RefreshTokenRequest): Promise<{ accessToken: string }> { const accessToken = await this.authService.getNewAccessToken(body.refreshToken); diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 2b80d736..5118b349 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -16,9 +16,7 @@ import { JwtStrategy } from "./jwt.strategy"; return { global: true, signOptions: { - expiresIn: `${configService.get( - 'JWT_ACCESS_TOKEN_EXPIRATION_TIME', - )}s`, + expiresIn: `${configService.get("JWT_ACCESS_TOKEN_EXPIRATION_TIME")}s`, }, secret: configService.get("JWT_ACCESS_TOKEN_SECRET"), }; diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 8679dd40..09303b22 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,5 +1,5 @@ import { Injectable } from "@nestjs/common"; -import { ConfigService } from '@nestjs/config'; +import { ConfigService } from "@nestjs/config"; import { JwtService } from "@nestjs/jwt"; import { UsersService } from "src/users/users.service"; import { LoginRequest } from "./types/login-request.type"; @@ -16,24 +16,20 @@ export class AuthService { async loginWithGithub(req: LoginRequest): Promise { const user = await this.usersService.findOrCreate( req.user.socialProvider, - req.user.socialUid, + req.user.socialUid ); const accessToken = this.jwtService.sign( { sub: user.id, nickname: user.nickname }, - { expiresIn: `${this.configService.get( - 'JWT_ACCESS_TOKEN_EXPIRATION_TIME', - )}s`, }, + { expiresIn: `${this.configService.get("JWT_ACCESS_TOKEN_EXPIRATION_TIME")}s` } ); const refreshToken = this.jwtService.sign( { sub: user.id }, - { expiresIn: `${this.configService.get( - 'JWT_REFRESH_TOKEN_EXPIRATION_TIME', - )}s`, }, + { expiresIn: `${this.configService.get("JWT_REFRESH_TOKEN_EXPIRATION_TIME")}s` } ); - return {accessToken, refreshToken}; + return { accessToken, refreshToken }; } async getNewAccessToken(refreshToken: string) { @@ -41,11 +37,9 @@ export class AuthService { const newAccessToken = this.jwtService.sign( { sub: payload.sub, nickname: payload.nickname }, - { expiresIn: `${this.configService.get( - 'JWT_ACCESS_TOKEN_EXPIRATION_TIME', - )}s`, }, + { expiresIn: `${this.configService.get("JWT_ACCESS_TOKEN_EXPIRATION_TIME")}s` } ); return newAccessToken; } -} \ No newline at end of file +} diff --git a/backend/src/auth/jwt.strategy.ts b/backend/src/auth/jwt.strategy.ts index fd0a2706..54b87a29 100644 --- a/backend/src/auth/jwt.strategy.ts +++ b/backend/src/auth/jwt.strategy.ts @@ -6,9 +6,7 @@ import { JwtPayload } from "src/utils/types/jwt.type"; import { AuthorizedUser } from "src/utils/types/req.type"; @Injectable() -export class JwtStrategy extends PassportStrategy( - PassportJwtStrategy, "jwt" -) { +export class JwtStrategy extends PassportStrategy(PassportJwtStrategy, "jwt") { constructor(configService: ConfigService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), diff --git a/backend/src/auth/types/refresh-token-request.type.ts b/backend/src/auth/types/refresh-token-request.type.ts index dd9a187b..a8cdd453 100644 --- a/backend/src/auth/types/refresh-token-request.type.ts +++ b/backend/src/auth/types/refresh-token-request.type.ts @@ -1,6 +1,6 @@ -import { IsString } from 'class-validator'; +import { IsString } from "class-validator"; export class RefreshTokenRequest { - @IsString() - refreshToken: string; -} \ No newline at end of file + @IsString() + refreshToken: string; +} From 10542448f2652061d217bc56e780ba917977e5ad Mon Sep 17 00:00:00 2001 From: xet-a Date: Sun, 25 Aug 2024 11:59:48 +0900 Subject: [PATCH 05/16] Add Refresh token initialization --- .../src/components/popovers/ProfilePopover.tsx | 15 ++++++++------- frontend/src/hooks/api/user.ts | 7 ++++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/popovers/ProfilePopover.tsx b/frontend/src/components/popovers/ProfilePopover.tsx index 8e8d563d..a5bacb04 100644 --- a/frontend/src/components/popovers/ProfilePopover.tsx +++ b/frontend/src/components/popovers/ProfilePopover.tsx @@ -1,3 +1,7 @@ +import DarkModeIcon from "@mui/icons-material/DarkMode"; +import LightModeIcon from "@mui/icons-material/LightMode"; +import LogoutIcon from "@mui/icons-material/Logout"; +import ManageAccountsIcon from "@mui/icons-material/ManageAccounts"; import { ListItemIcon, ListItemText, @@ -6,16 +10,12 @@ import { Popover, PopoverProps, } from "@mui/material"; -import LogoutIcon from "@mui/icons-material/Logout"; -import ManageAccountsIcon from "@mui/icons-material/ManageAccounts"; import { useDispatch } from "react-redux"; -import { setAccessToken } from "../../store/authSlice"; -import { setUserData } from "../../store/userSlice"; -import DarkModeIcon from "@mui/icons-material/DarkMode"; -import LightModeIcon from "@mui/icons-material/LightMode"; +import { useNavigate } from "react-router-dom"; import { useCurrentTheme } from "../../hooks/useCurrentTheme"; +import { setAccessToken, setRefreshToken } from "../../store/authSlice"; import { setTheme } from "../../store/configSlice"; -import { useNavigate } from "react-router-dom"; +import { setUserData } from "../../store/userSlice"; function ProfilePopover(props: PopoverProps) { const dispatch = useDispatch(); @@ -24,6 +24,7 @@ function ProfilePopover(props: PopoverProps) { const handleLogout = () => { dispatch(setAccessToken(null)); + dispatch(setRefreshToken(null)); dispatch(setUserData(null)); }; diff --git a/frontend/src/hooks/api/user.ts b/frontend/src/hooks/api/user.ts index f4b99026..177dd907 100644 --- a/frontend/src/hooks/api/user.ts +++ b/frontend/src/hooks/api/user.ts @@ -1,10 +1,10 @@ -import { useDispatch, useSelector } from "react-redux"; -import { selectAuth, setAccessToken } from "../../store/authSlice"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import axios from "axios"; -import { GetUserResponse, UpdateUserRequest } from "./types/user"; import { useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { selectAuth, setAccessToken, setRefreshToken } from "../../store/authSlice"; import { User, setUserData } from "../../store/userSlice"; +import { GetUserResponse, UpdateUserRequest } from "./types/user"; export const generateGetUserQueryKey = (accessToken: string) => { return ["users", accessToken]; @@ -32,6 +32,7 @@ export const useGetUserQuery = () => { dispatch(setUserData(query.data as User)); } else if (query.isError) { dispatch(setAccessToken(null)); + dispatch(setRefreshToken(null)); dispatch(setUserData(null)); axios.defaults.headers.common["Authorization"] = ""; } From fbecc95c8984e36f93316bae18610195902fd2f1 Mon Sep 17 00:00:00 2001 From: xet-a Date: Sun, 25 Aug 2024 12:00:44 +0900 Subject: [PATCH 06/16] Update Axios interceptor to handle token expiration and refresh logic --- frontend/src/App.tsx | 78 ++++++++++++---------- frontend/src/pages/auth/callback/Index.tsx | 1 - frontend/src/store/authSlice.ts | 10 +-- 3 files changed, 47 insertions(+), 42 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 59448186..a6abd200 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -52,42 +52,6 @@ if (import.meta.env.PROD) { const router = createBrowserRouter(routes); -axios.interceptors.response.use( - res => { - console.log("inspector test: " + res.data.json); - return res; - }, - async error => { - const state = store.getState(); - const dispatch = useDispatch(); - const originalRequest = error.config; - - if (error.response.status === 401 && !originalRequest._retry) { - originalRequest._retry = true; - - const refreshToken = state.auth.refreshToken; - const response = await axios.post('/auth/refresh', { refreshToken }); - - if (response.status === 200) { - const newAccessToken = response.data.accessToken; - dispatch(setAccessToken(newAccessToken)); - - axios.defaults.headers.common['Authorization'] = `Bearer ${newAccessToken}`; - originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`; - - return axios(originalRequest); - } else { - dispatch(setAccessToken(null)); - dispatch(setRefreshToken(null)); - dispatch(setUserData(null)); - } - } - - return Promise.reject(error); - } -); - - axios.defaults.baseURL = import.meta.env.VITE_API_ADDR; function SettingLoader() { @@ -97,6 +61,7 @@ function SettingLoader() { function App() { const config = useSelector(selectConfig); + const dispatch = useDispatch(); const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); const theme = useMemo(() => { const defaultMode = prefersDarkMode ? "dark" : "light"; @@ -134,6 +99,47 @@ function App() { }); }, [handleError]); + useEffect(() => { + const handleRefreshTokenExpiration = () => { + dispatch(setAccessToken(null)); + dispatch(setRefreshToken(null)); + dispatch(setUserData(null)); + // axios.defaults.headers.common["Authorization"] = ""; + }; + + const interceptor = axios.interceptors.response.use( + (response) => response, + async (error) => { + if (error.response?.status === 401) { + if (error.config.url === "/auth/refresh") { + handleRefreshTokenExpiration(); + return Promise.reject(error); + } else if (!error.config._retry) { + error.config._retry = true; + const refreshToken = store.getState().auth.refreshToken; + try { + const response = await axios.post("/auth/refresh", { refreshToken }); + const newAccessToken = response.data.accessToken; + dispatch(setAccessToken(newAccessToken)); + axios.defaults.headers.common["Authorization"] = + `Bearer ${newAccessToken}`; + error.config.headers["Authorization"] = `Bearer ${newAccessToken}`; + return axios(error.config); + } catch (refreshError) { + handleRefreshTokenExpiration(); + return Promise.reject(refreshError); + } + } + } + return Promise.reject(error); + } + ); + + return () => { + axios.interceptors.response.eject(interceptor); + }; + }, [dispatch]); + return ( diff --git a/frontend/src/pages/auth/callback/Index.tsx b/frontend/src/pages/auth/callback/Index.tsx index d1c338eb..1f7dd193 100644 --- a/frontend/src/pages/auth/callback/Index.tsx +++ b/frontend/src/pages/auth/callback/Index.tsx @@ -20,7 +20,6 @@ function CallbackIndex() { dispatch(setAccessToken(accessToken)); dispatch(setRefreshToken(refreshToken)); - }, [dispatch, navigate, searchParams]); return ; diff --git a/frontend/src/store/authSlice.ts b/frontend/src/store/authSlice.ts index 7e0d0cea..5d3c4cda 100644 --- a/frontend/src/store/authSlice.ts +++ b/frontend/src/store/authSlice.ts @@ -4,12 +4,12 @@ import { RootState } from "./store"; export interface AuthState { accessToken: string | null; - refreshToken: string | null; + refreshToken: string | null; } const initialState: AuthState = { accessToken: null, - refreshToken: null, + refreshToken: null, }; export const authSlice = createSlice({ @@ -19,9 +19,9 @@ export const authSlice = createSlice({ setAccessToken: (state, action: PayloadAction) => { state.accessToken = action.payload; }, - setRefreshToken(state, action: PayloadAction) { - state.refreshToken = action.payload; - }, + setRefreshToken(state, action: PayloadAction) { + state.refreshToken = action.payload; + }, }, }); From e8af8f90c348776a828106dc6017e7ced1176a6d Mon Sep 17 00:00:00 2001 From: xet-a Date: Sun, 25 Aug 2024 12:05:31 +0900 Subject: [PATCH 07/16] Update environment variable comments for clarity --- backend/.env.development | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/.env.development b/backend/.env.development index f69cbda4..149d2296 100644 --- a/backend/.env.development +++ b/backend/.env.development @@ -14,10 +14,12 @@ GITHUB_CLIENT_SECRET=your_github_client_secret_here # Example: http://localhost:3000/auth/login/github (For development mode) GITHUB_CALLBACK_URL=http://localhost:3000/auth/login/github -# JWT_AUTH_SECRET: Secret key for JWT authentication. -# This key is used to sign and verify JWT tokens. +# JWT_ACCESS_TOKEN_SECRET: Secret key for signing and verifying access tokens. +# JWT_ACCESS_TOKEN_EXPIRATION_TIME: Expiration time for access tokens in seconds. JWT_ACCESS_TOKEN_SECRET=you_should_change_this_secret_key_in_production JWT_ACCESS_TOKEN_EXPIRATION_TIME=86400 +# JWT_REFRESH_TOKEN_SECRET: Secret key for signing and verifying refresh tokens. +# JWT_REFRESH_TOKEN_EXPIRATION_TIME: Expiration time for refresh tokens in seconds. JWT_REFRESH_TOKEN_SECRET=you_should_change_this_secret_key_in_production JWT_REFRESH_TOKEN_EXPIRATION_TIME=604800 From 07fcb404b8ea0f93cbb88f6b87369feb260dc63f Mon Sep 17 00:00:00 2001 From: xet-a Date: Mon, 9 Sep 2024 00:00:01 +0900 Subject: [PATCH 08/16] Separate dtos related to refresh token --- backend/src/auth/dto/refresh-token-request.dto.ts | 6 ++++++ backend/src/auth/dto/refresh-token-response.dto.ts | 6 ++++++ backend/src/auth/types/refresh-token-request.type.ts | 6 ------ 3 files changed, 12 insertions(+), 6 deletions(-) create mode 100644 backend/src/auth/dto/refresh-token-request.dto.ts create mode 100644 backend/src/auth/dto/refresh-token-response.dto.ts delete mode 100644 backend/src/auth/types/refresh-token-request.type.ts diff --git a/backend/src/auth/dto/refresh-token-request.dto.ts b/backend/src/auth/dto/refresh-token-request.dto.ts new file mode 100644 index 00000000..72fe7db5 --- /dev/null +++ b/backend/src/auth/dto/refresh-token-request.dto.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class RefreshTokenRequestDto { + @ApiProperty({ type: String, description: "The refresh token to request a new access token" }) + refreshToken: string; +} diff --git a/backend/src/auth/dto/refresh-token-response.dto.ts b/backend/src/auth/dto/refresh-token-response.dto.ts new file mode 100644 index 00000000..0dbfb96d --- /dev/null +++ b/backend/src/auth/dto/refresh-token-response.dto.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class RefreshTokenResponseDto { + @ApiProperty({ type: String, description: "The new access token" }) + newAccessToken: string; +} diff --git a/backend/src/auth/types/refresh-token-request.type.ts b/backend/src/auth/types/refresh-token-request.type.ts deleted file mode 100644 index a8cdd453..00000000 --- a/backend/src/auth/types/refresh-token-request.type.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { IsString } from "class-validator"; - -export class RefreshTokenRequest { - @IsString() - refreshToken: string; -} From 85c3325c63d34349c2c91bef7d9a8126b53f96d5 Mon Sep 17 00:00:00 2001 From: xet-a Date: Mon, 9 Sep 2024 00:02:29 +0900 Subject: [PATCH 09/16] Add `@ApiProperty` decorator --- backend/src/auth/types/login-response.type.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/auth/types/login-response.type.ts b/backend/src/auth/types/login-response.type.ts index 829c3df9..991f2e59 100644 --- a/backend/src/auth/types/login-response.type.ts +++ b/backend/src/auth/types/login-response.type.ts @@ -3,5 +3,7 @@ import { ApiProperty } from "@nestjs/swagger"; export class LoginResponse { @ApiProperty({ type: String, description: "Access token for CodePair" }) accessToken: string; + + @ApiProperty({ type: String, description: "Refresh token to get a new access token" }) refreshToken: string; } From 209d9a1117b433b2cd81c0084a237833163e8dab Mon Sep 17 00:00:00 2001 From: xet-a Date: Mon, 9 Sep 2024 00:07:22 +0900 Subject: [PATCH 10/16] Update `@ApiBody` and `@ApiResponse` type --- backend/src/auth/auth.controller.ts | 20 ++++++++++++-------- backend/src/users/users.module.ts | 4 ++-- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index cdadbfa0..257da929 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -10,12 +10,13 @@ import { } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { AuthGuard } from "@nestjs/passport"; -import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { ApiBody, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { Public } from "src/utils/decorators/auth.decorator"; import { AuthService } from "./auth.service"; +import { RefreshTokenRequestDto } from "./dto/refresh-token-request.dto"; +import { RefreshTokenResponseDto } from "./dto/refresh-token-response.dto"; import { LoginRequest } from "./types/login-request.type"; import { LoginResponse } from "./types/login-response.type"; -import { RefreshTokenRequest } from "./types/refresh-token-request.type"; @ApiTags("Auth") @Controller("auth") @@ -36,7 +37,7 @@ export class AuthController { }) @ApiResponse({ type: LoginResponse }) async login(@Req() req: LoginRequest): Promise { - const { accessToken, refreshToken } = await this.authService.loginWithGithub(req); + const { accessToken, refreshToken } = await this.authService.loginWithSocialProvider(req); return { url: `${this.configService.get("FRONTEND_BASE_URL")}/auth/callback?accessToken=${accessToken}&refreshToken=${refreshToken}`, @@ -47,10 +48,13 @@ export class AuthController { @Public() @Post("refresh") @UseGuards(AuthGuard("refresh")) - @ApiOperation({ summary: "Refresh Access Token" }) - @ApiResponse({ type: LoginResponse }) - async refresh(@Body() body: RefreshTokenRequest): Promise<{ accessToken: string }> { - const accessToken = await this.authService.getNewAccessToken(body.refreshToken); - return { accessToken }; + @ApiOperation({ + summary: "Refresh Access Token", + description: "Generates a new Access Token using the user's Refresh Token.", + }) + @ApiBody({ type: RefreshTokenRequestDto }) + @ApiResponse({ type: RefreshTokenResponseDto }) + async refresh(@Body() body: RefreshTokenRequestDto): Promise { + return await this.authService.getNewAccessToken(body.refreshToken); } } diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index 7930a041..858a5073 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -1,8 +1,8 @@ import { Module } from "@nestjs/common"; -import { UsersService } from "./users.service"; +import { CheckService } from "src/check/check.service"; import { PrismaService } from "src/db/prisma.service"; import { UsersController } from "./users.controller"; -import { CheckService } from "src/check/check.service"; +import { UsersService } from "./users.service"; @Module({ providers: [UsersService, PrismaService, CheckService], From aaf39e44b53dd0950c77323434404843590dcb73 Mon Sep 17 00:00:00 2001 From: xet-a Date: Mon, 9 Sep 2024 00:08:17 +0900 Subject: [PATCH 11/16] Separate JWT secrets for access and refresh tokens --- backend/src/auth/auth.service.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 09303b22..e77cb5b6 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -2,6 +2,7 @@ import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { JwtService } from "@nestjs/jwt"; import { UsersService } from "src/users/users.service"; +import { RefreshTokenResponseDto } from "./dto/refresh-token-response.dto"; import { LoginRequest } from "./types/login-request.type"; import { LoginResponse } from "./types/login-response.type"; @@ -13,7 +14,7 @@ export class AuthService { private readonly configService: ConfigService ) {} - async loginWithGithub(req: LoginRequest): Promise { + async loginWithSocialProvider(req: LoginRequest): Promise { const user = await this.usersService.findOrCreate( req.user.socialProvider, req.user.socialUid @@ -21,18 +22,24 @@ export class AuthService { const accessToken = this.jwtService.sign( { sub: user.id, nickname: user.nickname }, - { expiresIn: `${this.configService.get("JWT_ACCESS_TOKEN_EXPIRATION_TIME")}s` } + { + secret: this.configService.get("JWT_ACCESS_TOKEN_SECRET"), + expiresIn: `${this.configService.get("JWT_ACCESS_TOKEN_EXPIRATION_TIME")}s`, + } ); const refreshToken = this.jwtService.sign( { sub: user.id }, - { expiresIn: `${this.configService.get("JWT_REFRESH_TOKEN_EXPIRATION_TIME")}s` } + { + secret: this.configService.get("JWT_REFRESH_TOKEN_SECRET"), + expiresIn: `${this.configService.get("JWT_REFRESH_TOKEN_EXPIRATION_TIME")}s`, + } ); return { accessToken, refreshToken }; } - async getNewAccessToken(refreshToken: string) { + async getNewAccessToken(refreshToken: string): Promise { const payload = this.jwtService.verify(refreshToken); const newAccessToken = this.jwtService.sign( @@ -40,6 +47,6 @@ export class AuthService { { expiresIn: `${this.configService.get("JWT_ACCESS_TOKEN_EXPIRATION_TIME")}s` } ); - return newAccessToken; + return { newAccessToken }; } } From 587e58d854453a6c96f1cafd2b8ae9213c6d1ec4 Mon Sep 17 00:00:00 2001 From: xet-a Date: Mon, 9 Sep 2024 00:10:43 +0900 Subject: [PATCH 12/16] Add logout logic and remove duplicate call --- frontend/src/App.tsx | 7 ++----- frontend/src/components/popovers/ProfilePopover.tsx | 5 ++--- frontend/src/hooks/api/user.ts | 5 ++--- frontend/src/store/authSlice.ts | 8 +++++++- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a6abd200..35f1f20e 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,7 +21,7 @@ import { useGetSettingsQuery } from "./hooks/api/settings"; import { useErrorHandler } from "./hooks/useErrorHandler"; import AuthProvider from "./providers/AuthProvider"; import { routes } from "./routes"; -import { setAccessToken, setRefreshToken } from "./store/authSlice"; +import { logout, setAccessToken } from "./store/authSlice"; import { selectConfig } from "./store/configSlice"; import { store } from "./store/store"; import { setUserData } from "./store/userSlice"; @@ -101,10 +101,8 @@ function App() { useEffect(() => { const handleRefreshTokenExpiration = () => { - dispatch(setAccessToken(null)); - dispatch(setRefreshToken(null)); + dispatch(logout()); dispatch(setUserData(null)); - // axios.defaults.headers.common["Authorization"] = ""; }; const interceptor = axios.interceptors.response.use( @@ -112,7 +110,6 @@ function App() { async (error) => { if (error.response?.status === 401) { if (error.config.url === "/auth/refresh") { - handleRefreshTokenExpiration(); return Promise.reject(error); } else if (!error.config._retry) { error.config._retry = true; diff --git a/frontend/src/components/popovers/ProfilePopover.tsx b/frontend/src/components/popovers/ProfilePopover.tsx index a5bacb04..30ed8f42 100644 --- a/frontend/src/components/popovers/ProfilePopover.tsx +++ b/frontend/src/components/popovers/ProfilePopover.tsx @@ -13,7 +13,7 @@ import { import { useDispatch } from "react-redux"; import { useNavigate } from "react-router-dom"; import { useCurrentTheme } from "../../hooks/useCurrentTheme"; -import { setAccessToken, setRefreshToken } from "../../store/authSlice"; +import { logout } from "../../store/authSlice"; import { setTheme } from "../../store/configSlice"; import { setUserData } from "../../store/userSlice"; @@ -23,8 +23,7 @@ function ProfilePopover(props: PopoverProps) { const navigate = useNavigate(); const handleLogout = () => { - dispatch(setAccessToken(null)); - dispatch(setRefreshToken(null)); + dispatch(logout()); dispatch(setUserData(null)); }; diff --git a/frontend/src/hooks/api/user.ts b/frontend/src/hooks/api/user.ts index 177dd907..463d3662 100644 --- a/frontend/src/hooks/api/user.ts +++ b/frontend/src/hooks/api/user.ts @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import axios from "axios"; import { useEffect } from "react"; import { useDispatch, useSelector } from "react-redux"; -import { selectAuth, setAccessToken, setRefreshToken } from "../../store/authSlice"; +import { logout, selectAuth } from "../../store/authSlice"; import { User, setUserData } from "../../store/userSlice"; import { GetUserResponse, UpdateUserRequest } from "./types/user"; @@ -31,8 +31,7 @@ export const useGetUserQuery = () => { if (query.isSuccess) { dispatch(setUserData(query.data as User)); } else if (query.isError) { - dispatch(setAccessToken(null)); - dispatch(setRefreshToken(null)); + dispatch(logout()); dispatch(setUserData(null)); axios.defaults.headers.common["Authorization"] = ""; } diff --git a/frontend/src/store/authSlice.ts b/frontend/src/store/authSlice.ts index 5d3c4cda..aefa66f2 100644 --- a/frontend/src/store/authSlice.ts +++ b/frontend/src/store/authSlice.ts @@ -1,5 +1,6 @@ import type { PayloadAction } from "@reduxjs/toolkit"; import { createSlice } from "@reduxjs/toolkit"; +import axios from "axios"; import { RootState } from "./store"; export interface AuthState { @@ -22,10 +23,15 @@ export const authSlice = createSlice({ setRefreshToken(state, action: PayloadAction) { state.refreshToken = action.payload; }, + logout: (state) => { + state.accessToken = null; + state.refreshToken = null; + axios.defaults.headers.common["Authorization"] = ""; + }, }, }); -export const { setAccessToken, setRefreshToken } = authSlice.actions; +export const { setAccessToken, setRefreshToken, logout } = authSlice.actions; export const selectAuth = (state: RootState) => state.auth; From 4c1136b49f068856a4cde915d30d46d914bc1ba4 Mon Sep 17 00:00:00 2001 From: xet-a Date: Tue, 10 Sep 2024 20:23:16 +0900 Subject: [PATCH 13/16] Update env variable comments for clarity --- backend/.env.development | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/.env.development b/backend/.env.development index 149d2296..3bd2cb51 100644 --- a/backend/.env.development +++ b/backend/.env.development @@ -16,11 +16,11 @@ GITHUB_CALLBACK_URL=http://localhost:3000/auth/login/github # JWT_ACCESS_TOKEN_SECRET: Secret key for signing and verifying access tokens. # JWT_ACCESS_TOKEN_EXPIRATION_TIME: Expiration time for access tokens in seconds. -JWT_ACCESS_TOKEN_SECRET=you_should_change_this_secret_key_in_production +JWT_ACCESS_TOKEN_SECRET=you_should_change_this_access_token_secret_key_in_production JWT_ACCESS_TOKEN_EXPIRATION_TIME=86400 # JWT_REFRESH_TOKEN_SECRET: Secret key for signing and verifying refresh tokens. # JWT_REFRESH_TOKEN_EXPIRATION_TIME: Expiration time for refresh tokens in seconds. -JWT_REFRESH_TOKEN_SECRET=you_should_change_this_secret_key_in_production +JWT_REFRESH_TOKEN_SECRET=you_should_change_this_refresh_token_secret_key_in_production JWT_REFRESH_TOKEN_EXPIRATION_TIME=604800 # FRONTEND_BASE_URL: Base URL of the frontend application. From 9bc05061af2df1a04968642d51029a75ce7061f5 Mon Sep 17 00:00:00 2001 From: xet-a Date: Tue, 10 Sep 2024 20:24:13 +0900 Subject: [PATCH 14/16] Split JwtService for access and refresh tokens --- backend/src/auth/auth.module.ts | 37 +++++++++++++++++------ backend/src/auth/auth.service.ts | 35 +++++++-------------- backend/src/utils/constants/jwt-inject.ts | 4 +++ 3 files changed, 42 insertions(+), 34 deletions(-) create mode 100644 backend/src/utils/constants/jwt-inject.ts diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 5118b349..68850b98 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -1,7 +1,8 @@ import { Module } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import { JwtModule } from "@nestjs/jwt"; +import { JwtService } from "@nestjs/jwt"; import { UsersModule } from "src/users/users.module"; +import { JwtInject } from "src/utils/constants/jwt-inject"; import { AuthController } from "./auth.controller"; import { AuthService } from "./auth.service"; import { GithubStrategy } from "./github.strategy"; @@ -9,22 +10,38 @@ import { JwtRefreshStrategy } from "./jwt-refresh.strategy"; import { JwtStrategy } from "./jwt.strategy"; @Module({ - imports: [ - UsersModule, - JwtModule.registerAsync({ + imports: [UsersModule], + providers: [ + AuthService, + GithubStrategy, + JwtStrategy, + JwtRefreshStrategy, + { + provide: JwtInject.ACCESS, useFactory: async (configService: ConfigService) => { - return { - global: true, + return new JwtService({ + secret: configService.get("JWT_ACCESS_TOKEN_SECRET"), signOptions: { expiresIn: `${configService.get("JWT_ACCESS_TOKEN_EXPIRATION_TIME")}s`, }, - secret: configService.get("JWT_ACCESS_TOKEN_SECRET"), - }; + }); + }, + inject: [ConfigService], + }, + { + provide: JwtInject.REFRESH, + useFactory: async (configService: ConfigService) => { + return new JwtService({ + secret: configService.get("JWT_REFRESH_TOKEN_SECRET"), + signOptions: { + expiresIn: `${configService.get("JWT_REFRESH_TOKEN_EXPIRATION_TIME")}s`, + }, + }); }, inject: [ConfigService], - }), + }, ], - providers: [AuthService, GithubStrategy, JwtStrategy, JwtRefreshStrategy], + exports: [JwtInject.ACCESS, JwtInject.REFRESH], controllers: [AuthController], }) export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index e77cb5b6..9f8dcc76 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,7 +1,7 @@ -import { Injectable } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; +import { Inject, Injectable } from "@nestjs/common"; import { JwtService } from "@nestjs/jwt"; import { UsersService } from "src/users/users.service"; +import { JwtInject } from "src/utils/constants/jwt-inject"; import { RefreshTokenResponseDto } from "./dto/refresh-token-response.dto"; import { LoginRequest } from "./types/login-request.type"; import { LoginResponse } from "./types/login-response.type"; @@ -10,8 +10,8 @@ import { LoginResponse } from "./types/login-response.type"; export class AuthService { constructor( private readonly usersService: UsersService, - private readonly jwtService: JwtService, - private readonly configService: ConfigService + @Inject(JwtInject.ACCESS) private readonly jwtAccessService: JwtService, + @Inject(JwtInject.REFRESH) private readonly jwtRefreshService: JwtService ) {} async loginWithSocialProvider(req: LoginRequest): Promise { @@ -20,32 +20,19 @@ export class AuthService { req.user.socialUid ); - const accessToken = this.jwtService.sign( - { sub: user.id, nickname: user.nickname }, - { - secret: this.configService.get("JWT_ACCESS_TOKEN_SECRET"), - expiresIn: `${this.configService.get("JWT_ACCESS_TOKEN_EXPIRATION_TIME")}s`, - } - ); - - const refreshToken = this.jwtService.sign( - { sub: user.id }, - { - secret: this.configService.get("JWT_REFRESH_TOKEN_SECRET"), - expiresIn: `${this.configService.get("JWT_REFRESH_TOKEN_EXPIRATION_TIME")}s`, - } - ); + const accessToken = this.jwtAccessService.sign({ sub: user.id, nickname: user.nickname }); + const refreshToken = this.jwtRefreshService.sign({ sub: user.id }); return { accessToken, refreshToken }; } async getNewAccessToken(refreshToken: string): Promise { - const payload = this.jwtService.verify(refreshToken); + const payload = this.jwtRefreshService.verify(refreshToken); - const newAccessToken = this.jwtService.sign( - { sub: payload.sub, nickname: payload.nickname }, - { expiresIn: `${this.configService.get("JWT_ACCESS_TOKEN_EXPIRATION_TIME")}s` } - ); + const newAccessToken = this.jwtAccessService.sign({ + sub: payload.sub, + nickname: payload.nickname, + }); return { newAccessToken }; } diff --git a/backend/src/utils/constants/jwt-inject.ts b/backend/src/utils/constants/jwt-inject.ts new file mode 100644 index 00000000..b2734970 --- /dev/null +++ b/backend/src/utils/constants/jwt-inject.ts @@ -0,0 +1,4 @@ +export const JwtInject = { + ACCESS: "JWT_ACCESS_SERVICE", + REFRESH: "JWT_REFRESH_SERVICE", +}; From c894c362bb60871ac146034e0985968d335a9308 Mon Sep 17 00:00:00 2001 From: xet-a Date: Tue, 10 Sep 2024 21:34:52 +0900 Subject: [PATCH 15/16] Fix interceptor logic for refresh token --- frontend/src/App.tsx | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 35f1f20e..1167004d 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -108,24 +108,18 @@ function App() { const interceptor = axios.interceptors.response.use( (response) => response, async (error) => { - if (error.response?.status === 401) { + if (error.response?.status === 401 && !error.config._retry) { if (error.config.url === "/auth/refresh") { + handleRefreshTokenExpiration(); return Promise.reject(error); - } else if (!error.config._retry) { + } else { error.config._retry = true; const refreshToken = store.getState().auth.refreshToken; - try { - const response = await axios.post("/auth/refresh", { refreshToken }); - const newAccessToken = response.data.accessToken; - dispatch(setAccessToken(newAccessToken)); - axios.defaults.headers.common["Authorization"] = - `Bearer ${newAccessToken}`; - error.config.headers["Authorization"] = `Bearer ${newAccessToken}`; - return axios(error.config); - } catch (refreshError) { - handleRefreshTokenExpiration(); - return Promise.reject(refreshError); - } + const response = await axios.post("/auth/refresh", { refreshToken }); + const newAccessToken = response.data.newAccessToken; + dispatch(setAccessToken(newAccessToken)); + axios.defaults.headers.common["Authorization"] = `Bearer ${newAccessToken}`; + return axios(error.config); } } return Promise.reject(error); From 30c9d071af75e250853b61c29c9a47a69f7b617b Mon Sep 17 00:00:00 2001 From: xet-a Date: Wed, 11 Sep 2024 23:38:18 +0900 Subject: [PATCH 16/16] Add error config token --- frontend/src/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1167004d..b6241fd7 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -119,6 +119,7 @@ function App() { const newAccessToken = response.data.newAccessToken; dispatch(setAccessToken(newAccessToken)); axios.defaults.headers.common["Authorization"] = `Bearer ${newAccessToken}`; + error.config.headers["Authorization"] = `Bearer ${newAccessToken}`; return axios(error.config); } }