From cc4cda8e855c9bc402c178eed311c3040e835d09 Mon Sep 17 00:00:00 2001 From: Hyyena Date: Sat, 21 Jan 2023 15:35:25 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8feat(OAuth=202.0):=20add=20kakao?= =?UTF-8?q?=20login=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close [BE/FEAT] 카카오톡 로그인 구현 #2 --- package.json | 1 + src/api/middlewares/authMiddleware.ts | 78 ++++++++++++++++++++++++++ src/api/middlewares/index.ts | 4 ++ src/api/routes/v1/auth.ts | 55 ++++++++++++++++-- src/config/config.ts | 7 +++ src/interfaces/User.ts | 7 +++ src/loaders/express.ts | 29 ++++++++-- src/loaders/logger.ts | 24 +++++++- src/models/user.ts | 4 ++ src/services/auth.ts | 29 +++++++++- src/services/passport/index.ts | 34 +++++++++++ src/services/passport/kakaoStrategy.ts | 54 ++++++++++++++++++ src/utils/jwtUtil.ts | 54 ++++++++++++++++++ types/express/index.d.ts | 30 +++++++--- yarn.lock | 7 +++ 15 files changed, 396 insertions(+), 21 deletions(-) create mode 100644 src/api/middlewares/authMiddleware.ts create mode 100644 src/services/passport/index.ts create mode 100644 src/services/passport/kakaoStrategy.ts create mode 100644 src/utils/jwtUtil.ts diff --git a/package.json b/package.json index 226541b..a475f31 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@types/morgan": "^1.9.4", "@types/multer": "^1.4.7", "@types/node": "^18.11.18", + "@types/passport": "^1.0.11", "@types/supertest": "^2.0.12", "@types/validator": "^13.7.10", "@typescript-eslint/eslint-plugin": "^5.47.1", diff --git a/src/api/middlewares/authMiddleware.ts b/src/api/middlewares/authMiddleware.ts new file mode 100644 index 0000000..33e4af9 --- /dev/null +++ b/src/api/middlewares/authMiddleware.ts @@ -0,0 +1,78 @@ +import { verifyToken } from "@/utils/jwtUtil"; + +export const verifyAccessToken = (req, res, next) => { + // 헤더에 토큰이 존재할 경우 + if (req.headers.authorization) { + /** + * Http Header에 담긴 JWT: { "Authorization": "Bearer jwt-token" } + * 위와 같이 헤더에 담긴 access token을 가져오기 위해 문자열 분리 + */ + const token = req.headers.authorization.split("Bearer ")[1]; + + // 토큰 검증 + const result = verifyToken(token); + + /** + * 토큰 검증 성공 시, + * req에 값 저장 후 콜백 함수 호출 + */ + if (result.success) { + req.userId = result.userId; + req.email = result.email; + req.name = result.name; + next(); + } + + /** + * 토큰 검증 실패 시, + * 클라이언트에 에러 코드와 함께 에러 메시지 응답 + */ + if (!result.success) { + return res.status(401).json({ + code: 401, + message: "Invalid Token", + }); + } + + /** + * 토큰 토큰 만료 시, + * 클라이언트에 에러 코드와 함께 에러 메시지 응답 + */ + if (result.message === "TokenExpiredError") { + return res.status(403).json({ + code: 403, + message: "Token has expired", + }); + } + } + + // 헤더에 토큰이 존재하지 않는 경우 + if (!req.headers.authorization) { + return res.status(404).json({ + code: 404, + message: "Token does not exist", + }); + } +}; + +export const isLoggedIn = (req, res, next) => { + if (req.isAuthenticated()) { + next(); + } else { + res.status(403).send("로그인이 필요한 서비스입니다."); + } +}; + +export const isNotLoggedIn = (req, res, next) => { + if (!req.isAuthenticated()) { + next(); + } else { + // 메시지를 생성하는 Query String(parameter)으로 사용할 것이기 때문에 Encoding을 해주어야 한다. + const message = encodeURIComponent("이미 로그인 상태입니다."); + + // 이전 request 객체의 내용을 모두 삭제하고, + // 새로운 요청 흐름을 만드는 것으로 새로고침을 하면 결과 화면만 새로고침 된다. + res.redirect(`/?error=${message}`); + console.log(message); + } +}; diff --git a/src/api/middlewares/index.ts b/src/api/middlewares/index.ts index 54ac432..ea10deb 100644 --- a/src/api/middlewares/index.ts +++ b/src/api/middlewares/index.ts @@ -1,5 +1,9 @@ import asyncHandler from "./asyncHandler"; +import { verifyAccessToken, isLoggedIn, isNotLoggedIn } from "./authMiddleware"; export default { asyncHandler, + verifyAccessToken, + isLoggedIn, + isNotLoggedIn, }; diff --git a/src/api/routes/v1/auth.ts b/src/api/routes/v1/auth.ts index b396bce..f1e56d8 100644 --- a/src/api/routes/v1/auth.ts +++ b/src/api/routes/v1/auth.ts @@ -1,18 +1,26 @@ import { Request, Response, Router, NextFunction } from "express"; +import passport from "passport"; import { Container } from "typedi"; -import logger from "winston"; +import { Logger } from "winston"; import asyncHandler from "@/api/middlewares/asyncHandler"; +import { + verifyAccessToken, + isLoggedIn, + isNotLoggedIn, +} from "@/api/middlewares/authMiddleware"; import { UserInputDTO } from "@/interfaces/User"; import AuthService from "@/services/auth"; const route = Router(); export default (app: Router) => { + const logger: Logger = Container.get("logger"); app.use("/auth", route); route.post( "/kakaotest", + verifyAccessToken, asyncHandler(async (req: Request, res: Response, next: NextFunction) => { logger.debug(req.body); console.log("🚀 ~ file: auth.ts:17 ~ asyncHandler ~ body", req.body); @@ -24,12 +32,51 @@ export default (app: Router) => { }), ); + /** + ** 로그아웃 처리 + * + * TODO: DB에서 refresh token 삭제 + */ + route.get( + "/logout", + verifyAccessToken, + asyncHandler(async (req: Request, res: Response) => { + res.cookie("refreshToken", "", { + maxAge: 0, + }); + return res.status(200).json({ + success: true, + }); + }), + ); + + // 카카오 로그인 페이지 route.get( "/kakao", - asyncHandler(async (req: Request, res: Response, next: NextFunction) => { - logger.debug(req.body); + passport.authenticate("kakao", { session: false, failureRedirect: "/" }), + ); + + // 카카오 로그인 리다이렉트 URI + route.get( + "/kakao/callback", + passport.authenticate("kakao", { session: false, failureRedirect: "/" }), + asyncHandler(async (req: Request, res: Response) => { + const user = req.user; + + const authServiceInstance = Container.get(AuthService); + const { token } = await authServiceInstance.createJwt(user); + logger.debug({ label: "JWT", message: token }); + + res.cookie("refreshToken", token.refreshToken, { + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: 14 * 24 * 60 * 60 * 1000, // 14 day + }); - return res.status(200).json({ test: "good" }); + return res + .status(200) + .json({ success: true, accessToken: token.accessToken }); }), ); }; diff --git a/src/config/config.ts b/src/config/config.ts index 646512d..c3399c0 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -79,4 +79,11 @@ export default { api: { prefix: "/api", }, + + // JWT + jwtSecret: env.JWT_SECRET, + + // 카카오 로그인 + kakaoId: env.KAKAO_REST_API_KEY, + kakaoRedirectUri: env.KAKAO_REDIRECT_URI, }; diff --git a/src/interfaces/User.ts b/src/interfaces/User.ts index 7c7b01f..968a94a 100644 --- a/src/interfaces/User.ts +++ b/src/interfaces/User.ts @@ -15,4 +15,11 @@ export interface User { export interface UserInputDTO { userId: string; + email: string; + name: string; +} + +export interface TokenDTO { + accessToken: string; + refreshToken: string; } diff --git a/src/loaders/express.ts b/src/loaders/express.ts index 21a1cb9..283c48e 100644 --- a/src/loaders/express.ts +++ b/src/loaders/express.ts @@ -1,6 +1,3 @@ -import Logger from "./logger"; -import routes from "@/api/index"; -import config from "@/config/config"; import bodyParser from "body-parser"; import cookieParser from "cookie-parser"; import cors from "cors"; @@ -9,6 +6,13 @@ import expressMySQLSession from "express-mysql-session"; import session from "express-session"; import morgan from "morgan"; import nunjucks from "nunjucks"; +import passport from "passport"; + +import Logger from "./logger"; + +import routes from "@/api/index"; +import config from "@/config/config"; +import passportConfig from "@/services/passport"; export default ({ app }: { app: express.Application }) => { app.set("port", config.port); @@ -27,6 +31,9 @@ export default ({ app }: { app: express.Application }) => { // It shows the real origin IP in the heroku or Cloudwatch logs app.enable("trust proxy"); + // Enable Cross Origin Resource Sharing to all origins by default + app.use(cors()); + // View Template app.set("view engine", "html"); nunjucks.configure("src/views", { @@ -49,12 +56,22 @@ export default ({ app }: { app: express.Application }) => { secret: config.cookieSecret, resave: false, saveUninitialized: true, - store: new MySQLStore(config.expressSession as any), // TODO: any 고쳐야 함 + store: new MySQLStore(config.expressSession as any), // TO DO: any 고쳐야 함 + cookie: { + httpOnly: true, + secure: false, + }, }), ); - // Enable Cross Origin Resource Sharing to all origins by default - app.use(cors()); + /** + *! express-session에 의존하기 때문에 세션 설정 코드보다 아래에 위치 + * passport.session()이 실행되면 세션 쿠키 정보를 바탕으로 + * /services/passport/index.ts의 deserializeUser 함수가 실행된다. + */ + passportConfig(); // passport 설정 + app.use(passport.initialize()); // 요청 객체에 passport 설정 사용 + // app.use(passport.session()); // req.session 객체에 passport 정보를 추가로 저장 // POST 방식의 파라미터 읽기 app.use(bodyParser.json()); // to support JSON-encoded bodies diff --git a/src/loaders/logger.ts b/src/loaders/logger.ts index e8995ac..c29edeb 100644 --- a/src/loaders/logger.ts +++ b/src/loaders/logger.ts @@ -1,16 +1,35 @@ +import path from "path"; + import winston from "winston"; import config from "@/config/config"; const transports = []; +const logFormat = winston.format.printf( + ({ timestamp, label, level, message, ...rest }) => { + let restString = JSON.stringify(rest, null, 2); + restString = restString === "{}" ? "" : restString; + + // 날짜 [시스템이름] 로그레벨 메세지 + return `${timestamp} [${ + label || path.basename(process.mainModule.filename) + }] ${level}: ${message || ""} ${restString}`; + }, +); + if (process.env.NODE_ENV !== "development") { transports.push(new winston.transports.Console()); } else { transports.push( new winston.transports.Console({ format: winston.format.combine( + winston.format.colorize(), winston.format.cli(), + winston.format.errors({ stack: true }), + winston.format.prettyPrint(), winston.format.splat(), + winston.format.json(), + logFormat, ), }), ); @@ -23,11 +42,14 @@ const LoggerInstance = winston.createLogger({ winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss", }), + winston.format.colorize({ all: true }), + winston.format.cli(), winston.format.errors({ stack: true }), + winston.format.prettyPrint({ colorize: true }), winston.format.splat(), winston.format.json(), ), - transports, + transports: transports, }); export default LoggerInstance; diff --git a/src/models/user.ts b/src/models/user.ts index 89f93a7..7958256 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -64,6 +64,10 @@ export default class User extends Model { @Column(DataType.ENUM(...Object.values(LoginType))) public login_type!: LoginType; + @AllowNull(true) + @Column(DataType.STRING(255)) + public refresh_token!: string; + /* * 관계에 대한 설정 */ diff --git a/src/services/auth.ts b/src/services/auth.ts index 0f8ff94..b78e9f2 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -3,7 +3,8 @@ import { Model } from "sequelize-typescript"; import { Service, Inject } from "typedi"; import config from "@/config/config"; -import { User, UserInputDTO } from "@/interfaces/User"; +import { User, UserInputDTO, TokenDTO } from "@/interfaces/User"; +import { createAccessToken, createRefreshToken } from "@/utils/jwtUtil"; @Service() export default class AuthService { @@ -14,7 +15,7 @@ export default class AuthService { /** * 테스트 함수 - * body로 요청 받은 유저 아이디(UUID)를 DB에서 찾은 후 JSON으로 전달해주는 함수수 + * body로 요청 받은 유저 아이디(UUID)를 DB에서 찾은 후 JSON으로 전달해주는 함수 */ public async test(userInputDTO: UserInputDTO): Promise<{ user: User }> { try { @@ -50,4 +51,28 @@ export default class AuthService { throw error; } } + + public async createJwt(user): Promise<{ token: TokenDTO }> { + try { + const accessToken = createAccessToken(user); + const refreshToken = createRefreshToken(user); + + const token = { accessToken, refreshToken }; + + return { token }; + } catch (error) { + this.logger.error(error); + throw error; + } + } + + /** + * TODO 1: DB에 refresh token 저장하는 메서드 구현 + * + * TODO 2: 토큰 재발급 메서드 구현 + ** access token이 만료된 경우, + ** refresh token을 이용해 access token 재발급 + ** 이후 refresh token도 재발급 + ** RTR(Refresh Token Rotation) -> refresh token은 일회성 + */ } diff --git a/src/services/passport/index.ts b/src/services/passport/index.ts new file mode 100644 index 0000000..c4497a9 --- /dev/null +++ b/src/services/passport/index.ts @@ -0,0 +1,34 @@ +import passport from "passport"; + +import kakao from "./kakaoStrategy"; // 카카오 로그인 + +import User from "@/models/user"; + +export default () => { + /** + * Serialization: 객체를 직렬화하여 전송 가능한 형태로 만드는 것 + * Deserialization: 직렬화된 파일 등을 역으로 직렬화하여 다시 객체 형태로 만드는 것 + */ + /* + type User = { + user_id?: string; + }; + + // 로그인 시 serializeUser 함수 실행 + passport.serializeUser((user: User, done) => { + console.log("확인"); + console.log(user.user_id); + done(null, user.user_id); + }); + + // 넘어온 id에 해당하는 데이터가 있으면, 데이터베이스에서 검색 + passport.deserializeUser((user_id, done) => { + console.log(user_id); + User.findOne({ where: { user_id: user_id } }) + .then((user) => done(null, user)) + .catch((err) => done(err)); + }); + */ + + kakao(); +}; diff --git a/src/services/passport/kakaoStrategy.ts b/src/services/passport/kakaoStrategy.ts new file mode 100644 index 0000000..977d9f2 --- /dev/null +++ b/src/services/passport/kakaoStrategy.ts @@ -0,0 +1,54 @@ +import passport from "passport"; +import KakaoStrategy from "passport-kakao"; + +import config from "@/config/config"; +import Logger from "@/loaders/logger"; +import User from "@/models/user"; + +export default () => { + passport.use( + new KakaoStrategy( + { + // 카카오 로그인 REST API 키 + clientID: config.kakaoId, + // 카카오 로그인 Redirect URI 경로 + callbackURL: config.kakaoRedirectUri, + }, + async (accessToken, refreshToken, profile, done) => { + // 로그인 성공 시 정보 출력 + Logger.verbose({ label: "KAKAO Profile", message: profile._json }); + try { + /** + * 로그인 히스토리 조회 + * 로그인 타입이 KAKAO이고, 카카오 아이디가 존재하는지 확인 + */ + const exUser = await User.findOne({ + where: { + email: profile._json.kakao_account.email, + login_type: "KAKAO", + }, + }); + + // 가입 여부 확인 + if (exUser) { + // 로그인 인증 완료 + done(null, exUser); + } else { + // 가입하지 않은 유저인 경우 회원가입 후 로그인 + const newUser = await User.create({ + email: profile._json.kakao_account.email, + name: profile.displayName, + password: null, + login_type: "KAKAO", + }); + // 회원가입 후 로그인 인증 완료 + done(null, newUser); + } + } catch (error) { + Logger.error(error); + done(error); + } + }, + ), + ); +}; diff --git a/src/utils/jwtUtil.ts b/src/utils/jwtUtil.ts new file mode 100644 index 0000000..a73f447 --- /dev/null +++ b/src/utils/jwtUtil.ts @@ -0,0 +1,54 @@ +import jwt from "jsonwebtoken"; + +import config from "@/config/config"; + +const secret = config.jwtSecret; + +// access token 발급 +export const createAccessToken = (user) => { + const payload = { + userId: user.user_id, + email: user.email, + name: user.name, + }; + + const accessToken = jwt.sign({ payload }, config.jwtSecret, { + expiresIn: "30m", + issuer: "woocheonjae", + }); + + return accessToken; +}; + +// refresh token 발급 +export const createRefreshToken = (user) => { + const refreshToken = jwt.sign({}, config.jwtSecret, { + expiresIn: "14d", + issuer: "woocheonjae", + }); + + return refreshToken; +}; + +// access token 검증 +export const verifyToken = (token) => { + let decoded = null; + try { + // 토큰 확인 + decoded = jwt.verify(token, secret); + + return { + success: true, + userId: decoded.payload.userId, + email: decoded.payload.email, + name: decoded.payload.name, + }; + } catch (error) { + return { + success: false, + message: error.message, + }; + } +}; + +// TODO: refresh token 검증 함수 구현 diff --git a/types/express/index.d.ts b/types/express/index.d.ts index 5ff3e39..24ea50a 100644 --- a/types/express/index.d.ts +++ b/types/express/index.d.ts @@ -1,16 +1,30 @@ import session from "express-session"; +import IUser from "../../src/models/user"; + export = session; -// process.d.ts -// interface ProcessEnv extends Dict { -// env: ProcessEnv; -// } +/** +process.d.ts +interface ProcessEnv extends Dict { + env: ProcessEnv; +} + +global.d.ts +interface Dict { + [key: string]: T | undefined; +} +*/ -// global.d.ts -// interface Dict { -// [key: string]: T | undefined; -// } +declare global { + namespace Express { + interface User { + user_id?: string; + email: string; + name: string; + } + } +} // ?: 지금은 필요 없는 것 같음. 관련 에러 any 타입 선언으로 해결 declare module "express-session" { diff --git a/yarn.lock b/yarn.lock index a3bc890..eb3a381 100644 --- a/yarn.lock +++ b/yarn.lock @@ -873,6 +873,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.18.tgz#8dfb97f0da23c2293e554c5a50d61ef134d7697f" integrity sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA== +"@types/passport@^1.0.11": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@types/passport/-/passport-1.0.11.tgz#d046b41e28b280f4e7994614fb976e9b449cb7c6" + integrity sha512-pz1cx9ptZvozyGKKKIPLcVDVHwae4hrH5d6g5J+DkMRRjR3cVETb4jMabhXAUbg3Ov7T22nFHEgaK2jj+5CBpw== + dependencies: + "@types/express" "*" + "@types/prettier@^2.1.5": version "2.7.2" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.2.tgz#6c2324641cc4ba050a8c710b2b251b377581fbf0" From 6e0a27ad3cccc8c18dc1ee3ad83de1b8c571131b Mon Sep 17 00:00:00 2001 From: Hyyena Date: Sat, 21 Jan 2023 21:48:29 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20feat(refresh=20token):=20add=20?= =?UTF-8?q?a=20method=20to=20update=20the=20refresh=20token=20to=20the=20d?= =?UTF-8?q?b?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/middlewares/authMiddleware.ts | 22 ---------------- src/api/middlewares/index.ts | 4 +-- src/api/routes/v1/auth.ts | 12 +++++---- src/services/auth.ts | 36 +++++++++++++++++++++++++-- src/services/passport/index.ts | 29 --------------------- 5 files changed, 42 insertions(+), 61 deletions(-) diff --git a/src/api/middlewares/authMiddleware.ts b/src/api/middlewares/authMiddleware.ts index 33e4af9..7c65503 100644 --- a/src/api/middlewares/authMiddleware.ts +++ b/src/api/middlewares/authMiddleware.ts @@ -54,25 +54,3 @@ export const verifyAccessToken = (req, res, next) => { }); } }; - -export const isLoggedIn = (req, res, next) => { - if (req.isAuthenticated()) { - next(); - } else { - res.status(403).send("로그인이 필요한 서비스입니다."); - } -}; - -export const isNotLoggedIn = (req, res, next) => { - if (!req.isAuthenticated()) { - next(); - } else { - // 메시지를 생성하는 Query String(parameter)으로 사용할 것이기 때문에 Encoding을 해주어야 한다. - const message = encodeURIComponent("이미 로그인 상태입니다."); - - // 이전 request 객체의 내용을 모두 삭제하고, - // 새로운 요청 흐름을 만드는 것으로 새로고침을 하면 결과 화면만 새로고침 된다. - res.redirect(`/?error=${message}`); - console.log(message); - } -}; diff --git a/src/api/middlewares/index.ts b/src/api/middlewares/index.ts index ea10deb..833c0d0 100644 --- a/src/api/middlewares/index.ts +++ b/src/api/middlewares/index.ts @@ -1,9 +1,7 @@ import asyncHandler from "./asyncHandler"; -import { verifyAccessToken, isLoggedIn, isNotLoggedIn } from "./authMiddleware"; +import { verifyAccessToken } from "./authMiddleware"; export default { asyncHandler, verifyAccessToken, - isLoggedIn, - isNotLoggedIn, }; diff --git a/src/api/routes/v1/auth.ts b/src/api/routes/v1/auth.ts index f1e56d8..64739ce 100644 --- a/src/api/routes/v1/auth.ts +++ b/src/api/routes/v1/auth.ts @@ -4,11 +4,7 @@ import { Container } from "typedi"; import { Logger } from "winston"; import asyncHandler from "@/api/middlewares/asyncHandler"; -import { - verifyAccessToken, - isLoggedIn, - isNotLoggedIn, -} from "@/api/middlewares/authMiddleware"; +import { verifyAccessToken } from "@/api/middlewares/authMiddleware"; import { UserInputDTO } from "@/interfaces/User"; import AuthService from "@/services/auth"; @@ -63,10 +59,16 @@ export default (app: Router) => { asyncHandler(async (req: Request, res: Response) => { const user = req.user; + // Auth Service 로직 가져오기 const authServiceInstance = Container.get(AuthService); + + // JWT 발급(access token, refresh token) const { token } = await authServiceInstance.createJwt(user); logger.debug({ label: "JWT", message: token }); + // DB에 refresh token 업데이트 + await authServiceInstance.updateRefreshToken(user, token.refreshToken); + res.cookie("refreshToken", token.refreshToken, { httpOnly: true, secure: true, diff --git a/src/services/auth.ts b/src/services/auth.ts index b78e9f2..d4e7197 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -52,6 +52,7 @@ export default class AuthService { } } + // JWT(access token, refresh token) 발급 메서드 public async createJwt(user): Promise<{ token: TokenDTO }> { try { const accessToken = createAccessToken(user); @@ -66,13 +67,44 @@ export default class AuthService { } } + // DB에 refresh token 저장하는 메서드 + public async updateRefreshToken(user, refreshToken) { + try { + const userRecord = await this.userModel.update( + { + refresh_token: refreshToken, + }, + { + where: { user_id: user.user_id }, + }, + ); + + if (!userRecord) { + throw new Error("Unable to update refresh token"); + } + } catch (error) { + this.logger.error(error); + throw error; + } + } + /** - * TODO 1: DB에 refresh token 저장하는 메서드 구현 - * * TODO 2: 토큰 재발급 메서드 구현 ** access token이 만료된 경우, ** refresh token을 이용해 access token 재발급 ** 이후 refresh token도 재발급 ** RTR(Refresh Token Rotation) -> refresh token은 일회성 */ + public async reCreateJwt( + accessToken, + refreshToken, + ): Promise<{ token: TokenDTO }> { + try { + const token = { accessToken, refreshToken }; + return { token }; + } catch (error) { + this.logger.error(error); + throw error; + } + } } diff --git a/src/services/passport/index.ts b/src/services/passport/index.ts index c4497a9..1ae1e34 100644 --- a/src/services/passport/index.ts +++ b/src/services/passport/index.ts @@ -1,34 +1,5 @@ -import passport from "passport"; - import kakao from "./kakaoStrategy"; // 카카오 로그인 -import User from "@/models/user"; - export default () => { - /** - * Serialization: 객체를 직렬화하여 전송 가능한 형태로 만드는 것 - * Deserialization: 직렬화된 파일 등을 역으로 직렬화하여 다시 객체 형태로 만드는 것 - */ - /* - type User = { - user_id?: string; - }; - - // 로그인 시 serializeUser 함수 실행 - passport.serializeUser((user: User, done) => { - console.log("확인"); - console.log(user.user_id); - done(null, user.user_id); - }); - - // 넘어온 id에 해당하는 데이터가 있으면, 데이터베이스에서 검색 - passport.deserializeUser((user_id, done) => { - console.log(user_id); - User.findOne({ where: { user_id: user_id } }) - .then((user) => done(null, user)) - .catch((err) => done(err)); - }); - */ - kakao(); }; From 86f3847c665aedb6f2a4ebe3a5358a9c7f198604 Mon Sep 17 00:00:00 2001 From: Hyyena Date: Mon, 23 Jan 2023 19:03:54 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B=20fix:=20modify=20validation?= =?UTF-8?q?=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/auth.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/services/auth.ts b/src/services/auth.ts index d4e7197..73b66c9 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -70,7 +70,7 @@ export default class AuthService { // DB에 refresh token 저장하는 메서드 public async updateRefreshToken(user, refreshToken) { try { - const userRecord = await this.userModel.update( + const hasRefreshToken = await this.userModel.update( { refresh_token: refreshToken, }, @@ -79,7 +79,9 @@ export default class AuthService { }, ); - if (!userRecord) { + const canUpdateRefreshToken = hasRefreshToken[0]; + + if (!canUpdateRefreshToken) { throw new Error("Unable to update refresh token"); } } catch (error) { From 069ed9f97f01b380464d43c6c825bb5d72bcd4de Mon Sep 17 00:00:00 2001 From: Hyyena Date: Thu, 26 Jan 2023 23:50:48 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=E2=9C=A8=20feat(logout):=20add=20method=20?= =?UTF-8?q?to=20delete=20refresh=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/routes/v1/auth.ts | 29 +++++++++++++++++--------- src/services/auth.ts | 43 ++++++++++++++++++++++++++++++++++++--- src/utils/jwtUtil.ts | 7 +++++-- 3 files changed, 64 insertions(+), 15 deletions(-) diff --git a/src/api/routes/v1/auth.ts b/src/api/routes/v1/auth.ts index 64739ce..4f5ad42 100644 --- a/src/api/routes/v1/auth.ts +++ b/src/api/routes/v1/auth.ts @@ -4,7 +4,7 @@ import { Container } from "typedi"; import { Logger } from "winston"; import asyncHandler from "@/api/middlewares/asyncHandler"; -import { verifyAccessToken } from "@/api/middlewares/authMiddleware"; +import { verifyToken } from "@/api/middlewares/authMiddleware"; import { UserInputDTO } from "@/interfaces/User"; import AuthService from "@/services/auth"; @@ -16,7 +16,7 @@ export default (app: Router) => { route.post( "/kakaotest", - verifyAccessToken, + verifyToken, asyncHandler(async (req: Request, res: Response, next: NextFunction) => { logger.debug(req.body); console.log("🚀 ~ file: auth.ts:17 ~ asyncHandler ~ body", req.body); @@ -28,18 +28,26 @@ export default (app: Router) => { }), ); - /** - ** 로그아웃 처리 - * - * TODO: DB에서 refresh token 삭제 - */ + // 로그아웃 처리 route.get( "/logout", - verifyAccessToken, - asyncHandler(async (req: Request, res: Response) => { - res.cookie("refreshToken", "", { + verifyToken, + asyncHandler(async (req, res) => { + // userId 변수에 user의 id 정보 저장 + const userId = req.userId; + logger.warn({ userId: userId }); + + // Auth Service 로직 가져오기 + const authServiceInstance = Container.get(AuthService); + + // DB에서 refresh token 삭제 + await authServiceInstance.deleteRefreshToken(userId); + + // 쿠키에 담은 refresh token 만료 처리 + res.cookie("refreshToken", null, { maxAge: 0, }); + return res.status(200).json({ success: true, }); @@ -57,6 +65,7 @@ export default (app: Router) => { "/kakao/callback", passport.authenticate("kakao", { session: false, failureRedirect: "/" }), asyncHandler(async (req: Request, res: Response) => { + // user 변수에 user 정보 저장 const user = req.user; // Auth Service 로직 가져오기 diff --git a/src/services/auth.ts b/src/services/auth.ts index 73b66c9..1834bfe 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -55,9 +55,11 @@ export default class AuthService { // JWT(access token, refresh token) 발급 메서드 public async createJwt(user): Promise<{ token: TokenDTO }> { try { + // access token 발급 const accessToken = createAccessToken(user); + // refresh token 발급 const refreshToken = createRefreshToken(user); - + // token 변수에 access token과 refresh token을 객체로 담아 저장 const token = { accessToken, refreshToken }; return { token }; @@ -70,7 +72,8 @@ export default class AuthService { // DB에 refresh token 저장하는 메서드 public async updateRefreshToken(user, refreshToken) { try { - const hasRefreshToken = await this.userModel.update( + // 매개변수로 받은 user의 id를 기준으로 refresh token을 저장 + const updateRefreshToken = await this.userModel.update( { refresh_token: refreshToken, }, @@ -79,8 +82,14 @@ export default class AuthService { }, ); - const canUpdateRefreshToken = hasRefreshToken[0]; + /* + * updateRefreshToken의 리턴값: [영향받은 행의 개수] + * update에 성공: [1] + * update에 실패: [0] + */ + const canUpdateRefreshToken = updateRefreshToken[0]; + // canUpdateRefreshToken의 값이 0(false)이면, 에러 발생 if (!canUpdateRefreshToken) { throw new Error("Unable to update refresh token"); } @@ -90,6 +99,34 @@ export default class AuthService { } } + // DB에서 refresh token 삭제하는 메서드 + public async deleteRefreshToken(userId) { + try { + // 매개변수로 받은 userId를 기준으로 refresh token을 삭제 + const deleteRefreshToken = await this.userModel.update( + { + refresh_token: null, + }, + { where: { user_id: userId } }, + ); + + /* + * deleteRefreshToken의 리턴값: [영향받은 행의 개수] + * update에 성공: [1] + * update에 실패: [0] + */ + const canDeleteRefreshToken = deleteRefreshToken[0]; + + // canDeleteRefreshToken의 값이 0(false)이면, 에러 발생 + if (!canDeleteRefreshToken) { + throw new Error("Unable to delete refresh token"); + } + } catch (error) { + this.logger.error(error); + throw error; + } + } + /** * TODO 2: 토큰 재발급 메서드 구현 ** access token이 만료된 경우, diff --git a/src/utils/jwtUtil.ts b/src/utils/jwtUtil.ts index a73f447..59a3926 100644 --- a/src/utils/jwtUtil.ts +++ b/src/utils/jwtUtil.ts @@ -31,11 +31,11 @@ export const createRefreshToken = (user) => { }; // access token 검증 -export const verifyToken = (token) => { +export const verifyAccessToken = (accessToken) => { let decoded = null; try { // 토큰 확인 - decoded = jwt.verify(token, secret); + decoded = jwt.verify(accessToken, secret); return { success: true, @@ -52,3 +52,6 @@ export const verifyToken = (token) => { }; // TODO: refresh token 검증 함수 구현 +export const verifyRefreshToken = (token) => { + return null; +};