JWT와 스프링시큐리티를 통한 인증

Seung jun Cha·2022년 7월 27일
0

1. JWT

1-1 개념

  • secret key를 이용하여 서명된 JSON 형태의 데이터다. 여기에 사용자 인증 정보를 넣어서 토큰을 발급해주면, 인증이 필요한 리소스에 접근 시 사용자가 서버에 토큰을 포함해서 http헤더에 실어서 전송하여 서버측에서는 복잡한 인증 과정 없이 토큰만으로 사용자를 인증 및 인가

  • JWT는 헤더, 페이로드, 서명 세 가지 정보를 base64로 인코딩한 값을 콤마('.')를 사이에 두고 이어붙인 형태로 생성된다.

  • 비대칭키 암호화 방식을 사용하기 때문에 서버측에서는 이 토큰을 받아서 시그니처를 복호화하여 디코딩하는 방식으로 토큰의 유효성을 검증할 수 있다.

  • Header, Payload는 인코딩될 뿐(16진수로 변경), 따로 암호화되지 않는다. 따라서 JWT 토큰에서 Header, Payload는 누구나 디코딩하여 확인할 수 있다. 여기서 누구나 디코딩할 수 있다는 말은 Payload에는 유저의 중요한 정보(비밀번호)가 들어가면 쉽게 노출될 수 있다는 말이다.
    하지만 Verify Signature는 SECRET KEY를 알지 못하면 복호화할 수 없다.

권한 부여 : 사용자가 로그인하면 각 후속 요청에 JWT가 포함되어 해당 토큰으로 서비스 및 리소스에 접근할 수 있다

정보 교환 : JWT는 정보를 안전하게 전송하는 좋은 방법. 예를 들어 공개/개인 키 쌍을 사용하여 JWT에 서명할 수 있기 때문에 발신자가 누구인지 확인할 수 있다. 또한 헤더와 페이로드를 사용하여 서명을 계산하므로 콘텐츠가 변조되지 않았는지 확인할 수도 있다.

1-2 인증 과정

  1. 사용자가 로그인을 한다.

  2. 서버에서는 계정정보를 읽어 사용자를 확인 후, 사용자의 고유한 ID값을 부여한 후, 기타 정보와 함께 Payload에 넣습니다.

  3. JWT 토큰의 유효기간을 설정합니다.

  4. 암호화할 SECRET KEY를 이용해 ACCESS TOKEN을 발급합니다.

  5. 사용자는 Access Token을 받아 저장한 후, 인증이 필요한 요청마다 토큰을 헤더에 실어 보냅니다.

  6. 서버에서는 해당 토큰의 Verify Signature를 SECRET KEY로 복호화한 후, 조작 여부, 유효기간을 확인합니다.

  7. 검증이 완료된다면, Payload를 디코딩하여 사용자의 ID에 맞는 데이터를 가져옵니다.

  • 세션/쿠키는 세션 저장소에 유저의 정보를 넣는 반면, JWT는 토큰 안에 유저의 정보들이 넣는다는 점이 다르다. 서버 측에서는 인증을 위해 암호화를 하냐, 별도의 저장소를 이용하냐는 차이가 발생한다.
    이는 Stateless 한 서버를 만드는 입장에서는 큰 강점이다. 여기서 Stateless는 어떠한 별도의 저장소도 사용하지 않는, 즉 상태를 저장하지 않는 것을 의미하며 이는 서버를 확장하거나 유지, 보수하는데 유리하다.

1-3 인증 타입

일반적으로 토큰은 요청 헤더의 Authorization 필드에 담아져 보내집니다.
Authorization: <type> <credentials>

서버는 다양한 종류의 토큰을 처리하기 위해 전송받은 type에 따라 토큰을 다르게 처리합니다.

  1. Basic
    사용자 아이디와 암호를 Base64로 인코딩한 값을 토큰으로 사용한다. (RFC 7617)

  2. Bearer
    JWT 혹은 OAuth에 대한 토큰을 사용한다. (RFC 6750)

  3. Digest
    서버에서 난수 데이터 문자열을 클라이언트에 보낸다. 클라이언트는 사용자 정보와 nonce를 포함하는 해시값을 사용하여 응답한다 (RFC 7616)

  4. HOBA
    전자 서명 기반 인증 (RFC 7486)

  5. Mutual
    암호를 이용한 클라이언트-서버 상호 인증 (draft-ietf-httpauth-mutual)

  6. AWS4-HMAC-SHA256
    AWS 전자 서명 기반 인증

1-4 Access Token, Refresh Token

  • Access Token(JWT)의 문제점은 제 3자에게 탈취 당할 경우 보안에 취약하다는 점이다. 유효기간을 길게하면 보안에 취약하고, 그렇다고 토큰의 유효기간을 짧게하면 사용자는 로그인을 자주 해서 새롭게 토큰을 발급받아야 하므로 불편하다.

  • Refresh Token은 Access Token과 똑같은 형태의 JWT이다.
    처음에 로그인을 완료했을 때 Access Token과 동시에 발급되는 Refresh Token은 긴 유효기간을 가지면서, Access Token이 만료됐을 때 새로 발급해주는 열쇠가 된다. Access Token 의 유효기간이 만료되더라도 Refresh Token의 유효기간 전까지는 Access Token을 새롭게 발급받을 수 있다

  1. 사용자가 ID , PW를 통해 로그인합니다.

  2. 서버에서는 회원 DB에서 값을 비교합니다(보통 PW는 일반적으로 암호화해서 들어갑니다)

3~4. 로그인이 완료되면 Access Token, Refresh Token을 발급합니다. 이때 일반적으로 회원DB에 Refresh Token을 저장해둡니다.

  1. 사용자는 Refresh Token은 안전한 저장소에 저장 후, Access Token을 헤더에 실어 요청을 보냅니다.

6~7. Access Token을 검증하여 이에 맞는 데이터를 보냅니다.

  1. 시간이 지나 Access Token이 만료됐다고 보겠습니다.

  2. 사용자는 이전과 동일하게 Access Token을 헤더에 실어 요청을 보냅니다.

10~11. 서버는 Access Token이 만료됨을 확인하고 권한없음을 신호로 보냅니다.

** Access Token 만료가 될 때마다 계속 과정 9~11을 거칠 필요는 없습니다.

사용자(프론트엔드)에서 Access Token의 Payload를 통해 유효기간을 알 수 있습니다. 따라서 프론트엔드 단에서 API 요청 전에 토큰이 만료됐다면 바로 재발급 요청을 할 수도 있습니다.

  1. 사용자는 Refresh Token과 Access Token을 함께 서버로 보냅니다.
  1. 서버는 받은 Access Token이 조작되지 않았는지 확인한후, Refresh Token과 사용자의 DB에 저장되어 있던 Refresh Token을 비교합니다. Token이 동일하고 유효기간도 지나지 않았다면 새로운 Access Token을 발급해줍니다.
  1. 서버는 새로운 Access Token을 헤더에 실어 다시 API 요청을 진행합니다.

2. JWT 구조

  1. HEADER : 토큰의 타입(type)해싱 알고리즘(암호화 방식)을 지정

  2. PAYLOAD : 토큰에 담을 하나의 정보로 한 조각의 정보를 클레임이라고 한다. 클레임은 name/value의 한 쌍으로 이루어져 있다.

    • 클레임의 종류
    1. registerd claim : 서비스에 필요한 정보가 아니라 토큰에 대한 정보들을 담기 위해 이름이 이미 정해진 클레임

      iss(발급자), sub(제목), aud(대상자), exp(만료시간), nbf(토큰의 활성날짜), iat(발급된시간), jti(JWT 고유식별자, 일회용 토큰에 사용)

    2. public claim : 사용자 정의 클레임으로. 공개용 정보 전달을 위해 사용

    3. private claim : 당사자간에 정보를 공유하기 위해 만들어진 사용자지정 클레임

  3. SIGNATURE : 토큰을 인코딩하거나 유효성 검증을 할 때 사용하는 고유한 암호화 코드
    Base64 방식으로 인코딩한 Header,payload 그리고 SECRET KEY를 더한 후 서명
    => Encoded Header + "." + Encoded Payload + "." + Verify Signature

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

3. JWT 사용

  1. 엔티티에 RefreshToken과 destroyRefreshToken 메서드 추가
  2. repository에 토큰을 이용해서 정보를 조회하는 쿼리메서드 추가
  3. JWT Service 생성 : AccessToken 생성, RefreshToken생성, RefreshToken 재발급, RefreshToken 삭제, AccessToken 삭제 등을 제공하는 클래스
    (토큰 생성, 토큰 재발급, 토큰 세팅, 토큰 삭제, 토큰 추출, 토큰 보내기)
 application-jwt 를 읽어올 수 있게
 
 spring:
  profiles:
    include: jwt 추가
  • application-jwt
application-jwt
jwt:
  secret: base64로 인코딩된 암호 키, HS512를 사용할 것이기 때문에, 512비트(64바이트) 이상이 되어야 합니다. 길게 써주세요

  access:
    expiration: 80
    header: Authorization

  refresh:
    expiration: 90
    header: Authorization-refresh
  1. jwt.secret : 토큰 해쉬 키 값, 여러 문자가 섞일수록 안전하지만 암호화 시간이 오래걸린다.(노출하면 안되는 값이기 때문에 gitignore 적용을 꼭 하자)

  2. jwt.require().build : 토큰을 가지고 옴
    .verify(accessToken).getClaim(USERNAME_CLAIM).asString(); : 토큰을 검증하고 값을 추출

  • JwtService
public interface JwtService {


    String createAccessToken(String username);
    String createRefreshToken();

    void updateRefreshToken(String username, String refreshToken);

    void destroyRefreshToken(String username);

    void sendToken(HttpServletResponse response, String accessToken, String refreshToken) throws IOException;

    String extractAccessToken(HttpServletRequest request) throws IOException, ServletException;

    String extractRefreshToken(HttpServletRequest request) throws IOException, ServletException;

    String extractUsername(String accessToken);

    void setAccessTokenHeader(HttpServletResponse response, String accessToken);
    void setRefreshTokenHeader(HttpServletResponse response, String refreshToken);
}
  • JwtServiceImpl
 @Override
    public String createAccessToken(String username) {
        return JWT.create()
           .withSubject(ACCESS_TOKEN_SUBJECT)  // 토큰 이름
           .withClaim(USERNAME_CLAIM, username)  // 토큰에 대한 정보, name/value 형식
           .withExpiresAt(new Date(System.currentTimeMillis() + accessTokenValidityInSeconds))  // 토큰 유효기간
           .sign(Algorithm.HMAC512(secret));  
           //  설정한 비밀키로 암호화, 토큰 암호화 알고리즘 , 
           들어가는 값은 비밀키로 서버만 알고있어야하는 값이다
    }
    
      @Override
    public void updateRefreshToken(String username, String refreshToken) {
        memberRepository.findByUsername(username).ifPresentOrElse(
                member -> member.updateRefreshToken(refreshToken),
                () -> new Exception("회원이 없습니다")
        );
    }
    
    
    
        @Override
    public void sendToken(HttpServletResponse response, String accessToken, String refreshToken) throws IOException {


        response.setStatus(HttpServletResponse.SC_OK);
        response.setContentType("application/json;charset=UTF-8");


        setAccessTokenHeader(response, accessToken);  // 토큰은 헤더에 담겨서 전송됨
        setRefreshTokenHeader(response, refreshToken);

        HashMap<String, String> tokenMap = new HashMap<>();
        tokenMap.put(ACCESS_TOKEN_SUBJECT, accessToken);
        tokenMap.put(REFRESH_TOKEN_SUBJECT, refreshToken);


        String token = objectMapper.writeValueAsString(tokenMap);  //토큰을 map에 담아서 JSON으로 변환


        response.getWriter().write(token);
    }
    
    
     @Override
    public String extractAccessToken(HttpServletRequest request) throws IOException, ServletException {
        return Optional.ofNullable(request.getHeader(accessHeader)).map(accessToken -> accessToken.replace(BEARER, "")).orElse(null);

    }

    @Override
    public String extractRefreshToken(HttpServletRequest request) throws IOException, ServletException {
        return Optional.ofNullable(request.getHeader(refreshHeader)).map(refreshToken -> refreshToken.replace(BEARER, "")).orElse(null);

    }

    @Override
    public String extractUsername(String accessToken) {
        return JWT.require(Algorithm.HMAC512(secret)).build() // JWT verifier를 생성합니다
                .verify(accessToken).getClaim(USERNAME_CLAIM).asString();  // 토큰을 검증하고  클레임 값을 추출
    }
  • RefreshToken을 포함하여 요청이 전송되는 경우는 다음 네 가지 상황이 있을 수 있습니다.
  1. 둘 다 유효한 경우 -> AccessToken재발급, 인증은 진행하지 않음.
  2. RefreshToken은 유효하고, AccessToken은 없거나 유효하지 않은 경우 -> AccessToken 재발급
  3. RefreshToken은 없거나 유효하지 않고, AccessToken은 유효한 경우 -> 인증은 성공되나, RefreshToken을 재발급하지는 않음
  4. RefreshToken과 AccessToken 모두 없거나 유효하지 않은 경우 -> 인증에 실패합니다. 403을 제공합니다. (이후 200을 제공하되, 서버와 클라이언트간에 약속한 코드를 추가하여 어떤 에러인지 보호하여 전달하겠습니다.)

4. JWT와 스프링시큐리티를 통한 인증

  1. 사용자가 로그인하면, 서버는 해당 사용자에 대한 정보를 기반으로 JWT를 생성합니다.

  2. 생성된 JWT는 서버에서 클라이언트에게 전달됩니다. 클라이언트는 이 토큰을 보관하고, 인증이 필요한 요청을 할 때마다 헤더에 포함하여 서버로 전송합니다.

  3. 클라이언트가 요청을 보낼 때, JWT가 헤더에 포함되어 전송되고 유효성을 검증합니다.

  4. JWT가 유효한 경우, 토큰에 포함된 사용자 정보를 이용하여 실제 사용자를 인증합니다.

  5. MemberDetailsServiceImpl 클래스의 loadUserByUsername 메서드를 통해 사용자 정보를 불러오고, getAuthentication메서드를 사용해서 UsernamePasswordAuthenticationToken을 생성합니다.
    이때 getAuthorities 메서드를 통해 가져온 권한 정보도 들어가게 됩니다.

  6. AuthenticationManager는 AuthenticationProvider에게 인증을 위임하고 DaoAuthenticationProvider는 내부적으로 UserDetailsService를 사용하여 사용자 정보를 조회하고, 비밀번호를 비교하여 사용자를 인증합니다.

  7. 인증에 성공하면, UserDetails 객체가 반환됩니다.

  8. Authentication 객체를 생성하고 SecurityContextHolder에 저장합니다. . 이 객체는 UserDetails를 구현한 객체로 현재 사용자의 세부 정보를 포함하고 있습니다.

  9. 이후의 요청에서는 이 인증 정보를 활용하여 사용자를 식별하고, 해당 사용자에 대한 권한을 확인하여 요청을 처리합니다.

0개의 댓글