Jazz Meet 프로젝트를 진행하면서 관리자 계정을 어떻게 구현해야 할지 고민한 내용입니다.

⭐ 첫 번째 고민: Cookie & Session vs. JWT

🛠️ Cookie & Session 기반 인증

Cookie와 Session 기반 인증 로직은 다음과 같습니다.

  1. 사용자 로그인 요청
  2. 서버에서 인증 처리
    1) 받은 아이디와 비밀번호 검증
    2) 인증이 성공하면, 서버는 이 사용자에 대한 세션 생성
  3. 서버는 생성된 세션 ID를 사용자의 웹 브라우저에 쿠키 형태로 전송
  4. 브라우저가 서버에 요청을 보낼 때마다 세션 ID를 쿠키로 같이 전송
  5. 사용자가 로그아웃을 요청하면, 서버는 해당 사용자의 세션을 종료(삭제)하고, 사용자의 브라우저에 저장된 쿠키(세션ID)를 무효화
    1) 이후 사용자가 다시 인증이 필요한 페이지에 접근하려고 하면, 서버는 유효한 세션 ID가 쿠키에 없기 때문에 사용자를 비인증 상태로 판단

Sesstion & Cookie 기반 인증의 장단점

장점
서버가 사용자의 로그인 상태를 쉽게 관리할 수 있고, 사용자는 여러 페이지를 이동하면서도 로그인 상태를 유지할 수 있습니다.

단점

  1. 쿠키를 사용하기 때문에 보안상 주의가 필요
  2. 서버에서 세션 저장소를 사용하므로 요청이 많아질 경우 서버에 부하가 심해짐
    세션 데이터를 저장할 때 기본적으로 서버의 메모리에 저장된다. 각 사용자별로 고유한 세션을 생성하고 유지해야 하기 때문에 사용자의 수가 많아질 수록 서버 메모리 사용량도 증가한다.

보완

  1. HTTPS를 통해 암호화되어 전송해야 하며, 쿠키에 저장된 정보는 민감한 정보를 직접 포함하지 않아야 합니다. 세션 하이재킹 공격에 대비하기 위해 쿠키에 플래그를 설정 하여 보호 조취를 취하는 것이 좋습니다.
    • 세션 하이재킹: 공격자가 사용자의 세션 토큰이나 세션 쿠키를 가로채 그 사용자로서 서버에 접근하는 보안 공격
    • 방어 플래그: Secure 플래그, HttpOnly 플래그, SameSite 플래그
  2. 서버 부하 방지
    • 세션 스토리지 외부화
      세션 데이터를 서버 메모리 대신 외부 데이터 스토리지 시스템(Redis 등)에 저장하여, 서버의 메모리 부담을 줄이고, 서버 간에 세션 정보를 공유할 수 있는 기능을 제공
    • 스테이트리스 설계 고려
      JWT와 같은 토큰 기반 인증 방식으로 서버 측에서 사용자 상태를 저장할 필요가 없어 서버 부하를 줄일 수 있음

🛠️ JWT 기반 인증

JSON Web Token, 인증에 필요한 정보들을 암호화 시킨 토큰을 의미합니다. JWT 토큰을 HTTP 헤더에 실어 서버가 클라이언트를 식별합니다.

JWT의 구조
{Header} . {Payload} . {Signature}

  • Header : 토큰의 유형(typ, 보통 JWT)과 해싱 알고리즘(alg, 예: HMAC SHA256 또는 RSA)이 포함된 JSON 객체, 헤더는 Base64Url 방식으로 인코딩
  • Payload : 토큰에 담을 클레임(claim)이 포함된 JSON 객체, Base64Url 방식으로 인코딩
  • Signature : 헤더의 인코딩된 값, 페이로드의 인코딩된 값, 비밀 키를 합친 후 헤더에 명시된 알고리즘을 사용하여 생성, 메시지가 중간에 변경되지 않았음을 검증하는 데 사용

JWT 기반 인증 로직은 다음과 같습니다.

  1. 클라이언트 로그인 요청이 들어오면, 서버는 검증 후 클라이언트 고유ID 등의 정보를 Payload 에 저장
  2. 암호화할 비밀키를 사용해 토큰 발급
  3. 클라이언트는 전달받은 토큰을 저장해두고, 서버에 요청할 때마다 토큰을 요청 헤더 Authorization 에 포함시켜 함께 전달
  4. 서버는 토큰의 Signature을 비밀키로 복호화한 다음, 위변조 여부 및 유효 기간 등을 확인
  5. 유효한 토큰이라면 요청에 응답

JWT 기반 인증의 장단점

장점

  • Header와 Payload를 가지고 Signature를 생성하므로 데이터 위변조를 막을 수 있습니다.
  • 인증 정보에 대한 별도의 저장소가 필요 없습니다.
  • 확장성이 우수합니다.
    상태 비저장
    분산 시스템이나 MSA에서 서비스 간 인증 용이

단점

  • JWT는 토큰의 길이가 길어, 인증 요청이 많아질 수록 네트워크 부하가 심해집니다.
  • Payload 자체는 암호화되지 않기 때문에 유저의 중요한 정보는 담을 수 없습니다.
  • 토큰을 탈취당하면 대처하기 어렵습니다.
    유효기간이 만료될 때까지 계속 사용이 가능하기 때문

보완

  1. 짧은 만료 기한 설정
    토큰이 탈취되더라도 빠르게 만료되기 때문에 피해를 최소화할 수 있습니다.
    → 그러나 사용자가 자주 로그인해야 하는 불편함

  2. Refresh Token
    1) 클라이언트가 로그인 요청을 보내면 서버는 Access Token 및 그보다 긴 만료 시간을 가진 Refresh Token을 발급
    2) 클라이언트는 Access Token이 만료되었을 때 Refresh Token을 사용하여 Access Token의 재발급을 요청
    3) 서버는 DB에 저장된 Refresh Token과 비교하여 유효한 경우 새로운 Access Token을 발급하고, 만료된 경우 사용자에게 로그인을 요구

→ Access Token의 만료 기한을 짧게 설정할 수 있으며, 사용자가 자주 로그인할 필요가 없습니다.
→ 검증을 위해 서버는 Refresh Token을 별도의 storage에 저장해야 합니다. 추가적인 I/O 작업이 일어나기 때문에 JWT의 장점을 완벽하게 누릴 수 없습니다. 클라이언트도 탈취 방지를 통해 Refresh Token을 보안이 유지되는 공간에 저장해야 합니다.


🛠️ 선택

세션보다 확장성이 우수한 JWT 방식을 도입하고, 토큰을 Access Token과 Refresh Token으로 나눠서 보안을 강화하기로 결정했습니다.


⭐ 두 번째 고민: Refresh Token을 서버에서 어떻게 관리해야 하는가?

처음에는 MySQL의 유저 테이블에 그대로 저장했으나, Refresh Token이 만료되었는지 주기적으로 확인해야 하는 로직(ex. 스케줄러 사용)이 별도로 필요했습니다. Access Token의 만료 시간이 짧은 만큼 Refresh Token의 db 접근이 많아지기 때문에 MySQL보다 더 빠르고 값에 만료 시간을 줄 수 있는 Redis를 사용하는 것이 좋을 것이라 판단했습니다.

따라서 Refresh Token의 저장소를 MySQL에서 Redis로 변경했습니다.


⭐ 세 번째 고민: Refresh Token 은 브라우저에서 어떻게 관리해야 하는가?

Refresh Token을 탈취 당할 경우 Access Token을 발급하여 사용자인 척 서비스에 접근을 할 수 있기 때문에 접근하지 못하게 쿠키에 다음 플래그를 적용하였습니다.

  • secure(true) : HTTPS 환경에서만 사용
  • httpOnly(true) : 자바스크립트로 접근 불가능
  • sameSite(lax) : 쿠키가 동일한 사이트의 요청 또는 일부 안전한 크로스 사이트에서 쿠키를 받을 수 있음
    특히 완전히 다른 도메인으로 등록해두었던 프론트 어드민을 백엔드 어드민의 하위로 변경하여 same site 정책에서 동일한 도메인으로 인식되도록 만들었습니다.

⭐ 네번째 고민: Redis를 사용하여 로그아웃한 유저를 매번 식별한다면 세션 저장소로 Redis를 사용하는 것과 동일하지 않나?

기존 로직은 다음과 같습니다.

  1. 로그아웃 시 redis 에 access token을 저장(블랙리스트)하고 다른 요청이 들어올 경우 이 redis에 저장된 토큰인지 확인
    만약 redis에 저장된 토큰이라면 예외처리

그런데 서버 부하를 줄이기 위해 JWT를 사용했는데, JWT의 단점인 탈취 시의 취약점을 상쇄하고자 Redis를 사용한다면, 기존 세션과 다를 바가 있는가? 라는 의문이 들었습니다.

이렇게 블랙리스트를 도입한다면 가볍다는 JWT의 장점도 제대로 활용 못하고 세션을 사용하는 것보다 보안이 떨어지는 것 같다고 생각했습니다.

따라서 블랙리스트를 삭제하기로 결정했습니다. Access Token은 1시간의 짧은 유효기간을 가지고 있고 이를 재발급하기 위한 Refresh Token은 httpOnly 쿠키로 보호되어 있기 때문에, Access Token을 자주 재발급해도 Refresh Token을 탈취당할 위험도 적으면서 Access Token을 탈취당해도 시간상 한계가 있어 공격 당할 위험이 적다고 판단했기 때문입니다.


⭐ 느낀 점

서버 안전성과 사용자 경험은 Trade-Off 관계를 가집니다. 개발/운영 중인 서비스의 주어진 환경에 맞춰 개발자의 적절한 선택이 필요함을 깨달았습니다. 최근 많은 기업이 MSA 방식으로 서비스를 만들기 때문에 확장성이 좋은 JWT 방식을 도입했지만 정작 저희 서비스는 단일 서버를 사용하기 때문에 JWT의 장점을 온전히 느끼지 못한 것 같습니다. 이후 프로젝트에서는 두 개 이상의 서버를 사용해봐야 겠다는 생각이 들었습니다.


참고

인증 방식 : Cookie & Session vs JWT
Redis를 이용한 토큰 탈취 대응 시나리오(feat. Refresh Token Rotation)
Access Token의 문제점과 Refresh Token
Refresh Token Rotation 과 Redis로 토큰 탈취 시나리오 대응

'project' 카테고리의 다른 글

[JazzMeet] 도메인 간 쿠키 공유 되지 않는 문제 해결  (0) 2024.05.12

❓문제

Refresh Token을 http only 쿠키로 사용하려고 다음과 같이 서버 코드를 작성해서 배포했습니다.

/**
 * 관리자 로그인 API
 */
@PostMapping("/api/admins/login")
public ResponseEntity<LoginAdminResponse> login(@RequestBody @Valid LoginAdminRequest loginAdminRequest) {
    Jwt jwt = adminService.login(loginAdminRequest);

    return ResponseEntity.ok()
        .header(HttpHeaders.SET_COOKIE, getRefreshToken(jwt).toString())
        .body(AdminMapper.INSTANCE.toLoginAdminResponse(jwt));
}

private ResponseCookie getRefreshToken(Jwt jwt) {
    return ResponseCookie.from("refreshToken", jwt.getRefreshToken())
        .maxAge(jwtProperties.getRefreshTokenExpiration())
        .path("/")
        .secure(true)
        .httpOnly(true)
        .build();
}

프론트 어드민 'jazzmeet-admin.site'에서 서버 'jazzmeet.site'로 로그인 API 요청을 보낼 때 HTTP Response 에는 쿠키가 포함돼서 나가지만 브라우저의 쿠키에는 저장되지 않는 문제가 발생했습니다.

 

⁉️ 해결 과정

1. 프론트의 요청 코드에 설정 추가

프론트의 로그인 요청 코드에 credentials:include 옵션을 추가했습니다. 이때 CORS 문제가 발생해서 백엔드에서도 cors 설정에 프론트 어드민을 allowedOrigins로 설정하고 allowCredentials(true) 옵션을 추가했습니다.

@Override
public void addCorsMappings(CorsRegistry registry) {
  registry.addMapping("/api/**")
      .allowedOrigins("https://www.jazzmeet-admin.site", "https://jazzmeet.site")
      .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
      .allowCredentials(true)
      .allowedHeaders("*");
}
  • addMapping : /api로 시작하는 모든 URL에 대해 CORS 정책이 적용됩니다.
  • allowedOrigins : 지정된 출처(origin)의 요청만 서버가 수락하도록 허용합니다.
    • 여기에 구체적인 출처를 명시하면 스프링은 Access-Control-Allow-Origin 헤더를 자동으로 만들어줍니다. 브라우저의 보안 정책에 따라, allowCredentials(true) 설정을 사용하면 Access-Control-Allow-Origin 헤더에 구체적인 출처(와일드카드 * X)를 요구합니다.
  • allowedMethods : 허용할 HTTP 메서드를 지정합니다.
    • OPTIONS: 서버가 지원하는 메서드를 조회할 때 사용되며, CORS preflight 요청에 대응하기 위해 필요합니다.
  • allowCredentials : 크로스 오리진 요청 시 쿠키와 같은 인증 정보를 포함할 수 있도록 허용합니다.
  • allowedHeaders : 설정한 HTTP 헤더를 요청에서 허용하겠다는 것을 의미합니다.

CORS 문제는 해결됐지만 여전히 쿠키가 저장되지 않았습니다.

 

2. 쿠키 생성 시 옵션 추가

쿠키 생성 시 sameSite("None"), secure(true) 옵션을 추가해주었습니다.
크롬의 20년도 업데이트에서 same site 속성의 기본 값이 None에서 Lax로 변경되었을 알 수 있었습니다. 이 속성은 웹 사이트 간 요청 위조(CSRF, Cross-Site Request Forgery) 공격을 방지하는 데 도움을 줍니다. 이는 다른 도메인 간 요청에서 사용자의 데이터 보호를 강화합니다.

same site 속성에는 세 가지 설정이 있습니다.

  • Strict: 쿠키는 오직 같은 사이트에서 발생한 요청에만 전송됩니다. 이는 가장 제한적인 옵션입니다.
  • Lax: 더 유연하며, 사용자가 다른 사이트에서 링크를 클릭해 사이트에 접근했을 때 쿠키를 전송할 수 있습니다.
  • None: 모든 크로스 사이트 요청에 쿠키를 전송하도록 합니다. 이 옵션을 사용하려면 반드시 쿠키를 Secure로도 설정해야 합니다, 즉 HTTPS를 통해서만 쿠키가 전송됩니다.

프론트 어드민 jazzmeet-admin.site 와 서버 어드민 jazzmeet.site는 아무런 연관이 없는 cross domain 이기 때문에 크로스 사이트 요청에도 쿠키를 요청할 수 있는 sameSite("None") 과 secure(true) 옵션을 추가해주었습니다.

그럼에도 여전히 브라우저의 쿠키에 저장이 되지 않고 있었습니다.

사실 여기서부터 조금 멘탈이 흔들리기 시작했습니다. 무슨 문제인지 알 수가 없어 몇 번 시도를 했지만 결국 문제는 2번과 5번의 시도로 해결할 수 있었습니다. 3, 4번은 이 문제 해결에 직접적인 영향을 주지 않았지만 일단 기록으로 남겼습니다.

 

3. 쿠키 생성 시 프론트 도메인을 명시

기본으로 옵션이 들어가는 도메인이 프론트 도메인과 달라 쿠키가 저장이 안 되는가 싶어서 쿠키 생성 시 domain("jazzmeet-admin.site") 옵션을 추가해주었습니다.

private ResponseCookie getRefreshToken(Jwt jwt) {
    return ResponseCookie.from("refreshToken", jwt.getRefreshToken())
        .maxAge(jwtProperties.getRefreshTokenExpiration())
        .path("/")
        .secure(true)
        .httpOnly(true)
        .domain("jazzmeet-admin.site")
        .build();
}

그러나 여전히 프론트 어드민 브라우저에는 쿠키가 저장되지 않았습니다.

 

4. 프론트 어드민을 서버의 서브 도메인으로 변경

서버 도메인과 프론트 어드민 도메인이 완전히 다른 cross domain이라 쿠키가 저장되는 않는 것일 수도 있겠다고 생각하여 프론트 어드민 도메인을 서버의 서브 도메인 admin.jazzmeet.site로 변경했습니다.

이때 우리의 프론트 어드민은 Amplify에 배포되어 있었기 때문에 Amplify의 도메인 관리에서 jazzmeet.site를 루트 도메인으로 설정하고, 서브 도메인으로 admin.jazzmeet.site를 추가한 후 www로 redirect 되는 옵션을 껐습니다. Amlify에서 발급된 키를 가비아의 jazzmeet.site DNS 관리에서 추가해주었고, admin.jazzmeet.site에 배포된 프론트 어드민 주소를 저장해주었습니다.

더해서 jazzmeet.site를 포함한 서브 도메인들에서도 쿠키를 공유할 수 있도록 하기 위해 domain(".jazzmeet.site") 으로 옵션을 수정해주었습니다.

private ResponseCookie getRefreshToken(Jwt jwt) {
  return ResponseCookie.from("refreshToken", jwt.getRefreshToken())
    .maxAge(jwtProperties.getRefreshTokenExpiration())
    .path("/")
    .secure(true)
    .httpOnly(true)
    .domain(".jazzmeet.site")
    .build();
}

여전히 쿠키는 브라우저에 저장되지 않았습니다.

 

5. 프론트의 로그인 요청에 옵션 추가

cross domain 상태라도 sameSite("None")과 secure(true) 옵션을 설정해주었다면 프론트 어드민을 서버의 서브 도메인으로 변경하지 않아도 정상적으로 쿠키가 공유되어야 했습니다. 그럼에도 계속해서 쿠키 저장이 되지 않아 프론트 코드를 살펴보았습니다.

여기서, 토큰 재발급 로직에만 credentials:'include' 옵션이 설정되어 있었고 로그인 요청 로직에는 설정되지 않았습니다. 이 옵션이 프론트에서 서버로 쿠키를 보낼 때에만 사용되는 줄 알았는데 서버에서 보낸 쿠키를 브라우저에 저장하기 위해서도 필요한 옵션이었습니다.

따라서 프론트 로그인 요청에 credentials:'include' 옵션을 추가해주어 성공적으로 쿠키를 공유할 수 있게 되었습니다.

 

❗정리

  1. cross domain 이라면 sameSite("None")secure(true) 옵션을 주고 프론트에서 쿠키를 보내거나 받는 요청에 'credentials:'include'`옵션을 설정하면 쿠키 공유가 가능합니다.
  2. 같은 도메인이라면 same site는 기본 설정(Lax)으로 두고 프론트 코드에 'credentials:'indluce'` 옵션을 추가하면 쿠키 공유가 가능합니다.

 

⭐ 배운 점

브라우저의 업데이트는 관심을 가지고 꾸준히 살펴봐야 제때 대응할 수 있다는 점을 깨달았습니다. 또한 문제를 마주했을 때 담당한 분야 뿐만 아니라 다른 분야도 살펴봐야 문제를 빨리 해결할 수 있다는 것을 배웠습니다.

 

📜 참고

새로운 SameSite=None; Secure 쿠키 설정에 대비

'project' 카테고리의 다른 글

[JazzMeet]Cookie & Session vs. JWT  (0) 2024.05.16

+ Recent posts