본문으로 건너뛰기

refresh token과 access token 이해하기

· 약 11분
arch-spatula

refresh token과 access token을 이해하기 위해 했던 노력입니다.

Custom hook에 대한 관심사 분리

import { Button, Input } from '../../Components';
import { useInput } from '../../hooks';

function SignIn() {
const { inputVal: emailValue, changeInputVal: changeEmail } = useInput();

return (
<div>
<h1>Sign In</h1>
<Input type="email" onChange={changeEmail} value={emailValue} />
<Button
onClick={(e) => {
console.log(e.target);
}}
>
Sign In
</Button>
</div>
);
}

export { SignIn };

이런 컴포넌트가 있습니다. 로그인 페이지를 담당하고 있습니다. 딜레마는 이것입니다. 페이지에 그대로 작성해도 당연히 동작하는데 마크업과 컨트롤러의 분리가 안 이루어집니다. hook이 컨트롤러 역할을 할 수 있게 해야 하는데 문제는 중복하는 로직이 아니라는 점입니다.

Presentation Domain Data Layering - Martin Fowler

이미지의 위치를 보면 전용 로직을 같이 배치해도 괜찮을 것 같습니다. 로그인 기능을 useSignIn으로 같은 page 모듈에 배치하고 해결할 수 있습니다. 그리고 useSignIn에서 전용으로 사용할 Model 클래스도 정의해 둘 수 있습니다.

페이지 단위로 프레젠테이션과 도메인을 불리할 수 있습니다.

refresh token access token

백엔드 엔지니어링 중에 오타를 나중에 발견했습니다.

refresh access token를 구현하는 것이 좋을 것 같습니다.

개념입니다.

사용자가 로그인하면 하나는 쿠키에 저장하고 다른 하나는 일반 메모리에 저장합니다.

JWT Bearer 토큰은 스토리지에서 담고 있다가 Header로 설정해서 요청을 보내면 됩니다. 이것은 access token입니다. 만료 혹은 유효하지 않으면 막으면 됩니다.

refresh 토큰은 cookie로 설정합니다.

이렇게 하면 장점은 새로고침 문제를 해결할 수 있습니다. 새로고침하면 refresh 토큰은 잔존하고 access token은 사라집니다.

사용자는 refresh 토큰을 서버에 보내면 서버는 access 토큰을 응답합니다. 그리고 access 토큰으로 서버에 요청을 보내고 데이터를 받는 방식입니다.

refresh 토큰 자체로 요청을 활용하면 의미는 크게 없습니다. 해커는 refresh의 응답을 활용할 수 없기 때문입니다.

여기서는 access이 만료됩니다. 그리고 refresh 토큰을 받아서 갱신합니다.

RFC 토큰 스펙 - The OAuth 2.0 Authorization Framework

  +--------+                                           +---------------+
| |--(A)------- Authorization Grant --------->| |
| | | |
| |<-(B)----------- Access Token -------------| |
| | & Refresh Token | |
| | | |
| | +----------+ | |
| |--(C)---- Access Token ---->| | | |
| | | | | |
| |<-(D)- Protected Resource --| Resource | | Authorization |
| Client | | Server | | Server |
| |--(E)---- Access Token ---->| | | |
| | | | | |
| |<-(F)- Invalid Token Error -| | | |
| | +----------+ | |
| | | |
| |--(G)----------- Refresh Token ----------->| |
| | | |
| |<-(H)----------- Access Token -------------| |
+--------+ & Optional Refresh Token +---------------+

토큰 2개를 저장하고 API 호출할 때는 Access 토큰을 제출합니다. 하지만 만료되는 경우가 있습니다. token error가 발생하면 access 토큰의 수명이 끝났다는 것입니다. 이 때 refresh 토큰을 보내고 Access 토큰을 갱신합니다.

토큰 수명별 응답

토큰의 수명별로 서버가 처리할 응답은 이렇게 볼 수 있습니다. 보통 access token의 갱신 주기는 짧게 두고 refresh token은 길게 둡니다. 그리고 갱신이 필요하면 refresh token을 서버에 재출하고 갱신합니다.

응답 예시

예전 과제를 보니까 Access 토큰으로 body에 응답합니다.

### 응답 예시

- status: 200 OK
- body

```json
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZ21haWwuY29tIiwic3ViIjo0LCJpYXQiOjE2NTk5MDQyMTUsImV4cCI6MTY2MDUwOTAxNX0.DyUCCsIGxIl8i_sGFCa3uQcyEDb9dChjbl40h3JWJNc"
}
```

원티드 프리온보딩 프론트엔드 - 선발 과제

이렇게 보면 set-cookie로 응답은 refresh 토큰에 설정하면 됩니다.

login

Auth Controller - Dave Gray

이 예시가 제일 직관적입니다.

const User = require('../models/User');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');

// @desc Login
// @route POST /auth
// @access Public
const login = async (req, res) => {
const { username, password } = req.body;

if (!username || !password) {
return res.status(400).json({ message: 'All fields are required' });
}

const foundUser = await User.findOne({ username }).exec();

if (!foundUser || !foundUser.active) {
return res.status(401).json({ message: 'Unauthorized' });
}

const match = await bcrypt.compare(password, foundUser.password);

if (!match) return res.status(401).json({ message: 'Unauthorized' });

const accessToken = jwt.sign(
{
UserInfo: {
username: foundUser.username,
roles: foundUser.roles,
},
},
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);

const refreshToken = jwt.sign(
{ username: foundUser.username },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);

// Create secure cookie with refresh token
res.cookie('jwt', refreshToken, {
httpOnly: true, //accessible only by web server
secure: true, //https
sameSite: 'None', //cross-site cookie
maxAge: 7 * 24 * 60 * 60 * 1000, //cookie expiry: set to match rT
});

// Send accessToken containing username and roles
res.json({ accessToken });
};

부분을 보면 이해가 됩니다. 어느정도 유효성을 검증하고 cookierefresh token을 설정합니다. 그리고 response bodyaccess token을 응답합니다. 응답을 받은 프론트엔드 엔지니어 입장에서 이 tokenheader에 설정하면 됩니다.

refresh

// @desc Refresh
// @route GET /auth/refresh
// @access Public - because access token has expired
const refresh = (req, res) => {
const cookies = req.cookies;

if (!cookies?.jwt) return res.status(401).json({ message: 'Unauthorized' });

const refreshToken = cookies.jwt;

jwt.verify(
refreshToken,
process.env.REFRESH_TOKEN_SECRET,
async (err, decoded) => {
if (err) return res.status(403).json({ message: 'Forbidden' });

const foundUser = await User.findOne({
username: decoded.username,
}).exec();

if (!foundUser) return res.status(401).json({ message: 'Unauthorized' });

const accessToken = jwt.sign(
{
UserInfo: {
username: foundUser.username,
roles: foundUser.roles,
},
},
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);

res.json({ accessToken });
}
);
};

accessToken 갱신하는 로직입니다.

logout

// @desc Logout
// @route POST /auth/logout
// @access Public - just to clear cookie if exists
const logout = (req, res) => {
const cookies = req.cookies;
if (!cookies?.jwt) return res.sendStatus(204); //No content
res.clearCookie('jwt', { httpOnly: true, sameSite: 'None', secure: true });
res.json({ message: 'Cookie cleared' });
};

module.exports = {
login,
refresh,
logout,
};

아주 직관적입니다.

이 예시는 Node.js 버전입니다. 저는 Deno 런타임에 맞게 변형해야 합니다.

Deno 버전

한가지 잘 못 알고 있던 지식이 있었습니다. 제가 만들었던 것은 미들웨어입니다. 컨트롤러가 아닙니다.

미들웨어는 요청과 응답 사이 처리를 담당하는 코드입니다.

컨트롤러는 라우팅 요청에 대한 실제 처리결과를 구현합니다.

// util/token.ts
import { create, getNumericDate, verify } from '../deps.ts';

class Token {
private static instance: Token;
readonly key: Promise<CryptoKey>;

private constructor() {
this.key = (async () => {
const key = await crypto.subtle.generateKey(
{ name: 'HMAC', hash: { name: 'SHA-512' } },
true,
['sign', 'verify']
);
return key;
})();
}

static getInstance(): Token {
if (!Token.instance) {
Token.instance = new Token();
}
return Token.instance;
}

async makeAccessToken(userId: string, expiresInSec = 3600) {
const jwt = await create(
{ alg: 'HS512' },
{ exp: getNumericDate(expiresInSec), sub: userId },
await this.key
);
return {
jwt,
expires: new Date(new Date().getTime() + expiresInSec * 1000),
};
}

async makeRefreshToken(userId: string, expiresInSec = 2592000) {
const jwt = await create(
{ alg: 'HS512' },
{ exp: getNumericDate(expiresInSec), sub: userId },
await this.key
);
return {
jwt,
expires: new Date(new Date().getTime() + expiresInSec * 1000),
};
}

async tokenToUserId(jwt: string) {
const { sub } = await verify(jwt, await this.key);
return sub;
}
}

export default Token;
// controllers/users.ts
async function signin({ request, response, cookies }: Context) {
try {
if (!request.hasBody) {
throw Error('body가 없습니다.');
}

const input = await request.body().value;
if (!input.email || !input.password) {
throw Error('이메일 혹은 비밀번호가 없습니다.');
}

const document = await mongoAPI.getUser(input.email);
if (document === null) throw Error('이메일이 없습니다.');
else {
if (await compare(input.password, document.passwordHash)) {
const { jwt: refreshToken, expires: refreshExpires } =
await token.makeRefreshToken(document._id);

const { jwt: access_token } = await token.makeAccessToken(
document._id,
60 * 60
);

cookies.set('user', refreshToken, { expires: refreshExpires });
response.status = 201;
response.body = { access_token };
} else {
throw Error('비밀번호가 일치하지 않습니다.');
}
}
} catch (error) {
response.status = 400;
response.body = {
success: false,
msg: `${error}`,
};
}
}

로그인 기능을 구현했습니다. 다음은 요청과 갱신 기능입니다.

먼저 요청에서 access token의 만료 검증입니다.

deps

Managing Dependencies - Deno 공식 문서

그동안 하고 싶었던 리팩토링을 했습니다. 모든 의존성을 하나의 모듈에 몰아 넣었습니다.

막상 해보니까 별로 어려운 작업이 아니었습니다.