ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Security] JWT 간단히 실습해보기
    Spring Framework 2024. 5. 19. 00:23

    참고 자료

    목표

    • 스프링 시큐리티와 JWT를 사용하여 간단한 인증, 인가를 구현해보며 JWT를 이해해본다.

    JWT란?

    더보기

    정의

    • JWT(Json Web Token)는 JSON 객체를 사용하여 정보를 안전하게 전송하기 위한 토큰 표준이다.

    등장 배경

    • 기존의 서버 기반 인증 방식(세션, 쿠키)의 보안 문제, 그리고 확장성과 분산 환경에서의 한계로 인해 등장했다.

    장점

    • Stateless : 서버는 토큰의 유효성만 검증하므로 세션 관리가 필요 없어 확장성이 좋다.
    • 분산 환경 지원 : 토큰에 인증 정보가 포함되어 있어 여러 서비스 간 인증 정보 공유가 쉽다.
    • 다양한 환경(웹, 모바일 앱, IoT 등)에서 사용 가능하다.

    한계

    • 토큰에 포함된 정보가 많을수록 토큰의 길이가 길어져 네트워크 부하가 증가할 수 있다.
    • 토큰의 만료 시간을 적절히 설정해야 한다. 만료 시간이 길면 보안 위험이 있고, 짧으면 사용자 경험이 저하될 수 있다. 이에 대한 대안으로 Refresh 토큰이 있다.
    • Access 토큰과 Refresh 토큰
      • Access 토큰
        • 사용자의 인증 정보를 포함하고 있으며, 보호된 리소스에 접근하기 위해 사용된다.
      • Refresh 토큰
        • 유효기간이 짧은 Access 토큰의 한계를 보완하기 위해 사용된다.
        • Refresh 토큰은 Access 토큰보다 긴 유효기간을 가지고 있다. 따라서 안전하게 저장하고 관리하는 것이 매우 중요하다.
        • Access 토큰이 만료되면, Refresh 토큰을 사용하여 새로운 Access 토큰을 발급받을 수 있다.

    구조

    • JWT는 헤더(Header), 페이로드(Payload), 서명(Signature) 이렇게 세 부분으로 구성되며 각 부분은 마침표(.)로 구분한다.
    • 헤더 : 토큰의 유형과 해싱 알고리즘을 지정한다.
    • 페이로드 : 사용자 데이터가 담기는 부분이다. 따라서 민감한 정보는 포함되지 않도록 주의해야 한다.
    • 서명 : 헤더와 페이로드를 결합한 후 암호화된 부분이다. JWT의 무결성을 검증하는 데 사용된다.

    JWT 인증, 인가 동작 원리

    • 로그인(인증) 성공 시 서버는 사용자에게 JWT를 생성하여 응답한다. 이 토큰은 일반적으로 사용자의 브라우저에 저장된다.
    • 경로 접근(인가) 시 사용자가 보낸 JWT를 검증하고 임시 세션을 생성한다. 이 세션은 단일 요청이 끝나면 소멸된다. 임시 세션을 생성하는 주된 이유는 요청 처리 과정에서 사용자 정보를 편리하게 사용하기 위함이다. JWT 자체는 Stateless하기 때문에 한번 발급된 토큰은 서버에서 별도로 관리하지 않는다. 그러나 요청을 처리하는 과정에서 사용자 정보가 필요한 경우가 많다. 이때 매번 JWT에서 사용자 정보를 추출하는 것은 비효율적이다.

    초기 설정

    DB 설정

    더보기
    @Data
    @Entity
    public class User {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private String username;
        private String password;
        private String role;
    }
    public interface UserRepository extends JpaRepository<User, Long> {
        Boolean existsByUsername(String username);
        User findByUsername(String username);
    }
    spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
    spring.datasource.url=jdbc:mysql://localhost:3306/jwt_study?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true
    spring.datasource.username=[사용자명]
    spring.datasource.password=[비밀번호]
    
    spring.jpa.hibernate.ddl-auto=update
    spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
    

     

    SecurityConfig

    • SessionCreationPolicy.STATELESS : JWT를 통한 인증, 인가 구현을 위해 세션을 stateless 상태로 설정한다.
    • formLogin을 disable하면 로그인 시 username과 password를 검증하는 UsernamePasswordAuthenticationFilter가 동작하지 않는다. JWT 방식에서는 로그인 성공 시 JWT를 발급해줘야 하므로 로그인 필터를 직접 구현하여 등록하면 된다.
    @Configuration
    @EnableWebSecurity
    public class SecurityConfig {
    
    	  @Bean
        public BCryptPasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    
            http.csrf(auth -> auth.disable());
            http.formLogin(auth -> auth.disable());
            http.httpBasic(auth -> auth.disable());
    
            http.authorizeHttpRequests(auth -> auth
                    .requestMatchers("/login", "/", "/join").permitAll()
                    .requestMatchers("/admin").hasRole("ADMIN")
                    .anyRequest().authenticated()
            );
    
            http.sessionManagement(session -> session
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS));
    
            return http.build();
        }
    }

    회원가입

    더보기

    JoinDto

    @Data
    public class JoinDto {
        private String username;
        private String password;
    }

     

     

    JoinService

    • ADMIN 권한 부여
    @Service
    @RequiredArgsConstructor
    public class JoinService {
    
        private final UserRepository userRepository;
        private final BCryptPasswordEncoder passwordEncoder;
    
        public void joinProcess(JoinDto joinDto) {
            String username = joinDto.getUsername();
            String password = joinDto.getPassword();
    
            if (userRepository.existsByUsername(username)) {
                return;
            }
    
            User user = new User();
            user.setUsername(username);
            user.setPassword(passwordEncoder.encode(password));
            user.setRole("ROLE_ADMIN");
    
            userRepository.save(user);
        }
    }

     

     

    JoinController

    @RestController
    @RequiredArgsConstructor
    public class JoinController {
    
        private final JoinService joinService;
    
        @PostMapping("/join")
        public String join(@ModelAttribute JoinDto joinDto) {
            joinService.joinProcess(joinDto);
            return "ok";
        }
    }
    

     

    CustomUserDetails, CustomUserDetailsService 구현

    더보기

    CustomUserDetails

    @RequiredArgsConstructor
    public class CustomUserDetails implements UserDetails {
    
        private final User user;
    
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            Collection<GrantedAuthority> collection = new ArrayList<>();
            collection.add(new GrantedAuthority() {
                @Override
                public String getAuthority() {
                    return user.getRole();
                }
            });
            return collection;
        }
    
        @Override
        public String getPassword() {
            return user.getPassword();
        }
    
        @Override
        public String getUsername() {
            return user.getUsername();
        }
    
        @Override
        public boolean isAccountNonExpired() {
            return true;
        }
    
        @Override
        public boolean isAccountNonLocked() {
            return true;
        }
    
        @Override
        public boolean isCredentialsNonExpired() {
            return true;
        }
    
        @Override
        public boolean isEnabled() {
            return true;
        }
    }
    

     

     

    CustomUserDetailsService

    @Service
    @RequiredArgsConstructor
    public class CustomUserDetailsService implements UserDetailsService {
    
        private final UserRepository userRepository;
    
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    
            User user = userRepository.findByUsername(username);
    
            if (user != null) {
                return new CustomUserDetails(user);
            }
    
            return null;
        }
    }
    

    JWT 발급 및 검증 클래스

    JWTUtil

    • JWT 발급과 검증을 담당할 클래스를 구현한다.
    • JWT의 서명을 생성하고 검증할 때 사용하는 비밀키를 application.properties에 spring.jwt.secret라는 키 값으로 정의해 두었다. 비밀키는 충분히 길어야 하며 대소문자, 숫자, 특수문자 등을 결합하여 예측 불가능한 문자열을 생성해야 한다.
    • 페이로드에 저장될 정보 : username, role, 발행일시, 만료일시
    • verifyWith(secretKey) : 검증 작업
    @Component
    public class JWTUtil {
    
        private final SecretKey secretKey;
    
        // 주입 받은 비밀키 값을 SecretKey 객체로 변환한다.
        public JWTUtil(@Value("${spring.jwt.secret}") String secret) {
            this.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());
        }
    		
        // JWT 발급
        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) // 서명
                    .compact();
        }
    }

     

    로그인 필터 구현

     

    LoginFilter

    • UsernamePasswordAuthenticationFilter을 상속 받았기 때문에 /login POST 요청을 처리한다.
    • attemptAuthentication() : 실제 로그인 검증 작업을 하는 AuthenticationManager에게 username과 password를 전달한다.
    • AuthenticationManager : UserDetailsService로부터 DB에서 조회한 사용자 데이터를 기반으로 로그인을 검증한다.
    • 로그인 성공 시 JWT를 발급하고 실패 시 401 상태코드를 반환한다.
    @RequiredArgsConstructor
    public class LoginFilter extends UsernamePasswordAuthenticationFilter {
    
        private final AuthenticationManager authenticationManager;
        private final JWTUtil jwtUtil;
        
        @Override
        public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
    
            String username = obtainUsername(request);
            String password = obtainPassword(request);
    
            UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);
            return authenticationManager.authenticate(authToken);
        }
    
        // 로그인 성공 시 JWT 발급
        @Override
        protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
    
            CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
    
            String username = customUserDetails.getUsername();
            String role = customUserDetails.getAuthorities().iterator().next().getAuthority();
    
            String token = jwtUtil.createJwt(username, role, 60 * 60 * 1000L); // 1시간
    
            // 토큰에 "Bearer "를 붙이는 것은 JWT 표준 규약
            response.addHeader("Authorization", "Bearer " + token);
        }
    
        // 로그인 실패 시 401 상태코드 반환
        @Override
        protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        }
    }

     

    JWT 검증 필터 구현

     

    JWTFilter

    • 헤더에 JWT가 포함된 요청의 경우 토큰을 검증하는 필터 클래스를 구현한다.
    • 토큰이 유효한 경우 사용자 정보를 생성하여 세션에 저장한다. 이 세션은 stateless이기 때문에 해당 요청이 끝나면 소멸 된다.
    @RequiredArgsConstructor
    public class JWTFilter extends OncePerRequestFilter {
    
        private final JWTUtil jwtUtil;
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
            String authorization = request.getHeader("Authorization");
    
            // Authorization 헤더 검증
            if (authorization == null || !authorization.startsWith("Bearer ")) {
                System.out.println("token null");
                filterChain.doFilter(request, response);
                return;
            }
    
            String token = authorization.split(" ")[1];
    
            // token 소멸 시간 검증
            if (jwtUtil.isExpired(token)) {
                System.out.println("token expired");
                filterChain.doFilter(request, response);
                return;
            }
    
            String username = jwtUtil.getUsername(token);
            String role = jwtUtil.getRole(token);
    
            User user = new User();
            user.setUsername(username);
            user.setPassword("temppassword");
            user.setRole(role);
    
            CustomUserDetails customUserDetails = new CustomUserDetails(user);
    
            // 스프링 시큐리티 인증 토큰 생성
            Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
    
            // 세션에 사용자 등록
            SecurityContextHolder.getContext().setAuthentication(authToken);
    
            filterChain.doFilter(request, response);
        }
    }

     

    로그인 필터와 JWT 필터 등록

     

    SecurityConfig

    @Configuration
    @EnableWebSecurity
    @RequiredArgsConstructor
    public class SecurityConfig {
    
        private final AuthenticationConfiguration authenticationConfiguration;
        private final JWTUtil jwtUtil;
    
        @Bean
        public BCryptPasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        @Bean
        public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
            return configuration.getAuthenticationManager();
        }
    
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    
            http.csrf(auth -> auth.disable());
            http.formLogin(auth -> auth.disable());
            http.httpBasic(auth -> auth.disable());
    
            http.authorizeHttpRequests(auth -> auth
                    .requestMatchers("/login", "/", "/join").permitAll()
                    .requestMatchers("/admin").hasRole("ADMIN")
                    .anyRequest().authenticated()
            );
    
            // JWT 필터 등록
            http.addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);
    				
            // 로그인 필터 등록
            http.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class);
    
            http.sessionManagement(session -> session
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS));
    
            return http.build();
        }
    }

     

    테스트

    /login POST 요청

    • 로그인 성공 시 JWT가 발급된 것을 확인할 수 있다. 이후 JWT를 포함시켜 요청하면 JWT 필터에 의해 해당 토큰이 유효한지 검증 작업이 이루어진다.

     


    MainController

    • 임시 세션 테스트
    @RestController
    public class MainController {
    
        @GetMapping("/")
        public String mainPage() {
    
            String name = SecurityContextHolder.getContext().getAuthentication().getName();
            String role = SecurityContextHolder.getContext().getAuthentication().getAuthorities().iterator().next().getAuthority();
    
            return "Main Controller : " + name + " " + role;
        }
    }

    좌 - 로그인 전 / 우 - 로그인 후


    AdminController

    • 인가된 사용자만 접근 가능
    @RestController
    public class AdminController {
    
        @GetMapping("/admin")
        public String adminPage() {
            return "Admin Controller";
        }
    }
    

    좌 - 유효하지 않은 JWT / 우 - 로그인 후 올바른 JWT

     


     

    말로만 듣던 JWT를 직접 공부해 본 건 오늘이 처음이다. 서버에 인증 정보를 관리하지 않고도 나름대로 보안성이 우수하고 다양한 기기에서 지원되는 방식이라는 점에서 큰 장점이 있는 기술이라는 것을 느꼈다. 하지만 그럼에도 불구하고 완벽한 보안은 없다는 것도 느꼈다. 모든 인증 방식에는 장단점과 한계가 있어서 여러가지 보안 기술을 함께 적용하여 단점을 보완해야 한다는 것도 배웠다.