Skip to content

Commit

Permalink
✨feat(OAuth 2.0): add kakao login API
Browse files Browse the repository at this point in the history
close [BE/FEAT] 카카오톡 로그인 구현 #2
  • Loading branch information
Hyyena committed Jan 21, 2023
1 parent 1e527b6 commit cc4cda8
Show file tree
Hide file tree
Showing 15 changed files with 396 additions and 21 deletions.
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
78 changes: 78 additions & 0 deletions src/api/middlewares/authMiddleware.ts
Original file line number Diff line number Diff line change
@@ -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);
}
};
4 changes: 4 additions & 0 deletions src/api/middlewares/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import asyncHandler from "./asyncHandler";
import { verifyAccessToken, isLoggedIn, isNotLoggedIn } from "./authMiddleware";

export default {
asyncHandler,
verifyAccessToken,
isLoggedIn,
isNotLoggedIn,
};
55 changes: 51 additions & 4 deletions src/api/routes/v1/auth.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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 });
}),
);
};
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
29 changes: 27 additions & 2 deletions src/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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은 일회성
*/
}
Loading

0 comments on commit cc4cda8

Please sign in to comment.