다중 서버 환경을 고려한 인증/인가

JeongMin·2024년 7월 2일
0
post-thumbnail

현재 진행 중인 프로젝트는 다중 서버로 구성되어 있습니다.

다중 서버 환경에서는 어떠한 인증/인가 방식을 사용하는 것이 적절한지 알아보겠습니다.

Http Stateless(비상태성)

HttpStateless(비상태성)라는 특징을 가지고 있습니다.

즉, 이전 요청과 다음 요청의 맥락이 이어지지 않는 것 입니다.

따라서 인증/인과 과정에서 상태를 가지고 있지 않기 때문에, 유저가 이전에 어떤 인증 과정을 거쳤는지 확인할 수 없습니다.

이러한 문제를 해결하기 위해 주로 세션토큰 방식을 사용합니다.


Session 인증

세션 인증은 사용자의 인증 정보가 서버 세션 저장소에 저장되는 방식입니다.

서버의 메모리에 저장되거나 서버의 로컬파일 또는 데이터베이스에 저장합니다. 그리고 민감 정보를 서버에서 관리해 안전합니다.

  1. 유저가 로그인하면 세션이 서버 메모리 또는 데이터베이스에 저장된다. 세션을 식별하기 위해 Session Id를 기준으로 저장한다.
  2. 서버에서 브라우저에 쿠키에 Session Id를 저장한다.
  3. 쿠키에 정보가 담겨있어 브라우저는 해당 사이트에 대한 모든 요청에 Session Id를 쿠키에 담아 전송한다.
  4. 서버는 클라이언트가 보낸 Session Id와 서버 메모리로 관리하고 있는 Session Id를 비교해 인증을 수행한다.

단점

세션 데이터의 양이 많아진다면 서버의 부담이 증가하게 됩니다.
그리고 Session 인증 방식은 수평 확장에 제한적입니다.

별도의 작업을 해주지 않는다면 세션 불일치 문제가 발생합니다.
그래서 Sticky Session, Session Clustering, Session Storage 외부 분리 등의 작업을 해주어야 합니다.


만약 인프라 구성이 다중 서버에 로드 밸런싱이 적용되어 있다면 세션 데이터를 관리하는 것이 어려울 것입니다.

이러한 문제를 해결하기 위해 Sticky SessionSession Storage 통해 해결할 수 있습니다.

Sticky Session

로드 밸런서가 세션 기간동안 동일한 클라이언트의 request를 항상 동일한 서버로 라우팅 해주는 방식입니다.

여러 서버들이 세션 데이터를 교환할 필요가 없고 정합성 이슈에서 벗어날 수 있습니다.

하지만 특정 서버에 과부하가 발생할 수 있고 트래픽이 균등하게 배분되지 않습니다.

Session Storage

서버에 세션 정보를 저장하는 것이 아닌 외부 DB 서버를 띄워 세션 정보를 외부 DB 서버에 저장하는 방식입니다.

서버들끼리 네트워크 요청을 할 필요가 없고 서버를 Stateless하게 유지할 수 있습니다.

한 서버에 문제가 발생해도 외부에 세션 정보가 저장되어 있어 안전하게 서비스를 제공할 수 있습니다.

세션 저장소가 하나이기 때문에 데이터 정합성 문제가 발생하지 않습니다.

하지만 세션 저장소에 장애가 발생하면 모든 세션이 이용 불가능하기 때문에 세션 저장소를 복제해 두어야 한다.

대표적으로 Redis에서 제공하는 세션 스토리지 기능을 활용할 수 있습니다.


Token 인증

토큰 인증 방식세션 인증 방식과 다르게 인증 정보를 클라이언트가 직접 들고 있는 방식입니다.

인증 정보가 토큰의 형태로 브라우저의 로컬 스토리지 또는 쿠키에 저장됩니다.

인증 과정에서 사용자가 가지고 있는 토큰을 HTTP의 Authorization 헤더에 담아 보냅니다.
그리고 그 토큰을 가지고 서버는 위변조 되었거나, 만료 시각이 지났는지 검증한 이후 토큰에 담긴 사용자 인증 정보를 확인해 사용자를 인가합니다.

상태를 유지하지 않는 Stateless한 Http의 성질을 활용하며 수평 확장에 유용한 방식입니다.

유저의 수가 아무리 많아져도 서버의 부담이 증가하지 않는 점이 있습니다.

단점

토큰 인증 방식은 토큰이 해커에게 탈취당할 경우, 해당 토큰이 만료되기 전까지 불이익을 받을 수 있습니다.

토큰 인증 방식 선택

유저 수의 증가에 따른 서버 부담이 적고 확장에 유리한 방식이기 때문에 토큰 인증 방식을 적용했습니다.

또한, 토큰이 탈취되었을 경우는 토큰 만료기간을 짧게 설정하거나 토큰 블랙 리스트를 활용해 접근을 제한하는 방법으로 해결할 수 있습니다.

토큰 방식중 가장 널리 사용되고 있는 JWT 방식을 이용했습니다.


JWT(JSON Web Token)

JWT는 인증에 필요한 정보들을 암호화시킨 JSON 토큰을 의미합니다.

JWT 인증 방식은 JWT 토큰(Access Token)을 HTTP 헤더에 실어 서버가 클라이언트를 식별하는 방식입니다.

JWT 구조

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
HeaderPayload를 인코딩한 후, 비밀 키를 사용해 서명한 값입니다. 토큰의 무결성을 확인하는 데 사용됩니다.

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret)

Header, Payload, Signature은 각각 Base64Url로 인코딩 된 후, 점으로 구분되어 최종 JWT 토큰을 형성합니다.

Access Token의 문제점을 보완하기 위한 Refresh Token

토큰 인증 방식에는 토큰이 탈취되었을 때, 토큰이 만료되기 전까지 피해를 볼 수 밖에 없는 보안적 약점이 있습니다.
Access Token(JWT)은 서버에 저장하는 개념이 아니기 때문에 토큰 자체를 무효화시킬 수 있는 방법이 없습니다.
그렇다고 Access Token을 서버에 저장하게 되는 순간 Stateless한 장점을 살릴 수 없게 됩니다.
Access Token의 만료 시간을 짧게 잡으면 안전하지 않을까?? 라는 생각도 해봤지만 사용자가 자주 로그인해야 하는 불편함이 생기게 됩니다.

이러한 문제를 해결하기 위해 Refresh Token을 사용합니다. Refresh TokenAccess Token을 발급받기 위해 사용합니다. Refresh Token을 도입하게 되면, Access Token의 만료 기간을 짧게 설정하고 Refresh Token의 만료 기간을 길게 설정하여 사용자가 로그인을 자주 하지 않아도 됩니다.
그리고 Access Token의 만료 기간을 짧게 설정했기 때문에 토큰이 탈취당해도 큰 피해를 줄일 수 있습니다.

완전히 Stateless 하지 않은 Refresh Token

결국 Refresh Token을 서버에 저장해야 하기 때문에 완전히 Stateless 하지 않게 됩니다.

Refresh Token을 저장소에서 조회해야 하지만 개별 애플리케이션 서버가 아닌 중앙 집중식 저장소에 저장된다면 어느정도 Stateless한 특성을 유지할 수 있습니다.

보안을 고려한다면 완벽한 Stateless 특성은 어느정도 포기해야 합니다.


Refresh Token을 Redis에 저장

기존에는 Refresh TokenMy SQL에 저장했지만 Redis의 여러 장점 때문에 저장 공간을 바꾸겠습니다.

Redis는 인메모리 데이터 저장소로 데이터 접근 속도가 빠르기 때문에 Refresh Token을 갱신할 때 높은 성능을 제공합니다.

그리고 Key에 TTL(Time to Live)을 설정할 수 있어, 리프레시 토큰의 유효 기간을 자동으로 관리할 수 있습니다.

스케줄러 등을 이용하여 주기적으로 만료된 토큰을 제거하는 작업을 별도로 하지 않아도 됩니다.

단순한 데이터 구조로 Key-Value 저장 방식으로 간단하게 Refresh Token을 저장하고 관리할 수 있습니다.

Redis Repository 적용

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이 정상적으로 저장된 것을 확인할 수 있습니다.

profile
📚개발 기록

0개의 댓글