현재 진행 중인 프로젝트는 다중 서버로 구성되어 있습니다.
다중 서버 환경에서는 어떠한 인증/인가
방식을 사용하는 것이 적절한지 알아보겠습니다.
Http
는 Stateless(비상태성)
라는 특징을 가지고 있습니다.
즉, 이전 요청과 다음 요청의 맥락이 이어지지 않는 것 입니다.
따라서 인증/인과
과정에서 상태를 가지고 있지 않기 때문에, 유저가 이전에 어떤 인증 과정을 거쳤는지 확인할 수 없습니다.
이러한 문제를 해결하기 위해 주로 세션
과 토큰 방식
을 사용합니다.
세션 인증
은 사용자의 인증 정보가 서버 세션 저장소에 저장되는 방식입니다.
서버의 메모리에 저장되거나 서버의 로컬파일 또는 데이터베이스에 저장합니다. 그리고 민감 정보를 서버에서 관리해 안전합니다.
Session Id
를 기준으로 저장한다.Session Id
를 저장한다.Session Id
를 쿠키에 담아 전송한다.Session Id
와 서버 메모리로 관리하고 있는 Session Id
를 비교해 인증을 수행한다.세션 데이터의 양이 많아진다면 서버의 부담이 증가하게 됩니다.
그리고 Session 인증 방식은 수평 확장에 제한적입니다.
별도의 작업을 해주지 않는다면 세션 불일치 문제가 발생합니다.
그래서 Sticky Session
, Session Clustering
, Session Storage
외부 분리 등의 작업을 해주어야 합니다.
만약 인프라 구성이 다중 서버에 로드 밸런싱이 적용되어 있다면 세션 데이터를 관리하는 것이 어려울 것입니다.
이러한 문제를 해결하기 위해 Sticky Session
과 Session Storage
통해 해결할 수 있습니다.
로드 밸런서가 세션 기간동안 동일한 클라이언트의 request를 항상 동일한 서버로 라우팅 해주는 방식입니다.
여러 서버들이 세션 데이터를 교환할 필요가 없고 정합성 이슈에서 벗어날 수 있습니다.
하지만 특정 서버에 과부하가 발생할 수 있고 트래픽이 균등하게 배분되지 않습니다.
서버에 세션 정보를 저장하는 것이 아닌 외부 DB 서버를 띄워 세션 정보를 외부 DB 서버에 저장하는 방식입니다.
서버들끼리 네트워크 요청을 할 필요가 없고 서버를 Stateless하게 유지할 수 있습니다.
한 서버에 문제가 발생해도 외부에 세션 정보가 저장되어 있어 안전하게 서비스를 제공할 수 있습니다.
세션 저장소가 하나이기 때문에 데이터 정합성 문제가 발생하지 않습니다.
하지만 세션 저장소에 장애가 발생하면 모든 세션이 이용 불가능하기 때문에 세션 저장소를 복제해 두어야 한다.
대표적으로 Redis
에서 제공하는 세션 스토리지 기능을 활용할 수 있습니다.
토큰 인증 방식
은 세션 인증 방식
과 다르게 인증 정보를 클라이언트가 직접 들고 있는 방식입니다.
인증 정보가 토큰의 형태로 브라우저의 로컬 스토리지 또는 쿠키에 저장됩니다.
인증 과정에서 사용자가 가지고 있는 토큰을 HTTP의 Authorization 헤더에 담아 보냅니다.
그리고 그 토큰을 가지고 서버는 위변조 되었거나, 만료 시각이 지났는지 검증한 이후 토큰에 담긴 사용자 인증 정보를 확인해 사용자를 인가합니다.
상태를 유지하지 않는 Stateless한 Http의 성질을 활용하며 수평 확장에 유용한 방식입니다.
유저의 수가 아무리 많아져도 서버의 부담이 증가하지 않는 점이 있습니다.
토큰 인증 방식은 토큰이 해커에게 탈취당할 경우, 해당 토큰이 만료되기 전까지 불이익을 받을 수 있습니다.
유저 수의 증가에 따른 서버 부담이 적고 확장에 유리한 방식이기 때문에 토큰 인증 방식을 적용했습니다.
또한, 토큰이 탈취되었을 경우는 토큰 만료기간을 짧게 설정하거나 토큰 블랙 리스트를 활용해 접근을 제한하는 방법으로 해결할 수 있습니다.
토큰 방식중 가장 널리 사용되고 있는 JWT 방식을 이용했습니다.
JWT
는 인증에 필요한 정보들을 암호화시킨 JSON 토큰
을 의미합니다.
JWT
인증 방식은 JWT 토큰(Access Token
)을 HTTP 헤더에 실어 서버가 클라이언트를 식별하는 방식입니다.
JWT
는 .
을 구분자로 나누어지는 세가지 문자열 조합이다.
.
을 기준으로 Header
, Payload
, Signature
를 의미합니다.
Header
alg
: 서명 암호화 알고리즘(ex: HMAC SHA256, RSA)
typ
: 토큰 유형
{
"alg": "HS256",
"typ": "JWT"
}
Payload
토큰에 담길 클레임들을 포함하며, 사용자 정보나 토큰의 만료 시간 등의 데이터를 JSON 형식으로 표현합니다.
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
Signature
Header
와 Payload
를 인코딩한 후, 비밀 키를 사용해 서명한 값입니다. 토큰의 무결성을 확인하는 데 사용됩니다.
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret)
Header
, Payload
, Signature
은 각각 Base64Url로 인코딩 된 후, 점으로 구분되어 최종 JWT 토큰을 형성합니다.
토큰 인증 방식
에는 토큰이 탈취되었을 때, 토큰이 만료되기 전까지 피해를 볼 수 밖에 없는 보안적 약점이 있습니다.
Access Token
(JWT)은 서버에 저장하는 개념이 아니기 때문에 토큰 자체를 무효화시킬 수 있는 방법이 없습니다.
그렇다고 Access Token
을 서버에 저장하게 되는 순간 Stateless한 장점을 살릴 수 없게 됩니다.
Access Token
의 만료 시간을 짧게 잡으면 안전하지 않을까?? 라는 생각도 해봤지만 사용자가 자주 로그인해야 하는 불편함이 생기게 됩니다.
이러한 문제를 해결하기 위해 Refresh Token
을 사용합니다. Refresh Token
은 Access Token
을 발급받기 위해 사용합니다. Refresh Token
을 도입하게 되면, Access Token
의 만료 기간을 짧게 설정하고 Refresh Token
의 만료 기간을 길게 설정하여 사용자가 로그인을 자주 하지 않아도 됩니다.
그리고 Access Token
의 만료 기간을 짧게 설정했기 때문에 토큰이 탈취당해도 큰 피해를 줄일 수 있습니다.
결국 Refresh Token
을 서버에 저장해야 하기 때문에 완전히 Stateless 하지 않게 됩니다.
Refresh Token
을 저장소에서 조회해야 하지만 개별 애플리케이션 서버가 아닌 중앙 집중식 저장소에 저장된다면 어느정도 Stateless한 특성을 유지할 수 있습니다.
보안을 고려한다면 완벽한 Stateless 특성은 어느정도 포기해야 합니다.
기존에는 Refresh Token
을 My SQL
에 저장했지만 Redis
의 여러 장점 때문에 저장 공간을 바꾸겠습니다.
Redis
는 인메모리 데이터 저장소로 데이터 접근 속도가 빠르기 때문에 Refresh Token
을 갱신할 때 높은 성능을 제공합니다.
그리고 Key에 TTL(Time to Live)
을 설정할 수 있어, 리프레시 토큰의 유효 기간을 자동으로 관리할 수 있습니다.
스케줄러 등을 이용하여 주기적으로 만료된 토큰을 제거하는 작업을 별도로 하지 않아도 됩니다.
단순한 데이터 구조로 Key-Value
저장 방식으로 간단하게 Refresh Token
을 저장하고 관리할 수 있습니다.
RedisConfig
@Configuration
public class RedisConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedisTemplate<?, ?> redisTemplate() {
RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new JdkSerializationRedisSerializer());
redisTemplate.setValueSerializer(new JdkSerializationRedisSerializer());
return redisTemplate;
}
}
yml
spring:
cache:
type: redis
redis:
host: 127.0.0.1
port: 6379
RefreshToken
@RedisHash(value = "refreshToken", timeToLive = {리프레시 토큰 만료 기간})
public class RefreshToken {
@Id
private String token;
@Indexed
private String memberId;
public RefreshToken(final String memberId,final String token) {
this.memberId = memberId;
this.token = token;
}
...
}
@RedisHash
애노테이션을 사용하여 value
를 설정하고 timeToLive
설정도 가능합니다.
timeToLive
에는 Refresh Token
만료 기간을 설정하면 됩니다.
RefreshTokenRedisRepository
public interface RefreshTokenRedisRepository extends CrudRepository<RefreshToken, String> {
List<RefreshToken> findByMemberId(final String memberId);
}
CrudRepository
에서 제공하는 메서드를 이용하면 Spring Data JPA
처럼 사용이 가능합니다.
필요한 메서드가 필요하면 RefreshTokenRedisRepository
에 추가합니다.
결과
로그인을 하고나서 레디스에서 Key
를 조회했을 때, Refresh Token
이 정상적으로 저장된 것을 확인할 수 있습니다.