Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨feat(OAuth 2.0): add kakao login API #33

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 56 additions & 0 deletions src/api/middlewares/authMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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",
});
}
};
2 changes: 2 additions & 0 deletions src/api/middlewares/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import asyncHandler from "./asyncHandler";
import { verifyAccessToken } from "./authMiddleware";

export default {
asyncHandler,
verifyAccessToken,
};
66 changes: 62 additions & 4 deletions src/api/routes/v1/auth.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
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 { verifyToken } 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",
verifyToken,
asyncHandler(async (req: Request, res: Response, next: NextFunction) => {
logger.debug(req.body);
console.log("🚀 ~ file: auth.ts:17 ~ asyncHandler ~ body", req.body);
Expand All @@ -24,12 +28,66 @@ export default (app: Router) => {
}),
);

// 로그아웃 처리
route.get(
"/logout",
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,
});
}),
);

// 카카오 로그인 페이지
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) => {
// user 변수에 user 정보 저장
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,
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 });
}),
);
};
7 changes: 7 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
7 changes: 7 additions & 0 deletions src/interfaces/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,11 @@ export interface User {

export interface UserInputDTO {
userId: string;
email: string;
name: string;
}

export interface TokenDTO {
accessToken: string;
refreshToken: string;
}
29 changes: 23 additions & 6 deletions src/loaders/express.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand All @@ -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", {
Expand All @@ -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
Expand Down
24 changes: 23 additions & 1 deletion src/loaders/logger.ts
Original file line number Diff line number Diff line change
@@ -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,
),
}),
);
Expand All @@ -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;
4 changes: 4 additions & 0 deletions src/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/*
* 관계에 대한 설정
*/
Expand Down
Loading