[SPOT] JWT 기반 인증 및 인가 도입

김민석·2024년 9월 5일
1

SPOT

목록 보기
5/11
post-thumbnail

인증 및 인가의 필요성

개발 과정에서 여러 기능을 구현하고 테스트하다 보니, 인증인가의 중요성을 자연스럽게 느끼게 되었다.

예를 들어, 내가 운영하는 스터디 정보를 타인이 마음대로 수정할 수 있다면 큰 문제가 발생할 것이다. 또한, 다른 사용자가 내 프로필을 이용해 문제될 만한 게시글을 작성한다면 이는 사용자 입장에서 매우 불쾌한 일이 될 것이다. 이러한 불편을 방지하기 위해서는 서버 측에서 인증인가 기능이 반드시 필요하다.

  • 인증은 사용자가 누구인지 확인하는 과정이다. 아이디와 패스워드로 로그인을 하는 경우나, 요즘 자주 사용되는 카카오, 구글 등 소셜 로그인을 통해 해당 사용자가 유효한 사용자인지 확인하는 절차다.

  • 인가는 인증이 완료된 사용자가 무엇을 할 수 있는지를 결정하는 과정이다. 즉, 인증된 사용자가 시스템 내에서 어떤 자원에 접근하거나, 어떤 작업을 수행할 권한이 있는지를 검증하는 절차다.

이 두 과정은 시스템 보안의 핵심 요소이며, SPOT에서도 보다 안전한 서비스를 위해 이를 구현하기로 했다.


JWT를 통한 인증 및 인가 도입

인증 및 인가를 구현하는 방법은 크게 세 가지로 나눌 수 있다:

  1. 쿠키 기반 인증 및 인가
  2. 세션 기반 인증 및 인가
  3. 토큰 기반 인증 및 인가

쿠키 기반 인증의 한계

쿠키 기반 방식은 인증 정보를 클라이언트에서 관리하기 때문에 보안에 취약하다. 예를 들어, 다른 사용자가 쿠키 값을 탈취할 경우, 해당 사용자가 아닌데도 불구하고 인증된 상태로 접근할 수 있다. 이러한 이유로 쿠키 기반 인증은 보안 측면에서 적합하지 않다고 판단했다.

세션 기반 인증의 한계

세션 기반 인증은 인증 정보를 서버에서 관리하므로 상대적으로 안전하다. 하지만 서비스가 확장되어 서버가 여러 대로 분산될 경우 문제가 발생할 수 있다. 예를 들어, 사용자가 서버 A에서 로그인한 후 서버 B로 요청이 전달되면, 인증이 유지되지 않아 새로 로그인해야 하는 상황이 발생할 수 있다. 이를 해결하기 위해서는 별도의 세션 저장소가 필요하지만, 당시 새로운 외부 저장소를 도입하기에는 부담이 있는 상황이었다.

JWT 기반 인증 및 인가 구현

결론적으로 SPOT은 JWT을 기반으로 한 인증 및 인가 구현을 선택했다.

JWT(Json Web Token)는 JSON 객체를 사용하여 정보를 전달하는 방식으로, 필요한 모든 정보를 토큰에 담아 전달할 수 있다는 장점이 있다. JWT는 클라이언트와 서버 간의 인증 및 인가에 사용되며, 한 번 발급된 토큰만으로도 다수의 서버에서 인증을 손쉽게 처리할 수 있다. 이는 여러 서버가 동시에 운영되는 환경에서 각 서버가 동일한 Secret Key를 사용해 JWT를 검증할 수 있기 때문에 가능하다.

JWT는 서명되어 전송되며, 이를 통해 토큰의 무결성이 보장된다. 즉, 서버는 해당 서명을 검증하여 JWT가 변조되지 않았는지 확인할 수 있다. 개인키로 서명된 JWT는 해당 키 없이 복호화할 수 없다는 특징이 있으며, 이로 인해 보안성 또한 뛰어나다.

하지만 JWT 역시 쿠키 기반 인증처럼 토큰 탈취에 취약할 수 있다. 만약 JWT를 탈취한다면, 그 토큰을 이용해 해당 사용자의 권한을 임의로 행사할 수 있기 때문에 보안 위험이 존재한다. 이 문제를 해결하기 위해선 JWT의 유효 기간을 적절히 설정하는 것이 중요하다.

AccessToken과 RefreshToken

JWT 기반 인증 시스템에서는 AccessTokenRefreshToken을 함께 사용해 보안성을 강화한다.

  • AccessToken: 클라이언트가 서버에 인증된 사용자임을 증명하는 데 사용되며, 이 토큰은 보통 짧은 유효 기간을 가진다.
  • RefreshToken: AccessToken의 유효 기간이 만료된 후 새 AccessToken을 발급받기 위해 사용된다. RefreshToken은 유효 기간이 비교적 길지만, 탈취에 대한 위험이 크기 때문에 관리가 중요하다.

만약 AccessToken이 만료되면, 사용자는 RefreshToken을 사용해 새로운 AccessToken을 발급받을 수 있다. 이 과정을 통해 사용자는 불필요하게 자주 로그인을 반복할 필요가 없지만, 탈취된 RefreshToken의 악용 가능성에 대비한 추가적인 안전장치가 필요하다고 생각 했다.

Refresh Token Rotation

RefreshToken의 탈취 위험을 줄이기 위해 Refresh Token Rotation이라는 방법을 적용 했다. 이 방식은 새로운 AccessToken을 발급받을 때마다 새로운 RefreshToken을 함께 발급하고, 이전에 발급된 RefreshToken은 무효화시킨다. 이를 통해 공격자가 RefreshToken을 탈취하더라도, 새로운 토큰 발급이 이뤄진 이후에는 더 이상 사용할 수 없게 되어 보안성을 강화한다.


적용

SPOT은 사용자의 로그인 및 회원 가입이 성공적으로 진행 된 경우, JWT 기반의 AccessToken과 RefreshToken를 클라이언트에게 응답한다. AccessToken은 실질적으로 인증을 진행하는 토큰이고, RefreshToken은 AccessToken 만료 시, 재발급을 받기 위해 사용되는 토큰이다.

다음은 성공적으로 회원 가입 및 로그인을 완료 한 경우 받게되는 응답이다.

{
  "isSuccess": true,
  "code": "MEMBER2001",
  "message": "회원 가입 및 로그인 완료",
  "result": {
    "tokens": {
      "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJtZW1iZXJJZCI6MSwidG9rZW5UeXBlIjoiYWNjZXNzIiwiaWF0IjoxNzI1NTExODYzLCJleHAiOjE3MjU1MTE4NjN9.fsNMFjSugM403h-MRMQxe695kH0bTsxykgw_Tkma1w4",
      "refreshToken": "eyJhbGciOiJIUzI1NiJ9.eyJtZW1iZXJJZCI6MSwidG9rZW5UeXBlIjoicmVmcmVzaCIsImlhdCI6MTcyNTUxMTg2MywiZXhwIjoxNzI1NTk4MjYzfQ.MSDR8iFiLmrV8mV611jgJJHMUhzHKbzMa8riXHGh2Qo",
      "accessTokenExpiresIn": 3600000
    },
    "email": "email",
    "memberId": 1
  }
}

클라이언트는 발급 받은 AccessToken을 헤더에 포함해서 API를 호출해야한다.

만약, 토큰을 아예 포함하지 않는다면, 다음과 같은 응답을 받게 된다.

혹은 만료되거나 유효하지 않은 토큰을 입력하게 된다면 다음과 같은 응답을 받게 된다.

AccessToken이 만료된 경우, AccessToken 재발급 API를 호출해야 한다.

AccessToken 재발급 로직은 다음과 같다.

  1. RefreshToken을 포함하여 AccessToken 재발급 API 호출 →
  2. RefreshToken 유효성 검증 후 사용자 정보 추출 →
  3. 위 정보를 통해 해당 사용자의 RefreshToken을 DB에서 조회 →
  4. 두 RefreshToken의 일치 여부 검증 후 Token 재발급

결과

위의 설명을 바탕으로 인증 및 인가 로직을 구현 했다. API 호출 시 사용자의 인증 여부를 확인하고, 사용자가 권한을 가진 작업만 수행할 수 있도록 다음과 같은 코드를 작성 했다.

public class SecurityUtils {

    // 현재 인증된 사용자의 ID를 반환
    public static Long getCurrentUserId() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null || !authentication.isAuthenticated()) {
            throw new GeneralException(ErrorStatus._UNAUTHORIZED);
        }
        return Long.valueOf(authentication.getName());
    }

    // 현재 인증된 사용자의 ID와 매개변수로 전달된 ID가 일치하는지 확인
    public static void verifyUserId(Long memberId) {
        // 관리자면, 모든 API 접근 가능
        if (isAdmin())
            return;

        Long currentUserId = getCurrentUserId();

        if (!Objects.equals(currentUserId, memberId))
            throw new GeneralException(ErrorStatus._MEMBER_NO_ACCESS);
    }

    // 현재 인증된 사용자의 역할이 ADMIN인지 확인
    public static boolean isAdmin() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        return authentication.getAuthorities().stream()
            .anyMatch(grantedAuthority -> grantedAuthority.getAuthority().equals("ROLE_ADMIN"));
    }

}

이 코드를 통해 현재 사용자가 요청한 자원에 접근할 권한이 있는지 확인하고, 필요한 경우 접근을 제한할 수 있었다.


이전에는 매번 API 호출 시, 사용자의 정보를 PathVariable이나 RequestBody로 받아와 검증을 진행했어야 했지만, 해당 기능 도입 후 사용자 인증 및 인가 과정이 훨씬 가독성도 좋아지고, 재사용도 수월해졌다.

profile
경험하며 성장하는 개발자 지망생

0개의 댓글