spring security + jwt 실습(2)

Minky·2021년 10월 6일
0

목표

회원가입, 로그인, 권한을 체크하는 Spring boot + Spring security를 이용한 JWT 인증,인가를 구현하고 적용된 설정/기능의 개념을 이해한다.(프로젝트의 전체적인 코드는 github에 있고, 이글의 목적은 구현한 방식과 프로세스를 이해하는것! 따라서 엔티티나 db설정같은것은 따로 기록해두지 않겠다.)

프로젝트 개발 환경

  • IntelliJ
  • Spring boot 2.5.5
  • java 11
  • gradle 7.2
  • h2
  • JPA/Hibernate

작성한 코드 참고 : https://github.com/somiyv/jwt-test

목차

  • 프로젝트 기본 설정
  • 회원가입/인증 프로세스 구현하기

기본 설정

  • gradle 설정
    사용할 IDE에서 Spring initialir를 통해 필요한 의존성을 추가하여 프로젝트를 생성하면 자동으로 필요한 의존성이 추가된다.(또는 build.gradle에 아래와 같이 추가해준다.) + jwt 관련
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
    
// jwt
    implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
}
  • security config
    기본적인 security 설정을 위한 config클래스.
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    public void configure(WebSecurity web) {
        web
                .ignoring() // 아래 관련 요청은 다무시한다.
                .antMatchers("/h2-console/**"
                        , "/favicon.ico");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests() // httpservletrequest를 사용하는 요청들에 대한 접근 제한 설정
                .antMatchers("/api/hello").permitAll() // 이 api는 인증안받을게
                .anyRequest().authenticated(); // 나머지는 다인증받을거야
    }
}
  • @EnableWebSecurity, extends WebSecurityConfigurerAdapter
    WebSecurityConfigurerAdapter를 상속받은 클래스에 @EnableWebSecurity 어노테이션을 달면 SpringSecurityFilterChain이 자동으로 포함하며, 기본적인 Web보안을 활성화 하겠다는 의미.

  • WebSecurityConfigurerAdapter의 메소드를 @override 하여 기본적인 설정을 한다.
    스프링 시큐리티의 전반적인 보안 기능 초기화 및 설정 담당. (HttpSecurity)

인증 프로세스 구현하기

1. 토큰 생성, 유효성 검증을 담당할 TokenProvider 생성

public class TokenProvider implements InitializingBean {

    private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);

    private static final String AUTHORITIES_KEY = "auth";

    private final String secret;
    private final long tokenValidityInMilliseconds;

    private Key key;


    public TokenProvider(
            @Value("${jwt.secret}") String secret,
            @Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds) {
        this.secret = secret;
        this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
    }

    @Override
    public void afterPropertiesSet() {
        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.tokenValidityInMilliseconds);

        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();

        Collection<? extends GrantedAuthority> 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;
    }
}
  • extends InitializingBean + afterPropertiesSet()
    스프링 bean이 초기화,소멸시 특정 작업을 할 수있도록 함. -> 빈이 생성되고 의존성 주입을 받은 후에 secret값을 decode해서 key변수에 할당하기 위함.

  • createToken()
    Authentication객체의 권한 정보를 이용해서 토큰 생성.

  • getAuthentication()
    token에 담겨있는 정보를 이용해 Authentication객체 리턴.
    Jwt토큰에서 claims를 생성하고, 그 권한정보를 이용해 User객체를 만들어 최종적으로 Authentication객체를 리턴.

  • UsernamePasswordAuthenticationToken
    Authentication 인터페이스의 구현체.

2. JWT를 위한 커스텀 필터 JwtFilter 생성

public class JwtFilter extends GenericFilterBean {

    private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class);

    public static final String AUTHORIZATION_HEADER = "Authorization";

    private TokenProvider tokenProvider;

    public JwtFilter(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

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

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

        filterChain.doFilter(servletRequest, servletResponse);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}
  • extends GenericFilterBean + doFilter()
    JWT 토큰의 인증정보를 SecurityContext에 저장하는 로직을 구현함.
    request의 header에서 토큰정보를 꺼내오고 유효성 체크후(tokenProvider.validateToken()) 유효하다면, securityContext에 인증정보(Authentication)을 저장한다.

  • resolveToken()
    request Header에서 토큰정보를 꺼내오기 위함.

3. provider, jwtFilter를 securityConfig에 적용할 JwtSecurityConfig 작성.

public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    private TokenProvider tokenProvider;

    public JwtSecurityConfig(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    public void configure(HttpSecurity http) {
        JwtFilter customFilter = new JwtFilter(tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}
  • extends SecurityConfigurerAdapter의 configure를 재정의해 jwtFilter를 통해 security로직에 필터를 등록한다.
  • http.addFilterBefore()
    커스텀 필터와, authenticationFilter를 추가하여, 커스텀 필터 인증 처리가 되면 자연스럽게 로직을 통과.
  • UsernamePasswordAuthenticationFilter
    스프링 시큐리티의 기본 인증 처리 담당 필터.

4. 인증/인가 체크후 error 클래스 작성

(1) 유효한 자격증명 없이 접근하려할때 401를 리턴할 클래스(JwtAuthenticationEntryPoint) 생성

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        // 유효한 자격증명을 제공하지 않고 접근하려 할때 401
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
} 
  • implements AuthenticationEntryPoint
    스프링 시큐리티 컨텍스트 내에 존재하는 인증절차 중, 인증과정이 실패하거나 인증헤더(Authorization)를 보내지 않게 되는 경우 401(UnAuthorized)라는 응답값을 던지는데, 이를 처리해주는 인터페이스.

  • commerce()
    401이 떨어질만한 에러가 발생할 경우 commerce라는 메소드가 실행된다.

(2) 권한이 존재하지 않아 403 리턴할 클래스 생성

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        //필요한 권한이 없이 접근하려 할때 403
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
} 
  • implements AccessDeniedHandler
    403(Forbidden)에 대한 처리를 위한 인터페이스.

4. securityConfig에 위의 설정들을 추가/적용.

JwtProvider, JwtFilter, JwtSecurityConfig등 각종 설정을 securityConfig에 적용시킨다.

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

    public SecurityConfig(TokenProvider tokenProvider, JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, JwtAccessDeniedHandler jwtAccessDeniedHandler) {
        this.tokenProvider = tokenProvider;
        this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
        this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    public void configure(WebSecurity web) {
        web.ignoring()
                .antMatchers(
                        "/h2-console/**"
                        ,"/favicon.ico"
                        ,"/error"
                );
    }

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                // token을 사용하는 방식이기 때문에 csrf를 disable.
                .csrf().disable()

                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)

                // enable h2-console
                .and()
                .headers()
                .frameOptions()
                .sameOrigin()

                // 세션을 사용하지 않기 때문에 STATELESS로 설정
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .authorizeRequests()
                .antMatchers("/api/hello").permitAll()
                .antMatchers("/api/authenticate").permitAll()
                .antMatchers("/api/signup").permitAll()

                .anyRequest().authenticated()

                .and()
                .apply(new JwtSecurityConfig(tokenProvider));
    }
}
  • @EnableGlobalMethodSecurity(prePostEnabled = true)
    @PreAuthorize 어노테이션을 메소드 단위로 추가하기 위함.
  • csrf().disable() : 토큰을 사용하기 때문에 disable
  • exceptionHandling()
    커스텀한 jwtAuthenticationEntryPoint(401), jwtAccessDeniedHandler(403) 추가
  • sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    스프링 시큐리티의 세션 정책 설정.
    스프링 시큐리티가 생성하지도 않고 기존것을 사용하지도 않는다(JWT토큰 방식을 쓸때 사용하는 설정)
  • permitAll() :인증이 필요없을때
  • .apply(new JwtSecurityConfig(tokenProvider))
    커스텀하게 작성한 jwtSecurityConfig도 적용.

참고

https://devuna.tistory.com/59 [튜나 개발일기]
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-jwt/lecture/65763
https://catsbi.oopy.io/c0a4f395-24b2-44e5-8eeb-275d19e2a536
https://kimchanjung.github.io/programming/2020/07/02/spring-security-02/

profile
소통하는 Web Developer가 되고 싶습니다 :)

0개의 댓글