JWT 사용하기

불꽃남자·2021년 7월 22일
1

서론

이전 포스트에선 JWT를 왜 쓰는지, 뭐하는 친구인지, 어떻게 쓸지에 대해 이야기했다.
이번 포스트에선 실제로 express 서버에 적용해보는 시간을 가져보려고 한다.

구현

로그인

클라이언트가 Token을 발급받기 위해서는 인증을 해야한다. 여기서는 인증 수단으로 ID와 Password를 입력받는 로그인을 사용하겠다.

router.post("/login", [
		check("userId").notEmpty().isLength({ min: 5, max: 12 }).isAlpha().isLowercase(),
		check("password").notEmpty().isLength({ min: 8, max: 16 })
	], async (req, res) => {
	try {
		console.group("detected GET request to /auth/login");
		
      		// 유저 인증 로직, 인증에 실패하면 throw됨
      		...
	
		const accessToken = user.generateAccessToken();
		const refreshToken = user.generateRefreshToken();
		
		user.refreshToken = refreshToken;
		await user.save();
		
		if (req.body.automaticLogin) {
			const refreshToken = user.generateRefreshToken();
		
			user.refreshToken = refreshToken;
			await user.save();
		
			res.cookie("refresh_token", refreshToken, {
				maxAge: 1000 * 60 * 60 * 24 * 14,
				httpOnly: true
			});	
		}
		res.send({
			accessToken
		});
		console.groupEnd();
	} catch (e) {
		console.log(`Error: ${e.message}`);
		console.groupEnd();
		res.status(500).send(e.message);
	}
});

/login Endpoint로 POST요청이 오면 유저 인증 로직(reqeust.body와 DB의 ID/Password를 비교하는 로직이다)을 거친 뒤 accessToken을 req.body에 포함하고, refreshToken을 선택적으로 cookie에 포함하여 응답한다.
기본적으로 accessToken을 res.body에 포함하고, req.body.automaticLogin이 true라면 refreshToken을 res.cookie에 포함한다.

POST 인 이유는 refreshToken이 DB에 추가 될 수 있기 때문이다. 이것은 이 요청이 Resource를 생성할 수 있으며 멱등성이 보장되어있지 않다는 의미를 내포하기 때문에 POST 요청이 적절하다고 판단했다.

...

userSchema.methods.generateAccessToken = function() {
	const token = jwt.sign(
		{
			userId: this.userId
		},
		process.env.JWT_ACCESS_SECRET,
		{
			expiresIn: "1h"
		}
	);
	return token;
}

userSchema.methods.generateRefreshToken = function() {
	const token = jwt.sign(
		{
			userId: this.userId
		},
		process.env.JWT_REFRESH_SECRET,
		{
			expiresIn: "14d"
		}
	);
	return token;
}

...

나는 accessToken과 refreshToken의 Secret key를 다르게 설정했는데, 그 이유는 다음과 같다.
내가 해커라면 accessToken의 Secret key를 손에 넣고 나서 같은 Secret key로 refreshToken도 뚫리는지 확인해볼 것이기 때문이다.

그리고 accessToken의 만료 시간을 1시간으로 지정했는데, 내 생각에 이런 Flow도 괜찮을 것 같다.

  1. 5분이나 10분단위로 accessToken의 만료 시간을 정하고,
  2. 서버에 유효한 accessToken이 담긴 요청을 받고 새로운 accessToken을 응답하는 로직을 만들고,
  3. 프론트엔드 코드에서 accessToken의 만료 시간이 1분 이하로 남으면(setTimeout 같은 메소드로 시간을 재는 게 좋겠다.) 2번의 API Endpoint로 요청을 보내고 accessToken을 갱신

하는 것이다. 그럼 accessToken의 보안도 향상시키고, 유저는 웹사이트를 종료하지 않는 이상 계속 로그인 되어 있는 상태를 유지할 수 있다.


... 코드를 짤 당시에는 그렇게 생각했었다. 근데 다시 생각해보니 accessToken의 Secret key가 노출되면 어짜피 Secret key를 바꿔야하는데, 그럼 refreshToken과 accessToken이 동일한 Secret Key를 써도 되는 게 아닐까?
게다가 동일한 Secret key를 쓰면 두 Token의 Secret key가 새로운 것으로 교체되니 오히려 보안에 좋다.
처음엔 accessToken의 Secret key를 알게 되면 유효기간을 늘려 사용할 수도 있겠다 생각했는데, 이 문제는 코드로 접근할 수 없는 곳에 Token을 둠으로써 예방할 수 있다.


refreshToken은 해당 User DB의 refreshToken 프로퍼티에 저장한다.

  1. 나중에 refreshToken을 검증할 때에 현재 DB에 해당 Token을 가지고 있는 User가 존재하는지 검증하기 위함이고,
  2. 해당 refreshToken이 탈취되어 악용되어지고 있다 판단되면 DB에서 삭제함으로써 실시간으로 통제를 하기 위함이다.
  3. 또 해당 유저가 Logout 요청을 보내면 DB에서 refreshToken을 삭제해 해당 유저가 가진 refreshToken을 무효화 할 수도 있겠다.

refreshToken은 cookie로, accessToken은 res.body로 전송한다.
왜냐하면 accessToken은 클라이언트의 state에 저장해놓았다가 Authorization header로 요청받을 것이기 때문이다. 그렇게 하면 사용자가 브라우저를 종료했을 때에 accessToken은 삭제되니 불특정다수가 사용하는 컴퓨터에서의 해킹 위험이 적어진다.


공공장소에서의 JWT에 대해 생각하다가 든 생각이 있다.
클라이언트는 accessToken이 유효하나 만료되었을 때에만 Cookie에 저장된 refreshToken과 함께 /refresh API로 요청을 보내고, 새로운 accessToken을 응답받는다.
그런데 이런 시나리오가 떠올랐다. 공공장소에서 웹사이트에 로그인하여 Cookie가 사용자의 컴퓨터에 저장되고, 나중에 누군가가 refreshToken cookie가 유효할 때에 /refresh API로 접근하면 보안이 뚫리는 것 아닌가?
그럼 자동 로그인 같은 옵션을 만들어서 옵션이 활성화 되었을 때에만 refreshToken을 발급하는 것은 어떤가? 현재 내가 떠올릴 수 있는 방법은 이게 최선이라고 판단했다.
구글링을 하며 본 내용 중 refreshToken과 함께 userID, userSecret을 요청하는 flow도 봤었는데, 이게 정확히 어떻게 이루어지는 건지를 몰라 포스팅을 하고 나서 알아볼 예정이다.


AccessToken 검증

이제 인가가 필요한 API에서 accessToken을 체크하는 기능을 만들어보자.
가장 적합한 방식은 미들웨어를 사용해서 체크하는 방식이라고 생각한다.

...

router.get("/check", checkAccessTokenMiddleware, async (req, res) => {
	console.group("detected GET request to /auth/check");
	try {
		const { userId } = res.user;
		if (!userId) {
			console.log("There is not userId");
			throw new Error("There is not userId");
		}
		res.send("check allow");
		console.groupEnd();
	} catch (e) {
		console.log(e);
		console.groupEnd();
		res.send("check disallow");
	}
});

...

이렇게 accessToken을 체크하는 미들웨어를 인가가 필요한 API마다 꽂아준다.
해당 미들웨어는 이렇게 생겼다.

...

const checkAccessTokenMiddleware = async (req, res, next) => {
	try {
		console.group("request passes through jwtMiddleware");
		const accessToken = req.headers.authorization.split(" ")[1];
		if (!accessToken) {
			res.sendStatus(400);
		}
		jwt.verify(accessToken, process.env.JWT_ACCESS_SECRET, (err, user) => {
			res.user = { userId: user.userId };
			
			console.groupEnd();
			return next();
		})
	} catch (e) {
		console.log(`Error: ${e.message}`);
		console.groupEnd();
		if (e.name === "TokenExpiredError") {
			res.status(403).send(e.message);
		}
		res.sendStatus(403);
	}
}

...

Authorization header에서 accessToken을 찾아 검증한다. Token이 없거나 검증에 실패하면 err를 던지거나 적절한 status를 응답한다.

RefreshToken으로 AccessToken 재발급

...

router.get("/refresh", async (req, res) => {
	console.group("detected GET request to /auth/refresh");
	try {
		const refreshToken = req.cookies.refresh_token;	
		if (!refreshToken) {
			throw new Error("There is not refreshToken");
		}
		
		jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, (e, user) => {
			if (e) {
				if (e.name === "TokenExpiredError") {
					throw new Error("It's Expired refreshToken");
				}
				throw new Error("It's invalid refreshToken");
			}
		});
		
		const user = await User.findOne({ refreshToken }).exec();
		if (!user) {
			throw new Error("It's invalid refreshToken");
		}
		
		const accessToken = user.generateAccessToken();
		console.log(user);
		
		console.groupEnd();
		res.send({ accessToken });
	} catch (e) {
		console.log(e);
		console.groupEnd();
		res.sendStatus(400);
	}
});

...

/refresh Endpoint로 GET 요청이 오면 cookie에 있는 refreshToken을 검증하고 AccessToken을 응답한다.

  1. Token이 있는지 체크
  2. Token이 만료되었는지 체크
  3. Token이 유효한지 체크
  4. DB에 해당 Token을 가지고 있는 유저가 있는지 체크 (중요함)

DB에 해당 Token을 가지고 있는 유저가 있는지 체크하는 게 특히 중요하다. 로그아웃 요청이 들어오면 DB에서 해당 유저의 refreshToken을 지우기 때문이다.

모든 체크를 통과하면 새로운 accessToken을 응답한다.


클라이언트에서

  1. accessToken이 없거나
  2. API를 요청했는데 403 status를 받게 되면

/refresh Endpoint로 API를 요청한다.

"서버 로직에서 accessToken의 유무를 체크하는데 왜 굳이 클라이언트에서도 accessToken의 유무를 체크하느냐?" 는 의문이 생길 수 있을텐데, 그에 대한 나의 대답은 이렇다.

클라이언트에서 요청을 보내기 전에 Token의 유무를 체크하고 /refresh로 요청을 보내는 것이 요청을 보내고 403 status를 받은 뒤 /refresh로 다시 요청을 보내는 것 보다 비용이 더 적게 든다고 판단했기 때문이다.

서버 로직에 accessToken의 유무를 다시 체크하는 이유는 2중 보안의 의미이다.

로그아웃

...

router.post("/logout", async (req, res) => {
	try {
		const refreshToken = req.cookies.refresh_token;	
		if (!refreshToken) {
			throw new Error("There is not refreshToken");
		}
		jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, (e, user) => {
			if (e) {
				if (e.name === "TokenExpiredError") {
					throw new Error("It's Expired refreshToken");
				}
				throw new Error("It's invalid refreshToken");
			}
		});
		
		const user = await User.findOne({ refreshToken }).exec();
      		if (!user) {
			throw new Error("There is not user");
		}
		user.refreshToken = null;
		await user.save();
		
		res.sendStatus(200);
	} catch (e) {
		res.sendStatus(400);
	}
});

...

/logout Endpoint로 POST 요청이 들어오면 cookie에서 refreshToken을 얻어 해당 refreshToken을 들고 있는 User를 DB에서 찾아서 해당 User의 refreshToken을 삭제한다.

이러면 이후에 사용자가 클라이언트에 접속했을 때 로그인이 해제된 상태의 뷰를 볼 것이다. 로그아웃을 했는데도 로그인이 풀리지 않는 것은 이치에 어긋나지 않는가?

🐾

이상의 코드가 내가 생각한 AccessToken / RefreshToken을 이용한 로그인 API의 Flow다.

조금(많이) 코드가 더럽기도하고 어설픈 구석도 있으나 PostMan으로 API를 테스트했을 때는 의도대로 잘 작동한다. 아마 프론트엔드 코드를 작성하면서 무언가 헛점을 발견할 것이라 생각한다.

쉽게 이야기하자면 이 Flow(인증 프로세스? 뭐라고 불러야 할까)에서는 클라이언트의 AccessToken의 유무가 로그인 상태를 결정짓는 것이고, RefreshToken의 유무가 브라우저를 종료해도 로그인 상태를 유지할 것인지 말 것인지를 결정짓는 것이다.


JWT를 사용한 유저 인증/인가 로직도 Flow도 공식 사이트의 예제나 로직이 없어서 혼란스러웠다. Access Token이나 Refresh Token의 개념만 추상적으로 나와 있었다.
그래서 며칠동안 구글링을 했는데, 그냥 개념만 따르면 되는 것으로 판단해 내 생각대로 로직을 짰다.

그리고 구글링하면서 지속적으로 나온 키워드가 OAuth다.
나는 OAuth가 그냥 "Google이나 Facebook같은 곳의 계정으로 로그인을 할 수 있게 해주는 기술"정도로 알고 있었다. 그것도 맞긴 한데, OAuth에서 사용하는 인증 방식이 JWT를 사용한 인증 방식인 것으로 판단된다.
지금은 그 정도만 파악했고 더 자세한 내용은 배워야한다. 배우고나면 포스팅 할 생각이다.


어설프게나마 코드를 작성하고 있으니 백엔드 프로그래밍도 재밌게 느껴진다. JWT를 이해하느라 시간이 좀 많이 걸리긴 했지만...

아무튼 그렇다. 다음 포스팅에서는 이 API에 기반해 프론트엔드의 로그인 로직을 작성할 것이다.

profile
프론트엔드 꿈나무, 탐구자.

0개의 댓글