[SpringBoot] JWT로 인증 관리하기

나르·2021년 11월 8일
1

Spring

목록 보기
5/25
post-thumbnail

1. Json Web Token(JWT)

특정 사용자가 서버에 접근을 했을 때, 이 사용자가 인증된 사용자인지 구분하기 위해서는 여러 방법을 사용할 수 있는데요. 대표적인 방법으로는 Session과 Cookie, 그리고 Token 이 있습니다.
토큰을 사용한다는 것은 요청과 응답에 토큰을 함께 보내 이 사용자가 유효한 사용자인지를 검색하는 방법입니다.

JWT는 JSON 객체를 사용해서 토큰 자체에 정보를 저장하는 Web Token 입니다.
일반적으로는 Authorization: <type> <credentials> 형태로 Request Header 에 담겨져 오기 때문에 Header 값을 확인해서 가져올 수 있습니다.


1.1. 토큰의 장단점

  • 장점
    • 클라이언트에 저장하기 때문에 Stateless하며, 서버 확장에 유리
    • Base64 URL Safe Encoding 이라 URL, Cookie, Header 어떤 형태로도 사용 가능
    • 토큰만 유효하다면 웹이 아닌 모바일에서도 사용 가능
    • 인증 정보를 다른 곳에서도 사용 가능 (OAuth)
    • HMAC(Hash-based Message Authentication)를 기반으로 토큰이 변조되면 바로 알아차릴 수 있음
  • 단점
    • Payload 의 정보가 많아지면 네트워크 사용량 증가
    • 다른 사람이 토큰을 decode 하여 데이터 확인 가능
    • 토큰을 탈취당한 경우 대처하기 어려움
    • 기본적으로는 서버에서 관리하는게 아니다보니 탈취당한 경우 강제 로그아웃 처리가 불가능
    • 그래서 유효시간을 짧게 가져가고 refresh token 을 발급하는 방식으로 많이 사용

1.2. 토큰의 구성요소

JWT는 .을 기준으로 헤더(header) - 내용(payload) - 서명(signature)으로 이루어져있습니다.

  • Header

    • typ : 토큰의 타입을 지정합니다. JWT라는 문자열이 들어가게 됩니다
    • alg : Signature 를 해싱하기 위한 알고리즘을 지정합니다.
  • Payload
    토큰에 담을 정보가 들어갑니다. 정보의 한 덩어리를 클레임(claim)이라고 부르며, key-value의 한 쌍으로 이루어져있습니다. 클레임의 종류는 세 종류로 나눌 수 있습니다.

    • 등록된(registered) 클레임
      토큰에 대한 정보를 담기 위한 클레임들이며, 이미 이름이 등록되어있는 클레임
      • iss : 토큰 발급자(issuer)
      • sub : 토큰 제목(subject)
      • aud : 토큰 대상자(audience)
      • iat : 토큰이 발급된 시간 (issued at)
      • exp : 토큰의 만료시간(expiraton). 시간은 NumericDate 형식이다. (예: 1480849147370)
      • nbf : 토큰의 활성 날짜(Not Before). NumericDate 형식으로 날짜를 지정하며, 이 날짜가 지나기 전까지는 토큰이 처리되지 않는다.
      • jti : JWT의 고유 식별자로서, 주로 일회용 토큰에 사용한다.
    • 공개(public) 클레임
      말 그대로 공개된 클레임, 충돌을 방지할 수 있는 이름을 가져야하며, 보통 클레임 이름을 URI로 짓는다.
    • 비공개(private) 클레임
      클라이언트 - 서버간에 통신을 위해 사용되는 클레임
  • Signature

    • 서버에서 토큰이 유효한지 검증하기 위한 문자열
    • Header 인코딩 + Payload 인코딩한 값을 Secret Key을 통해 해쉬값을 생성하므로 데이터 변조 여부를 판단 가능
    • Secret Key 는 노출되지 않도록 서버에서 잘 관리 필요

1.3. 토큰 인증 타입

Authorization: <type> <credentials> 형태에서 <type> 부분에 들어갈 값입니다.
엄격한 규칙이 있는건 아니고 일반적으로 많이 사용되는 형태라고 생각하면 됩니다.

Basic 사용자 아이디와 암호를 Base64 로 인코딩한 값을 토큰으로 사용
Bearer JWT 또는 OAuth 에 대한 토큰을 사용
HOBA 전자 서명 기반 인증
Digest 서버에서 난수 데이터 문자열을 클라이언트에 보냄
클라이언트는 사용자 정보와 nonce 를 포함하는 해시값을 사용하여 응답
Mutual 암호를 이용한 클라이언트-서버 상호 인증
AWS4-HMAC-SHA256 AWS 전자 서명 기반 인증

2. 구현

2.1. Dependency 추가

https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt

<!-- JWT -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

2.2. JWT 토큰 생성

@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
    private final JwtProperties jwtProperties;

    public String generateToken(User user) {
        Date now = new Date();
        return Jwts.builder()
                .setHeader(createHeader())   // (1)
                .setClaims(createClaims(user))  // (2)
                .setIssuedAt(now)  // (3)
                .setExpiration(new Date(now.getTime()+ Duration.ofHours(3).toMillis())) // (4)
                .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecret())  // (5)
                .compact();
    }
    private Map<String, Object> createHeader() {
        Map<String, Object> header = new HashMap<>();
        header.put("typ","JWT");
        header.put("alg","HS256"); // 해시 256 암호화
        return header;
    }

    private Map<String, Object> createClaims(User user) { // payload
        Map<String, Object> claims = new HashMap<>();
        claims.put("id",user.getId());
        claims.put("email",user.getEmail());
        return claims;
    }
}
  1. header을 지정합니다. 위처럼 create 함수를 통해 반환하는 방법 외에도 .setHeaderParam(Header.TYPE, Header.JWT_TYPE) 등을 통해 지정할 수 있습니다.
  2. payload에 들어갈 claim을 등록합니다.
  3. payload에 들어갈 iat(생성시간)을 등록합니다.
  4. payload에 들어갈 exp(만료시간)을 설정합니다. 3시간 후로 지정했습니다.
  5. 해싱 알고리즘과 시크릿 키를 설정합니다.
    모든 설정이 끝나면 compact()를 통해 JWT 토큰을 만들 수 있습니다.

2.3. 토큰 복호화 및 클레임 확인

 private Claims getClaims(String token) {
        try{
            return Jwts.parser()
                .setSigningKey(jwtProperties.getSecret())
                .parseClaimsJws(token)
                .getBody();
        // 토큰 유효성 확인
        } catch (SecurityException e) {
            log.info("Invalid JWT signature.");
            throw new CustomJwtRuntimeException();
        } catch (MalformedJwtException e) {
            log.info("Invalid JWT token.");
            throw new CustomJwtRuntimeException();
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT token.");
            throw new CustomJwtRuntimeException();
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT token.");
            throw new CustomJwtRuntimeException();
        } catch (IllegalArgumentException e) {
            log.info("JWT token compact of handler are invalid.");
            throw new CustomJwtRuntimeException();
        }
    }
    public String getUserEmailFromToken(String token) {
        return (String) getClaims(token).get("email");
    }

getBody()를 호출하게 되면 Claim 객체를 반환하게 되는데, 여기에서 저장된 클레임 정보들을 확인할 수 있습니다. Jwts 모듈에서 호출될 수 있는 Exception은 다음과 같습니다.

  • UnsupportedJwtException : 지원되지 않는 형식이거나 구성의 JWT 토큰
  • MalformedJwtException : 유효하지 않은 구성의 JWT 토큰
  • ExpiredJwtException : 만료된 JWT 토큰
  • SignatureException : 잘못된 JWT 서명
  • IllegalArgumentException : 잘못된 JWT

2.4. Test Code

public class UserJwtTest extends ServerApplicationTests {

    @Autowired
    private JwtTokenProvider jwtManager;

    @Test
    @DisplayName("토큰 생성 및 복호화 테스트")
    void tokenTest() {
        LocalDateTime now = LocalDateTime.now();
        final User user = User.builder()
                .user_name("gildong")
                .password("password")
                .email("gildong@gmail.com")
                .userOauthType(UserOauthType.LOCAL)
                .build();
                
        final String token = jwtManager.generateToken(user);
        String email = jwtManager.getUserEmailFromToken(token);
        MatcherAssert.assertThat(email,is("gildong@gmail.com"));
    }
}

2.5. Controller에 추가

서비스가 잘 동작하는 것을 확인했으니 controller에 로그인시 토큰 발급을 추가해줍니다.

UserController.java

@PostMapping("/login")
    public ResponseEntity<?> loginUser(@RequestBody LoginRequestDto loginRequestDto){
        LoginResponseDto loginResponseDto = userService.loginUser(loginRequestDto);
        if (loginResponseDto!=null) return ResponseEntity.ok(loginResponseDto);
        else return new ResponseEntity(HttpStatus.NO_CONTENT);
    }

UserService.java

public LoginResponseDto loginUser(LoginRequestDto loginRequestDto) {
        User user = searchUserByEmail(loginRequestDto.getEmail());
        if (user==null||!user.getPassword().equals(loginRequestDto.getPassword())){
            return null;
        }
        String token = jwtTokenProvider.generateToken(user);
        return new LoginResponseDto(token);
    }

LoginRequestDto.java

@Getter
@Setter
public class LoginRequestDto {

    private String email;
    private String password;

    public LoginRequestDto(String email, String password) {
        this.email = email;
        this.password = password;
    }
}

LoginResponseDto.java

@Getter
public class LoginResponseDto {
    private final String accessToken;

    public LoginResponseDto(String accessToken) {
        this.accessToken = accessToken;
    }
}

리퀘스트를 보내면 엑세스토큰을 발급해줍니다.

Reference

https://datatracker.ietf.org/doc/html/rfc7519#section-4.1
https://velopert.com/2350
https://memostack.tistory.com/200#toc-JWT%20%ED%86%A0%ED%81%B0%20%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0
https://shinsunyoung.tistory.com/110?category=327358

profile
💻 + ☕ = </>

2개의 댓글

comment-user-thumbnail
2022년 8월 11일

감사합니다

답글 달기
comment-user-thumbnail
2022년 9월 14일

Everything is very open and honest, and the challenges are broken down in great detail. The information is without a doubt beneficial to have. Is my website extremely successful in terms of making money? Please take a look at this: wheel spinner

답글 달기