Spring Security - ID/PW 기반 로그인 및 JWT 발급

이유석·2024년 11월 22일

Spring-Security

목록 보기
4/10
post-thumbnail

인증 (Authentication)

  • 어떤 개체(사용자 또는 장치)의 신원을 확인하는 과정
    ex. ID/PW 로그인 방식, SMS 인증, OAuth 2, Token 방식

인가 (Authorization)

  • 어떤 개체가 어떤 리소스에 접근할 수 있는지 도는 어떤 동작을 수행할 수 있는지를 검증하는 것
    ex. Role, Authority 기반 인가 처리

CORS (Cross Origin Resource Sharing)

  • 도메인 또는 포트가 다른 서버의 자원을 요청하는 방법
  • 하지만, 동일 출처 정책(same-origin policy) 때문에 CORS 같은 상황이 발생하면 외부 서버에 요청한 데이터를 보안 목적으로 차단한다.
  • Spring Security 는 모든 CORS 요청을 차단하므로, 이를 명시적으로 허용해줘야한다.
  • 동일 출처 정책 : “같은 출처에서만 리소스를 공유할 수 있다”라는 규칙을 가진 정책

CSRF (Cross Site Request Forgery)

  • 사이트간 요청 위조 → 공격자가 사용자의 요청을 위조하여 공격하는 방법

  • CSRF 성공 조건
    1. 사용자가 이미 보안이 취약한 서버에 로그인 되어있는 상태이어야 한다.
    2. 쿠키 기반의 서버 세션 정보를 획득할 수 있어야 한다.
    3. 공격자는 서버를 공격하기 위해서 요청 방법에 대해 미리 파악하고 있어야 한다.

  • Token 인증 방식을 주로 활용하는 REST API 서버에 CSRF 공격을 성공하기는 어렵다.

REST API 를 위한 Spring Security 설정

  1. build.gradle 의존성 추가

    implementation 'org.springframework.boot:spring-boot-starter-security'
  2. SecurityConfig 설정 컴포넌트 생성

  • WebSecurityConfig.java
    @Configuration
    @EnableWebSecurity
    public class WebSecurityConfig {
    		@Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    
            http.cors(withDefaults());
            // CSRF 보안 disable
            http.csrf(AbstractHttpConfigurer::disable);
    
    				// REST API 는 무상태성 이다. -> session : STATELESS
            http.sessionManagement((sessionManagement) -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
    
    				// 모든 요청에 대해서 인증정보가 필요하다.
    				http.authorizeHttpRequests((authorizeRequests) ->
                    authorizeRequests
                            .anyRequest().authenticated()
            );
    
            return http.build();
    
        }
        
        // CORS 허용
        @Bean
        public CorsConfigurationSource corsConfigurationSource() {
            CorsConfiguration configuration = new CorsConfiguration();
    
            configuration.addAllowedOriginPattern("*");
            configuration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
            configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
            configuration.setAllowCredentials(true);
    
            UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
            // 모든 API Endpoint 에 동일한 configuration 적용
            source.registerCorsConfiguration("/**", configuration); 
    
            return source;
        }
    }

ID/PW 기반 로그인 및 JWT 발급

로그인 및 JWT 발급 처리 순서

  1. 클라이언트에서 /user/login (서버 로그인 API)으로 사용자 ID/PW 정보를 요청 본문에 넣어서 요청한다.
  2. 요청이 들어오면 JwtAuthFilter 를 거친다.
    → JwtAuthFilter 는 UsernamePasswordAuthenticationFilter 이전에 위치해있다.
  3. 로그인 요청에 대해서는 인증이 필요한 요청이 아니므로 토큰 정보가 필요하지 않다. → JWT 토큰 검증 Pass
  4. 로그인 API에서 로그인 비즈니스 로직(로그인 요청 검증 및 JWT 생성)이 수행된다.
    • SpringSecurity 의 AuthenticationManager 를 사용하여 인증을 수행한다.

JwtAuthFilter 를 AuthenticationFilter(UsernamePasswordAuthenticationFilter) 이전에 위치시키는 이유

  • 요청이 들어오면 인증 처리가 완료된 상태에서 Spring Security의 인가 로직이 실해되어야 한다.
  • Spring Security 는 요청이 들어오면 인증 처리를 위해 AuthenticationFilter 를 만나게 된다.
  • JwtAuthFilter 에서 인증 처리를 수행함으로서, AuthenticationFilter 를 대체하게 된다.
    → JwtAuthFilter를 AuthenticationFilter 이전에 배치하여 JWT 인증이 우선 수행되도록 합니다.

로그인 및 JWT 발급 구현

Spring Security 설정 파일 수정

  • 로그인 요청에 대한 인증 무효화 및 JwtAuthFilter 위치 설정
  • 로그인시, ID/PW 인증을 수행하기 위한 AuthenticationManager 빈 등록
    @Configuration
    @EnableWebSecurity
    public class WebSecurityConfig {
    		
    		// JWT 관련 처리를 수행하는 Util 클래스
    		private final JwtProvider jwtProvider;
    		
    		@Bean
        AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
            return authenticationConfiguration.getAuthenticationManager();
        }
    		
    		@Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    
            http.cors(withDefaults());
            http.csrf(AbstractHttpConfigurer::disable);
    
            http.sessionManagement((sessionManagement) -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
    
    				// 로그인 요청에 대해서는 인증 권한이 필요 없음
    				http.authorizeHttpRequests((authorizeRequests) ->
                    authorizeRequests
    				                .requestMatchers("/user/login").permitAll()  
                            .anyRequest().authenticated()
            );
            
            // JwtAuthFilter 는 UsernamePasswordAuthenticationFilter 이전에 위치한다.
            http.addFilterBefore(new JwtAuthFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
    
            return http.build();
    
        }
        
        @Bean
        public CorsConfigurationSource corsConfigurationSource() {...}
    }

JwtProvider 클래스 선언

  • JWT 관련 처리를 수행하는 JwtProvider 클래스이다.
  • 각 함수의 자세한 구현은 아래에서 자세히(해당 함수를 사용할 때) 다루겠습니다.
@Component
public class JwtProvider {

		// secretKey 를 활용한 Key 생성
    private final Key key;

    public JwtProvider(@Value("${jwt.secret.key}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    /**
     * email, authorityList 을 가지고 AccessToken, RefreshToken 을 생성
     */
    public TokenInfoResponse generateToken(String email, List<String> authorityList) {...}

		/**
     * JWT 생성
     */
    public String generateJWT(String subject, List<String> authorityList, String type, Date issuedAt, long expireTime) {...}
    
    /**
     * Jwt 토큰을 복호화
     */
    private Claims parseClaims(String token) {...}

    /**
     * JWT 토큰을 복호화하여 토큰에 들어있는 정보를 추출하여 Authentication 생성
     */
    public Authentication getAuthentication(String jwtToken) {...}

		/**
     * JWT 검증 수행
     */
    public boolean validateToken(String token) {...}

		/**
     * JWT 타입 검증
     */
    public void validateTokenType(String token, String tokenType) {...}

    /**
     * JWT 잔여 유효기간
     */
    public Long getExpiration(String token) {...}

    /**
     * JWT 타입 추출
     */
    public String getType(String token) {...}

    /**
     * Request Header 에서 토큰 정보 추출
     */
    public String resolveToken(HttpServletRequest request) {...}
}

JwtAuthFilter 생성

  • JWT 관련 처리를 수행하는 JwtProvider 의 의존성을 주입받는다.
  • 요청 헤더에있는 JWT 를 추출 후, 검증을 수행한다.
    → 로그인 요청에는 JWT가 존재하지 않기 때문에, 검증을 수행하지 않고 다음 필터로 넘어간다.
@RequiredArgsConstructor
public class JwtAuthFilter extends GenericFilterBean {

    private final JwtProvider jwtProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
		    // 요청 헤더에 있는 JWT 를 추출한다.
        String token = jwtProvider.resolveToken((HttpServletRequest) request);

				// JWT 존재 시, JWT 검증 수행
        if (token != null && jwtProvider.validateToken(token)) {
		        // JWT가 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
            Authentication authentication = jwtProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            request.setAttribute("resolvedToken", token);
        }

				// 다음 필터로 넘어감
        chain.doFilter(request, response);
    }

}
  • JwtProvider - resolveToken (요청 헤더에 있는 JWT 추출)
    @Component
    public class JwtProvider {
    
    		private static final String AUTHORIZATION_HEADER = "Authorization";
        private static final String BEARER_TYPE = "Bearer";
    
    		// secretKey 를 활용한 Key 생성
        private final Key key;
    
        public JwtProvider(@Value("${jwt.secret.key}") String secretKey) {...}
    
        ...
    
        /**
         * Request Header 에서 토큰 정보 추출
         */
    		public String resolveToken(HttpServletRequest request) {
            String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
            if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) {
                try {
                    return bearerToken.substring(7);
                } catch (StringIndexOutOfBoundsException e) {
                    throw new CustomCommonException(UserErrorCode.MISSING_JWT);
                }
            }
    
            return null;
        }
    }

로그인 API 에서 로그인 비즈니스 로직 수행

@RequiredArgsConstructor
@RequestMapping("/user")
@RestController
public class UserAuthAPI {

    private final UserAuthService userAuthService;

    @PostMapping("/login")
    public ResponseEntity<?> loginBasic(HttpServletRequest httpServletRequest, @RequestBody @Valid LoginRequest request) {
		    // 로그인 요청 검증 수행 -> ID/PW 인증
        Authentication authentication = userAuthService.authenticateBasic(request);
        // 로그인 비즈니스 로직 수행 (JWT 생성 및 반환)
        return ResponseEntity.ok(userAuthService.login(httpServletRequest, authentication));
    }
}

UserAuthService ID/PW 인증

@RequiredArgsConstructor
@Service
public class UserAuthService {

    private final AuthenticationManager authenticationManager;

    public Authentication authenticateBasic(LoginRequest request) {
		    // 로그인 요청 데이터(ID/PW)를 갖고 인증정보가 없는 Authentication 생성
        UsernamePasswordAuthenticationToken authenticationToken 
		        = new UsernamePasswordAuthenticationToken(request.email, request.password);
				
				// AuthenticationManager 를 통해 ID/PW 인증 수행
        Authentication authentication = authenticationManager.authenticate(authenticationToken);

        return authentication;
    }
}
  • AuthenticationManager 를 통해 ID/PW 인증 과정
    • AuthenticationManager 의 authenticate(Authentication)를 통해서 인증을 수행하게 된다.
    • 해당 인증 과정에서 UserDetailService 의 loadUserByUsername(String) 을 통해서 DB의 사용자 Entity 를 불러오게 된다.
      → 불러와진 사용자 Entity 와 Authentication(로그인 요청 데이터로 만들어진 것)를 비교한다.
      → ID/PW 가 일치하면 인증정보가 있는 Authentication 을 반환한다.
    • UserDetailsService 인터페이스 구현
      @Service
      @RequiredArgsConstructor
      public class CustomUserDetailService implements UserDetailsService {
      
          private final UserEntityRepository userRepository;
      
          @Override
          public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
              UserEntity user = userRepository.findFirstByEmail(username).orElseThrow(() -> new UsernameNotFoundException(UserErrorCode.INVALID_CREDENTIALS.getMessage()));
      
              return createUserDetails(user);
          }
      
          // 해당하는 User 의 데이터가 존재한다면 UserDetails 객체로 만들어서 리턴
          private UserDetails createUserDetails(UserEntity user) {
              return new User(user.getEmail(), user.getPassword(), user.getAuthorities());
          }
      }

UserAuthService 로그인 비즈니스 로직 수행

  • Authentication 정보 (ID, 권한 목록)을 활용하여 JWT (AccessToken, RefreshToken)을 생성한다.
  • RefreshToken 을 In-Memory DB(Redis) 에 저장한다. → 추후, AccessToken 재발급을 위해서
@RequiredArgsConstructor
@Service
public class UserAuthService {

		private final JwtProvider jwtProvider;
    private final AuthenticationManager authenticationManager;
    
    private final RefreshTokenRedisRepository refreshTokenRedisRepository;

    public TokenInfoResponse login(HttpServletRequest httpServletRequest, Authentication authentication) {
        // 1. Authentication 으로부터 권한 목록을 추출한다.
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        List<String> authorityList = authorities.stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList());

				// 2. Authentication 정보 (ID, 권한 목록)을 활용하여 JWT (AccessToken, RefreshToken)을 생성한다.
        TokenInfoResponse response = jwtProvider.generateToken(authentication.getName(), authorityList);

        // 3. RefreshToken 을 Redis 에 저장
        refreshTokenRedisRepository.save(RefreshToken.builder()
                .id(authentication.getName())
                .ip(NetworkUtil.getClientIp(httpServletRequest))
                .authorityList(response.authorityList())
                .refreshToken(response.refreshToken())
                .build());

        return response;
    }
}
  • JwtProvider - generateToken (JWT 발급)
    @Component
    public class JwtProvider {
    
    		private static final String AUTHORIZATION_HEADER = "Authorization";
        private static final String BEARER_TYPE = "Bearer";
        
        public static final String TYPE_ACCESS = "access";
        public static final String TYPE_REFRESH = "refresh";
        
        public static final long ACCESS_TOKEN_EXPIRE_TIME = 60 * 60 * 1000L;               //60분 -> 1000L -> 1초 for java
        public static final long REFRESH_TOKEN_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000L;     //7일
    
        private final Key key;
    
        public JwtProvider(@Value("${jwt.secret.key}") String secretKey) {...}
    
        ...
    
        /**
         * email, authorityList 을 가지고 AccessToken, RefreshToken 을 생성
         */
        public TokenInfoResponse generateToken(String email, List<String> authorityList) {
    
            Date now = new Date();
    
            // Access JWT Token 생성
            String accessToken = generateJWT(email, authorityList, TYPE_ACCESS, now, ACCESS_TOKEN_EXPIRE_TIME);
    
            // Refresh JWT Token 생성
            String refreshToken = generateJWT(email, authorityList, TYPE_REFRESH, now, REFRESH_TOKEN_EXPIRE_TIME);
    
            return TokenInfoResponse.builder()
                    .authorityList(authorityList)
                    .grantType(BEARER_TYPE)
                    .accessToken(accessToken)
                    .accessTokenExpirationTime(ACCESS_TOKEN_EXPIRE_TIME)
                    .refreshToken(refreshToken)
                    .refreshTokenExpirationTime(REFRESH_TOKEN_EXPIRE_TIME)
                    .build();
        }
        
        /**
         * JWT 생성
         */
        public String generateJWT(String subject, List<String> authorityList, String type, Date issuedAt, long expireTime) {
            return Jwts.builder()
                    .setSubject(subject)
                    .claim(AUTHORITY_KEY, authorityList)
                    .claim(TYPE_KEY, type)
                    .setIssuedAt(issuedAt)
                    .setExpiration(new Date(issuedAt.getTime() + expireTime)) //토큰 만료 시간 설정
                    .signWith(key, SignatureAlgorithm.HS256)
                    .compact();
        }
        ...
    }
  • TokenInfoResponse - 로그인 결과
{
    "authorityList": [
        "ROLE_FACTORY"
    ],
    "grantType": "Bearer",
    "accessToken": "{AccessToken JWT}",
    "accessTokenExpirationTime": 3600000, // 60분
    "refreshToken": "{RefreshToken JWT}",
    "refreshTokenExpirationTime": 604800000  // 7일
}
profile
소통을 중요하게 여기며, 정보의 공유를 통해 완전한 학습을 이루어 냅니다.

0개의 댓글