728x90

개발 스펙

  • Java(11)
  • Amazon Corretto JDK(11)
  • Spring Boot(2.5.3)
  • Spring Security(boot-starer)
  • JWT(0.9.1)

Config

1. Dependency 추가

Spring Security와 JWT의 Dependency 추가

dependencies {
	...
    
	// Spring Security
    implementation "org.springframework.boot:spring-boot-starter-security"
    // Spring Security Test
	testImplementation 'org.springframework.security:spring-security-test'
    // JWT
    implementation 'io.jsonwebtoken:jjwt:0.9.1'
    
	...
}

2. 정적 자원 제공 클래스 생성

정적 자원을 제공하는 클래스를 생성하여 아래와 같이 설정한다.

@Configuration 
public class WebMvcConfig implements WebMvcConfigurer {

    private static final String[] CLASSPATH_RESOURCE_LOCATIONS = {
            "classpath:/static/",
            "classpath:/public/",
            "classpath:/",
            "classpath:/resources/",
            "classpath:/META-INF/resources/",
            "classpath:/META-INF/resources/webjars/"
    };

    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        // 경로에 해당하는 url을 forword
//        registry.addViewController("/loginPage").setViewName("login");
        // 우선순위를 가장 높게 잡는다.
//        registry.setOrder(Ordered.HIGHEST_PRECEDENCE);
    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        // 정적 자원의 경로를 허용
        registry.addResourceHandler("/**").addResourceLocations(CLASSPATH_RESOURCE_LOCATIONS);
    }

}

3. SpringSecurity 설정

스프링 시큐리티의 웹 보안 기능을 초기화 및 설정

WebSecurityConfigurerAdapter를 상속받아 HttpSecurity를 이용해 설정한다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    // JWT 제공 클래스
    private final JwtProvider jwtProvider;
    // 인증 실패 또는 인증헤더가 전달받지 못했을때 핸들러
    private final AuthenticationEntryPoint authenticationEntryPoint;
    // 인증 성공 핸들러
    private final AuthenticationSuccessHandler authenticationSuccessHandler;
    // 인증 실패 핸들러
    private final AuthenticationFailureHandler authenticationFailureHandler;
    // 인가 실패 핸들러
    private final AccessDeniedHandler accessDeniedHandler;

    /**
     * Security 적용 무시
     */
    @Override
    public void configure(WebSecurity web) {
        web.ignoring()
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations())
                .mvcMatchers("/docs/**");
    }

    /**
     * 보안 기능 초기화 및 설정
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {

        final String[] GET_WHITELIST = new String[]{
                "/login",
                "/user/login-id/**",
                "/user/email/**",
                "/affiliate"
        };

        final String[] POST_WHITELIST = new String[]{
                "/client-user"
        };

        http.csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and().exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint) // 인증 실패
                .accessDeniedHandler(accessDeniedHandler) // 인가 실패
                .and().authorizeRequests()
                .antMatchers(HttpMethod.GET, GET_WHITELIST).permitAll() // 해당 GET URL은 모두 허용
                .antMatchers(HttpMethod.POST, POST_WHITELIST).permitAll() // 해당 POST URL은 모두 허용
                .antMatchers("/client-user/**").hasAnyRole(UserType.CL.getRoll()) // 권한 적용
                .anyRequest().authenticated() // 나머지 요청에 대해서는 인증을 요구
                .and() // 로그인하는 경우에 대해 설정함
                .formLogin().disable() // 로그인 페이지 사용 안함
//                .loginPage("/user/loginView") // 로그인 성공 URL을 설정함
//                .successForwardUrl("/index") // 로그인 실패 URL을 설정함
//                .failureForwardUrl("/index").permitAll()
                .addFilterBefore(authenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class);
    }

    /**
     * 사용자 요청 정보로 UserPasswordAuthenticationToken 발급하는 필터
     */
    @Bean
    public CustomAuthenticationFilter authenticationFilter() throws Exception {
        CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter(authenticationManager());
        // 필터 URL 설정
        customAuthenticationFilter.setFilterProcessesUrl("/login");
        // 인증 성공 핸들러
        customAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
        // 인증 실패 핸들러
        customAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);
        // BeanFactory에 의해 모든 property가 설정되고 난 뒤 실행
        customAuthenticationFilter.afterPropertiesSet();
        return customAuthenticationFilter;
    }

    /**
     * JWT의 인증 및 권한을 확인하는 필터
     */
    @Bean
    public JwtFilter jwtFilter() {
        return new JwtFilter(jwtProvider);
    }

}

회원 인증

1. UsernamePasswordAuthenticationFilter 구현

사용자 요청 정보로 UserPasswordAuthenticationToken 발급 후 AuthenticationManager에게 전달하고 AuthenticationProvider의 인증 메서드를 실행하는 UsernamePasswordAuthenticationFilter를 구현

public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    public CustomAuthenticationFilter(AuthenticationManager authenticationManager) {
        super.setAuthenticationManager(authenticationManager);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        final UsernamePasswordAuthenticationToken authRequest;

        final LoginDTO loginDTO;
        try {
            // 사용자 요청 정보로 UserPasswordAuthenticationToken 발급
            loginDTO = new ObjectMapper().readValue(request.getInputStream(), LoginDTO.class);
            authRequest = new UsernamePasswordAuthenticationToken(loginDTO.getLogin(), loginDTO.getPass());
        } catch (IOException e) {
            throw new NotValidException();
        }
        setDetails(request, authRequest);

        // AuthenticationManager에게 전달 -> AuthenticationProvider의 인증 메서드 실행
        return this.getAuthenticationManager().authenticate(authRequest);
    }

}

2. AuthenticationProvider 구현

AuthenticationManager 하위에 실제로 인증을 처리할 AuthenticationProvider를 구현

@Component
@RequiredArgsConstructor
public class AuthenticationProviderImpl implements AuthenticationProvider {

    private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;

    /**
     * 인증 구현
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // 전달 받은 UsernamePasswordAuthenticationToken
        UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;

        // AuthenticaionFilter에서 생성된 토큰으로부터 아이디와 비밀번호를 추출
        String username = token.getName();
        String password = (String) token.getCredentials();
        // 해당 회원 Database 조회
        UserDetailsImpl userDetail = (UserDetailsImpl) userDetailsService.loadUserByUsername(username);

        // 비밀번호 확인
        if (!passwordEncoder.matches(password, userDetail.getPassword()))
            throw new BadCredentialsException(userDetail.getUsername() + "Invalid password");

        // 인증 성공 시 UsernamePasswordAuthenticationToken 반환
        return new UsernamePasswordAuthenticationToken(userDetail.getUsername(), "", userDetail.getAuthorities());
    }

    /**
     * provider의 동작 여부를 설정
     */
    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }

}

3. UserDetailsService 구현

인증 과정 중 실제 Database에 회원을 데이터를 조회하는UserDetailsService를 구현

@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = userRepository.findFirstUserByLoginOrderByIdAsc(username).orElseThrow(() -> new NotFoundDataException("User"));
        return new UserDetailsImpl(
                user.getLogin(),
                user.getPass(),
                user.getEmail(),
                Collections.singleton(new SimpleGrantedAuthority("ROLE_" + user.getType().getRoll()))
        );
    }

}

4. UserDetails 구현

회원 데이터를 조회하고 해당 정보와 권한을 저장하는 UserDetails를 구현

@AllArgsConstructor
@Getter
@ToString
public class UserDetailsImpl implements UserDetails {

    private final String username;
    private final String password;
    private final String email;
    private final Collection<? extends GrantedAuthority> authorities;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return false;
    }

    @Override
    public boolean isAccountNonLocked() {
        return false;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return false;
    }

    @Override
    public boolean isEnabled() {
        return false;
    }
}

5. PasswordEncoder 구현

기본적으로 제공해주는 PasswordEncoder를 구현한 클래스를 사용할 경우 Bean 등록만 하면된다.  
(BCryptPasswordEncoder OR DelegatingPasswordEncoder)

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

 

 

Spring Password Encoder

Spring에서는 인증/권한인가 등의 처리가 필요할 때 사용하라고 만든 Spring Security 패키지가 존재한다. 그 중 유저가 입력하는 Password를 암호화해서 저장하는 방법에 대해서 알아보자 아, 그 전에

gompangs.tistory.com

 

다른 로직의 비밀번호 인증이 필요할 경우는 PasswordEncoder를 구현해서 사용한다.

@Component
public class PasswordEncoderImpl implements PasswordEncoder {

    /**
     * 비밀번호 해시
     */
    @Override
    public String encode(CharSequence rawPassword) {
        return passwordEncode((String) rawPassword); // 커스텀 메서드
    }

    /**
     * 비밀번호 확인
     */
    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        return passwordMatches((String) rawPassword, encodedPassword); // 커스텀 메서드
    }

}

6. AuthenticationSuccessHandler 구현

인증 성공 시 핸들링하는 AuthenticationSuccessHandler를 구현

@Component
@RequiredArgsConstructor
public class AuthenticationSuccessHandlerImpl implements AuthenticationSuccessHandler {

    private final JwtProvider jwtProvider;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        // 전달받은 인증정보 SecurityContextHolder에 저장
        SecurityContextHolder.getContext().setAuthentication(authentication);
        // JWT Token 발급
        final String token = jwtProvider.generateJwtToken(authentication);
        // Response
        ApiResponse.token(response, token);
    }

}

7. AuthenticationFailureHandler 구현

인증 실패 시 핸들링하는 AuthenticationFailureHandler를 구현

@Component
public class AuthenticationFailureHandlerImpl implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
        ApiResponse.error(response, ApiResponseType.UNAUTHORIZED_RESPONSE);
    }

}

JWT 인증

1. JwtProvider 생성

JWT를 발급하고 확인하는 클래스 생성

@Component
@RequiredArgsConstructor
public final class JwtProvider {

    private final UserDetailsService userDetailsService;

    // secret key
    @Value("${jwt.secret-key}")
    private String secretKey;

    // access token 유효시간
    private final long accessTokenValidTime = 2 * 60 * 60 * 1000L;

    // refresh token 유효시간
    private final long refreshTokenValidTime = 2 * 7 * 24 * 60 * 60 * 1000L;

    @PostConstruct
    private void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    /**
     * 토큰에서 Claim 추출
     */
    private Claims getClaimsFormToken(String token) {
        return Jwts.parser().setSigningKey(DatatypeConverter.parseBase64Binary(secretKey)).parseClaimsJws(token).getBody();
    }

    /**
     * 토큰에서 인증 subject 추출
     */
    private String getSubject(String token) {
        return getClaimsFormToken(token).getSubject();
    }

    /**
     * 토큰에서 인증 정보 추출
     */
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getSubject(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    /**
     * 토큰 발급
     */
    public String generateJwtToken(Authentication authentication) {
        Claims claims = Jwts.claims().setSubject(String.valueOf(authentication.getPrincipal()));
        claims.put("roles", authentication.getAuthorities());
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + accessTokenValidTime))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

    /**
     * 토큰 검증
     */
    public boolean isValidToken(String token) {
        try {
            Claims claims = getClaimsFormToken(token);
            return !claims.getExpiration().before(new Date());
        } catch (JwtException | NullPointerException exception) {
            return false;
        }
    }

}

2. JwtFilter 생성

Header를 통해 JWT의 인증 요청이 왔을때 처리하는 Filter 생성

@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";
    private final JwtProvider jwtProvider;

    /**
     * 토큰 인증 정보를 현재 쓰레드의 SecurityContext 에 저장하는 역할 수행
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {

        // Request Header에서 토큰 추출
        String jwt = resolveToken(request);

        // Token 유효성 검사
        if (StringUtils.hasText(jwt) && jwtProvider.isValidToken(jwt)) {
            // 토큰으로 인증 정보를 추출
            Authentication authentication = jwtProvider.getAuthentication(jwt);
            // SecurityContext에 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    /**
     * Request Header에서 토큰 추출
     */
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }
    
}

3. AuthenticationEntryPoint 구현

토큰 인증이 실패하거나 인증 헤더를 정상적으로 받지 못했을때 핸들링하는 AuthenticationEntryPoint를 구현

@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        ApiResponse.error(response, ApiResponseType.UNAUTHORIZED_RESPONSE);
    }

}

4. AccessDeniedHandler 구현

토큰 인가 실패 시 핸들링하는 AccessDeniedHandler를 구현

@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        ApiResponse.error(response, ApiResponseType.FORBIDDEN_RESPONSE);
    }

}

Flowchart


전체 소스는 github에서 확인하실 수 있습니다.

 

GitHub - devbeekei/SpringSecurityJwt: SpringSecurity + JWT example

SpringSecurity + JWT example. Contribute to devbeekei/SpringSecurityJwt development by creating an account on GitHub.

github.com

 

+ Recent posts