[SpringBoot] 스프링부트 로그인 구현- (3) JWT 코드, Security 설정 추가

최가희·2022년 1월 22일
0

SpringBoot

목록 보기
3/13
post-thumbnail

01. JWT 설정 추가

1. application.yml에 JWT 설정 추가

jwt:
  header: Authorization
  secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK
  token-validity-in-seconds: 86400
  • secret
    이 튜토리얼에서는 HS512 알고리즘을 사용하기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다.
    +) 참고로 이 값은 secret key를 Base64로 인코딩한 값을 사용한 것이다.
  • token-validity-in-seconds
    토큰의 만료시간은 86400초로 설정

2. build.gradle에 JWT 관련 라이브러리 추가

dependencies {
	...
	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'
   }


02. JWT 관련 코드 개발

1. jwt 패키지 생성 후 Token Provider 클래스 생성

토큰의 생성, 토큰의 유효성 검증등을 담당


2. TokenProvider 작성

import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;

@Component // 빈 생성
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;

    /* 의존성 주입 */
    // @Value("${jwt.secret}") _ application.yml에 정의된 내용 가져옴
    public TokenProvider(
            @Value("${jwt.secret}") String secret,
            @Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds) {
        this.secret = secret;
        this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
    }

    /* 위에서 주입받은 secret 값을 BASE64 decode해서 key변수에 할당 */
    @Override
    public void afterPropertiesSet() {

        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    /* Authentication 객체의 권한 정보를 이용해서 토큰 생성 */
    public String createToken(Authentication authentication) { // Authentication 파라미터를 받아서
        // 권한들
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        // application.yml에서 설정했던 만료시간 설정하고
        long now = (new Date()).getTime();
        Date validity = new Date(now + this.tokenValidityInMilliseconds);

        // jwt 토큰 생성
        return Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .signWith(key, SignatureAlgorithm.HS512)
                .setExpiration(validity)
                .compact();
    }

    /* 토큰에 담겨있는 권한 정보를 이용해 Authentication 객체를 리턴하는 메소드 */
    public Authentication getAuthentication(String token) { // 토큰을 파라미터로 받아서
        // 토큰을 이용해 claim을 만들고
        Claims claims = Jwts
                .parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();

        // claim의 권한 정보들을 이용해서
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // User 객체를 만들어주고
        User principal = new User(claims.getSubject(), "", authorities);

        // User 객체와 토큰, 권한 정보를 이용해서 최종적으로 Authentication 객체 리턴
        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 토큰이 잘못되었습니다.");
        }
        // 문제가 있으면 false, 정상이면 true 리턴
        return false;
    }
}

3. JWT를 위한 커스텀 필터를 만들기 위한 JwtFilter 클래스 생성

jwt > JwtFilter.java 생성

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

public class JwtFilter extends GenericFilterBean {

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

    public static final String AUTHORIZATION_HEADER = "Authorization";

    // TokenProvider 주입 받음
    private TokenProvider tokenProvider;

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

    // GenericFilterBean을 extends해서 doFilter 오버라이딩
    // 실제 필터링 로직은 doFilter 내부에 작성
    /* 토큰의 인증정보를 SecurityContext에 저장하는 역할 수행 */
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
            throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String jwt = resolveToken(httpServletRequest); //resolveToken을 통해 토큰을 받아와서
        String requestURI = httpServletRequest.getRequestURI();

        // 유효성 검증을 하고 정상 토큰이면
        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            // 토큰에서 authentication 객체를 받아와서
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            // SecurityContext에 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);
            logger.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
        } else {
            logger.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
        }

        filterChain.doFilter(servletRequest, servletResponse);
    }

    /* Request Header에서 토큰 정보를 꺼내오기 위한 메소드 */
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

SecurityContextHolder
애플리케이션과 상호작용하는 Authentication의 정보가 저장되는 공간


4. JwtSecurityConfig 클래스 작성

TokenProvider, JwtFilter를 SecurityConfig에 적용할 때 사용

jwt > JwtSecurityConfig.java 생성

import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

// SecurityConfigurerAdapter를 extends하고
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    private TokenProvider tokenProvider;

    // TokenProvider를 주입받아서
    public JwtSecurityConfig(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    // configure 메소드를 오버라이딩 하여
    @Override
    public void configure(HttpSecurity http) {
        // JwtFilter를 통해 Security 로직에 필터를 등록함
        JwtFilter customFilter = new JwtFilter(tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

5. JwtAuthenticationEntryPoint 클래스 작성

유효한 자격증명을 제공하지 않고 접근하려 할 때 401 Unathorized 에러를 리턴

jwt > JwtAuthenticationEntryPoint.java 생성

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

// AuthenticationEntryPoint를 implements 하고
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        // 유효한 자격증명을 제공하지 않고 접근하려 할때 401 에러 send
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

6. JwtAccessDeniedHandler 클래스 작성

필요한 권한이 존재하지 않는 경우 403 Forbidden 에러 리턴

jwt > JwtAccessDeniedHandler.java 생성

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

// AccessDeniedHandler를 implements 하고
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        //필요한 권한이 없이 접근하려 할때 403 에러 리턴
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}


03. Security 설정 추가

위에서 우리가 만들었던 5개의 클래스를 SecurityConfig에 추가

config > SecurityConfig.java

import com.example.tutorial.jwt.JwtAccessDeniedHandler;
import com.example.tutorial.jwt.JwtAuthenticationEntryPoint;
import com.example.tutorial.jwt.JwtSecurityConfig;
import com.example.tutorial.jwt.TokenProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;


@EnableWebSecurity // 기본적인 웹 보안을 활성화
@EnableGlobalMethodSecurity(prePostEnabled = true) // @PreAuthorize 어노테이션을 메소드 단위로 추가하기 위해 적용
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final TokenProvider tokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

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

    
    @Bean
    public PasswordEncoder passwordEncoder() {
        // PasswordEncoder는 BCryptPasswordEncoder를 사용
        return new BCryptPasswordEncoder();
    }

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

    @Override
    //WebSecurityConfigurerAdapter의 configure 메소드 오버라이딩
    protected  void configure(HttpSecurity http) throws Exception{
        http
                // 우리는 토큰을 사용하기 때문에 csrf설정 disable
                .csrf().disable()

                // exception을 핸들링할 때 우리가 만들었던 클래스들 추가해줌
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)

                // h2-console을 위한 설정
                .and()
                .headers()
                .frameOptions()
                .sameOrigin()

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

                .and()
                .authorizeRequests() //HttpServletRequest를 사용하는 요청들에 대한 접근제한 설정
                .antMatchers("/api/hello").permitAll() //'/api/hello'에 대한 요청은 인증없이 접근 허용
                .antMatchers("/api/authenticate").permitAll() //로그인, 회원가입 API는 토큰이 없는 상태에서
                .antMatchers("/api/signup").permitAll() // 요청이 들어오기 때문에 permitAll 설정

                .anyRequest().authenticated() //나머지 요청들은 모두 인증되어야 함

                // JwtFilter를 addFilterBefore로 등록했던 JwtSecurityConfig도 적용
                .and()
                .apply(new JwtSecurityConfig(tokenProvider));
    }
}


서버를 재시작하면 잘 돌아간다.

0개의 댓글