Spring Security - 3 (JWT)

·2024년 6월 19일
post-thumbnail

1. JWT

1-1. JWT란?

JWT는 Json Web Token의 약자로 일반적으로 클라이언트와 서버 사이에서 통신할 때 권한을 위해 사용하는 토큰이다.

1-2. JWT의 형태

JWT는 header, payload, signiture로 구성이 된다. header는 알고리즘(토큰 종류 및 형신), payload는 내용(유저의 정보, 만료시간 등), signiture는 head와 payload 그리고 조작을 감지하는 sign을 포함하여 암호화를 한 값이 들어간다. sign값은 SHA256(Base(header) + Base(payload), secret) 이런 식으로 만들어진다. 밑은 jwt 형태 예시입니다. (jwt.io라는 사이트인데 한번쯤 들어가서 확인해보면 좋을 것 같다.)

1-3. JWT의 장점

JWT 안에 정보를 넣어서 활용할수도 있다. 하지만 payload에 비밀번호와 같은 민감한 정보는 탈취될 시 열어볼 수 있어서 이러한 정보는 담지 않는 것이 좋다. 또한 DB에 가지 않아도 인증이 가능해지기 때문에 확장성이 높아진다는 장점이 있다. 그렇다면 JWT를 활용하는 것을 직접 구현해보자.

JSON으로 로그인 받기

jwt를 구현하기 전에 이제까지는 폼으로 로그인을 받았는데 보통 JSON을 많이 사용하기 때문에 JSON으로 받아보겠다.

2-1. SecurityFilterChain 설정

이제 form로그인과 basic 로그인은 안 쓸 것이기 때문에 disable을 시켜놓자. 그리고 jwt를 사용할 때 session을 stateless로 관리하기위한 설정도 하자.

2-2. LoginFilter 구현하기

기존의 usernamePasswordAuthenticationFilter를 상속하여 구현해보자
밑에는 UsernamePasswordAuthenticationFilter의 필터인데, attemptAuthentication메소드를 통해 request에서 username과 password를 빼오고 그것으로 UsernamePasswordAuthenticationToken을 만들어서 AuthenticationManager로 인증을 해주는 모습을 볼 수 있습니다!

이와 같이 인증 해주기 위해 우리 필터에도 attemptAuthentication 메소드를 가져와서 구현하자.
위에서는 obtain으로 구현하였는데, 이렇게 되면 form형태로 받아지기 때문에 objectMapper를 사용하여 json형식으로 받아보겠다. 우선 objectMapper를 new로 생성해주자

그 다음 json을 받을 DTO를 만들어준다.

그 후 objectMapper의 readValue를 통해 loginRequestDTO의 형태로 정보를 받아준다 (읽어 올때는 IOException 처리를 해주어야하기 때문에 try-catch문을 사용헸다.)

아까 UserPasswordAuthenticationFilter에서 UserPasswordAuthenticationToken을 만들어서 authenticationManager로 인증을 해준 것처럼 JSON으로 받아온 데이터로 위와 같이 해준다. (authenticationManager는 DI시키시면 된다.)

아까 objectMapper로 json을 받는 코드는 readBody 메소드를 만들어서 보기 좋게 바꾸어주겠다.

이제 성공 시에 어떻게 할지 짜보자. 일단 response 200으로 보내보자.

실패시에는 401로 하겠다.



성공 시와 실패 시 잘 작동하는 것을 볼 수 있다.

3. JWT 사용하기 위한 클래스 만들기

jwt를 이용하기 쉽게 클래스들을 만들어보자.

3-1. JWT 의존성 주입

implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

3-2. JWT 사용 클래스 만들기

@Component
public class JWTUtil {

    private final SecretKey secretKey;
    private final long accessTokenValidityMilliseconds;

    public JWTUtil(
            @Value("${spring.jwt.secret}") final String secretKey,
            @Value("${spring.jwt.access-token-time}") final long accessTokenValidityMilliseconds) {
        this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
        this.accessTokenValidityMilliseconds = accessTokenValidityMilliseconds;
    }

    public String createAccessToken(String email, String role) {
        return createToken(email, role, accessTokenValidityMilliseconds);
    }

    private String createToken(String email, String role, long validityMilliseconds) {
        Claims claims = Jwts.claims();
        claims.put("email", email);
        claims.put("role", role);

        ZonedDateTime now = ZonedDateTime.now();
        ZonedDateTime tokenValidity = now.plusSeconds(validityMilliseconds / 1000);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(Date.from(now.toInstant()))
                .setExpiration(Date.from(tokenValidity.toInstant()))
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
    }

    public String getEmail(String token) {
        return getClaims(token).getBody().get("email", String.class);
    }

    public boolean isTokenValid(String token) {
        try {
            Jws<Claims> claims = getClaims(token);
            Date expiredDate = claims.getBody().getExpiration();
            Date now = new Date();
            return expiredDate.after(now);
        } catch (ExpiredJwtException e) {
            throw new AuthHandler(ErrorStatus._AUTH_EXPIRE_TOKEN);
        } catch (SignatureException
                 | SecurityException
                 | IllegalArgumentException
                 | MalformedJwtException
                 | UnsupportedJwtException e) {
            throw new AuthHandler(ErrorStatus._AUTH_INVALID_TOKEN);
        }
    }

    private Jws<Claims> getClaims(String token) {
        return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
    }
}

3-3. 환경 설정 하기

@Value 값으로 받아온 secret키나 access 만료 시간을 yml설정 파일에 설정해 주어야 한다. secret키나 access키는 각자 맞게 설정하면 된다.)

4. 인증 후 JWT 발급

4-1. Authentication에서 정보 가져오기

인증 후 Authentication의 Principal을 PrincipalDetails로 바꿔주는 코드이다. (현재 Authentication 객체가 authResult로 되어있는 모습을 볼 수 있다.)

4-2. 정보 받아오기

principalDetails(전에 만든 UserDetails 구현한)에 있는 email과 role을 받아오자.

4-3. 토큰 만들기

아까 jwtUtil에서 만들었던 코드를 이용하여 AccessToken을 만들어주자.

4-4. 헤더에 추가하기

Authentication에 Bearer 토큰으로 헤더에 넣는다.

4-5. 성공 응답 통일 해주기

4-6. 실패 시 응답 통일 해주기

4-7. 만든 LoginFilter 배치하기

UsernamePasswordAuthenticationFilter가 있는 곳에 배치하자. 아까 LoginFilter에서 인증 및 jwt 생성을 위해 생성자 주입 시켜주었던 필드들을 SecurityConfig에서 받아서 넣어주겠다.




정상적으로 응답 통일과 토큰이 들어와 있는 것을 볼 수 있다.

5. JWT 검증하기

이제 토큰을 주는 부분을 만들었으니 토큰이 들어올 때 검증하는 부분을 만들어주자.

5-1. 검증하는 필터 만들기

JWTFilter를 만들어주겠다.

5-2. 헤더에서 Token 가져오기

Authorization의 헤더 값에서 토큰을 받을 것이기 때문에 Authorization에서 받아오자.

5-3. 토큰 추출하기

헤더 값이 있는 지 보고 Bearer 토큰인지 확인하고 Bearer를 떼주는 작업.

5-4. 토큰 검증하기

위에서 추출한 토큰을 아까 적었던 isTokenValid를 통해 token을 검증하고 맞다면 토큰에서 email을 빼서 loadUserByUsername을 통해 userDeatils를 만들어준다.

5-5. 정보 SecurityContextHolder에 넣기

userDetails가 null인지 체크하고 null이 아니라면 Authentication을 만들어서 SecurityContextHolder에 넣어준다.(UserPasswordAuthenticationToken은 AbstractAuthenticationToken을, Authentication클래스를 상속 받으므로)

6. JWT 필터 에러 핸들링

기존에 사용했던 RestControllerAdvice는 RestController의 에러를 잡아준다. 하지만 필터 단에서 에러가 발생할 수 있기 때문에 따로 필터로 에러를 만들어 주어야 한다.

6-1. JWT 에러 필터 만들기

JWTExceptionFilter를 만들어서 OncePerRequestFilter를 상속시키자.

6-2. doFilterInternal 메소드 구현하기

7. 인증 & 인가 에러 핸들링

7-1. AccessDeniedHandler 구현 (인가 에러 핸들링)

7-2. 인증 에러 관리

profile
고민0

0개의 댓글