본문 바로가기
공부/프로그래밍

[springboot, jwt] jwt로 security 적용 & error 때 result form 지정해 리턴하기

by demonic_ 2021. 4. 7.
반응형

로그인에 성공하면 JWT로 토큰을 발행하도록 할 것이다. 이번 포스팅 샘플 프로젝트는 Kakao 로그인 API를 이용해 로그인 시스템을 구현하는데, 이럴때는 비밀번호가 있지 않고 USER의 ID만 존재한다.

 

우선 토큰발생하는 클래스를 만들어보자.

JwtManager 클래스를 만든다.(이전 포스팅에 자세히 설명되어 있음)

lemontia.tistory.com/1021

 

[springboot, jwt] jwt 로 토큰 생성, 유효시간 관리 하기

이번에는 jwt를 이용한 로그인 인증을 만들려 한다. 일전에 @EnableAuthorizationserver deprecated 되면서 찾던 중 jwt가 있어 이걸 활용하기로 했다(DB로 토큰유효성 확인도 안해서 더 나은거 같기도 하고

lemontia.tistory.com

 

public class JwtManager {
  // 30분
  private long ACCESS_TOKEN_VALIDATiON_SECOND = 60 * 30;

  // 1개월
  private long REFRESH_TOKEN_VALIDATiON_SECOND = 60 * 60 * 24 * 30;


  private String secretKey;

  public JwtManager(String secretKey) {
    this.secretKey = secretKey;
  }
  public JwtManager(
      String secretKey
      , Long accessTokenValidationSecond
      , Long refreshTokenValidationSecond
  ) {
    this.secretKey = secretKey;
    this.ACCESS_TOKEN_VALIDATiON_SECOND = accessTokenValidationSecond;
    this.REFRESH_TOKEN_VALIDATiON_SECOND = refreshTokenValidationSecond;
  }

  private Key getSigninKey(String secretKey) {
    byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
    return Keys.hmacShaKeyFor(keyBytes);
  }

  // 토큰 해석
  public Claims validTokenAndReturnBody(String token) {
    try {
      return Jwts.parserBuilder()
          .setSigningKey(getSigninKey(secretKey))
          .build()
          .parseClaimsJws(token)
          .getBody();
    } catch(ExpiredJwtException | UnsupportedJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e) {
      e.printStackTrace();
      throw new InvalidParameterException("유효하지 않은 토큰입니다");
    }
  }

  // 유저id 조회
  public String getName(String token) {
    return validTokenAndReturnBody(token).get("username", String.class);
  }

  // 토큰 만료
  private Boolean isTokenExpired(String token){
    Date expiration = validTokenAndReturnBody(token).getExpiration();
    return expiration.before(new Date());
  }

  public String generateAccessToken(String username) {
    return doGenerateToken(username, ACCESS_TOKEN_VALIDATiON_SECOND * 1000l);
  }

  public String generateRefreshToken(String username) {
//        return doGenerateToken("refresh-" + username, REFRESH_TOKEN_VALIDATiON_SECOND * 1000l);
    return doGenerateToken(username, REFRESH_TOKEN_VALIDATiON_SECOND * 1000l);
  }

  public Long getValidationAccessTokenTime(){
    return ACCESS_TOKEN_VALIDATiON_SECOND;
  }

  private String doGenerateToken(String username, Long expireTime) {
    Claims claims = Jwts.claims();
    claims.put("username", username);

    return Jwts.builder()
        .setClaims(claims)
        .setIssuedAt(new Date())
        .setExpiration(new Date(System.currentTimeMillis() + expireTime))
        .signWith(getSigninKey(secretKey), SignatureAlgorithm.HS256)
        .compact();
  }
}

해당 클래스를 빈으로 등록한다.

...
  @Bean
  public JwtManager jwtManager() {
    String secretKey = "[sercetKey 입력]";
    Long accessTokenExpireSecond = 60 * 60 * 24; // 1일
    Long refreshTokenExpireSecond = 60 * 60 * 24 * 30; // 1개월
    return new JwtManager(secretKey, accessTokenExpireSecond, refreshTokenExpireSecond);
  }
...

 

이제 토큰을 전달할 Object를 생성한다

@Getter
public class TokenDto {
  @NonNull
  private String accessToken;
  @NonNull
  private String refreshToken;
  @NonNull
  private Long expireSec; // 만료시간(second)

  public TokenDto(@NonNull String accessToken, @NonNull String refreshToken,
      @NonNull Long expireSec) {
    this.accessToken = accessToken;
    this.refreshToken = refreshToken;
    this.expireSec = expireSec;
  }
}

토큰을 생성관리하는 서비스를 만들었다

@Service
@RequiredArgsConstructor
public class JwtService {
  private final JwtManager jwtManager;

  // 토큰 발급
  public TokenDto createToken(String key) {
    String accessToken = jwtManager.generateAccessToken(key);
    String refreshToken = jwtManager.generateRefreshToken(key);
    return new TokenDto(
        accessToken
        , refreshToken
        , jwtManager.getValidationAccessTokenTime());
  }

  // refresh 토큰 갱신
  public TokenDto createTokenByrefreshToken(String refreshToken) {
    String key = jwtManager.getName(refreshToken);
    String accessToken = jwtManager.generateAccessToken(key);
    String refreshTokenNew = jwtManager.generateRefreshToken(key);
    return new TokenDto(
        accessToken
        , refreshTokenNew
        , jwtManager.getValidationAccessTokenTime());
  }
}

그럼 이제 로그인 할때 토큰을 발행해 보자.

@Service
@RequiredArgsConstructor
public UserService {
  private final JwtService jwtService;
...
  // 로그인 & 토큰발행
  public ResponseToken login(Long userId) {
    return new ResponseToken(jwtService.createToken(userId.toString()));
  }
...

리턴할때의 ResponseToken 파일은 다음과 같다.

@Getter
public class ResponseToken {
  @NonNull
  private String accessToken;
  @NonNull
  private String refreshToken;
  @NonNull
  private Long expireSec;

  public ResponseToken(TokenDto tokenDto) {
    this.accessToken = tokenDto.getAccessToken();
    this.refreshToken = tokenDto.getRefreshToken();
    this.expireSec = tokenDto.getExpireSec();
  }
}

호출하고 나면 다음과 같은 결과를 받는다.

 

 

이제 중요한 Filter를 거는 것이다. 다음처럼 설정한다.

@Configuration
@RequiredArgsConstructor
public class JwtSecurityConfig extends WebSecurityConfigurerAdapter {

  private final JwtManager jwtManager;

  protected void configure(HttpSecurity http) throws Exception {
    http
        .cors().and()
        .csrf().disable()// rest api이므로 csrf 보안이 필요없으므로 비활성화
        .httpBasic().disable() // rest api 이므로 로그인폼으로 이동 비활성화
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션필요없음
        .and()
        .authorizeRequests().antMatchers(whitelistedUriPatterns()).permitAll()
        .and()
        .authorizeRequests().anyRequest().authenticated()
        .and()
        .addFilterBefore(new JwtAuthenticationFilter(jwtManager) // jwt 로 접근허용 필터 생성
            , UsernamePasswordAuthenticationFilter.class)
        .exceptionHandling()  // exception이 날 경우 catch하여 result form 일관하게 만듬(필요없음 제거)
        .authenticationEntryPoint(new CustomAuthenticationEntryPoint())
    ;
  }
  

  private String[] whitelistedUriPatterns() {
    return new String[]{
        "/health",
        "/oauth2/authorization/**",
        "/swagger*/**", "/webjars*/**", "/v2/api-docs",
        "/user/login/**",
    };
  }
}

 

addFilterBefore 와 authenticationEntryPoint를 등록한다.

filter는 접근하는 것에 대해 필터하는 역할로 JwtAuthenticationFilter를 구현하여 등록했다. JwtAuthenticationFilter 파일을 보자.

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
  private final JwtManager jwtManager;

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
      FilterChain filterChain) throws ServletException, IOException {
    // 헤더에서 로드
    HttpServletRequest requestServlet = (HttpServletRequest) request;
    String authorization = requestServlet.getHeader("Authorization");

    // 유효한지 확인
    if(StringUtils.isEmpty(authorization) == false) {
      String accessToken = authorization.replace("Bearer ", "");
      String name = jwtManager.getName(accessToken);

      // username이 나오지 않는다면 잘못된 토큰이 만들어진 것이므로 에러를 리턴한다
      if(StringUtils.isEmpty(name) == true) {
        // error
        throw new InvalidParameterException("유효하지 않은 토큰입니다");
      }

      // 여기서는 외부 소셜로그인을 통해 가입한 것이기 때문에 비밀번호 관리를 하지 않지만, 
      // 만약 자체 로그인 시스템이 있다면 DB조회하여 비밀번호를 넣어주어야 한다.
      Authentication authentication = new UsernamePasswordAuthenticationToken(
          name,
          "password"
          , getAuthorities());

      // 접근 허가
      SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    filterChain.doFilter(request, response);
  }

  // 권한을 설정하는 곳인데, 추가하고 싶다면 다음 2가지중 하나를 해야한다
  // 1) username을 기반으로 DB에 조회하여 ROLE_ 을 확인한다
  // 2) token을 만들때 ROLE_을 넣는다.
  private Collection<? extends GrantedAuthority> getAuthorities() {
    List<GrantedAuthority> grantedAuthorityList = new ArrayList<>();
    grantedAuthorityList.add(new SimpleGrantedAuthority("ROLE_USER"));

    return grantedAuthorityList;
  }
}

 

이제 유효하지 않은 토큰이 올 때마다 Exception이 발생하게 두었다.

OncePerRequestFilter 를 상속받아 구현했는데, 처음엔 GenericFilterBean를 구현했는데 필터를 여러번 호출하는게 보여 1 Reuqest당 1번만 호출하는 OncePerRequestFilter로 교체했다.

 

이제 Exception이 발생한 경우 대비한 결과 유형을 설정한 CustomAuthenticationEntryPoint 파일을 보자.

// 토큰 유효성 실패 시, Exception 결과를 정해진 form으로 리턴
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

  ObjectMapper objectMapper = new ObjectMapper();

  @Override
  public void commence(HttpServletRequest request, HttpServletResponse response,
      AuthenticationException authException) throws IOException, ServletException {

    System.out.println("======================= token error");
    response.setStatus(HttpStatus.UNAUTHORIZED.value());
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    try (OutputStream os = response.getOutputStream()) {
      HashMap fail = new HashMap();
      fail.put("errMsg", "유효하지 않은 토큰입니다");
      fail.put("errCd", "ERROR");
      objectMapper.writeValue(os, fail);
      os.flush();
    }
  }
}

설정한대로 잘 오는지 확인

output form 설정을 비슷한걸로 이전에 포스팅한게 있는데 봐도 좋을 듯 하다.

 

lemontia.tistory.com/1005

 

[spring security, oauth2] invalid_token 에러를 핸들링(ExceptionHandler)하여 output form 설정하기

저번 글에 이어 쓴 글이다. 이전에 Oauth2 토큰 발급을 요청할때 Exception을 핸들링 하는 것에 대해 알아봤다. 이번에는 ResourceServer 에서 핸들링 하는 방법을 알아보려 한다. 유효하지 않은 토큰으로

lemontia.tistory.com

 

 

 

끝.

반응형

댓글