JWT 적용을 위한 사전 작업
1. 의존 라이브러리 추가
// Spring Security 적용
implementation 'org.springframework.boot:spring-boot-starter-security'
// JWT 기능을 위한 jjwt 라이브러리
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
2. SecurityConfiguration 추가
- H2 웹 콘솔을 정상적으로 사용 .frameOptions().sameOrigin()
- CSRF(Cross-Site Request Forgery) 공격에 대한 Spring Security에 대한 설정을 비활성화 csrf().disable()
- CORS 설정 추가 .cors(withDefaults())
- CORS에 대해
- https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
- https://en.wikipedia.org/wiki/Cross-origin_resource_sharing - JSON 포맷으로 Username과 Password를 전송하는 방식 사용 .formLogin().disable()
- HTTP Basic 인증 방식을 비활성화 - Security Filter 비활성화 .httpBasic().disable()
- 우선 모든 HTTP request 요청에 대해 접근 허용, 추후 변경 예정 .authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll())
- PasswordEncoder Bean 객체 생성
- 구체적인 CORS 정책을 위한 CorsConfigurationSource Bean 생성
- - 모든 Origin에 대한 스크립트 기반의 HTTP 통신을 허용 setAllowedOrigins()
- - 파라미터로 지정한 HTTP Method에 대한 HTTP 통신을 허용 setAllowedMethods()
- - CorsConfigurationSource 인터페이스의 구현 클래스인 UrlBasedCorsConfigurationSource 클래스 객체 생성
- - 모든 URL에 앞서 구성한 CORS 정책(CorsConfiguration) 적용 registerConfiguration()
3. 회원 가입 로직 수정
- MemberDto.Post 클래스에 패스워드 필드 추가
// 패스워드 필드 추가
@NotBlank
private String password;
* 실제 서비스에서는 회원 가입 시, 사용자가 입력한 패스워드가 맞는지 재확인하기 위해, 패스워드 입력 확인 필드가 추가로 존재하는 경우가 많다.
* 입력한 두 패스워드가 일치하는지를 검증하는 로직도 필요하다.
* 패스워드의 생성 규칙(대/소문자, 패스워드 길이, 특수 문자 포함 여부 등)에 대한 유효성 검증도 실시한다.
- Member Entity 클래스에 패스워드 필드 추가
// 패스워드 필드 추가
@Column(length = 100, nullable = false)
private String password;
// @ElementCollection 애너테이션으로 사용자 등록 시, 사용자의 권한을 등록하기 위한 권한 테이블 생성
@ElementCollection(fetch = FetchType.EAGER)
private List<String> roles = new ArrayList<>();
- 사용자 등록 시, 패스워드와 사용자 권한 저장
- PasswordEncoder와 CustomAuthorityUtils 클래스를 DI 받도록 필드 추가
- 패스워드 단방향 암호화 passwordEncoder.encode()
- 등록하는 사용자의 권한 정보 생성 authorityUtils.createRoles(),
publisher.publishEvent(new MemberRegistrationApplicationEvent())
** Spring Security 기반의 애플리케이션에 JWT를 적용하기 위해서는 jjwt나 Java JWT 같은 별도의 라이브러리가 필요
JWT 자격 증명을 위한 로그인 인증 구현
- 로그인 인증에 성공한 사용자에게 JWT를 생성 및 발급하는 과정
- 클라이언트가 서버 측에 로그인 인증 요청(Username/Password를 서버 측에 전송)
- 로그인 인증을 담당하는 Security Filter(JwtAuthenticationFilter)가 클라이언트의 로그인 인증 정보 수신
- Security Filter가 수신한 로그인 인증 정보를 AuthenticationManager에게 전달해 인증 처리를 위임
- AuthenticationManager가 Custom UserDetailsService(MemberDetailsService)에게 사용자의 UserDetails 조회를 위임
- Custom UserDetailsService(MemberDetailsService)가 사용자의 크리덴셜을 DB에서 조회한 후, AuthenticationManager에게 사용자의 UserDetails를 전달
- AuthenticationManager가 로그인 인증 정보와 UserDetails의 정보를 비교해 인증 처리
- JWT 생성 후, 클라이언트의 응답으로 전달
1. UserDetailsService를 구현한 MemberDetailsService 클래스 구현
- 데이터베이스에서 사용자의 크리덴셜을 조회한 후, 그 크리덴셜을 AuthenticationManager에게 전달하는 Custom UserDetailsService 구현
2. 로그인 인증 정보 역직렬화(Deserialization)를 위한 LoginDto 클래스 생성
@Getter
public class LoginDto {
private String username;
private String password;
}
3. JWT를 생성하는 JwtTokenizer 구현
- 로그인 인증에 성공한 클라이언트에게 JWT를 생성 및 발급하고 클라이언트의 요청이 들어올 때마다 전달된 JWT를 검증하는 역할
- JwtTokenizer 클래스를 Spring Container(ApplicationContext)에 Bean으로 등록 @Component
- JWT 생성 및 검증 시 사용되는 Secret Key 정보 추가 @Value(${...})
- Access Token에 대한 만료 시간 정보 추가 @Value(${...})
- Refresh Token에 대한 만료 시간 정보 추가 @Value(${...})
- JWT의 만료 일시를 지정하고 이는 JWT 생성 시 사용 getTokenExpiration() (Date 타입)
4. JwtTokenizer의 정보에 맞춰 application.yml 파일 코드 추가
** JWT 서명에 사용되는 Secret Key 정보는 민감한 정보이므로, 시스템 환경 변수의 변수로 등록
** 시스템 환경 변수에 등록한 변수는 application.yml 파일의 프로퍼티명과 동일한 문자열을 사용하지 않도록 주의
** 일반적으로 Access Token의 만료 시간은 Refresh Token보다 짧은 것이 권장되고, 보안 강화의 이유로 Refresh Token을 제공하지 않는 애플리케이션도 존재
5. 로그인 인증 요청을 처리하는 Custom Security Filter 구현
- 로그인 인증 정보를 수신하고 인증 처리의 엔트리포인트 역할
- UsernamePasswordAuthenticationFilter 클래스 상속
- AuthenticationManager와 JwtTokenizer를 DI 받음
- 인증을 시도하는 메서드 구현 attempAuthentication()
- - Username/Password를 DTO 클래스로 역직렬화하기 위해 ObjectMapper 인스턴스 생성
- - 역직렬화 objectMapper.readValue(request.getInputStream(), LoginDto.class)
- - Username/Password를 포함한 UsernamePasswordAuthenticationToken 생성
- - UsernamePasswordAuthenticationToken을 AuthenticationManager에게 전달하며 인증 처리 위임 authenticate()
- 인증 정보를 이용해 인증에 성공할 경우 호출되는 메서드 구현 successfulAuthentication()
- - Member Entity 클래스의 객체를 얻음 authResult.getPrincipal()
- - Access Token 생성 delegateAccessToken(member)
- - Refresh Token 생성 delegateRefreshToken(member)
- delegateAccessToken(), delegateRefreshToken() 메서드 구현
6. Custom Filter 추가를 위한 SecurityConfiguration 설정 추가
** Spring Security에서는 직접 Configuration을 커스터마이징(customizations)할 수 있다.
- 커스터마이징된 Configuration을 추가 apply()
- AbstractHttpConfigurer를 상속해서 Custom Configurer 클래스를 구현
- Configuration을 커스터마이징 configure() 메서드 오버라이딩
- SecurityConfigurer간에 공유되는 객체(AuthenticationManager)를 얻음 getSharedObject(Authentication.class)
- JwtAuthenticationFilter를 생성하면서 JwtAuthenticationFilter에서 사용되는 AuthenticationManager와 JwtTokenizer를 DI 해줌
- 디폴트 request URL을 변경 setFilterProcessesUrl()
- Spring Security Filter Chain에 JwtAuthenticaionFilter를 추가 addFilter()
로그인 인증 성공 및 실패에 따른 추가 처리
- Spring Security에서는 AuthenticationSuccessHandler(성공 시), AuthenticationFailureHandler(실패 시)를 지원
1. AuthenticationSuccessHandler 구현하는 클래스 생성
- 메서드 구현 및 추가 처리 onAuthenticationSuccess()
- AuthenticationSuccessHandler에 대해
- https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/web/authentication/AuthenticationSuccessHandler.html
** Authentication 객체에 사용자의 정보를 얻은 후, HttpServletResponse로 출력 스트림을 생성해서 reponse로 전송할 수도 있다.
2. AuthenticationFailureHandler 구현하는 클래스 생성
- 메서드 구현 및 추가 처리 onAuthenticationFailure()
- 출력 스트림에 Error 정보를 담는 메서드 구현 sendErrorResponse()
- - ErrorResponse를 JSON 문자열로 변환하기 위한 Gson 라이브러리의 인스턴스를 생성
- - ErrorResponse 객체를 생성하고, HttpStatus.UNAUTHORIZED 상태 코드 전달 ErrorResponse.of()
- - response가 JSON 형태라는 것을 HTTP Header에 추가 setContentType(MediaType.APPLICATION_JSON_VALUE)
- - response의 상태가 401임을 클라이언트에게 알려주기 위해 HTTP Header에 추가 setStatus(HttpStatus.UNAUTHORIZED.value())
- Gson을 이용해 ErrorResponse 객체를 JSON 포맷 문자열로 변환 후, 출력 스트림 생성
getWriter().write(gson.toJson(errorResponse, ErrorResponse.class))
- AuthenticationFailureHandler에 대해
- https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/web/authentication/AuthenticationFailureHandler.html
3. AuthenticationSuccessHandler와 AuthenticationFailureHandler를 SecurityConfiguration에 추가
- new 키워드를 사용해 AuthenticationSuccessHandler 인터페이스의 구현 클래스와 AuthenticationFailureHandler 인터페이스의 구현 클래스를 추가
** Security Filter마다 두 핸들러의 구현 클래스를 각각 생성할 것이기 때문에, Bean으로 등록해서 DI 받는 것이 아닌, new 키워드를 사용해서 객체를 생성한다.
4. JwtAuthenticationFilter 클래스에 AuthenticationSuccessHandler 호출 코드 추가
- AuthenticationSuccessHandler 클래스의 onAuthenticationSuccess() 메서드 호출
** onAuthenticationSuccess() 메서드를 호출하면, AuthenticationSuccessHandler를 구현한 클래스의 onAuthenticationSuccess() 메서드가 호출된다.
** 별도의 코드를 추가하지 않더라도, 로그인 인증에 실패하면 AuthenticationFailureHandler를 구현한 클래스의 onAuthenticationFailure() 메서드가 알아서 호출된다.
** Username/Password 기반의 로그인 인증은 반드시 UsernamePasswordAuthenticationFilter만 이용해서 구현할 필요는 없으며, OncePerRequestFilter를 구현하거나 Controller에서 API 엔드포인트로 구현하는 방법도 존재한다.
- OncePerRequestFilter에 대해
- https://www.baeldung.com/spring-onceperrequestfilter
JWT 검증 기능 구현
- 클라이언트 측에서 JWT를 이용해 자격 증명이 필요한 리소스에 대한 request 전송 시, request header를 통해 전달받은 JWT를 서버 측에서 검증하는 기능 구현
1. JWT 검증 필터 구현
- JWT를 검증하는 전용 Security Filter를 구현
- OncePerRequestFilter를 확장해서 request 당 한 번만 실행되는 Security Filter 구현
** 인증과 관련된 Filter는 성공이냐 실패냐를 단 한 번만 판단하면 된다. 즉, 여러 번 판단할 필요가 없다. - JWT를 검증하고 Claims(토큰에 포함된 정보)를 얻는 데 사용되는 JwtTokenizer와 JWT 검증에 성공 시, Authentication 객체에 채울 사용자의 권한을 생성하는 데 사용되는 CustomAuthorityUtils를 DI 받음
- JWT를 검증하는 데 사용되는 메서드 구현 verifyJws()
- - request의 header에서 JWT를 얻음 getHeader(), "Bearer " 부분 제거 replace()
- ** 서명된 JWT를 JWS(JSON Web Token Signed)라고 부른다.
- - JWT 서명(Signature)를 검증하기 위한 Secret Key를 얻음 getSecretKey()
- - JWT에서 Claims를 파싱 getClaims().getBody()
- ** Claims가 정상적으로 파싱되면, 서명 검증은 성공한 것이다.
- Authentication 객체를 SecurityContext에 저장하기 위한 메서드 구현 setAuthenticationToContext()
- - JWT에서 파싱한 Claims에서 username을 얻음 get()
- - JW의 Claims에서 얻은 권한 정보로 List<GrantedAuthority>를 생성
- - username과 List<GrantedAuthority>를 포함한 Authentication 객체 생성
- - SecurityContext에 Authentication 객체 저장 SecurityContextHolder.getContext().setAuthentication()
- ** SecurityContext에 Authentication을 저장하게 되면, Spring Security의 세션 정책에 따라 세션을 생성 or 안할 수도 있다.
- SecurityContext에 Authentication을 저장하고 나서, 다음 Security Filter를 호출 doFilter()
- 특정 조건에 true면, 해당 Filter를 건너뛰고 다음 Filter를 호출하는 shouldNotFilter()를 오버라이딩
- - Authorization header의 값을 얻음 getHeader()
- - Authorization header의 값이 null or "Bearer"로 시작하지 않으면, 해당 Filter를 건너띄도록 정의
return authorization == null || ! authorization.startsWith("Bearer"); - ** 실수로 JWT를 포함하지 못한 리소스 요청을 보낸 경우에, Authentication이 정상적으로 SecurityContext에 저장되지 않은 상태이기 때문에, 다른 SecurityFilter를 거쳐 결국에는 Exception을 던짐
2. SecurityConfiguration 업데이트
- 세션 정책 설정 추가, JwtVerificationFilter 추가
- 세션을 생성하지 않도록 설정 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
- Spring Security Session Policy
- https://www.baeldung.com/spring-security-session - JwtVerifcationFilter의 인스턴스를 생성하면서, 생성자로 JwtTokenizer와 CustomAuthorityUtils의 객체를 DI 받음
- JwtVerificationFilter를 JwtAuthenticationFilter가 수행된 후 바로 다음에 동작하도록 추가 builder.addFilterAfter()
- ** 인증 과정이 수행된 후에 JwtVerficationFilter가 수행되는 것이 자연스러운 흐름이다.
서버 측 리소스에 역할(Role) 기반 권한 적용
- 자격 증명 뿐이 아니라, 특정 리소스에 접근할 권한을 설정해야 함
- .anyRequest().permitAll() 부분을 수정
- 회원 등록의 경우, 해당 URL과 HTTP Mehtod(POST)이면 접근 허용
.antMatchers(HttpMethod.POST, "/*/members").permitAll() - 회원 정보 수정의 경우, USER 권한만 가진 사용자만 접근 허용
.antMatchers(HttpMethod.PATCH, "/*/members/**").hasRole("USER")
**는 하위 URL로 어떤 URL이 와도 매치가 된다는 의미이다. - 모든 회원 정보의 목록의 경우, ADMIN 권한을 가진 관리자만 접근 허용
.antMatchers(HttpMethod.GET, "/*/members").hasRole("ADMIN") - 특정 회원에 대한 정보 조회의 경우, USER, ADMIN 권한을 가진 사용자만 접근 허용
.antMatchers(HttpMethod.GET, "/*/members/**").hasAnyRole("USER", "ADMIN") - 특정 회원을 삭제하는 경우, USER 권한을 가진 사용자만 접근 허용
.antMatchers(HttpMethod.DELETE, "/*/members/**").hasRole("USER") - 나머지 리소스의 경우 모든 접근 허용
.anyRequest().permitAll()
예외 처리
1. JwtVerificationFilter에 예외 처리 로직 추가
- JWT에 대한 서명 검증에 실패할 경우, throw되는 SingatureException에 대한 처리 추가
- JWT가 만료될 경우, ExpiredJwtException에 대한 처리 추가
- try-catch문 이용해서, 특정 예외를 catch하면, HttpServletRequest의 Attribute로 추가
try {
Map<String, Object> claims = verifyJws(request);
setAuthenticationToContext(claims);
} catch (SignatureException se) {
request.setAttribute("exception", se);
} catch (ExpiredJwtException ee) {
request.setAttribute("exception", ee);
} catch (Exception e) {
request.setAttribute("exception", e);
}
** 예외 처리 로직에서 Exception을 다시 throw하지 않고, Attribute에 추가하는 처리만 한다.
** 이렇게 되면, SecurityContext에 클라이언트의 인증 정보(Authentcation 객체)가 저장되지 않고, 이대로 Security Filter 로직을 수행하게 되면 AuthenticationException이 발생한다.
** 이 AuthenticationException은 AuthenticationEntryPoint가 처리하게 된다.
2. AuthenticationEntryPoint 인터페이스를 구현하는 클래스 생성
- AuthenticationException이 발생할 경우에 호출될 로직을 commence() 메서드에 구현
- ErrorResponse를 생성해서 클라이언트에게 전송 - ErrorResponder 클래스 구현
- ErrorResponse를 출력 스트림으로 생성하는 역할
- AuthenticationEntryPoint에 대해
- https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/web/AuthenticationEntryPoint.html
3. AccessDeniedHandler 인터페이스를 구현하는 클래스 생성
- 요청한 리소스에 대해 적절한 권한이 없을 경우 호출되는 핸들러
- 호출될 로직을 handle() 메서드에 구현
- AccessDeniedException이 발생하면, ErrorResponse를 생성해서 클라이언트에게 전송
- AccessDeniedHandler에 대해
- https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/web/access/AccessDeniedHandler.html
4. SecurityConfiguration 업데이트
- new 키워드를 사용해 AuthenticationEntryPoint 인터페이스의 구현 클래스와 AccessDeniedHandler 인터페이스의 구현 클래스를 추가
** 추후에 Spring Security를 통해 보안이 적용된 상태에서 Spring Rest Docs를 이용해 API 문서를 업데이트할 것.
- JWT using java and Spring Security
- https://www.toptal.com/java/rest-security-with-jwt-spring-security-and-java
'Develop > Spring' 카테고리의 다른 글
OAuth2 개요 (0) | 2022.11.25 |
---|---|
JWT - Secret Key를 시스템 환경 변수로 등록할 때 주의점 (0) | 2022.11.25 |
JWT 개요 (0) | 2022.11.24 |
Spring Security에서 지원하는 표현식(Spring EL) (0) | 2022.11.22 |
Spring Security 권한 부여 처리 흐름 (0) | 2022.11.22 |