예전에 인증과 인가에 관련된 포스팅을 작성한 적이 있었다. 그 과정에서 세션 방식과 토큰 방식이 쓰인다고 하였고, 토큰 방식에서는 JWT(JSON Web Token)을 일반적으로 사용한다고 이야기를 했었다.
그 이후로 스프링 시큐리티로 직접 인증과 인가를 구현해야 할 일이 생긴 적이 있었다. Access Token과 Refresh Token을 통해서 구현한 적이 있었는데, 처음에 이를 도대체 어떻게 구현해야 할지 상당한 난제였다. 로직 자체를 구현하는 것 자체는 크게 어렵지 않으나, 스프링 시큐리티와 맞물려서 구현하는 과정에서 상당히 오랜 시간이 걸렸다. 여담으로, username과 password로 로그인하는 스프링 시큐리티의 userDetails의 구현체를 변경하여, 전화번호를 통해서 로그인이 되게끔 구현해야 했기에 정말 힘들었던 것 같다.
여차저차 기능 구현이 끝났고 정상적으로 작동하였다. 문제는 시대생팀의 타운홀 미팅 때 내가 구현한 스프링 시큐리티에 대한 기술적인 질문이 굉장히 많이 들어왔다는 점이다. 왜 그렇게 구현을 했는 지? 부터 시작해서 이러면 어떡하고 저러면 어떡하고.. 등의 다양한 걱정 및 문제점에 대해서 질문을 많이 받았으나, RFC 표준을 지켜서 만들었다.라는 한마디로 많은 부분을 커버한 적이 있었다. 나머지는 피드백을 반영하여 수정하였다.
이번 포스팅에서는 예전에 봤었던 RFC 표준에 좀 더 자세히 살을 덧붙여서 포스팅해보려고 한다. 개인적인 호기심으로 인증/인가 구현의 정말 표준은 무엇일까? 하는 고민도 있었기 때문이다.
본론
HTTP의 특징 중 하나는 stateless(무상태)하다는 점이다. 무슨 소리냐 하면, 어떤 클라이언트가 매번 서버에 요청을 보낼 때마다. 서버 쪽에서는 어떤 클라이언트인 지 알 수가 없다. 왜냐하면 stateless는 서버가 클라이언트의 정보를 들고 있지 않는 것을 의미한다. 따라서 로그인된 사용자에게 그 사용자의 마이페이지 등을 보여주기 위해서는 유저를 식별해야 하고, 별도의 트릭을 통해서 stateful 하게 사용자를 확인해야 한다.
그렇다면, 어떤 트릭을 써서 인증/인가를 구현할 수 있을까? 그 방법으로는 세션 방식과 토큰 방식이 있다. 관련된 내용을 포스팅한 적이 있었는데 레퍼런스로 첨부한다. 이번 포스팅에서는 간단하게만 다뤄보자.
- [tistory] 인증과 인가에 대해서 : https://marsboy.tistory.com/10
인증과 인가에 대해서
들어가며인증과 인가는 백엔드 개발자라면 당연히 알아야 하는 개념 중 하나이다. 인증가 인가에 대한 개념 자체는 크게 복잡한 내용을 다루고 있지 않지만, 문제는 쉽지 않은 구현 과정이다. HT
marsboy.tistory.com
세션 방식
stateful 하다는 것은 기본적으로 서버에서 클라이언트의 정보를 들고 있는 것을 의미한다. 세션 방식은 인증이 된 사용자의 정보를 세션 스토어에 보관하고, sessionId를 발급해 준다. 클라이언트는 매 HTTP 요청마다 이러한 sessionId를 같이 보내주고, 서버에서는 이 sessionId가 실제로 세션 스토어에 있는지 확인한다. 정상적으로 확인이 된 경우에는 다양한 서비스들을 인가해주게 된다.
앞서 말했던 세션 스토어는 redis와 같은 캐시를 위한 데이터 스토어를 사용한다. 다양한 이유가 있는데, 데이터베이스 등에 세션을 올려두어 매번 데이터베이스를 통해 확인하기에는 오버헤드가 너무 크다. 인메모리 데이터 그리드인 redis의 쪽이 속도가 빠르고 자주 사용되는 정보이기 때문이다. 추가적으로 sessionId의 경우에는 왠만해서는 정합성이 다른 것에 비해 크게 중요하지 않기 때문에 캐시 해두는 것도 상관이 없다. 그 외에도 TTL(Time To Live) 등의 기능을 지원하는 redis 등에 저장하여 사용자의 정보를 서버쪽에서 들고 있는 방식이다.
토큰 방식
토큰 방식은 뭘까? 토큰 방식은 일단 stateless하다. 그렇지만 stateful 하게 동작하는데, 어떤 트릭을 썼을까? 세션 방식을 보면 알겠지만 stateful의 정의 그대로 서버에서 클라이언트의 정보를 들고 있는 것이다. 그렇기 때문에 사용자가 100배 늘면, 서버에서 관리해야 하는 사용자의 정보도 그에 맞게 100배 가까이 늘어난다. 그것이 stateful이기 때문이다.
하지만 이러한 토큰 방식에서는 stateless하기 때문에, 사용자가 1000배가 되어도 서버에서는 어떠한 일도 일어나지 않는다. 토큰 방식을 사용하는 경우에 서버는 클라이언트가 함께 보낸 토큰의 유효성을 검증하고 일치한다면 다양한 기능을 인가해주기 때문에, 세션 스토어를 뒤적거리는 일이 없다. 그저 토큰의 유효성을 검증하는 일만 있다. 그렇기 때문에 서버를 한 번 유저에게 토큰을 발급해 주면, 그러한 토큰은 stateless 하다는 특징을 띤다.
이렇게 세션 방식과 토큰 방식에 대해서는 위에 레퍼런스로 달아둔 예전 포스팅에서도 설명했었다. 이제 중요한 것은 본론인데, 그렇다면 어떤 방식을 사용해야 하며, 대부분의 기업들은 어떤 식으로 인증과 인가를 구현하고 있을까? 자세히 알아보자.
JWT의 원리 및 인증 방식
먼저, JWT에 대한 이야기를 곁들이려고 한다. JWT는 위처럼 Header, Payload, Signature로 이루어져 있다. Header는 Token의 종류나 sign 알고리즘이 정의되어 있고, Payload는 여러 데이터들이 들어있다. ( 민감한 정보는 담으면 안된다. )
마지막으로 가장 중요한, Signature는 인코딩된 헤더와 인코딩 된 페이로드를 Secret Key로 암호화한 것이다. 헤더에 SHA256 암호화 알고리즘이 정리되어 있다면, 해당 방식을 이용하여 암호화를 한다. 공식 문서는 아래와 같다.
- [jwt] introduction : https://jwt.io/introduction
위 레퍼런스에 따르면, Signature는 아래와 같이 생성된다는 것을 볼 수 있다.
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
여기서 SHA256 알고리즘을 사용하는 것을 알 수 있는데, SHA256 알고리즘은 해싱(Hashing)에 사용되는 단방향 암호화 알고리즘이다. 즉 암호화가 된 것은 복호화할 수 없다는 것이다. 복호화할 수 없다? 그렇다면 서버는 어떻게 토큰의 유효성을 검증할 수 있는 걸까?
위 사이트는 jwt.io라는 사이트이다. 간단하게 JWT를 생성해볼 수 있는데, 여기서 Header와, PAYLOAD는 base64로 인코딩 되어 있다. encrypt가 아니라 encoding이기 때문에 간단하게 decoding 할 수 있다. base64 디코드 사이트에 접속하여 직접 입력하면 아래와 같은 디코딩 결과를 볼 수 있다.
이렇게 정리하면, Payload 부분은 간단하게 디코딩이 되는 것을 알 수 있기 때문에, 민감한 정보를 담으면 안 된다는 것을 알 수 있다. ( 전화번호 등 )
단방향 알고리즘을 쓰기 때문에, 검증하는 방법은 이러한 Payload를 Secret Key를 통해서 SHA256으로 암호화하여, Signiture와 같은 지 아닌 지 비교하여 확인하는 것이다!
따라서, 서버에서 JWT를 발급해줄 때, 서버에 내장된 Secret Key를 통해서 만들어진 JWT를 제공해 주고, 서버에 JWT로 요청이 올 때, 위와 같은 방법으로 유효성을 검증한다. 이렇게 JWT 토큰을 사용하는 것까지 알아보았는데, 실제로 프로덕트의 인증 인가를 구현한다면 어떻게 구현할까? 그에 대한 내용은 RFC 문서를 자세히 살펴보자.
RFC OAuth2.0 표준에 따른 인증과 인가 플로우
RFC(Request For Comments)는 프로토콜이나 기술의 표준 규격을 서술한 문서를 말한다. HTTP 및 SMP 등 다양한 인터넷에서 사용하는 프로토콜들을 정의해 놓은 것을 말한다.
이 중에서 OAuth 2.0에 대해서 다룬 RFC 6749에 대해서 살펴볼 예정이다. [page 9]의 Refresh Token에 대한 설명을 확인해보면 다음과 같은 설명이 나와있다.
Refresh tokens are credentials used to obtain access tokens. Refresh tokens are issued to the client by the authorization server and are used to obtain a new access token when the current access token becomes invalid or expires, or to obtain additional access tokens with identical or narrower scope (access tokens may have a shorter lifetime and fewer permissions than authorized by the resource owner). Issuing a refresh token is optional at the discretion of the authorization server. If the authorization server issues a refresh token, it is included when issuing an access token - RFC 6749 [ Page 9 ]
리프레시 토큰은 액세스 토큰을 발급하기 위해 사용하는 토큰이다. 액세스 토큰 일반적으로 짧은 lifetime을 가지고 있다. 리프레시 토큰을 구현하는 것은 선택 사항이다. 라고 나와있다.
Unlike access tokens, refresh tokens are intended for use only with authorization servers and are never sent to resource servers. - RFC 6479 [ Page 10 ]
또한, 바로 밑에는 액세스 토큰과는 다르게 리프레시 토큰은 서버에서만 가지고 있고, 리소스 서버로 전송되지 않는다고 한다.
최종적으로 Access Token과 Refresh Token을 사용해서 구현한 인증/인가 로직의 표준은 위와 같이 구성되어 있다. 맨 처음에 액세스 토큰과 리프레시 토큰을 함께 발급해서 클라이언트에게 주고, Resource Server에 액세스 토큰을 통해서 리소스를 받는다. 그때, 액세스 토큰을 통하여 리소스 서버에 인가가 실패하면, 인증 서버에서 리프레시 토큰을 통해 액세스 토큰을 재발급받는다. 선택적으로 리프레시 토큰도 새롭게 재발급해서 줄 수 있다.
RFC 표준에 따르면 OAuth2.0 구현에 사용하는 일반적인 내용이지만, 실제로 인증 인가 로직을 구현할 때도 사용한다고 한다. 이렇게 발급받은 토큰은 프론트엔드에서 어떻게 핸들링해야할까? Access Token은 자바스크립트 로컬 변수에 저장하여, HTTP Request를 보낼 때마다 항상 Header에 Bearer와 함께 보내줘야 한다. 그리고 Refresh Token은 쿠키에 저장하되, HTTPS ONLY로 저장하는 것이 가장 안전하다고 한다.
- [심재철] 토큰을 어디에 저장해야 안전할까? : https://simsimjae.tistory.com/482
토큰을 어디에 저장해야 안전할까
이 글은 독자가 JWT, 세션등에 대한 이해를 갖고 있다는 전제하에 정리 차원에서 작성한 글입니다. TL;DR 1. 리프레시 토큰은 HTTP ONLY SECURE 쿠키에 저장하자. 2. 액세스 토큰은 프로그램상 자바스크
simsimjae.tistory.com
위 내용에 따르면 CSRF(Cross-Site Resource Forgery) 교차 사이트 요청 위조 공격이나 XSS(Croos Site Scripting) 등의 공격을 막기 위해서 위와 같이 처리해야한다는 것이다.
백엔드에서 Access Token과 Refresh Token을 발급하는 로직을 구현하기 위해서 캐시를 써서 구현했다고 하자. 프론트엔드에서는 아래와 같이 구현할 수 있다. 기본적으로 액세스 토큰을 항상 Bearer 헤더로 같이 보내고, 인증이 실패하여 401 상태 코드가 반환된 경우에 리프레시 토큰을 재발급하는 API로 라우팅되도록 구성할 수 있다.
// apiClient.js
import axios from 'axios';
// 로컬 스토리지에서 Access Token 가져오기
const getAccessTokenFromLocalStorage = () => {
return localStorage.getItem('accessToken');
};
// 로컬 스토리지에 Access Token 저장하기
const saveAccessTokenToLocalStorage = (token) => {
localStorage.setItem('accessToken', token);
};
// 로그인 페이지로 리디렉션
const redirectToLoginPage = () => {
window.location.href = '/login';
};
// Axios 인스턴스 생성
const apiClient = axios.create({
baseURL: 'https://your-api.com', // 실제 API URL로 변경하세요.
});
// 요청 인터셉터: 요청 시 Access Token을 Authorization 헤더에 추가
apiClient.interceptors.request.use(
(config) => {
const accessToken = getAccessTokenFromLocalStorage();
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 응답 인터셉터: 401 에러 발생 시 토큰 갱신 처리
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// 토큰 갱신 시도가 아직 없는 경우에만 처리
if (error.response && error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// 토큰 갱신 함수 호출
await refreshAccessToken();
// 새로운 토큰으로 Authorization 헤더 업데이트
originalRequest.headers.Authorization = `Bearer ${getAccessTokenFromLocalStorage()}`;
// 원래의 요청 재시도
return apiClient(originalRequest);
} catch (refreshError) {
// 토큰 갱신 실패 시 로그인 페이지로 리디렉션
redirectToLoginPage();
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
// Access Token 갱신 함수
const refreshAccessToken = async () => {
try {
// Refresh Token은 httpOnly 쿠키에 저장되어 있으므로 자동으로 전송됩니다.
const response = await axios.post('https://your-api.com/auth/refresh-token', null, {
withCredentials: true, // 쿠키 전송을 위해 필요합니다.
});
const newAccessToken = response.data.accessToken;
// 새로운 Access Token 저장
saveAccessTokenToLocalStorage(newAccessToken);
// Axios 인스턴스의 기본 헤더 업데이트
apiClient.defaults.headers.common['Authorization'] = `Bearer ${newAccessToken}`;
} catch (error) {
// 에러 발생 시 토큰 제거 및 예외 전달
localStorage.removeItem('accessToken');
throw error;
}
};
// 예시: 로그인 함수
export const login = async (credentials) => {
try {
const response = await axios.post('https://your-api.com/auth/login', credentials, {
withCredentials: true, // Refresh Token을 쿠키로 받기 위해 필요합니다.
});
const accessToken = response.data.accessToken;
// Access Token 저장
saveAccessTokenToLocalStorage(accessToken);
// Axios 인스턴스의 기본 헤더 설정
apiClient.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
return response.data;
} catch (error) {
throw error;
}
};
// 예시: 로그아웃 함수
export const logout = () => {
// 서버에 로그아웃 요청을 보낼 수 있습니다.
localStorage.removeItem('accessToken');
redirectToLoginPage();
};
export default apiClient;
길고 복잡한 Access Token과 Refresh Token에 대해서 알아보았다. 그렇다면 다른 회사 들은 어떤 식으로 인증/인가를 구현하는 것일까? 예제를 통해서 알아보자.
Netflix Authorization
넷플릭스의 기술 블로그에서 어떻게 넷플릭스의 인증과 인가가 구축되어 있는지 확인할 수 있다. 넷플릭스를 사용해 본 적이 있다면 최대 다섯 명까지 공유해서 쓸 수 있는 기능이 있는 것을 알 것이다. 그러한 기능을 구현하기 위해서는 꽤나 복잡한 로직이 들어가야 할 것이다. 우리는 그중에 Refresh Token과 같은 기능을 어떻게 구현했는지에 중점을 맞춰 확인해 보자.
- [Netflix] dge-authentication-and-token-agnostic-identity-propagation : https://netflixtechblog.com/edge-authentication-and-token-agnostic-identity-propagation-514e47e0b602
Edge Authentication and Token-Agnostic Identity Propagation
How Netflix implemented a secure, token-agnostic identity solution that works with services operating at massive scale.
netflixtechblog.com
위 블로그에 따르면 EAS(Edge Application Service)라고 부르는 부분에서 모든 인증 로직이 구현된다. 다양한 토큰 로직을 처리하는 모든 기능들이 집합되어 있는 서비스이고, Edge인 이유는 토큰의 수신과 발신은 전부 이 Edge 안에서 이루어지기 때문에 위와 같이 이야기가 되었다.
위 flowchart의 왼쪽 하단에 있는 부분이 EAS이다. 자세히 살펴보면 아래와 같다는 점을 알 수 있다.
일반적으로 대부분의 Valid 하고 유효한 토큰(95%)은 그대로 인증을 통과한다. 하지만 5%의 경우에는 갱신이나 키 교체를 통해서 다른 써드파티 서비스에 접근하게 된다. ( 여기서 ZUUL은 넷플릭스에서 자체 개발한 API 클라우드 서버라고 한다. ) 넷플릭스에서는 자체적으로 passport라는 인터페이스를 만들어 인증/인가에 일관적으로 사용하고 있다고 한다.
추가적으로, 세션 방식을 통해서 서버에서 클라이언트의 정보를 들고 있는 경우에는 클라이언트 사이드에서 로그아웃을 하지 않아도, 서버 사이드에서 로그아웃을 시킬 수 있다. 서버쪽 캐시에 저장된 refresh token을 삭제하거나, 클라이언트에서 보내는 Access Token에 대한 정보를 블랙리스트에 올려 서버 쪽에서 로그아웃을 시킬 수도 있다.
우아한형제들 기술블로그
- [우아한테크] aop를 이용한 auth2 캐시 적용하기 : https://techblog.woowahan.com/2617/
aop를 이용한 oauth2 캐시 적용하기 | 우아한형제들 기술블로그
서론 저는 현재 미래사업부문에서 신규 서비스를 만들고 있으며, 신규 서비스는 기존 우아한 형제들에서 서비스중인 배달의민족과 디펜던시를 갖지 않는 독립적인 서비스다 보니 모든 것을 처
techblog.woowahan.com
국내 회사인 우아한형제들에서도 리프레시 토큰을 사용하여 인증/인가를 구현하는 것을 확인할 수 있다. 내용에서는 토큰을 발급/재발급하는 과정에서 쿼리가 여러번 나가는 문제를 해결하는 트러블슈팅에 대한 내용을 위주로 다루고 있지만, 전하고 싶은 포인트는 이러한 리프레시 토큰을 사용한 인증/인가 로직을 거의 대부분의 회사에서 이용하고 있다는 점이다.
마치며
예전에 공부했던 인증 인가 방식에 비해 조금 더 풍성한 내용을 작성할 수 있게 되었다는 점에서 스스로 그동안 많이 성장했다는 걸 느낄 수 있었다.
그리고 뭔가 표준이라고 이야기를 하니 드는 생각이, 앞으로 다양한 기술적 챌린지에 도전하는 엔지니어가 되기 위해서 이미 Well-Known 해결책이 있는 경우는 빠르게 해결하고, 알려지지 않은 어려운 기술적 챌린지에 도전하는 사람이 되기 위해서 표준과 같은 기본기를 열심히 공부해야겠다고 생각했다. 아직 훨씬 멀었구만..
참고
- [RFC] 6749 : https://www.rfc-editor.org/rfc/rfc6749
- [심재철] 토큰을 어디에 저장해야 안전할까 :https://simsimjae.tistory.com/482
- [Velog] juul이란? : https://velog.io/@jkijki12/Zuul%EC%9D%B4%EB%9E%80
- [Netflix] medium : https://netflixtechblog.com/edge-authentication-and-token-agnostic-identity-propagation-514e47e0b602
- [우아한테크] aop를 이용한 auth2 캐시 적용하기 : https://techblog.woowahan.com/2617/
감사합니다.
'Developer' 카테고리의 다른 글
git, github에 대해서 (0) | 2024.01.21 |
---|---|
vi 명령어 및 설정 (1) | 2023.12.11 |
인증과 인가에 대해서 (2) | 2023.10.02 |
Webhook에 대해서 (1) | 2023.10.01 |