SpringBoot JWT Tutorial 리뷰

큰모래·2023년 5월 21일
0

JWT 소개

JSON 객체를 사용해 토큰 자체에서 정보를 저장하고 있는 Web Token

Header, Payload, Signature 3가지 부분으로 나뉘어져 있다.

Header
Signature를 해상하기 위한 알고리즘 정보가 담겨있다.

Payload
서버와 클라이언트가 주고 받는, 시스템에서 실제 사용되는 데이터에 대한 정보를 담고 있다.

Signature
토큰의 유효성 검증을 위한 문자열

장점

  • 중앙의 인증서버, 데이터 스토어에 대한 의존성이 없다. 따라서 시스템 수평 확장에 유리하다.
  • Base64 URL Safe Encoding 방식이기 때문에 URL, Cookie, Header 모두 사용 가능하다.

단점

  • Payload에 담는 데이터가 많아지면 네트워크 사용량이 증가하기 때문에 데이터 설계에 대한 고려가 필요하다.
  • 토큰이 클라이언트에 저장되기 때문에, 서버에서 클라이언트에 대한 조작이 불가능하다.

JWT 설정

application.yml

jwt:
  header: Authorization
  //시크릿 키
  secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK
  //토큰 만료시간
  token-validity-in-seconds: 86400 

build.gradle

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

JWT 구현

TokenProvider

토큰의 생성 및 유효성 검증을 담당한다.


@Component
public class TokenProvider implements InitializingBean {

    private final Logger logger = (Logger) LoggerFactory.getLogger(TokenProvider.class);
    private static final String AUTHORITIES_KEY = "auth";
    private final String secret;
    private final long tokenValidityInMillseconds;
    private Key key;

    public TokenProvider(@Value("${jwt.secret}") String secret, @Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds) {

        this.secret = secret;
        this.tokenValidityInMillseconds = tokenValidityInSeconds * 1000;
    }


    @Override
    public void afterPropertiesSet() throws Exception {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public String createToken(Authentication authentication) {
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();
        Date validity = new Date(now + this.tokenValidityInMillseconds);

        return Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .signWith(key, SignatureAlgorithm.HS512)
                .setExpiration(validity)
                .compact();
    }

    public Authentication getAuthentication(String token) {
        Claims claims = Jwts
                .parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();

        List<SimpleGrantedAuthority> authorities = Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());

        User principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }

    public boolean validateToken(String token) {

        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            logger.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            logger.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            logger.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            logger.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }
}
  • 생성자를 통해 시크릿키와 만료기간을 주입받는다.
  • InitializingBeanimplements해서 오버라이드한 afterPropertiesSet() 메서드에서 시크릿키를 디코딩하고 HmacSha 알고리즘에 사용할 수 있는 비밀키를 생성한다.
  • createToken()
    • 권한정보, 토큰 유효기간을 담아 Jwt Token 생성
  • getAuthentication()
    • token에 담긴 정보를 파싱한 정보를 토대로 Authentication 구현체 객체를 반환한다.
  • validateToken()
    • token에 대한 유효성 검사를 진행한다.

JwtFilter

Jwt 인증정보를 SecurityContext에 저장하는 역할

@Slf4j
public class JwtFilter extends GenericFilterBean {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    private TokenProvider tokenProvider;
    public JwtFilter(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String jwt = resolveToken(httpServletRequest);
        String requestURI = httpServletRequest.getRequestURI();


        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            log.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
        } else {
            log.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
        }

        chain.doFilter(request, response);
        
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
            return bearerToken.substring(7);
        }

        return null;
    }
}
  • doFilter()
    • Jwt Token에 담긴 authentication 정보를 SecurityContext에 저장한다.

JwtSecurityConfig

  • 위에서 만든 JwtFilterJwtSecurityConfig에 등록한다.
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private TokenProvider tokenProvider;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.addFilterBefore(
                new JwtFilter(tokenProvider),
                UsernamePasswordAuthenticationFilter.class
        );
    }
}

JwtAuthenticationEntryPoint

  • 유효하지 않은 자격으로 접근할 때 401 UNAUTHORIZED 에러 발생
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

JwtAccessDeniedHandler

  • 권한이 없을 경우 403 Forbidden 에러
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}

SecurityConfig

package me.bigsand.jwtTutorial.config;

import lombok.RequiredArgsConstructor;
import me.bigsand.jwtTutorial.jwt.JwtAccessDeniedHandler;
import me.bigsand.jwtTutorial.jwt.JwtAuthenticationEntryPoint;
import me.bigsand.jwtTutorial.jwt.JwtSecurityConfig;
import me.bigsand.jwtTutorial.jwt.TokenProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@EnableWebSecurity
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final TokenProvider tokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> {
            web.ignoring()
                    .antMatchers("/h2-console/**", "/favicon.ico");
        };
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeHttpRequests()
                .antMatchers("/api/hello", "/api/authenticate", "/api/signup").permitAll()
                .anyRequest().authenticated()
                .and()
                .apply(new JwtSecurityConfig(tokenProvider));

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
  • token 방식이기 때문에 csrf를 disable 한다.
  • session을 사용하지 않으므로 STATELESS로 설정
  • exceptionHandling() : 예외처리 기능 작동
    • authenticationEntryPoint() : 인증실패 시 처리
    • accessDeniedHandler() : 인가실패 시 처리

CustomUserDetailsService

  • username을 토대로 userRepository에서 user 엔티티를 얻고 해당 엔티티를 토대로 Spring Security User 객체를 생성하여 반환한다.
@Component("userDetailsService")
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository.findOneWithAuthoritiesByUsername(username)
                .map(user -> createUser(username, user))
                .orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다."));
    }

    private org.springframework.security.core.userdetails.User createUser(String username, User user) {
        if (!user.isActivated()) {
            throw new RuntimeException(username + " -> 활성화되어 있지 않습니다.");
        }

        List<SimpleGrantedAuthority> grantedAuthorities = user.getAuthorities().stream()
                .map(authority -> new SimpleGrantedAuthority(authority.getAuthorityName()))
                .collect(Collectors.toList());

        return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), grantedAuthorities);
    }
}

AuthController (로그인 api)

@Controller
@RequestMapping("/api")
@RequiredArgsConstructor
public class AuthController {

    private final TokenProvider tokenProvider;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;

    @PostMapping("/authenticate")
    public ResponseEntity<TokenDto> authorize(@Valid @RequestBody LoginDto loginDto) {

        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());

        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        String jwt = tokenProvider.createToken(authentication);

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + jwt);

        return new ResponseEntity<>(new TokenDto(jwt), httpHeaders, HttpStatus.OK);
    }

}
  • loginDto로부터 받은 아이디와 비밀번호로 UsernamePasswordAuthenticationToken 인증 객체를 생성한다.
  • authenticationTokenAuthentication 객체로 변환한다.
    • 이때, 인증 객체를 얻기 위해서는 UserDetailsService를 구현한 CustomUserDetailsServiceloadByUsername 메서드에서 db의 유저 정보에 접근해 User 정보를 얻는 로직을 만들어야 한다.
  • SecurityContext에 인증 객체를 저장한다.
  • 인증 객체를 통해 jwt Token을 생성한다.
  • 헤더와 바디에 jwt Token을 담아 반환한다.

Postman Test

  • 기존에 따로 생성해둔 username과 password로 jwt token이 잘 생성되는 것을 확인할 수 있다.
profile
큰모래

0개의 댓글