-
[Udemy] Spring Security 사용자 정의, JWT수업 내용 정리 2024. 11. 23. 17:50
* 해당 강의에 대한 정리 글입니다.
https://www.udemy.com/course/spring-security-6-jwt-oauth2-korean/?couponCode=CPSALEBRAND24
1. Authorization 구현
권한 기반 인가
- GrantedAuthority 구현 -> SimpleGrantedAuthority.java: role 필드 보유(String)
- getAuthority(): 로그인한 사용자에게 할당된 역할, 권한 get
- 로그인한 사용자의 UserDetails를 Authentication 구현 클래스 객체 형태로 저장: UserDetailsService에서 loadUesrByUsername 시 authorities에 유저의 권한 정보(getRole())를 List로 전달(한 유저가 여러 역할을 가질 수 있으므로)
@Service @RequiredArgsConstructor public class EazyBankUserDetailsService implements UserDetailsService { private final CustomerRepository customerRepository; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { Customer customer = customerRepository.findByEmail(username).orElseThrow(() -> new UsernameNotFoundException("User details not found for the user: " + username)); List<GrantedAuthority> authorities = customer.getAuthorities().stream().map(authority -> new SimpleGrantedAuthority(authority.getName())).collect(Collectors.toList()); return new User(customer.getEmail(), customer.getPwd(), authorities); } }
- authorities에 @JsonIgnore 어노테이션 추가: 권한 제어를 백엔드에서 수행
SecurityConfig.java
@Configuration public class ProjectSecurityConfig { @Bean SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { // CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new CsrfTokenRequestAttributeHandler(); http.securityContext(contextConfig -> contextConfig.requireExplicitSave(false)) // .sessionManagement(sessionConfig -> sessionConfig.sessionCreationPolicy(SessionCreationPolicy.ALWAYS)) // .cors 설정... // .csrf 설정... .authorizeHttpRequests((requests) -> requests .requestMatchers("/myAccount").hasAuthority("VIEWACCOUNT") .requestMatchers("/myBalance").hasAnyAuthority("VIEWBALANCE", "VIEWACCOUNT") .requestMatchers("/myLoans").hasAuthority("VIEWLOANS") .requestMatchers("/myCards").hasAuthority("VIEWCARDS") .requestMatchers("/user").authenticated() .requestMatchers("/notices", "/contact", "/error", "/register", "/invalidSession").permitAll()); http.formLogin(withDefaults()); http.httpBasic(hbc -> hbc.authenticationEntryPoint(new CustomBasicAuthenticationEntryPoint())); http.exceptionHandling(ehc -> ehc.accessDeniedHandler(new CustomAccessDeniedHandler())); return http.build(); }
- hasAuthority(): requestMatchers()에 해당하는 api에 적용하고자 하는 권한명 전달
hasAnyAuthority(): 여러 권한을 지정하는 경우 - access(): 권한 부여에 대한 복잡한 로직 혹은 규칙이 존재하는 경우 사용할 수 있음
역할 기반 인가
- 역할 관련 테이블 생성: 데이터베이스 내부에는 Role에 해당하는 접두사(default: ROLE_)를 붙이고 생성해야 함
SecurityConfig.java
@Configuration public class ProjectSecurityConfig { @Bean SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { // CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new CsrfTokenRequestAttributeHandler(); http.securityContext(contextConfig -> contextConfig.requireExplicitSave(false)) // .sessionManagement(sessionConfig -> sessionConfig.sessionCreationPolicy(SessionCreationPolicy.ALWAYS)) // .cors 설정... // .csrf 설정... .authorizeHttpRequests((requests) -> requests .requestMatchers("/myAccount").hasRole("USER") .requestMatchers("/myBalance").hasAnyRole("USER", "ADMIN") .requestMatchers("/myLoans").hasRole("USER") .requestMatchers("/myCards").hasRole("USER") .requestMatchers("/user").authenticated() .requestMatchers("/notices", "/contact", "/error", "/register", "/invalidSession").permitAll()); http.formLogin(withDefaults()); http.httpBasic(hbc -> hbc.authenticationEntryPoint(new CustomBasicAuthenticationEntryPoint())); http.exceptionHandling(ehc -> ehc.accessDeniedHandler(new CustomAccessDeniedHandler())); return http.build(); }
- hasRole(): requestMatchers()에 해당하는 api에 적용하고자 하는 역할명 전달
hasAnyRole(): 여러 역할을 지정하는 경우 - access(): 권한 부여에 대한 복잡한 로직 혹은 규칙이 존재하는 경우 사용할 수 있음
- 권한 부여 실패 시 403 에러 반환 -> 리다이렉트하거나 비즈니스 로직에 따라 Authorization 이벤트를 트리거할 수 있음
2. Custom Filter 정의
- VirtualFilterChain -> 필터체인 내부의 시큐리티 필터를 호출하는 로직
- (1) Filter Interface 구현: java 내부에 존재(jakarta.servlet)
doFilter() 오버라이드해서 비즈니스로직 사용자 정의
init(): 대체로 데이터베이스나 데이터 소스에 연결하는 로직 작성
destroy(): 대체로 데이터베이스나 데이터 소스와의 연결을 해제하는 로직 작성 - (2) GenericFilterBean: spring boot 라이브러리 내에 존재
서블릿 관련 init 매개변수를 읽어야 하는 요구 사항이 있거나 서블릿 컨텍스트 세부 정보, 환경 속성 세부 정보를 읽을 수 있는 옵션이 필요한 경우 해당 클래스 활용 가능 - (3) OncePerRequestFilter: GenericFilterBean 확장하여 정의, 각 요청에 대해 필터가 최대 한 번만 실행되도록 보장
RequestValidationBeforeFilter.java
public class RequestValidationBeforeFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse res = (HttpServletResponse) response; String header = req.getHeader(HttpHeaders.AUTHORIZATION); if(null != header) { header = header.trim(); if(StringUtils.startsWithIgnoreCase(header, "Basic ")) { byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8); byte[] decoded; try { decoded = Base64.getDecoder().decode(base64Token); String token = new String(decoded, StandardCharsets.UTF_8); // un:pwd int delim = token.indexOf(":"); if(delim== -1) { throw new BadCredentialsException("Invalid basic authentication token"); } String email = token.substring(0,delim); if(email.toLowerCase().contains("test")) { res.setStatus(HttpServletResponse.SC_BAD_REQUEST); return; } } catch (IllegalArgumentException exception) { throw new BadCredentialsException("Failed to decode basic authentication token"); } } } chain.doFilter(request, response); } }
- 이메일에 "test"라는 문자가 포함되면 authorize하지 않고 400 오류를 반환하도록 하는 비즈니스 로직
- chain.doFilter(): 필터 내에서 비즈니스 로직 수행 후 필터체인 내부의 다음 필터 호출
AuthoritiesLoggingAfterFilter.java
@Slf4j public class AuthoritiesLoggingAfterFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if(null != authentication) { log.info("User " + authentication.getName() + " is successfully authenticated and " + "has the authorities " + authentication.getAuthorities().toString()); } chain.doFilter(request,response); } }
AuthoritiesLoggingAtFilter.java
@Slf4j public class AuthoritiesLoggingAtFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { log.info("Authentication Validation is in progress"); chain.doFilter(request,response); } }
CsrfCookieFilter.java
public class CsrfCookieFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); // Render the token value to a cookie by causing the deferred token to be loaded csrfToken.getToken(); filterChain.doFilter(request, response); } }
SecurityConfig.java
@Configuration public class ProjectSecurityConfig { @Bean SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { // CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new CsrfTokenRequestAttributeHandler(); http.securityContext(contextConfig -> contextConfig.requireExplicitSave(false)) // .sessionManagement(sessionConfig -> sessionConfig.sessionCreationPolicy(SessionCreationPolicy.ALWAYS)) // .cors 설정... // .csrf 설정... .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class) .addFilterBefore(new RequestValidationBeforeFilter(), BasicAuthenticationFilter.class) .addFilterAfter(new AuthoritiesLoggingAfterFilter(), BasicAuthenticationFilter.class) .addFilterAt(new AuthoritiesLoggingAtFilter(), BasicAuthenticationFilter.class) // .authorize 설정... http.formLogin(withDefaults()); http.httpBasic(hbc -> hbc.authenticationEntryPoint(new CustomBasicAuthenticationEntryPoint())); return http.build(); } }
- addFilterBefore(): 두 번째 인자로 작성된 필터 이전에 사용자 정의 필터 실행
- addFilterAfter(): 두 번째 인자로 작성된 필터 이후에 사용자 정의 필터 실행 -> 같은 순서에 여러 개의 필터가 적용된 경우, 무작위 순서로 실행
- addFilterAt(): 두 번째 인자로 작성된 필터와 동일한 순서에 사용자 정의 필터 실행 -> 둘 중 어느 필터가 먼저 적용될 지 알 수 없음(무작위 순서로 실행)
3. JWT 기반 인증
- 클라이언트가 입력과 함께 api 요청 시 백엔드 서버는 자격 증명을 검증하고 토큰을 응답으로 제공
- 토큰 생성 후 클라이언트 애플리케이션은 보안 api에 접근할 때마다 해당 토큰을 포함하여 요청 전송
- 토큰이 유효하다면 백엔드 서버는 성공 응답 반환
JWT - Json Web Token
- Self-contained: 로그인한 사용자의 역할과 권한에 대한 정보를 저장할 수 있어 백엔드 서버에 매번 의존할 필요가 없음
- Statelessness: 사용자 정보는 토큰 정보의 일부이기 때문에 세션의 도움으로 애플리케이션이 기억할 필요가 없음 -> 토큰을 읽고 사용자 정보를 가져오는 방식
- period(.)를 기준으로 Header, Payload, Signature으로 구분하며 Base64로 인코딩
(1) Header: 토큰에 대한 메타정보 저장 -> 토큰의 종류, 토큰 서명에 따른 알고리즘 저장
(2) Payload: 사용자와 역할에 대한 정보 저장 -> 비즈니스 로직에 필요한 인가에 사용될 수 있으며 최대한 가벼운 정보로 유지
(3) Signature: 서명(선택) -> 서명값을 가지고 토큰의 변조 여부를 확인(해싱: header + "." + payload, secret) - jwt.io
- jwt 관련 의존성 추가 -> jsonwebtoken(jjwt-api, impl, jackson)
SecurityConfig.java
@Configuration public class ProjectSecurityConfig { @Bean SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new CsrfTokenRequestAttributeHandler(); http.sessionManagement(sessionConfig -> sessionConfig.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .cors(corsConfig -> corsConfig.configurationSource(new CorsConfigurationSource() { @Override public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(Collections.singletonList("http://localhost:4200")); config.setAllowedMethods(Collections.singletonList("*")); config.setAllowCredentials(true); config.setAllowedHeaders(Collections.singletonList("*")); config.setExposedHeaders(Arrays.asList("Authorization")); config.setMaxAge(3600L); return config; } })) .csrf(csrfConfig -> csrfConfig.csrfTokenRequestHandler(csrfTokenRequestAttributeHandler) .ignoringRequestMatchers( "/contact","/register", "/apiLogin") .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())) .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class) .addFilterBefore(new RequestValidationBeforeFilter(), BasicAuthenticationFilter.class) .addFilterAfter(new AuthoritiesLoggingAfterFilter(), BasicAuthenticationFilter.class) .addFilterAt(new AuthoritiesLoggingAtFilter(), BasicAuthenticationFilter.class) .addFilterAfter(new JWTTokenGeneratorFilter(), BasicAuthenticationFilter.class) .addFilterBefore(new JWTTokenValidatorFilter(), BasicAuthenticationFilter.class) .requiresChannel(rcc -> rcc.anyRequest().requiresInsecure()) // Only HTTP .authorizeHttpRequests((requests) -> requests .requestMatchers("/myAccount").hasRole("USER") .requestMatchers("/myBalance").hasAnyRole("USER", "ADMIN") .requestMatchers("/myLoans").hasRole("USER") .requestMatchers("/myCards").hasRole("USER") .requestMatchers("/user").authenticated() .requestMatchers("/notices", "/contact", "/error", "/register", "/invalidSession", "/apiLogin").permitAll()); http.formLogin(withDefaults()); http.httpBasic(hbc -> hbc.authenticationEntryPoint(new CustomBasicAuthenticationEntryPoint())); http.exceptionHandling(ehc -> ehc.accessDeniedHandler(new CustomAccessDeniedHandler())); return http.build(); }
- config.setExposedHeaders(Arrays.asList("Authorization")): 헤더명 Authorization을 백엔드에서 UI로 헤더를 노출하고 동일한 헤더를 사용하여 JWT 값 전송
- JWT 값을 생성하는 필터와 유효성을 검증하는 커스텀 필터 생성하여 필터 체인에 추가
JWTTokenGeneratorFilter.java
public class JWTTokenGeneratorFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 현재 인증된 세부 정보를 읽는다. Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (null != authentication) { // 인증이 있는 경우 비즈니스 로직 실행 // 환경 클래스의 객체를 생성해서 getEnvironment() 출력 할당 Environment env = getEnvironment(); // GenericFilterBean의 구성으로 환경 변수를 읽을 수 있다. if (null != env) { // 환경 변수로 지정된 시크릿 값을 가져온다. -> 없는 경우 고려할 기본값을 함께 전달한다. String secret = env.getProperty(ApplicationConstants.JWT_SECRET_KEY, ApplicationConstants.JWT_SECRET_DEFAULT_VALUE); // 시크릿 값을 바이트 형태로 전달해서 시크릿 키 셋 SecretKey secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); String jwt = Jwts.builder().issuer("Eazy Bank").subject("JWT Token") // issuer(): 발행 조직 명확성 .claim("username", authentication.getName()) // 제공할 정보 추가 .claim("authorities", authentication.getAuthorities().stream().map( GrantedAuthority::getAuthority).collect(Collectors.joining(","))) .issuedAt(new Date()) .expiration(new Date((new Date()).getTime() + 30000000)) // 토큰 만료 시간 설정 .signWith(secretKey).compact(); // 디지털 서명 추가하고 .compact(): 문자열 형식으로 변환 response.setHeader(ApplicationConstants.JWT_HEADER, jwt); // 헤더에 "Authorization" 추가하고 jwt 변수에 jwt 할당 } } // 요청과 응답을 전달하여 다음 필터를 호출한다. filterChain.doFilter(request, response); } @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { return !request.getServletPath().equals("/user"); } }
- 요청에 따라 해당 로직은 인증이 성공한 후에 한 번만 실행되어야 함
- /user 경로가 아닌 요청의 경우 해당 필터는 실행되지 않아야 함
- ApplicationConstants 파일을 final class로 생성하여 환경 변수 값을 저장한다.(public static final JWT_SECRET ...)
- claim(): jwt 객체 내부에 전달할 값 추가
public final class ApplicationConstants { public static final String JWT_SECRET_KEY = "JWT_SECRET"; public static final String JWT_SECRET_DEFAULT_VALUE = "jxgEQeXHuPq8VdbyYFNkANdudQ53YUn4"; public static final String JWT_HEADER = "Authorization"; }
JWTTokenValidatorFilter.java
public class JWTTokenValidatorFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String jwt = request.getHeader(ApplicationConstants.JWT_HEADER); if(null != jwt) { try { Environment env = getEnvironment(); if (null != env) { String secret = env.getProperty(ApplicationConstants.JWT_SECRET_KEY, ApplicationConstants.JWT_SECRET_DEFAULT_VALUE); SecretKey secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); if(null != secretKey) { // claims에서 username, authority 정보 가져옴 Claims claims = Jwts.parser().verifyWith(secretKey) .build().parseSignedClaims(jwt).getPayload(); // parseSignedClaims에서 헤더 정보 가져옴 String username = String.valueOf(claims.get("username")); String authorities = String.valueOf(claims.get("authorities")); // 위 작업에서 얻은 username과 authorities를 제공하고 password는 null로 전달한다. // authentication 객체를 생성할 때 마다 Authenticated는 true로 생성된다. (이미 인증이 완료됨) Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, AuthorityUtils.commaSeparatedStringToAuthorityList(authorities)); // setAuthentication(): 인증 객체를 SecurityContextHolder에 저장한다. SecurityContextHolder.getContext().setAuthentication(authentication); } } } catch (Exception exception) { // JWT 값 검증에 실패하는 경우 발생하는 예외 처리 throw new BadCredentialsException("Invalid Token received!"); } } filterChain.doFilter(request,response); } @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { return request.getServletPath().equals("/user"); } }
- /user 경로의 요청의 경우 해당 필터는 실행되지 않아야 하며 이후의 모든 보안 api에 대한 요청은 해당 필터가 실행되어야 함
Client
- Authorization 값을 sessionStorage에 설정(로컬 스토리지에 저장하지 않음)하고 requestHeader에 해당 값을 전달
- logout 시 Authorization 값을 null로 변경해서 권한 삭제
사용자 인증이 필요한 REST API 구축(인증 수동 호출)
SecurityConfig.java
@Bean public AuthenticationManager authenticationManager(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) { EazyBankUsernamePwdAuthenticationProvider authenticationProvider = new EazyBankUsernamePwdAuthenticationProvider(userDetailsService, passwordEncoder); ProviderManager providerManager = new ProviderManager(authenticationProvider); // Authentication 객체 내부의 비밀번호를 삭제하지 않음(다른 유효성 검사를 위해 비밀번호를 사용하는 경우에 설정) providerManager.setEraseCredentialsAfterAuthentication(false); return providerManager; }
- 메소드 내에서 인증 제공자 객체 생성
UserController.java
@PostMapping("/apiLogin") public ResponseEntity<LoginResponseDTO> apiLogin (@RequestBody LoginRequestDTO loginRequest) { String jwt = ""; // 로그인 요청을 Authentication 객체로 변환(생성) Authentication authentication = UsernamePasswordAuthenticationToken.unauthenticated(loginRequest.username(), loginRequest.password()); // authenticationManager을 통해 인증에 대한 결과를 Authentication 객체로 반환 Authentication authenticationResponse = authenticationManager.authenticate(authentication); if(null != authenticationResponse && authenticationResponse.isAuthenticated()) { // 인증이 성공한 경우 if (null != env) { // 인증에 성공한 경우 jwt를 수동으로 생성하는 로직 실행 String secret = env.getProperty(ApplicationConstants.JWT_SECRET_KEY, ApplicationConstants.JWT_SECRET_DEFAULT_VALUE); SecretKey secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); jwt = Jwts.builder().issuer("Eazy Bank").subject("JWT Token") .claim("username", authenticationResponse.getName()) .claim("authorities", authenticationResponse.getAuthorities().stream().map( GrantedAuthority::getAuthority).collect(Collectors.joining(","))) .issuedAt(new java.util.Date()) .expiration(new java.util.Date((new java.util.Date()).getTime() + 30000000)) .signWith(secretKey).compact(); } } // HttpStatus.OK를 반환하면서 헤더명을 Authentication으로 jwt값을 함께 전송하고 body에 responseDTO와 jwt 값을 전송 // 보통은 한 곳에만 전송한다... return ResponseEntity.status(HttpStatus.OK).header(ApplicationConstants.JWT_HEADER,jwt) .body(new LoginResponseDTO(HttpStatus.OK.getReasonPhrase(), jwt)); }
- jwt 값을 responseBody에 보내는 rest api 구현
- LoginRequestDto, LoginResponseDto 생성
'수업 내용 정리' 카테고리의 다른 글
[Udemy] Spring Security 예외 처리, CORs, CSRF (0) 2024.11.22 [Udemy] Spring Security Basic (1) 2024.11.21 Argo cd (3) 2024.10.17 [Udemy] Twitter4j & Kafka (0) 2024.09.23 docker 설정 (0) 2024.08.26