개요
2024.08.30 - [Spring/Spring Security] - Spring Security 6 - 일반 로그인 | 폼 로그인
2024.08.31 - [Spring/Spring Security] - Spring Security 6 - OAuth 2 Client 간편 로그인 | 카카오 로그인
2024.09.01 - [Spring/Spring Security] - Spring Security 6 - 세션 기반 인증 유지 | Authentication Persistence
지난 글 들에서 로그인 기능과 세션 기반 인증 유지 방법에 대해서 알아보았다.
이번 글에선 JWT 관련 컴포넌트를 제공해주는 spring-security-oauth2-resource-server 패키지와 구성방법에 대해서 알아볼 것이다.
이후에는 커스텀하여 JWT를 세션의 attribute에서 resolve 하는 방법도 알아볼 것이다.
추가된 코드
build.gradle
implementation 'org.springframework.security:spring-security-oauth2-resource-server'
SecurityConfig
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.oauth2ResourceServer(conf -> conf.jwt(
jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverterGenerator())));
}
private Converter<Jwt, UsernamePasswordAuthenticationToken> jwtAuthenticationConverterGenerator() {
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("authorities");
jwtGrantedAuthoritiesConverter.setAuthorityPrefix("");
return (Jwt jwtSrc) -> {
String id = jwtSrc.getSubject();
String username = jwtSrc.getClaimAsString("username");
Collection<GrantedAuthority> authorities = jwtGrantedAuthoritiesConverter.convert(
jwtSrc);
Set<UserRole> roles = authorities.stream()
.map(GrantedAuthority::getAuthority)
.map(UserRole::valueOf)
.collect(Collectors.toSet());
Provider provider = new Provider(Long.parseLong(id), username, roles);
return new UsernamePasswordAuthenticationToken(provider, null, authorities);
};
}
private void successHandler(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
Object principal = authentication.getPrincipal();
if (principal instanceof Me me) {
sendResponse(response, new JwtResponse(me.toProvider()));
return;
}
sendResponse(response, "로그인에 성공했습니다.");
}
@Getter
private final class JwtResponse {
private final OAuth2AccessToken accessToken;
private JwtResponse(Provider provider) {
Instant issuedAt = Instant.now();
accessToken = toAccessToken(provider, issuedAt);
}
private OAuth2AccessToken toAccessToken(Provider provider, Instant issuedAt) {
Instant expiredAt = issuedAt.plus(Duration.ofSeconds(ACCESS_TOKEN_EXPIRES_IN));
JwtClaimsSet claims = JwtClaimsSet.builder()
.claims((claim) -> claim.putAll(provider.toClaims()))
.expiresAt(expiredAt)
.issuedAt(issuedAt)
.build();
Jwt encode = jwtEncoder.encode(JwtEncoderParameters.from(claims));
return new OAuth2AccessToken(TokenType.BEARER, encode.getTokenValue(), issuedAt,
}
Provider
public record Provider(long userId, String username, Set<UserRole> roles) {
public Map<String, Object> toClaims() {
Map<String, Object> claims = new HashMap<>();
claims.put("username", username);
claims.put("sub", String.valueOf(userId));
claims.put("authorities", Strings.join(roles, ' '));
return claims;
}
// provider 속성 변경과 security config 과의 의존성을 줄이기 위해 분리했다.
// JwtClaimsSet claims = JwtClaimsSet.builder()
// .claims((claim) -> claim.putAll(provider.toClaims())
}
JwtBeanGenerator
@Bean
public JwtDecoder jwtDecoder(RSAKey rsaKey) throws JOSEException {
return NimbusJwtDecoder.withPublicKey(rsaKey.toRSAPublicKey())
.build();
}
@Bean
public JwtEncoder jwtEncoder(JWKSource<SecurityContext> jwkSource) {
return new NimbusJwtEncoder(jwkSource);
}
@Bean
public JWKSource<SecurityContext> jwkSource(RSAKey rsaKey) {
return new ImmutableJWKSet<>(new JWKSet(rsaKey));
}
RSAKey 빈은
2024.09.06 - [Spring/Spring Security] - 부록: JWT용 RSA 키 페어 자동 생성하기
[부록: JWT용 RSA 키 페어 자동 생성하기
spring-security-tistory/src/main/java/example/springsecuritytistory/security/JwtBeanGenerator.java at master · jsween5723/sprin티스토리 블로깅을 위한 예제. Contribute to jsween5723/spring-security-tistory development by creating an account on
sween.tistory.com](https://sween.tistory.com/23)
글에서 참조할 수 있다.
요청 시 필수로 거치는 필터의 추가
BearerTokenAuthenticationFilter가 추가되었다.
다른 AuthenticationFilter들과 다르게 AbstractAuthenticationProcessingFilter를 상속받지 않으며
OncePerRequestFilter를 상속받아 모든 요청에서 해당 필터를 거친다.
이외 AbstractAuthenticationProcessingFilter 경로일 땐 해당되는 필터가 먼저 적용되고, 아닐 땐 이 필터가 동작한다고 보면 된다.
마찬가지로 JwtAuthenticationProvider와 협력하여 인증을 시도한다.
필터 분석
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String token;
try {
token = this.bearerTokenResolver.resolve(request);
// 헤더 혹은 쿼리스트링에서 토큰을 취득한다.
// DefaultBearerTokenResolver를 사용하며 Authorization 헤더의 Bearer 기준 파싱 혹은
// 쿼리스트링의 access_token을 취득한다.
// 추후 커스텀 설명 예정
}
catch (OAuth2AuthenticationException invalid) {
this.logger.trace("Sending to authentication entry point since failed to resolve bearer token", invalid);
this.authenticationEntryPoint.commence(request, response, invalid);
// 토큰 파싱 실패 예외 처리 BearerTokenAuthenticationEntryPoint가 기본적으로 동작하며
// WWW_AUTHENTICATE 헤더를 자동으로 처리해주므로 추후 커스텀할 때 유의하며 처리할 것이다.
return;
}
if (token == null) {
this.logger.trace("Did not process request since did not find bearer token");
// 비어 있을 경우 ANONYMOUS로 처리하기 위해 다음 필터로 넘김
filterChain.doFilter(request, response);
return;
}
BearerTokenAuthenticationToken authenticationRequest = new BearerTokenAuthenticationToken(token);
authenticationRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
//AuthenticationProvider에서 처리 가능하도록 Authentication 인스턴스로 변환
try {
AuthenticationManager authenticationManager = this.authenticationManagerResolver.resolve(request);
//JwtAuthenticationProvider가 동작
Authentication authenticationResult = authenticationManager.authenticate(authenticationRequest);
//AbstractAuthenticationProcessingFilter.successfulAuthentication
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authenticationResult);
this.securityContextHolderStrategy.setContext(context);
this.securityContextRepository.saveContext(context, request, response);
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authenticationResult));
}
filterChain.doFilter(request, response);
}
catch (AuthenticationException failed) {
//AbstractAuthenticationProcessingFilter.unsuccessfulAuthentication
this.securityContextHolderStrategy.clearContext();
this.logger.trace("Failed to process authentication request", failed);
this.authenticationFailureHandler.onAuthenticationFailure(request, response, failed);
}
}
보면 AbstractAuthenticationProcessingFilter를 상속받지 않기 때문에 onSuccess, onFailure등이 템플릿 메소드로 나뉘어 있지 않아 길어진 것을 볼 수 있다.
JwtAuthenticationProvider
그 다음으로 중요한 로직인 인증을 담당하는 클래스를 분석해보자
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
BearerTokenAuthenticationToken bearer = (BearerTokenAuthenticationToken) authentication;
Jwt jwt = getJwt(bearer);
// 토큰을 decode한다.
AbstractAuthenticationToken token = this.jwtAuthenticationConverter.convert(jwt);
// decode한 내용 (claim 해쉬맵)을 Authentication으로 만든다.
if (token.getDetails() == null) {
token.setDetails(bearer.getDetails());
}
this.logger.debug("Authenticated token");
return token; //반환한다.
}
private Jwt getJwt(BearerTokenAuthenticationToken bearer) {
try {
return this.jwtDecoder.decode(bearer.getToken());
}
catch (BadJwtException failed) {
this.logger.debug("Failed to authenticate since the JWT was invalid");
throw new InvalidBearerTokenException(failed.getMessage(), failed);
}
catch (JwtException failed) {
throw new AuthenticationServiceException(failed.getMessage(), failed);
}
}
JWT는 사용처 별로 구조가 제각각이기 때문에 인터페이스가 주어진다
Decoder의 경우 NimbusJwtDecoder 구현체를 Security가 제공해 생성할 수 있으며 변경된 코드 영역에서 참고하면된다.
또한 Decoder도 SpringBoot auto configuration을 지원하는데 다음과 같다.
하지만 JwtEncoder 구현체는 자동 구성을 지원하지 않는다.
2024.09.06 - [Spring/Spring Security] - 부록: JWT용 RSA 키 페어 자동 생성하기
[부록: JWT용 RSA 키 페어 자동 생성하기
spring-security-tistory/src/main/java/example/springsecuritytistory/security/JwtBeanGenerator.java at master · jsween5723/sprin티스토리 블로깅을 위한 예제. Contribute to jsween5723/spring-security-tistory development by creating an account on
sween.tistory.com](https://sween.tistory.com/23)
해당 글을 참조해 구성하는 것을 추천한다.
JwtDecoder SpringBoot 사용자화
spring:
security:
oauth2:
resourceserver:
jwt:
jws-algorithms: RS256
public-key-location: key.pub
JwtAuthenticationConverter SpringBoot 사용자화
JwtAuthenticationConverter는 기본 구현체의 경우 일부지만 Spring Boot에서 auto configuration을 지원한다.
spring:
security:
oauth2:
resourceserver:
jwt:
authorities-claim-name: authorities
authorities-claim-delimiter: ,
authority-prefix: ROLE_
principal-claim-name: id
이렇게 4가지이다.
하지만 JWT에 속성이 너무 적으며 분산환경에서 사용자 데이터베이스 부하를 줄이는 데 목적이 있는 JWT의 목적성이 흐려진다고 생각하여
직접 구현하겠다.
취향으로 인해 람다형식으로 구현하였지만 Bean이나 Component로 선언하여도 상관없다.
private Converter<Jwt, UsernamePasswordAuthenticationToken> jwtAuthenticationConverterGenerator() {
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("authorities");
jwtGrantedAuthoritiesConverter.setAuthorityPrefix("");
return (Jwt jwtSrc) -> {
String id = jwtSrc.getSubject();
String username = jwtSrc.getClaimAsString("username");
Collection<GrantedAuthority> authorities = jwtGrantedAuthoritiesConverter.convert(
jwtSrc);
Set<UserRole> roles = authorities.stream()
.map(GrantedAuthority::getAuthority)
.map(UserRole::valueOf)
.collect(Collectors.toSet());
Provider provider = new Provider(Long.parseLong(id), username, roles);
return new UsernamePasswordAuthenticationToken(provider, null, authorities);
};
}
'Spring > Spring Security' 카테고리의 다른 글
Spring Security 6 - SDK, 보일러플레이트 코드 없이 파이어베이스 id토큰 식별하기 | OAuth2 Resource server (0) | 2024.10.01 |
---|---|
부록: JWT용 RSA 키 페어 자동 생성하기 (0) | 2024.09.06 |
Spring Security 6 - 세션 기반 인증 유지 | Authentication Persistence (0) | 2024.09.01 |
부록: JPA 엔티티에 UserDetails, OAuth2User 구현하지 말기 (0) | 2024.09.01 |
부록: 빈 생성 기능과 필터체인 분리 (0) | 2024.09.01 |