티스토리 뷰

Spring

[SpringBoot] SpringBoot로 SpringSecurity 기반의 JWT 토큰 구현하기

망나니개발자 2019. 12. 13. 15:23
반응형

현대 웹서비스에서는 토큰을 사용하여 사용자들의 인증 작업을 처리하는 것이 가장 좋은 방법이다. 이번에는 토큰 기반의 인증 시스템에서 주로 사용하는 JWT(Json Web Token)에 대해 SpringBoot와 Spring Security 기반으로 직접 제작해보도록 하겠다.

1. Spring Security 처리 과정


Spring Security 아키텍쳐는 위와 같으며 각각의 처리 과정에 대해서 자세히 알아보도록 하자.(아래에서 설명하는 내용은 Json Web Token을 활용한 Spring Security의 구현 방식으로, Session과 Token 기반의 차이점에 대해서는 여기를 참고하시고, Form을 활용한 Session기반의 구현 방식이 궁금하시다면 여기를 참고해주세요!)

 

 

 

[ 0. 사전 세팅 ]

먼저 프로젝트에서 사용할 Dependency들을 build.gradle에 추가해준다.

dependencies {

    implementation 'io.jsonwebtoken:jjwt:0.9.1'
    
    implementation 'org.mariadb.jdbc:mariadb-java-client'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    
    implementation 'org.springframework.boot:spring-boot-starter-security'
    
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
    testImplementation 'org.springframework.security:spring-security-test'
}

 

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

@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 mapping을 /common/test로 forward한다.
        registry.addViewController( "/" ).setViewName( "forward:/index" );
        // 우선순위를 가장 높게 잡는다.
        registry.setOrder(Ordered.HIGHEST_PRECEDENCE);
    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/**").addResourceLocations(CLASSPATH_RESOURCE_LOCATIONS);
    }

}

 

그리고 SpringSecurity에 대한 기본적인 설정들을 추가한다. SpringSecurity에 대한 설정 클래스에서는

  1. configure 메소드를 통해 정적 자원들에 대해서는 Security를 적용하지 않음을 추가한다.
  2. configure 메소드를 통해 어떤 요청에 대해서는 로그인을 요구하고, 어떤 요청에 대해서 로그인을 요구하지 않을지 설정한다.
  3. form 기반의 로그인을 비활성화 한다.
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    // 정적 자원에 대해서는 Security 설정을 적용하지 않음.
    @Override
    public void configure(WebSecurity web) {
        web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().authorizeRequests()
                // 토큰을 활용하는 경우 모든 요청에 대해 접근이 가능하도록 함
                .anyRequest().permitAll()
                .and()
                // 토큰을 활용하면 세션이 필요 없으므로 STATELESS로 설정하여 Session을 사용하지 않는다.
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                // form 기반의 로그인에 대해 비활성화 한다.
            .formLogin()
                .disable();
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

 

 이번 예제에서는 토큰을 활용하고, 세션을 활용하지 않도록 설정해준다.

 

 

 

[ 1. 로그인 요청 ]

사용자는 로그인 하기 위해 아이디와 비밀번호를 입력해서 로그인 요청을 하게 된다. 이번에 작성하는 예제에서는 로그인 API를 호출하고, Json으로 사용자의 아이디와 비밀번호를 보내는 상황이다.

 

 

[ 2.  UserPasswordAuthenticationToken 발급 ]

전송이 오면 AuthenticationFilter로 요청이 먼저 오게 되고, 아이디와 비밀번호를 기반으로 UserPasswordAuthenticationToken을 발급해주어야 한다. 프론트 단에서 유효성 검사를 하겠지만, 안전을 위해서 다시 한번 아이디와 패스워드의 유효성 검사를 해주는 것이 좋지만 아래의 예제에서는 생략하도록 하겠다.(아이디나 비밀번호의 null 여부 등) 해당 Filter를 직접 구현하면 아래와 같다.

 

@Log4j2
public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

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

    @Override
    public Authentication attemptAuthentication(final HttpServletRequest request, final HttpServletResponse response) throws AuthenticationException{
        final UsernamePasswordAuthenticationToken authRequest;
        try{
            final User user = new ObjectMapper().readValue(request.getInputStream(), User.class);
            authRequest = new UsernamePasswordAuthenticationToken(user.getEmail(), user.getPw());
        } catch (IOException exception){
            throw new InputNotFoundException();
        }
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

 

위에서 사용되는 사용자 정보를 담는 User 객체는 아래와 같다.

@Entity
@Table(name = "USER")
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends Common implements Serializable {

    @Column(nullable = false, unique = true, length = 50)
    private String email;

    @Setter
    @Column(nullable = false)
    private String pw;

    @Setter
    @Column(nullable = false, length = 50)
    @Enumerated(EnumType.STRING)
    private UserRole role;

}

 

 

 

만약 아이디와 비밀번호가 제대로 전달되지 않았을 경우에는 예외 처리를 해주어야 하므로 InputNotFoundException 클래스를 생성하여 처리한다.

public class InputNotFoundException extends RuntimeException {

    public InputNotFoundException(){
        super();
    }

}

 

 

 

 

이렇게 직접 제작한 Filter를 이제 적용시켜야 하므로 UsernamePasswordAuthenticationFilter 필터 이전에 적용시켜야 한다. 그리고 해당 CustomAuthenticationFilter가 수행된 후에 처리될 Handler 역시 Bean으로 등록하고 CustomAuthenticationFilter의 핸들러로 추가해주어야 하는데, 해당 코드들은 WebSecurityConfig에 아래와 같이 추가해줄 수 있다.

@Log4j2
public class CustomLoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(final HttpServletRequest request, final HttpServletResponse response,
                                        final Authentication authentication) {
        final User user = ((MyUserDetails) authentication.getPrincipal()).getUser();
        final String token = TokenUtils.generateJwtToken(user);
        response.addHeader(AuthConstants.AUTH_HEADER, AuthConstants.TOKEN_TYPE + " " + token);
    }

}

CustomLoginSuccessHandler는 AuthenticationProvider를 통해 인증이 성공될 경우 처리되는데, TokenUtils에 대해서는 아래에서 작성하도록 하겠다. 또한 인증과 관련해 자주 사용되는 상수는 아래의 AuthConstants 클래스에 정의해두었다.

 

 

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class AuthConstants {

    public static final String AUTH_HEADER = "Authorization";
    public static final String TOKEN_TYPE = "BEARER";

}

 

 

 

로그인이 성공하면 TokenUtils를 통해 토큰을 생성하고, response에 이를 추가하여 반환한다.

 

 

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    // 정적 자원에 대해서는 Security 설정을 적용하지 않음.
    @Override
    public void configure(WebSecurity web) {
        web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().authorizeRequests()
                // 토큰을 활용하는 경우 모든 요청에 대해 접근이 가능하도록 함
                .anyRequest().permitAll()
                .and()
                // 토큰을 활용하면 세션이 필요 없으므로 STATELESS로 설정하여 Session을 사용하지 않는다.
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                // form 기반의 로그인에 대해 비활성화 한다.
            .formLogin()
                .disable()
            .addFilterBefore(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
	}
    
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Bean
    public CustomAuthenticationFilter customAuthenticationFilter() throws Exception {
        CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter(authenticationManager());
        customAuthenticationFilter.setFilterProcessesUrl("/user/login");
        customAuthenticationFilter.setAuthenticationSuccessHandler(customLoginSuccessHandler());
        customAuthenticationFilter.afterPropertiesSet();
        return customAuthenticationFilter;
    }

    @Bean
    public CustomLoginSuccessHandler customLoginSuccessHandler() {
        return new CustomLoginSuccessHandler();
    }
    
}

 CustomAuthenticationFilter를 빈으로 등록하는 과정에서 UserName파라미터와 UserPassword파라미터를 설정할 수 있다. 이러한 과정을 거치면 UsernamePasswordToken이 발급되게 된다.

 

 

[ 3. UsernamePasswordToken을 Authentication Manager에게 전달 ]

AuthenticationFilter는 생성한 UsernamePasswordToken을 AuthenticationManager에게 전달한다. AuthenticationManager은 실제로 인증을 처리할 여러 개의 AuthenticationProvider를 가지고 있다.

 

[ 4. UsernamePasswordToken을 Authentication Provider에게 전달 ]

AuthenticationManager는 전달받은 UsernamePasswordToken을 순차적으로 AuthenticaionProvider들에게 전달하여 실제 인증의 과정을 수행해야 하며, 실제 인증에 대한 부분은 authenticate 함수에 작성을 해주어야 한다. SpringSecurity에서는 Username으로 DB에서 데이터를 조회한 다음에, 비밀번호의 일치 여부를 검사하는 방식으로 작동을 한다. 그렇기 때문에 먼저 UsernamePasswordToken 토큰으로부터 아이디를 조회해야 하고 그 코드는 아래와 같다.

@RequiredArgsConstructor
@Log4j2
public class CustomAuthenticationProvider implements AuthenticationProvider {

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        final UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
        // AuthenticaionFilter에서 생성된 토큰으로부터 아이디와 비밀번호를 조회함
        final String email = token.getName();
        
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }

}

 

 

[ 5. UserDetailsService로 조회할 아이디를 전달 ]

AuthenticationProvider에서 아이디를 조회하였으면, UserDetailsService로부터 아이디를 기반으로 데이터를 조회해야 한다. UserDetailsService는 인터페이스이기 때문에 이를 implements한 클래스를 작성해주어야 한다. 실제 반환값을 작성하는 부분은 7번에서 다룬다.

@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public MyUserDetails loadUserByUsername(String email) {
    
    }
    
}

 

또한 User와 관련된 SQL을 처리하는 JpaRepository를 구현한 UserRepository는 아래와 같다.

@Repository
public interface UserRepository extends JpaRepository <User, Long> {

    User findByEmailAndPw(String email, String pw);

    Optional<User> findByEmail(String email);
}

 

 

 

[ 6. 아이디를 기반으로 DB에서 데이터 조회 ]

전달받은 아이디를 기반으로 DB에서 조회하는 구현체는 우리가 직접 개발한 User일 것이고, UserDetailsService의 반환값은 UserDetails 인터페이스이기 때문에 이를 implements하여 구현한 MyUserDetails를 아래와 같이 작성할 수 있다.

@RequiredArgsConstructor
@Getter
public class MyUserDetails implements UserDetails {

    @Delegate
    private final User user;
    private final Collection<? extends GrantedAuthority> authorities;

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

    @Override
    public String getPassword() {
        return user.getPw();
    }

    @Override
    public String getUsername() {
        return user.getEmail();
    }

    @Override
    public boolean isAccountNonExpired() {
        return user.getIsEnable();
    }

    @Override
    public boolean isAccountNonLocked() {
        return user.getIsEnable();
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return user.getIsEnable();
    }

    @Override
    public boolean isEnabled() {
        return user.getIsEnable();
    }
}

 

 

[ 7. 아이디를 기반으로 조회한 결과를 반환 ]

아이디를 기반으로 조회한 결과를 반환하기 위해서는 위에서 작성하던 UserDetailsServiceImpl을 마무리해주어야 하는데, 그 전에 사용자의 아이디를 기반으로 데이터가 조회하지 않았을 경우 처리해주기 위한 Exception 클래스를 추가해주어야 한다.

public class UserNotFoundException extends RuntimeException{

    public UserNotFoundException(String email){
        super(email + " NotFoundException");
    }

}

 

그리고 조회한 결과를 CustomAuthenticaionProvider로 반환하는 UserDetailsServceImpl을 마무리해주면 아래와 같다.

@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public MyUserDetails loadUserByUsername(String email) {
        return userRepository.findByEmail(email).map(u -> new MyUserDetails(u, Collections.singleton(new SimpleGrantedAuthority(u.getRole().getValue())))).orElseThrow(() -> new UserNotFoundException(email));
    }
    
}

위의 예제에서는 UserRepository로부터 조회한 결과를 Optional로 반환하고 있기 때문에 map 함수를 이용해서 새로운 UserDetails 객체로 생성하여 반환하고 있다. (만약 Optional에 대해 잘 모르신다면 여기를 참고해주세요!)

 

 

[ 8. 인증 처리 후 인증된 토큰을 AuthenticationManager에게 반환 ]

이제 CustomAuthenticationProvider에서 UserDetailsService를 통해 조회한 정보와 입력받은 비밀번호가 일치하는지 확인하여, 일치한다면 인증된 토큰을 생성하여 반환해주어야 한다. DB에 저장된 사용자 비밀번호는 암호화가 되어있기 때문에, 입력으로부터 들어온 비밀번호를 PasswordEncoder를 통해 암호화하여 DB에서 조회한 사용자의 비밀번호화 매칭되는지 확인해주어야 한다. 만약 비밀번호가 매칭되지 않는 경우에는 BadCredentialsException을 발생시켜 처리해준다.

@RequiredArgsConstructor
@Log4j2
public class CustomAuthenticationProvider implements AuthenticationProvider {

    private final UserDetailsService userDetailsService;
    private final BCryptPasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(final Authentication authentication) throws AuthenticationException {
        final UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
        // AuthenticaionFilter에서 생성된 토큰으로부터 아이디와 비밀번호를 조회함
        final String userEmail = token.getName();
        final String userPw = (String) token.getCredentials();
        // UserDetailsService를 통해 DB에서 아이디로 사용자 조회
        final MyUserDetails userDetails = (MyUserDetails) userDetailsService.loadUserByUsername(userEmail);
        if (!passwordEncoder.matches(userPw, userDetails.getPassword())) {
            throw new BadCredentialsException(userDetails.getUsername() + "Invalid password");
        }

        return new UsernamePasswordAuthenticationToken(userDetails, userPw, userDetails.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }

}

 

위와 같이 완성된 CustomAuthenticaionProvider를 이제 Bean으로 등록해주어야 하는데, 이것을 WebSecurityConfig에 작성하면 아래와 같다.

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

    private final UserDetailsService userDetailsService;
    
    // 정적 자원에 대해서는 Security 설정을 적용하지 않음.
    @Override
    public void configure(WebSecurity web) {
        web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().authorizeRequests()
                // 토큰을 활용하는 경우 모든 요청에 대해 접근이 가능하도록 함
                .anyRequest().permitAll()
                .and()
                // 토큰을 활용하면 세션이 필요 없으므로 STATELESS로 설정하여 Session을 사용하지 않는다.
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                // form 기반의 로그인에 대해 비활성화 한다.
            .formLogin()
                .disable()
            .addFilterBefore(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);    
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public CustomAuthenticationFilter customAuthenticationFilter() throws Exception {
        CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter(authenticationManager());
        customAuthenticationFilter.setFilterProcessesUrl("/user/login");
        customAuthenticationFilter.setAuthenticationSuccessHandler(customLoginSuccessHandler());
        customAuthenticationFilter.afterPropertiesSet();
        return customAuthenticationFilter;
    }

    @Bean
    public CustomLoginSuccessHandler customLoginSuccessHandler() {
        return new CustomLoginSuccessHandler();
    }

    @Bean
    public CustomAuthenticationProvider customAuthenticationProvider() {
        return new CustomAuthenticationProvider(userDetailsService, bCryptPasswordEncoder());
    }

    @Override
    public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) {
        authenticationManagerBuilder.authenticationProvider(customAuthenticationProvider());
    }

}

 

 

[ 9. 인증된 토큰을 AuthenticationFilter에게 전달 ]

AuthenticaitonProvider에서 인증이 완료된 UsernamePasswordAuthenticationToken을 AuthenticationFilter로 반환하고, AuthenticationFilter에서는 LoginSuccessHandler로 전달한다.

 

[ 10. 인증된 토큰을 기반으로 JWT 발급 ]

LoginSuccessHandler로 넘어온 요청은 /user/loginSuccess로 redirect된다. 전달받은 Authentication 정보를 활용해 Json Web Token을 생성해주어야 하는데, 토큰과 관련된 요청을 처리하는 TokenUtils를 아래와 같이 만들어줄 수 있다.

@Log4j2
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class TokenUtils {

    private static final String secretKey = "ThisIsA_SecretKeyForJwtExample";

    public static String generateJwtToken(User user) {
        JwtBuilder builder = Jwts.builder()
                .setSubject(user.getEmail())
                .setHeader(createHeader())
                .setClaims(createClaims(user))
                .setExpiration(createExpireDateForOneYear())
                .signWith(SignatureAlgorithm.HS256, createSigningKey());

        return builder.compact();
    }

    public static boolean isValidToken(String token) {
        try {
            Claims claims = getClaimsFormToken(token);
            log.info("expireTime :" + claims.getExpiration());
            log.info("email :" + claims.get("email"));
            log.info("role :" + claims.get("role"));
            return true;

        } catch (ExpiredJwtException exception) {
            log.error("Token Expired");
            return false;
        } catch (JwtException exception) {
            log.error("Token Tampered");
            return false;
        } catch (NullPointerException exception) {
            log.error("Token is null");
            return false;
        }
    }

    public static String getTokenFromHeader(String header) {
        return header.split(" ")[1];
    }

    private static Date createExpireDateForOneYear() {
        // 토큰 만료시간은 30일으로 설정
        Calendar c = Calendar.getInstance();
        c.add(Calendar.DATE, 30);
        return c.getTime();
    }

    private static Map<String, Object> createHeader() {
        Map<String, Object> header = new HashMap<>();

        header.put("typ", "JWT");
        header.put("alg", "HS256");
        header.put("regDate", System.currentTimeMillis());

        return header;
    }

    private static Map<String, Object> createClaims(User user) {
        // 공개 클레임에 사용자의 이름과 이메일을 설정하여 정보를 조회할 수 있다.
        Map<String, Object> claims = new HashMap<>();

        claims.put("email", user.getEmail());
        claims.put("role", user.getRole());

        return claims;
    }

    private static Key createSigningKey() {
        byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(secretKey);
        return new SecretKeySpec(apiKeySecretBytes, SignatureAlgorithm.HS256.getJcaName());
    }

    private static Claims getClaimsFormToken(String token) {
        return Jwts.parser().setSigningKey(DatatypeConverter.parseBase64Binary(secretKey))
                .parseClaimsJws(token).getBody();
    }

    private static String getUserEmailFromToken(String token) {
        Claims claims = getClaimsFormToken(token);
        return (String) claims.get("email");
    }

    private static UserRole getRoleFromToken(String token) {
        Claims claims = getClaimsFormToken(token);
        return (UserRole) claims.get("role");
    }
}

 

인증이 성공되고 나면 CustomLoginSuccessHandler에서 Token이 생성되게 되고, 생성된 토큰을 반환하게 된다.

 

 

[ 10. 인증된 토큰을 기반으로 JWT 발급 ]

LoginSuccessHandler로 넘어온 요청은 /user/loginSuccess로 redirect된다. 전달받은 Authentication 정보를 활용해 Json Web T

 

 

2. Spring Security 처리 과정


이제 토큰을 생성해 주는 부분까지는 마무리를 하였고, 토큰을 발급받은 사용자만 원하는 로직을 처리할 수 있도록 해주어야 한다. 아래의 내용에서는 Interceptor를 활용해 유효한 토큰을 가진 사용자만 접근할 수 있도록 접근 제어를 해주고 있다.

 

 

[ 1. 유효한 토큰 검증을 위한 인터셉터 추가 ]

이 클래스는 토큰을 검증하도록 설정한 API에 대해 요청을 intercept하여 토큰의 유효성 검사를 진행한다. 유효성 검사에 실패하면 예외 API로 redirect를 시키고 있다.

@Log4j2
public class JwtTokenInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler) throws IOException {
        final String header = request.getHeader(AuthConstants.AUTH_HEADER);

        if (header != null) {
            final String token = TokenUtils.getTokenFromHeader(header);
            if (TokenUtils.isValidToken(token)) {
                return true;
            }
        }
        response.sendRedirect("/error/unauthorized");
        return false;
    }

}

 

 

[ 2. 예외 처리 컨트롤러 추가 ]

토큰의 유효성 검증에 실패한 경우 아래의 /error/unauthorized API로 redirect된다.

@RestController
@RequestMapping(value = "/error")
public class ErrorController {

    @GetMapping(value = "/unauthorized")
    public ResponseEntity<Void> unauthorized() {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }

}

 

 

[ 3. 인터셉터 추가 및 패턴 적용 ]

작성한 인터셉터 클래스를 설정에 추가하고, 토큰의 유효성을 검증할 API의 Path 패턴을 적용한다.

이번 예제에서는 전체 사용자를 조회하는 /user/findAll 에 대해 유효한 토큰을 헤더에 포함시켜 요청한 경우만 API를 호출가능하도록 설정하였다.

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    	// 작성한 인터셉터를 추가한다.
        registry.addInterceptor(jwtTokenInterceptor())
        // 예제의 경우 전체 사용자를 조회하는 /user/findAll 에 대해 토큰 검사를 진행한다.
                .addPathPatterns("/user/findAll");
    }

    @Bean
    public FilterRegistrationBean<HeaderFilter> getFilterRegistrationBean() {
        FilterRegistrationBean<HeaderFilter> registrationBean = new FilterRegistrationBean<>(createHeaderFilter());
        registrationBean.setOrder(Integer.MIN_VALUE);
        registrationBean.addUrlPatterns("/*");
        return registrationBean;
    }

    @Bean
    public HeaderFilter createHeaderFilter() {
        return new HeaderFilter();
    }

    @Bean
    public JwtTokenInterceptor jwtTokenInterceptor() {
        return new JwtTokenInterceptor();
    }

}

 

 

[ 4. 사용자 API 추가 ]

모든 사용자가 호출 가능한 회원가입 API와 위에서 적용한 PathPattern으로 유효한 토큰을 전송한 사용자만 호출가능한 전체 사용자 목록 조회 API를 추가하자.

@RequiredArgsConstructor
@RestController
@RequestMapping(value = "/user")
public class UserController {

    private final BCryptPasswordEncoder passwordEncoder;
    private final UserService userService;

    @PostMapping(value = "/signUp")
    public ResponseEntity<String> signUp(@RequestBody final SignUpDTO signUpDTO) {
        return userService.findByEmail(signUpDTO.getEmail()).isPresent()
                ? ResponseEntity.badRequest().build()
                : ResponseEntity.ok(TokenUtils.generateJwtToken(userService.signUp(signUpDTO)));
    }

    @GetMapping(value = "/list")
    public ResponseEntity<UserListResponseDTO> findAll() {
        final UserListResponseDTO userListResponseDTO = UserListResponseDTO.builder()
                .userList(userService.findAll()).build();

        return ResponseEntity.ok(userListResponseDTO);
    }

}

 

위의 로직을 구현하기 위해 다음과 같은 DTO 클래스를 추가하였다.

@Getter
public class SignUpDTO {

    private String email;
    private String pw;

}
@Getter
@Builder
public class UserListResponseDTO {

    private final List<User> userList;

}

 

그리고 UserService에서는 다음과 같은 로직들로 위의 API를 처리하고 있다.

@RequiredArgsConstructor
@Service
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder passwordEncoder;

    @Override
    public User signUp(final SignUpDTO signUpDTO) {
        final User user = User.builder()
                .email(signUpDTO.getEmail())
                .pw(passwordEncoder.encode(signUpDTO.getPw()))
                .role(UserRole.ROLE_USER)
                .build();

        return userRepository.save(user);
    }

    @Override
    public Optional<User> findByEmail(final String email) {
        return userRepository.findByEmail(email);
    }

    @Override
    public List<User> findAll() {
        return userRepository.findAll();
    }
}

 

 

 

3. Spring Security 처리 과정 요약


[ Spring Security 처리 과정 요약 ]

  1. 사용자가 아이디 비밀번호로 로그인을 요청함
  2. AuthenticationFilter에서 UsernamePasswordAuthenticationToken을 생성하여 AuthenticaionManager에게 전달
  3. AuthenticaionManager는 등록된 AuthenticaionProvider(들)을 조회하여 인증을 요구함
  4. AuthenticaionProvider는 UserDetailsService를 통해 입력받은 아이디에 대한 사용자 정보를 DB에서 조회함
  5. 입력받은 비밀번호를 암호화하여 DB의 비밀번호화 매칭되는 경우 인증이 성공된 UsernameAuthenticationToken을 생성하여 AuthenticaionManager로 반환함
  6. AuthenticaionManager는 UsernameAuthenticaionToken을 AuthenticaionFilter로 전달함
  7. AuthenticationFilter는 전달받은 UsernameAuthenticationToken을 LoginSuccessHandler로 전송하고, 토큰을 response의 헤더에 추가하여 반환함

 

 

4. Spring Security 샘플 코드 및 실행


[ Spring Security 예제 실행 방법 ]

  1. https://github.com/MangKyu/SpringSecurity-Example으로부터 소스를 클론받는다.
  2. CREATE DATABASE security DEFAULT CHARSET UTF8; 으로 데이터베이스를 생성한다.
  3. application.properties에서 DB username과 password를 개인에 맞게 변경해준다.
  4. back 폴더로 가서 서버를 실행시킨다.
  5. frontend 폴더로 가서 클라이언트를 실행시킨다.
    npm install
    npm run dev

  6. SignUp을 통해 먼저 회원가입을 한다.
  7. 아직 로그인을 하지 않았기 때문에 Get All Users를 클릭하면 에러가 발생한다.
  8. 회원가입한 계정으로 Sign In을 한다.
  9. 로그인이 성공한 후에는 Get All Users를 클릭하면 사용자 목록을 얻을 수 있다.

 

 

만약 로그인을 했을때 위의 그림과 같이 Authorization: BEAREAR {token} 이 보인다면 토큰이 정상적으로 생성된 것이다.

 

관련 포스팅

  1. 토큰 기반 인증 시스템과 서버 기반 인증 시스템의 차이 (1/3)
  2. JWT(Json Web Token) 토큰이란? (2/3)
  3. SpringBoot로 Spring Security 기반의 JWT 토큰 구현하기(3/3)

 

 

 

참고 자료

반응형
댓글
댓글쓰기 폼
  • 이전 댓글 더보기
  • 튜브 안녕하세요 !

    덕분에 열심히 따라가고 있습니다 근데 UserVO 클래스와 UserRepository 클래스는 올리지 않으신건가요 ㅠㅠ?
    2020.10.19 17:15
  • 망나니개발자 제가 위에 설명에는 안적어두고, 깃에 넣어뒀는데 수정해드렸습니다:)
    아래에 깃에도 코드가 있으니 참고해주세요!!
    https://github.com/MangKyu/SpringSecurity-Example/tree/master/back/src/main/java/com/mang/example/security/app/user
    2020.10.19 18:57 신고
  • 튜브 오우 감사해요!!!
    근데 혹시 데이터베이스 테이블 생성할때 security로 정해야하는 이유가 있나요 ??

    코드에 security database를 명시해둔 곳은 없어서요! User테이블은 알겠습니다만 ㅠㅠ
    2020.10.20 14:03
  • 망나니개발자 SpringBoot에서는 properties에서 설정을 관리할 수 있습니다. 저 같은 경우에는 기본적인 DB의 이름을 properties에 security로 잡아두었기 때문입니다!
    https://github.com/MangKyu/SpringSecurity-Example/blob/master/back/src/main/resources/application.properties
    2020.10.20 14:15 신고
  • woong 안녕하세여
    보다보니 없는게 보여서요
    authconstants 는 뭐죠
    직접 정의한 클래스 아닌가요? 안보여서요
    2020.12.10 11:16
  • 망나니개발자 넵 직접 정의하였는데, 말씀해주셔서 내용에 추가하였습니다! 확인 부탁드려요! 2020.12.10 22:01 신고
  • 초보 안녕하세요. spring boot를 공부하고 있는 초보 개발자 입니다.
    flow에 대해서 이해하려고 하고 있는데,
    로그인 정보를 입력한 뒤 로그인api uri를 호출하게 되면 필터가 가로채서 인증 과정을 하게 되는 것이 맞는지 궁금합니다.
    그리고 정상적인 로그인을 통해 인증이 완료되면 인증된 토큰을 클라이언트쪽으로 반환을 해줘야 하는데, loginsuccesshandler를 통해 restcontroller 없이 반환이 되는 건지 궁금합니다.
    2021.03.09 02:22
  • 망나니개발자 넵! 필터가 가로채서 인증 과정을 하게 되는 것이 맞고, 인증이 완료되면 handler에서 처리하게 됩니다. 2021.03.09 10:02 신고
  • 초보 답변 감사합니다.
    필터가 dispatcher servlet 앞단에서 실행이 되게 되는 것으로 알고있는데, 그럼 필터에서 모든과정을 거친 뒤에 dispathcer servlet쪽으로 가지 않고 필터에서 바로 client쪽으로 반환이 되는 건가요? ㅠㅠ 검색을 해도 잘 나오지 않아 이렇게 계속 질문드리네요 ㅠㅠ
    2021.03.09 11:17
  • 망나니개발자 넵 Spring Context로 가지 않고 Filter에 의해 처리될 겁니다! 2021.03.09 18:41 신고
  • 자바이러스 url(리소스)에 대한 접근권한을 세분화시키고 싶으면 interceptor에서 jwt token값으로부터 Admin, User 여부를 가져와서 검증하는 로직을 추가하면 될까요?? 2021.04.27 22:58 신고
  • 망나니개발자 넵! 그렇게 하실 수 있습니다! 2021.04.27 23:29 신고
  • 익명 안녕하세요. 좋은 코드 공유 감사드립니다.

    logout 할 경우 login 시 만든 토큰을 제거할 수 있나요?
    2021.07.28 16:58
  • 망나니개발자 토큰은 클라이언트에게 넘겨지고, 클라이언트가 요청할 때마다 헤더에 얹어서 보내게 됩니다. 그렇기 때문에 제거는 불가능하고, 대신 블랙리스트 관리 등을 해야할 것 같습니다:) 2021.07.28 21:05 신고
  • 구독자 제가 node를 잘 몰라서 그런데 최신 node.js를 설치해서 하니깐 아래와 같은 에러가 납니다. npm audit fix 해도 안됩니다.

    >npm install
    npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\fsevents):
    npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})

    added 1797 packages from 1891 contributors and audited 1873 packages in 111.591s

    11 packages are looking for funding
    run `npm fund` for details

    found 1092 vulnerabilities (635 low, 125 moderate, 328 high, 4 critical)
    run `npm audit fix` to fix them, or `npm audit` for details

    >npm run dev
    > frontend@1.0.0 dev C:\workspace\SpringSecurity-Example\master\frontend
    > webpack-dev-server --inline --progress --config build/webpack.dev.conf.js

    10% building modules 1/1 modules 0 activeevents.js:377
    throw er; // Unhandled 'error' event
    ^

    Error: getaddrinfo ENOTFOUND skoh:8083
    at GetAddrInfoReqWrap.onlookup [as oncomplete] (dns.js:69:26)
    Emitted 'error' event on Server instance at:
    at GetAddrInfoReqWrap.doListen [as callback] (net.js:1502:12)
    at GetAddrInfoReqWrap.onlookup [as oncomplete] (dns.js:69:17) {
    errno: -3008,
    code: 'ENOTFOUND',
    syscall: 'getaddrinfo',
    hostname: 'skoh:8083'
    }
    npm ERR! code ELIFECYCLE
    npm ERR! errno 1
    npm ERR! frontend@1.0.0 dev: `webpack-dev-server --inline --progress --config build/webpack.dev.conf.js`
    npm ERR! Exit status 1
    npm ERR!
    npm ERR! Failed at the frontend@1.0.0 dev script.
    npm ERR! This is probably not a problem with npm. There is likely additional logging output above.

    npm ERR! A complete log of this run can be found in:
    npm ERR! C:\Users\skoh8\AppData\Roaming\npm-cache\_logs\2021-08-19T05_50_52_513Z-debug.log
    2021.08.19 15:00
  • 망나니개발자 혹시 사용중이신 노드 버전이 어떻게 될까요!? 2021.08.19 22:10 신고
  • 구독자 Windows Installer
    node-v14.17.5-x86.msi
    입니다.
    2021.08.20 02:19
  • 망나니개발자 로그를 확인해보니 작성된 소스코드쪽 문제는 아닌 것 같은데, 한번 이 내용 참고해보시겠어요!?
    https://stackoverflow.com/questions/23259697/error-getaddrinfo-enotfound-in-nodejs-for-get-call
    2021.08.20 11:32 신고
  • 구독자 감사합니다!
    윈도우 환경변수에 이상한 값들이 들어가있었네요.
    2021.08.20 19:18
  • 망나니개발자 잘 해결하셨나보네요!! 다행입니닷:) 2021.08.20 20:29 신고
  • 우창혁 안녕하세요 질문이 있어 글 하나 남깁니다. 로그인 과정에서 굳이 spring security 기능을 사용하는 이유가 있을까요? login 서비스에서 그냥 id와 비밀번호를 dto로 받은 후에 id에 해당하는 계정을 db에서 가져와 비밀번호 체크를 하고 맞으면 token create 아니면 에러 이런 식으로 코드를 짜면 사실 무척 간단한데 혹시 spring security에서 제공하는 여러가지 filter와 loadUserByUsername같은 메쏘드를 쓰는 이유가 있을까요? 2021.12.05 20:23
  • 망나니개발자 안녕하세요 창혁님, 먼저 방문해주셔서 감사합니다:)
    말씀해주신대로 LoginService에 해당 로직을 구현하는 것이 적합하지 않은 이유는 로그인되지 않은 사용자의 요청이 스프링 컨테이너 내부까지 들어오기 때문입니다.
    인증되지 않은 사용자의 요청이라면 어떤 악의적 의도가 있을 수 있으므로 비지니스 로직들이 처리되는 스프링 컨테이너에 들이오기 전에 검증하고 차단하는 것이 바람직합니다.
    이를 방지하기 위해서는 결국 스프링 컨테이너 전에 요청을 처리하는 필터를 도입해야 합니다.
    그렇다면 이제 필터에 로직을 추가해야하는데, 이를 일일이 구현하는 것 보다는 당연히 미리 만들어진 필터를 사용하면 더욱 유용할 것입니다. 그 외에도 SpringSecurity는 결국 하나의 보안 프레임워크이기 때문에 체계적이며 기본적인 보안 공통 모듈들을 상당히 많이 제공하고 있으며, 기본적인 흐름이 정의되어 있으므로 SpringSecurity를 아는 개발자라면 새로운 프로젝트에 투입되어도 흐름을 따라가기 쉬울 것입니다.

    추가로 필터에 대해 모르시면 여기를 참고해주세요!
    https://mangkyu.tistory.com/173?category=761302
    2021.12.05 22:35 신고
  • 우창혁 답변 너무 감사합니다! 설득이 바로 되네요... 2021.12.06 12:07
  • 망나니개발자 의미있는 질문이였던 것 같습니다ㅎㅎ 물론 항상 보안 프레임워크를 쓰는게 옳지는 않습니다. POC 성격의 프로젝트나 단기간에 하는 심플한 프로젝트라면 충분히 Spring Security를 도입하지 않고 처리하는 것도 좋은 방향이라고 생각합니다:)
    창혁님의 현재 프로젝트 상황을 고려해서 판단을 하시면 될 것 같아요ㅎㅎ
    2021.12.06 15:22 신고
  • tae0y 일주일 헤맸는데 이 글 보고 이해했습니다! 토큰 발급/확인 모두 잘 되네요.
    좋은글 감사합니다.
    2022.02.24 22:07
  • 망나니개발자 오오.... 이 포스팅도 지금 보면 고칠 내용이 많을 것 같은데 그래도 잘 읽어주셔서 감사합니다:) 2022.02.25 00:44 신고
  • FunnyDevelop 내용도 너무 좋고
    답변도 너무 좋으시네요

    복 받으세요!
    2022.03.20 13:16 신고
  • 망나니개발자 도움이 되었다니 뿌듯하네요ㅎㅎ 감사합니다! 2022.03.20 14:12 신고
  • faker 스프링 시큐리티를 배우는 많은 개발자들에게 단비같은 글 감사합니다.^^
    spring security 에서 세션을 이용한 방법과 jwt 토큰을 이용한 방법간에 차이는 '토큰 기반 인증 시스템과 서버 기반 인증 시스템의 차이 (1/3)' 포스팅에서 확인 하였습니다.

    소규모 프로젝트의 경우에는 서버기반 인증 시스템을 사용한다고 하셨는데, 실무를 경험해보지 못한 취업 준비생으로서, 소규모 기준이 예상이 안되네요.
    혹, 실례가 안된다면 실무자 입장에서 서버 기반 인증 시스템이 아닌 jwt 토큰 인증 방식을 선택하게 되는 규모에 대해서 좀더 설명 해 주실 수 있을까요.?

    추가로 실무에서 서버 기반 인증 시스템이 아닌 jwt 토큰 인증 방식을 사용해야 하는 구체적 이유나 조건들이 있으면 같이 말씀 해주시면 감사하겠습니다.
    2022.03.23 23:46
  • 망나니개발자 안녕하세요 faker님!
    고려 사항으로 세션에 저장해야 하는 사용자 정보의 크기, 서버 스펙, 확장성 등이 있어서 딱 몇 명이라고 특정하기는 어려울 것 같습니다! 또 세션 만료 시간을 어떻게 부여함에 따라서도 달라질 수 있습니다!
    세션 기반으로 인증을 하게 되면 메모리에 불필요하게 사용자의 인증 정보를 담고있어야 합니다. 그래서 어느정도 규모가 있다면 서버에서 상태를 관리하지 않는 방식으로 인증 시스템을 구현하는 것이 좋습니다ㅎㅎ 물론 해당 프로젝트들의 규모가 작거나 POC 성격이거나 빠르게 구현해야 하는 상황이라면 세션 기반이 더 적합할 수 있습니다. 세션 인증 방식은 아무래도 개발하는 비용이 더 클 수 밖에 없습니다!
    2022.03.24 02:23 신고
  • faker 좋은 글 감사합니다. 추가로 질문 드려요.
    jwt 인증 구현을 구글링해보면, 토큰 발급과정부터 토큰 인증과정까지 여러 방법들이 존재하는 것을 확인 했습니다.

    크게 2가지 방법이 존재 했습니다.

    1) 첫번째 방법 (위 포스팅과 동일한 방법, 인터셉터 이용한 방식)
    - 스프링 시큐리티 로그인 로직에서 사용자 정보로 인증 및 토큰 발급.
    - 인터셉터로 토큰 인증 수행.

    2) 두번째 방법( 일반 컨트롤러와 토큰을 이용한 방법)
    - 토큰을 발급받는 별도에 컨트롤러 생성하여, 해당 컨트롤러를 통해 토큰 발급.
    - 스프링 시큐리티에 토큰인증 커스텀 필터를 추가하여 스프링 시큐리티를 통해 인증 수행.

    두가지 방법의 각각의 가지는 장단점이 있을지 궁금합니다.
    그리고 개인적으로 로그인, 토큰 발급, 토큰 인증 등의 모든 보안 절차를 별도의 외부 컨트롤러나 외부 필터 없이 스프링 시큐리티 하나로 해결 할 수 있는 방법이 존재하는지도 궁금합니다.(보안에 전적인 책임을 스프링 시큐리티 에 위임하도록 하는게 단일책임 원칙으로서 좀 더 타당한지 않은가..? 라는 개인적인 초보 개발자 생각입니다.)

    2가지 방법들을 공부 한 후, 프로젝트에 도입하고자 하니 몇가지가 걸렸습니다.

    1번 방법은 인증 할 url 을 필터로 처리하게 되고, 스프링 시큐리티가 아닌, 스프링 웹단에서 처리한다는 게 조금 걸리고,

    2번 방법은 토큰 발급을 위한 url 을 스프링 시큐리티의 통제가 아닌 별도에 컨트롤러로 처리하는게 조금 걸렸습니다.

    물론, 구글링한 곳들도 스프링 시큐리티 만으로 해결하는 코드는 아직 발견하지 못한걸 보면, 그렇게 하지 않는데는 분명 무언가 문제가 있어서 그런것이라고 생각을 들지만, 아직 그 문제에 대한 근거를 찾지 못해서 답답하네요...

    2022.03.27 17:21
  • 망나니개발자 상당히 깊게 공부를 하시는 것 같아서 인상깊네요!
    우선 크게 2가지 방법을 찾으신 것 같은데요, 정석적인 시스템이라고 한다면 인증/인가를 위한 게이트웨이 시스템이 가장 앞에 붙어서 그 게이트웨이 단에서 인증/인가 및 토큰 처리 등을 해주어야 한다고 생각을 합니다!
    여기서 "정석적인"이라는 표현을 사용한 이유는 그렇다면 인증/인가를 위한 게이트웨이 시스템을 구축할 만큼의 여력이 있지를 판단해야 하기 때문인데, 대부분 큰 규모가 아니라면 빠른 사업의 확장이 중요하기 때문에 이러한 시스템은 우선순위에서 밀려날 가능성이 높습니다.
    그렇다면 결국 "타협"을 해야하는데, 제 포스팅은 1번으로 되어 있지만 제가 만약에 구축을 해야 한다면 2번의 방향으로 할 것 같습니다. 우선 토큰을 발급하는 것 역시 하나의 비지니스 로직 처리이기 때문에 별도의 API로 컨트롤러를 만다는 것이 전혀 이상하지 않고, 필터는 웹 컨테이너 단에서 동작하기 때문에 "인증" 단계라면 스프링 컨테이너 전의 단계에서 거르는 것이 좋아 보이기 때문입니다ㅎㅎ "인가"라면 인터셉터가 괜찮을 것 같구요!
    크게 도움이 되었을지는 모르겠지만 조금이나마 답답함을 해소하시는 근거가 되셨으면 좋겠네요!
    2022.03.28 01:26 신고
  • faker 답답함이 해소 되네요. ㅎㅎ 매번 자료조사를 할때마다 신기할만큼 관련 포스팅이 블로그에 있는걸 보고 깜짝 놀라고, 포스팅의 좋은 퀄리티로 2번 놀라고 갑니다.

    많은 분들에게 좋은 정보와 개발자로서의 탐구자세를 간접적으로 전달하는 이 블로그가 앞으로도 계속 유지 해 나갔으면 하는 개인적인 바람이 있습니다. ㅎㅎ

    마지막으로 좋은 답변 감사합니다.
    2022.03.28 17:42
  • 망나니개발자 별거 없는 블로그지만... 좋은 얘기 남겨주셔서 감사합니다ㅎㅎㅎ 앞으로도 열심히 포스팅 이어가겠습니다:) 2022.03.28 20:10 신고
  • 스프링초보 안녕하세요! jwt에 대해 공부하고 있는데요. jwt인증 방식은 서버에 유저 정보를 저장하지 않아 메모리를 점유하지 않는다는 것으로 알고 있는데 일부 블로그에서는 contextholder 에 토큰값을 저장하는 식으로 구현이 되어있더라구요. SecurityContextHolder에 유저 정보를 토큰으로 저장하고 있다가 클라이언트 요청이 왔을때 검증하는 방식도 jwt토큰 방식이 맞는건지 궁금합니다!! 혹시 제가 질문한 사항중에 이상한 부분 있으시면 말씀해주세요!! 2022.04.16 14:05
  • 망나니개발자 말씀해주신대로 JWT 인증 방식은 서버에 유저 정보를 저장하지 않는 Stateless 방식이여야 합니다. 만약 JWT를 서버에 저장한다면 굳이 JWT로 저장할 필요 없이 유저 정보 자체를 저장하면 될 것입니다. 그러므로 참고하신 내용들은 올바른 내용들은 아닐 것 같습니다ㅎㅎ JWT 인증 방식이라고 하면 클라이언트가 헤더에 JWT를 얹어서 요청을 보내고, 서버가 이를 검증하는 프로세스가 올바른 것 같습니다:) 2022.04.16 17:00 신고
  • 눈꾸꿈 안녕하세요! 블로그 보면서 항상 도움 많이 받고있어요!
    저는 JWT + OAuth 로그인 함께 구현하면서 필터에서 토큰을 인증하는 방식으로 구현했는데 API url마다 토큰의 유무를 체크하는 부분이 없는거같아서 여기서 인터셉터에 토큰 유무를 확인하는 핸들러를 추가하는것이 나을까요? 아니면 필터에서 인증하지말고 아예 전부 다 인터셉터에서 구현하는게 좋을까요?
    제가 구현한 방식은
    1. 요청 도착
    2-1. 요청에 토큰이 실려있는 경우, 토큰 정확한 형식이고 유효한지 체크한 후 SecurityContext에 저장
    2-2. 요청에 토큰이 실려있지 않은 경우, 필터 통과
    3. restController로 가서 비즈니스 로직 실행
    비즈니스 로직 실행 중 사용자의 ID가 필요한 경우 SecurityContext에서 사용자 정보 꺼내쓰기
    사용자 ID가 필요한 경우인데 토큰이 오지 않아 SecurityContext에 저장되지 않으면 예외 발생시켜 응답보내기

    이렇게 구현하였는데 맞는 방식일까요?ㅜㅜ
    필터보다 인터셉터에서 JWT 토큰 인증하는 것이 더 좋은 방법일까요?
    2022.04.21 21:57
  • 망나니개발자 안녕하세요! 적어주신 내용을 바탕으로 제 눈에 보이는 몇가지 코멘트 드리겠습니다! 제가 적어드린 내용이 정답은 아니니 참고만하시고, 합리적인 판단을 하시면 됩니다ㅎㅎ


    1. 검증되지 않은 요청은 인터셉터가 아닌 필터에서 처리
    인증된 사용자의 요청이 아닌 경우는 스프링 컨테이너 영역이 아닌 웹 컨테이너 영역인 필터에서 처리하는 것이 가장 안전합니다. 필터와 인터셉터를 자세히 모르시면 이 글을 참고해주세요!
    https://mangkyu.tistory.com/173

    2. 인증되지 않은 요청은 컨트롤러까지 도달 X
    위와 같은 이유로 요청에 토큰이 없다면 필터를 통과하는게 아니라 에러를 던지는게 맞을 것 같습니다! 컨트롤러까지 요청이 가지 않아야 합니다.

    3. SecurityContext에 저장
    JWT 기반의 인증 방식은 Stateless해야하는데, SecurityContext에 저장하는 것은 Stateful한 방법입니다. Stateless하도록 구조를 변경해야 할 것 같습니다.
    2022.04.22 00:17 신고
  • 만두 1. 인증 로직은 필터에서 처리하는 게 불필요한 코드 호출을 최소화할 수 있어서 인터셉터에서 처리하는 것 보다 유리한 것 같은데, 어떤 이유로 인터셉터에서 처리를 권장하시는지 궁금합니다!

    3. 세션방식일 때 JSESESSIONID를 메모리에 저장해서 stateful하다고 하지 않나요..? 반대로 토큰방식일 때는 JwtFilter로 DB에서 유저 정보를 불러와서 SecurityContext에 User 객체를 담는 것은 ThreadLocal이기 때문에 요청이 끝나면 그 즉시 사라지므로, stateless로 이해하고 있었는데, 혹시 제가 잘못 이해하고 있는 걸까요?
    2022.04.26 19:22
  • 망나니개발자 안녕하세요! 1번에서 적은 제목이랑 내용이 상반되네요ㅎㅎ;;;;; 필터에서 하는게 더 적합하다는 얘기였는데 제목을 잘못 적었네요. 수정했습니다! 필터가 더 적합한 것이 맞습니다ㅎㅎ
    3번에서 SpringSecurity에서 Stateless옵션을 주면 SecurityContext에 set해도 저장이 안되는 것으로 알고 있습니다. 그리고 쓰레드로컬조차 사용하지 않는게 Stateless 한거라고 저는 이해하고 있습니다ㅎㅎ 혹시 제가 잘못 이해한거면 정정해주세요!
    2022.04.26 21:21 신고
  • 만두 음 일단 ThreadLocal은 thread가 종료되면 함께 사라지는 걸로 알고 있습니다.

    또한, Stateful은 클라이언트의 이전 request의 상태(쿠키, 세션 등)를 서버에 계속 유지함으로써 매 요청마다 이전의 상태를 활용하는 것으로 알고 있습니다.

    그러나 SecurityContext는 ThreadLocal에 저장되기 때문에, http 요청 처리가 종료되면, 해당 요청을 처리한 thread와 함께 ThreadLocal에 저장되어 있던 값들도 함께 사라져서 stateless라고 생각했는데.. 혹시 어떤 이유로 ThreadLocal이 stateful이라 생각하시는지 궁금합니다..!

    ---
    stateless 옵션을 주신다는 말씀이 시큐리티 설정에서 SessionCreationPolicy.STATELESS을 말씀하시는 거라면, 이는 단순히 세션을 사용하지 않는 걸로 알고 있습니다. 해당 옵션을 적용해도 세션과 별개로 ThreadLocal에 저장된 SecurityContext에 Authentication 객체를 저장하고 꺼내는게 가능합니다. (현재 이렇게 사용하고 있습니다.)
    2022.04.27 06:12
  • 망나니개발자 SessionCreationPolicy.STATELESS 옵션은 제가 착각하고 있었군요ㅎㅎ 시큐리티 안쓴지가 너무 오래되어서 헷갈렸네요 죄송합니다!
    쓰레드 로컬은 말씀해주신대로 쓰레드에 할당되는 공간인데, 스프링은 쓰레드 풀을 통해 쓰레드를 재사용합니다. 그러면 쓰레드 로컬이 정상적으로 해제되지 않았다면 이전 다른 사용자의 정보값이 남아있기 때문에 저는 그렇게 생각을 했습니다ㅎㅎ 물론 springsecurity가 그럴일은 없지만 threadlocal 자체가 stateful한 object를 저장하는 방식이니까요 (제가 잘못 생각할 수 있으니 참고용으로만 보시면 됩니다)
    그리고 SecurityContext에 객체를 저장하고 꺼내는게 가능하다고 할지라도 testability 때문에 저는 굳이 쓰레드 로컬을 사용하는 SecurityContext 사용은 안할 것 같습니다ㅎㅎ
    2022.04.27 13:58 신고
  • 만두 음 비슷한 예로 Singleton도 stateless로 설계하는 걸로 알고 있는데요. Singleton 객체를 설계할 때 stateless를 위해 ThreadLocal이 쓰이는 걸로 알고 있습니다.

    말씀하신 것과 동일하게 저도 Thread pool 환경에서는 ThreadLocal을 사용하는 경우 마지막에 반드시 해당 data를 삭제해 주는 걸로 알고 있습니다.
    만약 이 방식에 문제가 발생할 수 있는 여지가 있다면, default로 bean을 Singleton으로 관리하는 Spring Framework의 설계 방식을 부정하는 게 아닌가 조심스레 말씀드려 봅니다..

    아니면 혹시 직접 해당 문제를 겪어보신 경험이 있으셔서 말씀하신 걸까요?

    ---
    ThreadLocal 자체가 stateful object를 저장하는 방식..? 이라는 말씀이 이해가 잘 안되는데 조금 더 자세히 설명해 주실 수 있을까요?

    서버에서 이전 요청에 대한 상태를 보관하고 다음 요청에 이를 활용하는 환경을 stateful이라고 하지 않나요..? ThreadLocal는 각 요청 Thread마다 개별적으로 할당되는 것이라 이를 활용한 방식은 stateless하다는 것으로 이해하고 있었는데, ThreadLocal에 stateful한 객체를 저장한다..? 라는 말이 잘 이해가 되지 않습니다.

    ---
    testability를 언급해 주셨는데요, 제가 경험이 짧아서 그런지.. 이 단어가 포괄하는 범위가 조금 광범위하다보니 어떤 의미로 말씀하신 건지 잘 이해가 안되는데.. 혹시 조금 더 자세히 설명해 주실 수 있을까요?
    직/간접적으로 경험해 보신 사례와 함께 설명해 주신다면 정말 좋을 것 같습니다!
    2022.04.27 20:47
  • 망나니개발자 음.... 제가 직접 얘기하는 것보다는 쓰레드 로컬이 엮인 테스트를 작성해는게 더 좋을것 같습니다ㅎㅎ 감사합니다:) 2022.04.28 01:06 신고
  • 만두 무언가 다른 생각이 있으신 걸로 기대했었는데.. 아닌가 보네요 :) 알겠습니다. 댓글은 여기까지 남기겠습니다. 2022.04.28 07:01
  • 눈꾸꿈 답변 너무 감사합니다 :)
    3번에 추가적으로 궁금한게 있는데 그럼 비즈니스 로직 중 사용자 ID가 필요하다면(예를들어 사용자ID(PK)를 통해 사용자의 detail정보 조회) 그냥 요청의 body나 파라미터로 받는 방법이 맞다는 말씀이신가요??
    저는 어차피 JWT 토큰에 사용자 ID가 저장되어있으니까 그걸 디코딩해서 id를 SecurityContext에 저장하고 필요할 떄 비즈니스 로직에서 꺼내썼거든요!
    2022.04.22 11:57
  • 망나니개발자 음... pk는 body로 받으면 안될 것 같습니다ㅎㅎ 토큰은 디코딩가능한 정보이므로 일반적으로 이메일과 같이 사용자를 식별할 수 있는 값이 담겨있잖아욤? 대신 사용자에 대한 구체적인 정보는 없을테니 필터에서 캐시나 데이터베이스로부터 사용자 정보를 조회해야 하지 않을까 싶어요ㅎㅎ 그리고 조회한 정보를 request에 넣어주든 argumentresolver를 사용하든 컨트롤러로 전달을 해주어야 할 것 입니다!
    토큰에 pk 자체를 넣는거는 테이블의 key가 노출되므로 보안 상의 문제로 안하는 것으로 알고 있어요ㅎㅎ
    2022.04.22 18:43 신고
  • 구름 안녕하세요. 혹시 토큰에 pk를 넣는 게 보안상으로 문제가 된다는 근거는 어디서 찾으셨는지 궁금합니다.

    어찌되었든 토큰에 유저를 식별할 수 있는 key는 포함될 수 밖에 없지 않을까요?
    2022.04.26 16:23
  • 망나니개발자 우선 저희 사내 보안검수 시에 잡히더라구요ㅎㅎ 그리고 관련된 내용은 워낙 많아서 조금만 구글링해보시면 많이 나올 것 입니다! 사실 인스타그램과 같은 경우에는 pk를 그대로 노출하기도 해서 크게 문제 삼지 않기도 합니다! 말씀해주신대로 유저를 식별하는 key를 노출하는 것은 불가피한데, 그래도 이왕이면 db의 pk보다는 uuid와 같은 값을 대신 내려주는게 더 바람직할 것 같다는 생각입니다! 2022.04.26 21:23 신고
  • 구름 아 우선 제가 말씀드린 PK는 auto increment인 경우를 말씀드린 것이었습니다. (혹은 PK를 uuid로 사용하거나)

    물론 PK가 주민번호와 같이 개인정보로 이루어져 있다면, 보안적으로 당연히 이슈가 있다고 생각합니다.

    그러나, PK가 단순히 auto increment 값이나, uuid, 혹은 개인 정보와 관련이 없는 값(더하여 추후에 바뀔 가능성이 희박한 값)에 해당하는 경우 노출해도 큰 문제가 없다고 알고 있습니다.

    auto increment와 같이 예측가능한 값은 크롤링이나 sql 인젝션에 좀 더 취약할 수 있다고는 하지만, 이들은 충분히 다른 방법으로 방지가 가능한 부분이고.. 언급해 주신대로 인스타그램을 포함해서 여러 서비스가 아직까지도 PK를 auto increment로 사용하면서 동시에 거리낌없이 노출도하는 걸 보면.. 이러한 방식이 보안적으로 크게 문제가 없다고 생각했었습니다.

    ---
    혹시 제가 모르고 있는 부분이 있다면 공유해 주시면 감사하겠습니다 (__)
    아니면 검색 키워드라도 공유해 주신다면 감사히 찾아보겠습니다.
    (저도 의견을 남기기 전엔 항상 구글링 해 보고 있습니다만.. 놓친 부분이 당연히 있을 거라고 생각하고 있습니다!)
    2022.04.27 06:29
  • 망나니개발자 아래 내용으로 보셔도 되고, 비슷한 키워드로 관련 내용 찾아보시면 될 것 같습니다ㅎㅎ 참고로 저도 PK가 아닌 값을 사용한다고 해서 드라마틱한 보안 상의 차이가 있을 것이라고는 생각하지는 않습니다
    https://stackoverflow.com/questions/26167344/is-it-secure-to-use-database-primary-key-values-in-api-directly
    2022.04.27 13:55 신고
  • 구름 공유해 주신 사이트 잘 읽어 보았습니다. 감사합니다 (__)
    요약해 보면, "DB table에서 특정 row를 식별할 수 있는 unique key가 노출되는 상황 + 해당 DB의 table에 쓰기 작업이 가능한 취약점을 발견한 경우" 이러한 상황에서는 key를 직접적으로 노출하면 보안적으로 이슈가 생길 수 있다. 라고 이해했습니다.

    사실상 unique key를 클라이언트에 전달하지 않고서는 클라이언트가 해당 object를 식별할 방법이 없는 걸로 알고 있는데요.. 이는 PK를 노출함으로써 이슈가 발생한다라기 보다는, 오히려 무결성을 해칠 수 있는 취약한 로직이 서버에 존재한다는 것에서 이슈가 발생한다고 봐야 하지 않나 싶습니다.

    오히려 이보다 제가 우려하는 점은 PK가 개인 정보와 관련이 있거나, auto increment 값으로 해당 사이트의 유저 수를 분석한다거나.. 어떻게 보면 passive attack에 대한 우려가 있겠지만, 이러한 내용은 크리티컬한 이슈가 아니라고 생각하기 때문에 많은 서비스에서도 PK를 그대로 노출하는 것이 아닌가 라는 생각을 해 봅니다..ㅎㅎ
    2022.04.27 21:07
  • 망나니개발자 이전 댓글에서 제가 언급한 부분을 잘 봐주시길 부탁드리겠습니다ㅎㅎ 감사합니다:) 2022.04.28 01:03 신고
  • 구름 그럼 보안상으로 드라마틱한 문제는 없지만, 사내 보안검수에 있어서 PK를 노출하지 않는다는 생각이셨던 것 정도로 이해하겠습니다. 감사합니다. 2022.04.28 07:03
  • kong 좋은 글 정말 잘 봤습니다. 덕분에 스프링 시큐리티에 대해 이해하는데 정말 많은 도움이 되었습니다.

    근데 혹시 LoginSuccessHandler에서 바로 response가 클라이언트에게 전송되는 건가요 ?
    만약 그렇다면 로그인 성공 시 response에 성공했다는 메세지를 담아 보내려면 어떻게 해야 할까요 ?
    2022.05.04 01:30 신고
  • 망나니개발자 response 객체를 받으므로 해당 객체를 통해 처리하면 될 것 같습니다ㅎㅎ 2022.05.04 15:52 신고
  • 원1 안녕하세요 본 게시글을 통해 관련 공부하는데 큰 도움이 도움이 되었습니다!
    하나 질문이 있어서 댓글 남기게 되었습니다.

    올려주신 글에서는 SecurityConfig를 작성하기 위해 WebSecurityConfigurerAdapter에서 필요한 부분을 오버라이드하셨는데 현재는 이 기능이 더 이상 사용되지 않아 지원이 되지 않고 있습니다. 그래서 필요한 것들을 모두 빈으로 등록하여야하는데 이 부분을 잘 모르겠습니다 ,,

    1) CustomAuthenticationFilter에 AuthenticationManager를 등록하기 위해 우선 AuthenticationManager를 빈으로 등록하여야 하는데 어떻게 해야하는지 잘 모르겠습니다. 기존에는 WebSecurityConfigurerAdapter 상위 클래스에 존재하는 AuthenticationManager에 등록하였는데 현재 어떻게 AuthenticationManager를 가져와 빈으로 등록해야하는지 질문 드리고 싶습니다.

    2) 기존에는 AuthenticationManagerBuilder에 CustomAuthenticationProvider를 직접 등록하셨는데 현재는 어떤 방식으로 해야할까요?
    2022.07.16 19:40 신고
  • 망나니개발자 관련된 내용이 아래의 공식문서에 예시 코드들과 함께 자세히 설명되어 있으니 참고하시면 충분히 해결하실 수 있을 것 같습니다ㅎㅎ 링크 첨부해드리겠습니다!
    https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter
    2022.07.17 00:38 신고
반응형
공지사항
Total
3,020,554
Today
1,528
Yesterday
4,868
TAG
more
«   2022/10   »
            1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31          
글 보관함