SpringSecurity와 JWT.1

ys·2024년 6월 29일

Spring공부

목록 보기
13/14

목표

🤔 SpringSecurity를 사용하지 않았던 경우!

  • JWT Token을 이용해 인가, 인증을 연습하고 있다
  • 만약 header의 token이 정상 유저로 판명됐다면, RequestContextHolder 에 저장해두었던, 사용자 정보를 가져와서 로그인한 user의 정보를 확인하였다
  • 로그인이 필요한 모든 페이지에서 해당 작업을 하기엔 코드에 중복이 너무 많으므로, 어노테이션을 하나 만들어서 -> HandlerMethodArgumentResolver를 통해 SRP원칙을 지켜주었다
  • Spring Security는 스프링 기반의 애플리케이션 보안을 담당하는 스프링 하위 프레임워크이다
  • Spring Security의 기능을 잘 사용할 수 있다면, 우리가 더 편리하게 인증 및 인가 작업을 할 수 있다
  • ✅ Spring Security와 JWT Token을 함께 사용하는 방법을 잘 공부해, Spring Security를 통해 쉽게 인증, 인가 작업을 해보자!

1. Config

  • Spring Security는 인증,인가 처리의 보안 프레임워크이고, 기본적으로 모든 기능들이 막혀있다
  • 우리는 필요한 기능들에 대해서 설정을 풀어서 Spring Security를 이용해야 한다
  • @Configuration@EnableWebSecurity 어노테이션을 통해 -> Spring Security의 설정파일임을 알려준다
  • 클래스 내부에 특정한 메소드들을 @Bean으로 등록해 Security 설정을 진행할 수 있다

filterChain

  • 인가, 로그인, 세션 설정을 진행해주고 SecurityFilterChain을 반환해주는 filterChain 메서드이다
  • HttpSecurity를 인자로 받는다
  • builder 타입으로 만들어서 반환해주면 된다
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception{


        httpSecurity
                .csrf((auth) -> auth.disable())     // csrf disable(Session 방식에서는 필요, JWT 방식에서는 필요 없음)
                .formLogin((auth) -> auth.disable()) // from 로그인 방식 disable
                .httpBasic((auth) -> auth.disable()) // http basic 인증 방식 disable
                .authorizeHttpRequests((auth) -> auth
                        .requestMatchers("/login", "/", "/join").permitAll()
                        .requestMatchers("/admin").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .sessionManagement((session) -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); // JWT 방식 -> Session 을 Stateless한 상태로 관리!

        return httpSecurity.build();
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder(){
        return new BCryptPasswordEncoder();
    }
}
  • spring security가 12.3버전부터 람다식 방식으로 대체(자세한건 공식문서 참조)
  • jwt token 인증 방식을 사용할 것이기 때문에 Form 로그인 방식, http Basic 인증 방식을 모두 disalbe해준다
  • authorizeHttpRequests를 통해 경로별 인가작업을 진행
    • permitAll()
    • 12버전 부터 requestMatchers()를 통해 경로 작업을 해줄 수 있다
    • anyRequest().authenticated() : 이외의 주소는, 인가작업 후 접근 가능
  • ✅ JWT는 세션을 항상 stateless하게 관리하므로 stateless설정을 넣어준다
    • sessionManagement()
    • sessionCreationPolicy()

2. 회원가입, 로그인 로직

  • 다음 원리로 Spring Security가 작동한다!

Spring Security 공식문서
https://spring.io/projects/spring-security#learn
https://docs.spring.io/spring-security/reference/servlet/architecture.html


A. Delegating Filter(Spring Security Filter 원리)


  • spring security에 대해서는 다음 블로그에서 정리를 해두었다
  • https://velog.io/@yys/Spring-Security
  • 스프링 시큐리티는 클라이언트의 요청이 여러개의 필터를 거쳐 DispatcherServlet(Controller)으로 향하는 중간 필터에서 요청을 가로챈 후 검증(인증/인가)을 진행한다.
  • Delegating Filter Proxy
    • 서블릿 컨테이너 (톰캣)에 존재하는 필터 체인에 DelegatingFilter를 등록한 뒤 모든 요청을 가로챈다.
  • 가로챈 요청은 SecurityFilterChain에서 처리 후 상황에 따른 거부, 리디렉션, 서블릿으로 요청 전달을 진행한다.
  • 다음과 같이 서블릿 필터 체인의 DelegatingFilter → Security 필터 체인 (내부 처리 후) → 서블릿 필터 체인의 DelegatingFilter 순서로 작동한다
  • 다음 그림은, SecurityFilterChain의 필터 목록과 순서이다
  • 다음에서 필요한 설정을 사용하고, 구현해서 커스텀도 사용할 수 있다

우리는 formLogin을 disable 해두었기 때문에 커스텀한 UsernamePasswordAuthenticationFilter을 만들어서 등록해주면 된다


B. UsernamePasswordAuthenticationFilter


  • 앞서 말했듯이 formLogin을 disable을 했기 때문에, UsernamePasswordAuthenticationFilter을 커스텀 한 후, security에 등록해주어야 한다
  • Spring Security 의 Authentication Manager에서 검증하기 위해서는 token에 담아야 한다
  • token에 담긴 검증을 위해 Authentication Manager 로 전달
  • attemptAuthentication에서 UsernamePasswordAuthenticationToken에 담아서 AuthenticationManager에 넘겨줘야 한다
@Slf4j
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        String email = request.getParameter("email");
        String password = obtainPassword(request);



        // Spring Security 의 Authentication Manager에서 검증하기 위해서는 token에 담아야 함
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(email, password, null);

        // token에 담긴 검증을 위해 Authentication Manager 로 전달
        return authenticationManager.authenticate(authToken);

    }


C. UserDetailsService 커스텀

  • UserDetailsService에서 UserDetails 객체에 담아서 AuthenticationManager에게 전달하면 검증을 하는 서비스 로직을 커스텀화한다
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        UserEntity userData = userRepository.findByEmail(email);

        if (userData != null){
            return new CustomUserDetails(userData);
        }

        return null;
    }
}
  • 이 커스텀 서비스는, 레파지토리에서 찾은 데이터가 null 이 아니면 UserDetails를 리턴하는 로직이다


D. UserDetails 커스텀 구현

  • AuthenticationManager와 UserDetailsService 사이의 데이터를 전달하는 SpringSecurity의 객체이다
  • 이것도 custom으로 구현한다
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {

    private final UserEntity userEntity;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return userEntity.getRole();
            }
        });
        return collection;
    }

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

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

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

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

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

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

3. 로그인 성공시 JWT 토큰 생성


  • UsernameAuthenticationFilter -> AutenticationManager -> (UserDetails) -> UserDetailsService -> UserRepository -> UserDetailService -> (UserDetails) -> AutenticationManager 과정까지 왔다
  • 여기서 로그인이 성공됐다면 successfulAuthentication 에서 성공 로직을 처리해 주면 된다
  • 우리는 여기서 JWT를 발급해주면 된다
  • 만약 로그인이 실패됐다면, unsuccessfulAuthentication에서 처리를 해주면 된다


A. JWT Token을 생성하는 JWTUtil


  • jwt 생성로직은 여기서 다루지 않겠다
@Component
public class JWTUtil {
    private SecretKey secretKey;

    public JWTUtil(@Value("${spring.jwt.secret}") String secret) {

        secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
    }

    public String getUsername(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("email", String.class);
    }

    public String getRole(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
    }

    public Boolean isExpired(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
    }

    public String createJwt(String email, String role, Long expiredMs) {

        return Jwts.builder()
                .claim("email", email)
                .claim("role", role)
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expiredMs))
                .signWith(secretKey)
                .compact();
    }
}


B. successfulAuthentication

  • authenticationManager에서 로그인이 정상으로 진행됐다면 successfulAuthentication 로 이동을 하게 된다
  • 우리가 만든 customUserDetails 에서 token의 정보로 사용할 email과 role을 가져와서 token을 만들어준다
  • 만들고 응답의 헤더에, Authorization이름, 그리고 Bearer 로 시작하게 넣어준다
//로그인 성공시 실행하는 메소드 (여기서 JWT를 발급하면 됨)
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
        CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();

        String email = customUserDetails.getUsername();

        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();

        String role = auth.getAuthority();

        String jwt = jwtUtil.createJwt(email, role, 60 * 60 * 10L);

        response.addHeader("Authorization","Bearer "+ jwt);
    }

    //로그인 실패시 실행하는 메소드
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
        response.setStatus(401);

    }

3. JWT Token 검증


  • 우리는 로그인이 성공시 응답의 header로 토큰값을 잘 넣어서 반환해주었다
  • 이제, 클라이언트가 token을 header로 실어서 다시 보냈다고 생각을 해보자!
  • 우리는 어떻게 token을 인증할까?

🤔 내 생각 !!!

  • JWT Token은 statless하다는 특징이 있지만...
  • JWT Token을 사용해 인증할 때에는 Session을 만들어서 사용하자!!
  • 실제 우리 대학교 홈페이지도 이렇게 구성되어 있다
    • 한번 로그인된 세션에서는 개인정보를 인증해야 하는 페이지에 대해서 계속 이용할 수 있는 것 이다
    • 만약 그렇지 않으면, 그 페이지를 인증할 때마다, Token을 클라이언트 사이드에서 서버 사이드로 보내주어야 하고
    • 그렇게 되면 Token의 탈취 위험이 증가하게 된다..
  • OncePerRequestFilter를 구현한 JWTFilter을 사용하자!
@Slf4j
@RequiredArgsConstructor
public class JWTFilter extends OncePerRequestFilter {

    private final JWTUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        // request에서 Authorization 헤더를 찾음
        String authorization = request.getHeader("Authorization");

        // Authrization 헤더 검증
        if (authorization == null || !authorization.startsWith("Bearer")){
            log.info("token null");
            filterChain.doFilter(request,response);

            // 조건이 해당되면 메서드 종료
            return;
        }

        String token = authorization.split(" ")[1];
        // 토큰 소멸 시간 검증
        if (jwtUtil.isExpired(token)){
            log.info("expired token");
            filterChain.doFilter(request,response);

            // 조건이 해당되면 메서드 종료
            return;
        }

        //토큰에서 username과 role 획득
        String email = jwtUtil.getUsername(token);
        String role = jwtUtil.getRole(token);

        //userEntity를 생성하여 값 set
        UserEntity userEntity = UserEntity.builder()
                .email(email)
                .password("temppassword")
                .role(role)
                .build();

        //UserDetails에 회원 정보 객체 담기
        CustomUserDetails customUserDetails = new CustomUserDetails(userEntity);

        //스프링 시큐리티 인증 토큰 생성
        Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
        //세션에 사용자 등록
        SecurityContextHolder.getContext().setAuthentication(authToken);

        filterChain.doFilter(request, response);


    }
}

4. 커스텀 filter 모두 Security 설정에 등록


@Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception{


        httpSecurity
                .csrf((auth) -> auth.disable())     // csrf disable(Session 방식에서는 필요, JWT 방식에서는 필요 없음)
                .formLogin((auth) -> auth.disable()) // from 로그인 방식 disable
                .httpBasic((auth) -> auth.disable()) // http basic 인증 방식 disable
                .authorizeHttpRequests((auth) -> auth
                        .requestMatchers("/login", "/", "/join").permitAll()
                        .requestMatchers("/admin").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class)
                .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration),jwtUtil), UsernamePasswordAuthenticationFilter.class)
                .sessionManagement((session) -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); // JWT 방식 -> Session 을 Stateless한 상태로 관리!

        return httpSecurity.build();
    }
  • addFilterAt : 해당 위치에 filter 추가
  • addFilterBefore : 해당 위치 filter 전에 추가
  • addFilterAfter : 해당 위치 filter 후에 추가

5. 토큰을 header에 넣고 로그인


- AdminController

@RestController
public class AdminController {

    @GetMapping("/admin")
    public String adminP(){
        return "admin Controller";
    }
}
  • 로그인을 성공한 후, header로 받은 token값을 이용해서

  • 인가가 필요한 admin 페이지에 해당 token값을 header에 넣은 후 요청을 보내본다

  • 정상 작동하는 모습을 볼 수 있다!!!

profile
개발 공부,정리

0개의 댓글