스프링시큐리티

Spring Security, JWT를 이용한 인증/인가

salmon16 2024. 5. 25. 20:21

스프링 시큐리티 프레임워크와 JWT를 이용해서 인증/인가를 구현해 보자.

버전 및 의존성

  • Spring Boot 3.2.5
  • Security 6.2.4
  • Lombok
  • Spring Data JPA = MySQL
  • Cradle - Groovy
  • intelliJ Ultimate
  • JWT 0.12.3

먼저 위해 SecurityConfig 파일을 작성해야 한다.  

@Configuration
@EnableWebSecurity // 시큐리티를 위한 config라는 걸 위해
public class SecurityConfig {

    //AuthenticationManager가 인자로 받을 AuthenticationConfiguraion 객체 생성자 주입
    private final AuthenticationConfiguration authenticationConfiguration;
    private final JWTUtil jwtUtil;

    public SecurityConfig(AuthenticationConfiguration authenticationConfiguration, JWTUtil jwtUtil) {

        this.authenticationConfiguration = authenticationConfiguration;
        this.jwtUtil = jwtUtil;
    }

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

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {

        return configuration.getAuthenticationManager();
    }
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        //csrf disable
        http
                .csrf((auth) -> auth.disable()); //세션 방식과 달리 jwt는 무상태라 가능
        //세션은 계속 고정되어 있기때문에 막아야 함
        //JWT방식을 이용할 거 가 때문에 아래 2개 disable
        //From 로그인 방식 disable
        http
                .formLogin((auth) -> auth.disable());

        //http basic 인증 방식 disable
        http
                .httpBasic((auth) -> auth.disable());

        //경로별 인가 작업
        http
                .authorizeHttpRequests((auth) -> auth
                        .requestMatchers("/login", "/", "/join").permitAll()
                        .requestMatchers("/admin").hasRole("ADMIN")
                        .anyRequest().authenticated());
        // addFilterAt == 추가할 필터, 대체될 필터
        http
                .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class);

        //세션 설정 세션을 Stateless 상태로 만들어 줘야한다.
        http
                .sessionManagement((session) -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }
}
  • JWT 방식은 세션 방식과다르게 Stateless 이므로 csrf를 disable해 주어도 된다.
  • JWT 방식을 사용할 거기 때문에 form 로그인 방식과 http basic 인증 방식은 사용하지 않는다
  • 경로별로 인가를 해준다. login, join = 모든 사용자 가능 , /admin = ADMIN 사용자만 가능 다른 나머지는 로그인 필요 

JWT를 이용한 로그인 과정

  1. 사용자의 요청으로 Login 요청을 받는다
  2. UsernamePasswordAuthenticationFilter가 ID, Password를 꺼내서 Authentication Manager에게 넘겨준다.
  3. Authentication Manager은 DB로 회원 정보를 가져와서 검증을 한다.
  4. 검증이 확인되면
  5. succussfulAuth가 JWTUtil을 통해 JWT를 생성해 사용자에게 넘겨준다.

스프링 시큐리티 필터 동작 원리

스프링 시큐리티는 클라이언트의 요청이 여러 개의 필터를 거쳐 DispatcherServlet(Controller)으로 향하는 중간 필터에서 요청을 가로챈 후 검증(인증/인가)을 진행한다.

 

사용자의 요청은 다음과 같은 과정으로 진행된다.

클라이언트 요청 -> 서블릿 컨테이너 -> 서블릿 필터 -> 서블릿(컨트롤러)

 

  • Delegating Filter Proxy

서블릿 컨테이너 (톰캣)에 존재하는 필터 체인에 DelegatingFilter를 등록한 뒤 모든 요청을 가로 챈다.

  • 서블릿 필터 체인의 DelegatingFilter -> Security 필터 체인 (내부 처리 후) -> 서블릿 필터 체인의 DelegatingFilter 과정으로 처리된다.
    • 가로챈 요청은 SecurityFilterChain에서 처리 후 상황에 따른 거부, 리디렉션, 서블릿으로 요청 전달을 진행한다.

  • SecurityFilterChain의 필터 목록과 순서

세션 방식의 Form 로그인 방식에서는 UsernamePasswordAuthenticationFilter에서 회원 검증을 진행했지만

나의 JWT 프로젝트는 Form로그인 방식을 disable 했기 때문에 해당 필터는 동작하지 않는다.

따라서 로그인을 진행하기 위해 커스텀 필터를 추가적으로 등록해야 한다.

 

  • 로그인 로직 구현 목표
    • 아이디, 비밀번호 검증을 위한 커스텀 필터 작성
    • DB에 저장되어 있는 회원 정보를 기반으로 검증할 로직 작성
    • 로그인 성공 시 JWT를 반환할 success 핸들러 생성
    • 커스텀 필터를 SecurityConfig에 등록

UsernamePasswordAuthentication 필터를 작성해 보자

public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;

    private final JWTUtil jwtUtil;

    public LoginFilter(AuthenticationManager authenticationManager, JWTUtil jwtUtil) {

        this.authenticationManager = authenticationManager;
        this.jwtUtil = jwtUtil;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        //클라이언트 요청에서 username, password 추출
        String username = obtainUsername(request);
        String password = obtainPassword(request);

        //스프링 시큐리티에서 username과 password를 검증하기 위해서는 token에 담아야 함 (DTO같은 느낌)
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);

        //token에 담은 검증을 위한 AuthenticationManager로 전달 검을을 한다.
        return authenticationManager.authenticate(authToken);
    }

    //로그인 성공시 실행하는 메소드 (여기서 JWT를 발급하면 됨)
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
        System.out.println("success");
    }

    //로그인 실패시 실행하는 메소드
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
        System.out.println("fail");
    }
}

UsernamePasswordAuthenticationFilter을 상속받는 LoginFilter를 작성했다. 

attemptAuthentication함수는 Authentication interface를 반환한다.

UsernamePasswordAuthenticationFilter는 authenticationManager로 인증 요청을 보내야 한다.

authenticationManager에 유저의 아이디와 비밀번호를 넘겨주려면 UsernamePasswordAuthenticationToken에 담아서 인자로 넘겨주어야 한다. (DTO 같은 역할)

만든 필터 등록하기

SecurityConfig에 내가 만든 LoginFilter를 등록해야 한다. 

http
                .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration)), UsernamePasswordAuthenticationFilter.class);

 

 

필터를 등록하기 위해 addFilterAt 함수를 이용한다 addFilterAt 함수는 인자로 (추가할 필터, 교체할 필터)를 넘겨주어 교체할 필터 자리에 추가할 필터를 넣어주는 함수이다.

 

또 AuthenticationManager를 Bean으로 등록해 주어야 한다. 이를 등록하기 위해 AuthenticationConfiguration을 주입받아 AuthenticationManager의 생성자로 넘겨주어야 한다.

	private final AuthenticationConfiguration authenticationConfiguration;

    public SecurityConfig(AuthenticationConfiguration authenticationConfiguration) {

        this.authenticationConfiguration = authenticationConfiguration;
    }
    
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {

        return configuration.getAuthenticationManager();
    }

UserDetailService 구현

 

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {

        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        //DB에서 조회
        UserEntity userData = userRepository.findByUsername(username);

        if (userData != null) {

            //UserDetails에 담아서 return하면 AutneticationManager가 검증 함
            return new CustomUserDetails(userData);
        }

        return null;
    }
}

 

Dto와 비슷한 역할을 하는 CustomUserDtails를 구현하자

권한 체크, 계정 블락, 만료 등의 기능을 수행할 수 있다. 

 

public class CustomUserDetails implements UserDetails {

    private final UserEntity userEntity;

    public CustomUserDetails(UserEntity userEntity) {

        this.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.getUsername();
    }

    // 특정 권한이 만료 되었는지
    @Override
    public boolean isAccountNonExpired() {

        return true;
    }

    // 계정이 Block되었는지
    @Override
    public boolean isAccountNonLocked() {

        return true;
    }


    @Override
    public boolean isCredentialsNonExpired() {

        return true;
    }

    @Override
    public boolean isEnabled() {

        return true;
    }
}

 

 

이제 로그인을 수행하면(/login으로 RequestParm에 username, password를 전송 이 부분은 UsernamePasswordAuthenticationFilter에 구현되어 있다. ) 성공했을 때 LoginFilter의 successfulAuthentication 메서드가 실패했을 땐 unsuccessfulAuthentication 메서드가 실행된다.

 

JWTUtil 구현 

이제 JWTUtil을 구현해 보자 로그인이 성공하면 JWT 토큰을 발급, 검증을 담당할 클래스가 필요하다. 

 

JWT 토큰이란

JWT는 Header.Payload.Signature 구조로 이루어져 있다. 각 요소는 다음 기능을 수행한다.

- Header
    - JWT임을 명시
    - 사용된 암호화 알고리즘
- Payload
    - 정보
- Signature
    - 암호화알고리즘((BASE64(Header))+(BASE64(Payload)) + 암호화키)

JWT의 특징은 내부 정보를 단순 BASE64 방식으로 인코딩하기 때문에 외부에서 쉽게 디코딩할 수 있다. 

외부에서 열람해도 되는 정보를 담아야 하며(name, Role), 토큰 자체의 발급처를 확인하기 위해서 사용한다.

(지폐와 같이 외부에서 그 금액을 확인하고 금방 외형을 따라서 만들 수 있지만 발급처에 대한 보장 및 검증은 확실하게 해야 하는 경우에 사용한다. 따라서 토큰 내부에 비밀번호와 같은 값 입력 금지)

 

암호 키 저장

 

암호화 키는 하드코딩 방식으로 구현 내부에 탑재하는 것을 지양하기 때문에 변수 설정 파일에 저장한다.

암호값은 최대한 길게 설정하면 된다. (256비트 이상을 암호화시킬 수 있게) 

application.properties

spring.jwt.secret=vmfhaltmskdlstkfkdgodyroqkfwkdbalroqkfwkdbalaaaaaaaaaaaaaaaabbbbb

 

JWTUtil

  •  토큰 Payload에 저장될 정보
    • username
    • role
    • 생성일
    • 만료일
  • JWTUtil 구현 메서드
    • JWTUtil 생성자
    • username 확인 메소드
    • role 확인 메소드
    • 만료일 확인 메소드

JWTUtil : 0.12.3 버전 

@Component
public class JWTUtil {

    private final 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("username", 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 username, String role, Long expiredMs) {

        return Jwts.builder()
                .claim("username", username)
                .claim("role", role)
                .issuedAt(new Date(System.currentTimeMillis())) //현재 발행시간
                .expiration(new Date(System.currentTimeMillis() + expiredMs)) //만료 시간
                .signWith(secretKey) //secretKey를 통해 암호화 
                .compact();
    }
}

 

@Value 애노테이션을 통해 secret을 받고 이를 이용해서 secretKey를 객체로 내부에 저장해 두어야 한다.

(string key는 이제 jwt에서 사용하지 않음)

다음 함수들은 jwt를 파싱 해서 이름, 역할, 만료기간을 확인하는 역할을 하고 createJwt는 로그인 성공 시 jwt토큰을 만들어

준다.

 

이제 위에서 작성한 로그인 성공 시 실행하는 메서드에 JWT 토큰을 반환하도록 수정해 주자 

//로그인 성공시 실행하는 메소드 (여기서 JWT를 발급하면 됨)
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {

        //특정한 유저를 principal에서 가져나온다.
        CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();

        String username = customUserDetails.getUsername();

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

        String role = auth.getAuthority();
        // 토큰 생성 유저 이름, 역할, 기간 
        String token = jwtUtil.createJwt(username, role, 60*60*10L);
        // 토큰 반환 Bearer다음 공백  필수 
        response.addHeader("Authorization", "Bearer " + token);
    }

로그인 실패했을 때는 간단하게 401 응답 코드를 반환한다.

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

 

JWT 검증 필터

JWT 토큰을 생성하는 거 까지 구현했다. 이제 시큐리티 filter chain에 JWT 토큰을 검증하는 필터를 구현해 보자

검증 필터를 통해 Authorization키에 JWT가 존재하는 경우 JWT를 검증하고 강제로 SecurityContextHolder에 세션을 생성한다. (여기서 세션은 STATLESS 상태로 관리되기 때문에 해당 요청이 끝나면 소멸된다.)

 

JWT 검증 후 세션(Authentication 객체)을 생성하는 이유는 인증 이후의 필터단(인가 필터 등)과 서블릿(컨트롤러)에서 유저에 대한 정보를 획득하기 위함이다.

 

시큐리티 로직은 N개의 필터로 이루어져 있다. 1번 필터에서 한 작업을 N번 필터에서 확인하려면 서로 내용이 공유가 되어야 하는데 그 공유 방법을 SecurityContextHolder의 SecurityContext의 Authentication 객체를 통해서 활용한다. 그래서 JWT를 들고 서버로 들어오면 Authentication객체를 만들어 SecurityContextHolder에게 위임하고 요청이 끝날 때까지 관리하게 한다. (이후 요청이 끝나고 응답이 진행되면 STATLESS이기 때문에 따로 저장하지 않고 초기화 시킴.)

 

 

 

JWTFilter 구현

public class JWTFilter extends OncePerRequestFilter { //요청에 대해 한번만 동작하는 필터 상속

    private final JWTUtil jwtUtil;

    public JWTFilter(JWTUtil jwtUtil) {

        this.jwtUtil = jwtUtil;
    }


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

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

        //Authorization 헤더 검증 널인지 토큰이 있는 지 검사
        if (authorization == null || !authorization.startsWith("Bearer ")) {

            System.out.println("token null");
            // 다음 필터로 넘겨주기 
            filterChain.doFilter(request, response);

            //조건이 해당되면 메소드 종료 (필수)
            return;
        }

        System.out.println("authorization now");
        //Bearer 부분 제거 후 순수 토큰만 획득
        String token = authorization.split(" ")[1];

        //토큰 소멸 시간 검증
        if (jwtUtil.isExpired(token)) {

            System.out.println("token expired");
            // 다음 필터 진행
            filterChain.doFilter(request, response);

            //조건이 해당되면 메소드 종료 (필수)
            return;
        }

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

        //userEntity를 생성하여 값 set
        UserEntity userEntity = new UserEntity();
        userEntity.setUsername(username);
        userEntity.setPassword("temppassword");
        userEntity.setRole(role);

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

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

        filterChain.doFilter(request, response);
    }
}

userEntity에서 비밀번호는 임의의 값으로 채워주면 된다. (DB에 요청 시 매번 쿼리가 날아가므로) 

만든 CustomUserDetails를 가지고 UsernamePasswordAuthenticationToken을 만들어서 authToken을 생성 후

SecurityContextHolder에 등록해 주면 된다. 그럼 특정 경로로 접근이 가능하다.

 

이후 JWTFilter를 시큐리티 필터에 등록을 해주면 된다.

LoginFilter 앞에다가 등록해 주면 된다.

//JWTFilter 등록
        http
                .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);

 

이후 header에 토큰을 넣어 로그인을 진행하게 된다면 잘 진행하는 것을 확인할 수 있다. 

 

JWTFilter는 OncePerRequestFilter를 상속받아 만들었기 때문에 모든 경로에 대해 request 요청시 동작한다. LoginFilter의 경우 UsernamePasswordAuthenticationFilter를 상속 받아 만들었기 때문에 디폴트로 설정된 POST /login 경로만 동작을 한다.

 

JWT는 세션을 stateless 하게 작동하지만 JWTFilter를 통과한 뒤 일시적으로 세션을 만든다.   

그러므로 SecurityContextHolder를 통해 사용자 이름을 확인할 수 있다. (아이디, ROLE 값 등)

 

세션에 있는 유저 정보 사용 방법

String name = SecurityContextHolder.getContext().getAuthentication().getName();

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

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

 

Principal 주입을 통한 방법 

@GetMapping("/")
    public String mainP(Principal principal) {
        String name1 = principal.getName();

 

CORS 설정

프런트엔드와의 CORS 문제를 해결하기 위해 cors 허용 설정을 해주어야 한다.

security, MVC 두 번의 설정이 필요하다.

 

Security

@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
				
				http
                .cors((corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {

                    @Override
                    public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {

                        CorsConfiguration configuration = new CorsConfiguration();

                        configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000"));
                        configuration.setAllowedMethods(Collections.singletonList("*"));
                        configuration.setAllowCredentials(true);
                        configuration.setAllowedHeaders(Collections.singletonList("*"));
                        configuration.setMaxAge(3600L);

												configuration.setExposedHeaders(Collections.singletonList("Authorization"));

                        return configuration;
                    }
                })));

        return http.build();
    }

프런트엔드는 3000 포트를 사용하므로 3000 포트에 대한 허용을 해주어야 한다. 

MVC

@Configuration
public class CorsMvcConfig implements WebMvcConfigurer {
    
    @Override
    public void addCorsMappings(CorsRegistry corsRegistry) {
        
        corsRegistry.addMapping("/**")
                .allowedOrigins("http://localhost:3000");
    }
}