Session 대신 JWT를 이용해 로그인 기능을 구현해보자.
JWT의 장점
인증(Authorization)은 크게 세션 기반과 토큰 기반으로 나뉩니다.
세션 기반
사용자의 인증 정보가
서버 메모리(= 세션 저장소)
에 저장되는 방식으로 로그인 시 사용자의 인증 정보를 세션 저장소에 저장하고 Session ID 라는 식별자를 사용자에게 발급합니다.클라이언트에 Cookie에 Session ID를 저장하여 전송하면, 서버는 전달받은 Session ID로 세션 저장소를 조회하여 사용자를 검증합니다.
즉, 세션 기반의 특징은 사용자의 정보가 서버 메모리에 보관 된다는 점이다.
토큰 기반
토큰 기반 인증은 로그인 시 인증 정보를 클라이언트에 직접 저장합니다.
서버로부터 생성되어 클라이언트에게 전달되는데, 사용자를 인증할 수 있는 정보 를 토큰으로 암호화한 후 저장합니다.
클라이언트는 HTTP 요청 시 토큰을 담아 보내며, 서버는 토큰의 유효기간과 유효성을 검증하여 사용자를 인증합니다.
즉, 토큰 기반의 특징은 별도로 세션을 저장할 메모리가 필요하지 않고 토큰을 생성하고, 다시 풀어내는 기능만 갖추면 됩니다.
JWT 장단점
세션 기반 방식은 사용자 정보가 서버 측에 있어 사용자가 늘어날수록 서버 메모리에 부하가 커질 수 있습니다.
반면에, 토큰 기반 인증은 서버 측엔 저장된 인증 정보가 없기 때문에 세션 불일치 문제로부터 자유로워 서버 확장성의 이점을 가집니다.
하지만 안정성 측면에서는 세션 기반이 확실히 이점을 가집니다.
토큰의 경우 악의적인 공격 (XSS, CSRF 등)에 의해 탈취 당하면 답이 없지만, 세션 기반은 서버 메모리에 정보가 저장되기 때문에 상대적으로 안전합니다.
구현 로직
위 그림을 참고하여 Access Token과 Refresh Token을 모두 이용한 서버 인증 방식으로 구현해보겠습니다.
사용자가 로그인을 시도합니다.
서버는 사용자를 DB 조회를 통해 확인하고 성공하면 RefreshToken과 AccessToken을 발급하고 사용자에게 응답해줍니다.
다음 요청부터 사용자는 로그인이 필요한 서비스에는 발급받은 AccessToken을 담아 요청합니다.
AccessToken이 유효하다면 서버는 서비스를 제공합니다.
AccessToken이 만료됐다면 RefreshToken을 담아 AccessToken 재발급을 요청합니다.
RefreshToken이 유효하다면 AccessToken을 재발급하고 서비스를 제공합니다.
RefreshToken도 만료됐다면 다시 로그인이 필요함을 알려줍니다.
RefreshToken 도입 이유
사용자 정보를 인증하는 것은 하나의 토큰으로도 가능합니다.
하지만 만약 해커가 토큰을 탈취해 서버에 요청한다면 토큰의 Stateless한 특징으로 서버에는 서비스를 제공하게됩니다.
이런 문제로 인해 Access Token의 유효기간을 짧게 설정해 토큰을 탈취당해도 금방 만료되어 해커가 악용할 수 없게 합니다.
하지만, 짧아진 유효기간은 실제 사용자가 서비스를 이용할 때 매번 로그인을 해야하는 문제가 생깁니다.
이러한 문제를 해결하기위해 RefreshToken을 도입합니다.
Refresh Token은 상대적으로 긴 유효기간을 갖으며 Access Token의 재발급에 사용되어 사용자가 매번 로그인 과정을 거치지 않도록 합니다.
Express + JWT
기존의 Session + Cookie 방식에서 JWT + Cookie 방식으로 바꿔보겠습니다.
app.js 수정하기
기존의 Session 관련 내용은 주석 처리 해주겠습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import express from "express";
import path from "path";
import "dotenv/config";
import bodyParser from "body-parser";
import cookieParser from "cookie-parser";
import passport from "passport";
import passportConfig from "./src/config/passport/index.js";
// import sessionMiddleware from "./src/config/session.js";
import userRouter from "./src/routes/route_user.js";
const app = express();
const __dirname = path.resolve();
// 앱 세팅
app.set("views", "./src/views");
app.set("view engine", "ejs");
// 미들웨어
app.use("/", express.static(`${__dirname}/src/public`));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(cookieParser(process.env.COOKIE_SECRET));
// cookieParser 메서드에 인자로 주어지는 secret은 환경변수로 따로 관리합니다.
app.use(passport.initialize());
passportConfig();
// app.use(sessionMiddleware);
// app.use(passport.session()); // session에 의존하므로 뒤에 작성
// router
app.use("/user", userRouter);
app.listen(process.env.PORT, () => {
console.log("Server on port", process.env.PORT);
});
passportConfig 수정
세션 방식에서 사용하던 serializeUser와 deserializeUser를 주석 처리해주겠습니다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// import passport from "passport";
import local from "./localStrategy.js";
// import UserStorage from "../models/model_user.js";
export default () => {
// 세션 용, app.js 의 passport.session() 실행 시 사용
// JWT 사용 시, 삭제
// passport.serializeUser((req, user, done) => {
// console.log("seri", user);
// done(null, user.id);
// // 자원낭비를 줄이기 위해 id만 저장합니다.
// });
// passport.deserializeUser(async (req, id, done) => {
// console.log("deseri", id);
// try {
// const user = await UserStorage.getUserInfo(id);
// // 모든 요청마다 DB SELECT 진행
// // 페이지 하나에도 수많은 파일 요청
// // DB 부하를 줄이기위해 캐싱 필요
// done(null, user);
// } catch (err) {
// done(err);
// }
// });
// 로컬 로그인
local();
};
jwt-util.js 만들기
사용할 모듈
- jsonwebtoken 모듈
Token 생성 함수, Token 검증 함수를 만들어 utils 폴더에 담아 관리하겠습니다.
- AccessToken은 짧은 만료기간 (2h)으로 설정하고 RefreshToken는 긴 만료기간 (14d)을 설정하겠습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import jwt from "jsonwebtoken";
import redisClient from "../config/redis.js";
const accessTokenSecret = process.env.ACCESS_TOKEN_SECRET;
const refreshTokenSecret = process.env.REFRESH_TOKEN_SECRET;
export default {
// AccessToken 생성 함수
accessSign: (userId) => {
try {
return jwt.sign({ id: userId }, accessTokenSecret, {
expiresIn: "2h", // 유효기간
algorithm: "HS256", // 암호화 알고리즘
});
} catch (err) {
throw err;
}
},
accessVerify: (accessToken) => {
try {
return jwt.verify(accessToken, accessTokenSecret);
} catch (err) {
throw err;
}
},
refreshSign: (userId) => {
try {
return jwt.sign({ id: userId }, refreshTokenSecret, {
expiresIn: "14d",
algorithm: "HS256",
});
} catch (err) {
throw err;
}
},
refreshVerify: (refreshToken) => {
try {
return jwt.verify(refreshToken, refreshTokenSecret);
} catch (err) {
throw err;
}
},
};
토큰 생성
로그인 성공 시 AccessToken과 RefreshToken을 생성한 후 Client에게 Cookie로 발급합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import passport from "passport";
import { createPasswordAndSalt } from "../utils/crypto.util.js";
import jwtUtil from "../utils/jwt.util.js";
import UserStorage from "../models/model_user.js";
const output = {
...
};
const process = {
login: (req, res, next) => {
try {
passport.authenticate("local", (err, user, response) => {
if (err) return next(err);
if (!user) return res.json(response);
// AccessToken, RefreshToken 생성
const accessToken = jwtUtil.accessSign(user.id);
const refreshToken = jwtUtil.refreshSign(user.id);
// AccessToken 쿠키로 발급
res.cookie("accessToken", accessToken);
// AccessToken 쿠키로 발급
res.cookie("refreshToken", refreshToken);
res.status(200).json({ success: true });
})(req, res, next); // 미들웨어 내의 미드뤠어는 (req, res, next)를 붙여줍니다.
} catch (err) {
next(err);
}
},
register: async (req, res, next) => {
...
},
logout: (req, res) => {
try {
// 로그아웃 요청시 토큰을 쿠키에서 삭제
res
.clearCookie("accessToken")
.clearCookie("refreshToken")
.redirect(req.headers.referer);
} catch (err) {
next(err);
}
},
};
export default { output, process };
jwt 검증 미들웨어 만들기
이제 로그인 성공 시 토큰을 발급하는 것까지 왼료했습니다.
다음은 Client가 AccessToken을 담아 요청했을 때 로그인 없이 서비스를 제공해보겠습니다.
(간단하게 사용자의 이름을 반환해보겠습니다.)
유저 정보를 제공하는 페이지를 요청할 때 Router에서 Controller로 가기전 jwt_auth 미들웨어를 거쳐가도록 구현해보겠습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// routes/user.js
import express from "express";
import ctrl_user from "../controllers/controller_user.js";
import validator_user from "./middleware/validator_user.js";
// 구현할 jwt 검증 미들웨어 파일 import
import jwt_auth from "../middleware/jwt_auth.js";
const Router = express.Router();
Router.get("/info", jwt_auth, ctrl.output.info);
Router.get("/login", ctrl_user.output.login);
Router.get("/register", ctrl_user.output.register);
Router.get("/logout", ctrl_user.process.logout);
Router.post("/login", validator_user.login, ctrl_user.process.login);
Router.post("/register", validator_user.register, ctrl_user.process.register);
export default Router;
4가지 Cass
case 1
: Token이 없는 경우case 2
: Access token이 유효 경우case 3
: Access token은 만료, Refresh token은 유효인 경우case 4
: Access token 만료, Refresh token 만료인 경우
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// middleware/jwt_auth.js
import jwtUtil from "../utils/jwt_util.js";
import UserStorage from "../models/model_user.js";
const authJwt = async (req, res, next) => {
if (!req.cookies.accessToken && !req.cookies.refreshToken) {
// case 1 : Token이 없을 경우
return next();
}
try {
const accessToken = req.cookies.accessToken;
// AccessToken 검증
const accessDecode = await jwtUtil.accessVerify(accessToken);
// case 2 : AccessToken이 유효안 경우
req.user = await UserStorage.getUserInfo(accessDecode.id);
return next();
} catch (decoded) {
// AccessToken 만료 시, 초기화
res.clearCookie("accessToken");
const refreshToken = req.cookies.refreshToken;
try {
// RefreshToken 검증
const refreshDecode = await jwtUtil.refreshVerify(refreshToken);
// case 3 : AccessToken은 만료, RefreshToken은 유효인 경우
const user = await UserStorage.getUserInfo(refreshDecode.id);
// 새로운 AccessToken 생성
let newAccessToken = await jwtUtil.accessSign(user.id);
// AccessToken 재발급
res.cookie("accessToken", newAccessToken);
req.user = user;
return next();
} catch (err) {
// case 4 : AccessToken 만료, RefreshToken 만료인 경우
// RefreshToken 만료 시, 초기화
res.clearCookie("refreshToken");
return next();
}
}
};
export default authJwt;
Controller의 info output 수정
jwt_auth 미들웨어에서 AccessToken을 확인한 후 유효하다면 사용자 정보를 req.user
에 담아 넘겨줍니다.
넘겨 받은 req.user에서 이름 정보를 넘겨줍니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import passport from "passport";
import { createPasswordAndSalt } from "../utils/crypto_util.js";
import UserStorage from "../models/model_user.js";
import jwtUtil from "../utils/jwt_util.js";
import redisClient from "../config/redis.js";
const output = {
login: (req, res) => {
res.render("user/login");
},
register: (req, res) => {
res.render("user/register");
},
info: async (req, res) => {
res.render("user/info", { name: req.user ? req.user.name : null });
},
};
const process = {
login: ...,
register: ...,
logout: ...,
};
export default { output, process };