[Spring Security] JWT API

wally·2022년 7월 1일
0

[Spring] Security

목록 보기
2/2

불필요한 의존성 제거 (spring-session)

  • spring-boot-starter-thymeleaf, thymeleaf-extras-springsecurity5, spring-session-jdbc
<dependency>
  <groupId>com.auth0</groupId>
  <artifactId>java-jwt</artifactId>
  <version>3.18.1</version>
</dependency>

application yaml 설정

server:
  port: 8080
jwt:
  header: token 
  issuer: prgrms
  client-secret: EENY5W0eegTf1naQB2eDeyCLl5kRS2b8xa5c4qLdS0hmVjtbvo8tOyhPMcAmtPuQ
  expiry-seconds: 60

header : jwt token 이 입력되서 들어오는 header 의미
issuer : 발행자 정보
client-secret : 토큰의 위변조방지에 사용되는 64byte secret 키
expiry-seconds : 토큰 발행 후 1분 후 만료됨을 의미

WebSecurityConfigure

사용하지 않는 필터 disable 및 session 사용안함 표시

@Configuration
@EnableWebSecurity
public class WebSecurityConfigure extends WebSecurityConfigurerAdapter {  
  
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
      .authorizeRequests()
        .antMatchers("/api/user/me").hasAnyRole("USER", "ADMIN")
        .anyRequest().permitAll()
        .and()
      /**
       * formLogin, csrf, headers, http-basic, rememberMe, logout filter 비활성화
       */
      .formLogin() // 현재 필요 없다
        .disable()
      .csrf() // 현재 페이지 기반 서비스가 아니므로 사용X
        .disable()
      .headers() // 필요 없다
        .disable()
      .httpBasic()
        .disable()
      .rememberMe()
        .disable()
      .logout() // 현재 사용 안함
        .disable()
      /**
       * Session 사용하지 않음
       */
      .sessionManagement() // session 을 사용하지 않음을 명시
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        .and()
      /**
       * 예외처리 핸들러
       */
      .exceptionHandling()
        .accessDeniedHandler(accessDeniedHandler())
        .and()
      /**
       * JwtSecurityContextRepository 설정
       */
      .securityContext()
        .securityContextRepository(securityContextRepository())
        .and()
      /**
       * jwtAuthenticationFilter 추가
       */
      //.addFilterAfter(jwtAuthenticationFilter(), SecurityContextPersistenceFilter.class)
      ;
  }
}

JwtConfigure 설정 추가

  • application.yaml 에 적은 설정을 binding 하는 클래스 작성
package com.prgrms.devcourse.configures;

import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component // 자동 스캔되게 설정
@ConfigurationProperties(prefix = "jwt") // jwt prefix 로 yaml 파일에서 바인딩
public class JwtConfigure {

  private String header;

  private String issuer;

  private String clientSecret;

  private int expirySeconds;

  public String getHeader() {
    return header;
  }

  public void setHeader(String header) {
    this.header = header;
  }

  public String getIssuer() {
    return issuer;
  }

  public void setIssuer(String issuer) {
    this.issuer = issuer;
  }

  public String getClientSecret() {
    return clientSecret;
  }

  public void setClientSecret(String clientSecret) {
    this.clientSecret = clientSecret;
  }

  public int getExpirySeconds() {
    return expirySeconds;
  }

  public void setExpirySeconds(int expirySeconds) {
    this.expirySeconds = expirySeconds;
  }

  @Override
  public String toString() {
    return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
      .append("header", header)
      .append("issuer", issuer)
      .append("clientSecret", clientSecret)
      .append("expirySeconds", expirySeconds)
      .toString();
  }

}

jwt 패키지 아래 jwt 에 필요한 클래스들 작성

Jwt

package com.prgrms.devcourse.jwt;

import com.auth0.jwt.JWTCreator;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;

import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

public final class Jwt {

  private final String issuer; // 앞에 3개의 필드 가져온다.

  private final String clientSecret; // 앞에 3개의 필드 가져온다.

  private final int expirySeconds; // 앞에 3개의 필드 가져온다.

  private final Algorithm algorithm; // 위변조 체크에 사용되는 알고리즘

  private final JWTVerifier jwtVerifier; // jwt 검증을 위한 필드

  public Jwt(String issuer, String clientSecret, int expirySeconds) {
    this.issuer = issuer;
    this.clientSecret = clientSecret;
    this.expirySeconds = expirySeconds;
    this.algorithm = Algorithm.HMAC512(clientSecret); // 64 byte 클라이언트 시크릿이 필요하다.
    this.jwtVerifier = com.auth0.jwt.JWT.require(algorithm) // Jwt 클래스와 maven 에 추가한 JWT 를 혼동하지 않기 위해 풀 패키지 작성
      .withIssuer(issuer)
      .build();
  }

  // 2. 토큰을 만드는 메서드(필요한 정보-Claims 를 받는다)
  public String sign(Claims claims) {
    Date now = new Date(); // Date을 쓰는 이유는 JWT 에서 날짜를 DateTime 으로 받기 때문이다. 보통 LocalDateTime 을 쓰는게 좋지만 쓰지않는 이유이다
    JWTCreator.Builder builder = com.auth0.jwt.JWT.create();
    builder.withIssuer(issuer);
    builder.withIssuedAt(now);
    if (expirySeconds > 0) {
      builder.withExpiresAt(new Date(now.getTime() + expirySeconds * 1_000L));
      // 현재 시간 + 주어진 expire 시간지나면 토큰 만료
    }
    builder.withClaim("username", claims.username);
    builder.withArrayClaim("roles", claims.roles);
    return builder.sign(algorithm); // 서명데이터까지 주어진 알고리즘으로 생성하여 최종적으로 토큰 생성
  }

  // 3. 토큰이 주어졌을때 토큰을 decode 해서 Claims 로 리턴하는 메서드
  public Claims verify(String token) throws JWTVerificationException {
    return new Claims(jwtVerifier.verify(token)); // verigy 에서 위변조 검사 후 토큰 만료시간 발행주체 id 등을 검증한 후 decodedJWT 반환
  }

  public String getIssuer() {
    return issuer;
  }

  public String getClientSecret() {
    return clientSecret;
  }

  public int getExpirySeconds() {
    return expirySeconds;
  }

  public Algorithm getAlgorithm() {
    return algorithm;
  }

  public JWTVerifier getJwtVerifier() {
    return jwtVerifier;
  }

  // 1. jwt 토큰을 만들거나 검증할때 필요한 데이터를 전달하기 위해 필요한 클래스
  static public class Claims {
    String username; // 유저이름
    String[] roles; // 퀀한 목록
    Date iat; // 토큰의 발행 일자
    Date exp; // 토큰의 만료 일자

    private Claims() {/*no-op*/}

    // DecodedJWT 를 통해 Claims 객체 초기화
    Claims(DecodedJWT decodedJWT) {
      Claim username = decodedJWT.getClaim("username"); // 유저이름 가져오기
      if (!username.isNull())
        this.username = username.asString();
      Claim roles = decodedJWT.getClaim("roles"); // roles 가져오기
      if (!roles.isNull()) {
        this.roles = roles.asArray(String.class);
      }
      this.iat = decodedJWT.getIssuedAt();
      this.exp = decodedJWT.getExpiresAt();
    }

    // username 과 roles 를 이용한 팩터리 메서드
    public static Claims from(String username, String[] roles) {
      Claims claims = new Claims();
      claims.username = username;
      claims.roles = roles;
      return claims;
    }

    // map 을 리턴하는 메서드
    public Map<String, Object> asMap() {
      Map<String, Object> map = new HashMap<>();
      map.put("username", username);
      map.put("roles", roles);
      map.put("iat", iat()); // Date 보다는 long 타입으로 변환
      map.put("exp", exp()); // Date 보다는 long 타입으로 변환
      return map;
    }

    long iat() {
      return iat != null ? iat.getTime() : -1;
    }

    long exp() {
      return exp != null ? exp.getTime() : -1;
    }

    void eraseIat() {
      iat = null;
    }

    void eraseExp() {
      exp = null;
    }

    @Override
    public String toString() {
      return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
        .append("username", username)
        .append("roles", Arrays.toString(roles))
        .append("iat", iat)
        .append("exp", exp)
        .toString();
    }
  }

}

만든 JWT 를 빈으로 등록하여 사용

@Configuration
@EnableWebSecurity
public class WebSecurityConfigure extends WebSecurityConfigurerAdapter{


  private final JwtConfigure jwtConfigure;
  
  public WebSecurityConfigure(JwtConfigure jwtConfigure) {
    this.jwtConfigure = jwtConfigure;
  }
  
  @Bean
  public Jwt jwt() {
    return new Jwt( // jwtConfigure를 통해 JWT 객체를 만들어 빈으로 등록
      jwtConfigure.getIssuer(),
      jwtConfigure.getClientSecret(),
      jwtConfigure.getExpirySeconds()
    );
  }

테스트를 위한 UserRestController

package com.prgrms.devcourse.user;

import com.prgrms.devcourse.jwt.Jwt;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api")
public class UserRestController {

  private final Jwt jwt;

  private final UserService userService;

  public UserRestController(Jwt jwt, UserService userService) {
    this.jwt = jwt;
    this.userService = userService;
  }

  /**
   * 보호받는 엔드포인트 - ROLE_USER 또는 ROLE_ADMIN 권한 필요함
   * @return 사용자명
   */
  @GetMapping(path = "/user/me")
  public String me() {
    return (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
  }

  /**
   * 주어진 사용자의 JWT 토큰을 출력함
   * @param username 사용자명
   * @return JWT 토큰
   */
  @GetMapping(path = "/user/{username}/token")
  public String getToken(@PathVariable String username) {
    UserDetails userDetails = userService.loadUserByUsername(username);
    String[] roles = userDetails.getAuthorities().stream()
      .map(GrantedAuthority::getAuthority)
      .toArray(String[]::new);
    return jwt.sign(Jwt.Claims.from(userDetails.getUsername(), roles));
  }

  /**
   * 주어진 JWT 토큰 디코딩 결과를 출력함
   * @param token JWT 토큰
   * @return JWT 디코드 결과
   */
  @GetMapping(path = "/user/token/verify")
  public Map<String, Object> verify(@RequestHeader("token") String token) {
    return jwt.verify(token).asMap();
  }

}
  • getToken : username 을 받아 jwt 를 만들어준다
  • verify : jwt 토큰을 검증하고 디코드 해준다.

  • 토큰 생성

  • encoded 된 토큰을 header 로 담아서 전달

jwt 인코딩 디코딩 사이트 : https://jwt.io/

미션

  • JWT 필터 (JwtAuthenticationFilter) 만들어보기
    • HTTP 요청 헤더에서 JWT 토큰이 있는지 확인
    • JWT 토큰에서 username, roles을 추출하여 UsernamePasswordAuthenticationToken을 생성
    • 앞서 만든 UsernamePasswordAuthenticationToken를 SecurityContext에 넣어줌
    • JWT 필터를 Spring Security 필터 체인에 추가 (어디에 추가하면 좋을지 고민)
    • 필터를 추가한 후 HTTP 요청에 JWT 토큰을 추가하면, GET /api/user/me API 호출이 성공해야 함
      • UserRestControllerTest 테스트를 통과해야 함
profile
클린코드 지향

0개의 댓글