Reactive 프로그래밍에서는 Spring Security 설정이 기본과는 다릅니다. 별로 레퍼런스가 없길래 아래와 같이 코드를 공유합니다.
1. LoginResponseDto
@Builder
@Data
public class LoginResponseDto {
private String token;
}
2. LoginRequestDto
@Data
public class LoginRequestDto {
private String username;
private String password;
}
3. CustomUserDetails
@Getter
@Setter
@Slf4j
public class CustomUserDetails implements UserDetails {
private String username;
private String password;
private boolean accountNonExpired;
private boolean accountNonLocked;
private boolean credentialsNonExpired;
private boolean enabled;
private List<String> permissions = new ArrayList<>();
public CustomUserDetails() {
}
public CustomUserDetails(String username) {
this.username = username;
}
@Override
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
@Override
public boolean isAccountNonExpired() {
return accountNonExpired;
}
@Override
public boolean isAccountNonLocked() {
return accountNonLocked;
}
@Override
public boolean isCredentialsNonExpired() {
return credentialsNonExpired;
}
@Override
public boolean isEnabled() {
return this.enabled;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> authorities = new ArrayList<>();
permissions.stream().forEach(permission -> {
authorities.add(new SimpleGrantedAuthority(permission));
});
// authorities.forEach(a->{
// log.info("getAuthorities={}",a.getAuthority());
// });
return authorities;
}
@Override
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
3. LoginService
@Slf4j
@Service
@RequiredArgsConstructor
public class LoginService {
private final JwtTokenProvider jwtTokenProvider;
private final ReactiveAuthenticationManager authenticationManager;
public Mono<LoginResponseDto> login(LoginRequestDto loginRequestDto, ServerHttpResponse response) {
if (loginRequestDto.getPassword() == null || loginRequestDto.getUsername() == null)
return Mono.error(new ServerWebInputException("User Input Invalidation"));
Authentication authentication = new UsernamePasswordAuthenticationToken(loginRequestDto.getUsername(),
loginRequestDto.getPassword());
return authenticationManager.authenticate(authentication)
.map(jwtTokenProvider::createToken)
.map(token -> {
response.getHeaders().add("Authorization","Bearer "+token);
return new LoginResponseDto(token);
});
}
}
4. ReactiveSecurityConfig
@Slf4j
@Configuration
@RequiredArgsConstructor
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class ReactiveSecurityConfig {
private final ApplicationContext applicationContext;
/**
* ServerHttpSecurity는 스프링 시큐리티의 HttpSecurity와 비슷한 웹플럭스용 클래스다.
* 이 클래스를 이용하여 모든 요청에 대해 인증 여부 체크를 정의할 수 있다.
* 이 클래스에 필터를 추가하여, 요청에 인증용 토큰이 존재할 경우 인증이 되도록 설정할 수 있다.
*
* SecurityWebFilterChain클래스를 생성하기 전에 DefaultMethodSecurityExpressionHandler클래스가 먼저 구성되어 있어야 한다.
* <p>
* authenticationEntryPoint: 애플리케이션이 인증을 요청할 때 해야 할 일들을 정의함.
* accessDeniedHandler: 인증된 사용자가 필요한 권한을 가지고 있을 않을 때 헤야 할 일들을 정의함.
*
* @param http
* @return
*/
@Bean
@DependsOn({"methodSecurityExpressionHandler"})
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
JwtTokenProvider jwtTokenProvider,
ReactiveAuthenticationManager reactiveAuthenticationManager) {
DefaultMethodSecurityExpressionHandler defaultWebSecurityExpressionHandler = this.applicationContext.getBean(DefaultMethodSecurityExpressionHandler.class);
defaultWebSecurityExpressionHandler.setPermissionEvaluator(myPermissionEvaluator());
return http
.exceptionHandling(exceptionHandlingSpec -> exceptionHandlingSpec
.authenticationEntryPoint((exchange, ex) -> {
return Mono.fromRunnable(() -> {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
});
})
.accessDeniedHandler((exchange, denied) -> {
return Mono.fromRunnable(() -> {
exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
});
}))
.csrf().disable()
.formLogin().disable()
.httpBasic().disable()
.authenticationManager(reactiveAuthenticationManager)
.securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
// authenticate
.addFilterAt(new JwtTokenAuthenticationFilter(jwtTokenProvider), SecurityWebFiltersOrder.HTTP_BASIC)
.authorizeExchange(exchange -> exchange
// 승인 목록
.pathMatchers(HttpMethod.OPTIONS).permitAll() // 사용가능 Method
.pathMatchers(HttpMethod.POST,"/user").permitAll() // 회원가입
.pathMatchers("/login").permitAll() // 로그인
// 권한 필터
.pathMatchers("/admin/**").hasRole("ADMIN") // admin 만 접근가능하도록 권한 설정
.pathMatchers("/**").hasAnyRole("USER","ADMIN") // 다른 모든 method 권한 설정
.anyExchange().authenticated()
)
.build();
}
@Bean
public PermissionEvaluator myPermissionEvaluator() {
return new PermissionEvaluator() {
@Override
public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
log.info("has Permission?");
if(authentication.getAuthorities().stream()
.filter(grantedAuthority -> {
log.info("My Authorities={}", grantedAuthority.getAuthority());
log.info("permission={}", permission);
return grantedAuthority.getAuthority().equals((String) permission);
})
.count() > 0) {
log.info("Yes Authorities={}", authentication.getAuthorities());
return true;
}
return false;
}
@Override
public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {
return false;
}
};
}
@Bean
public ReactiveUserDetailsService userDetailsService(UserRepository userRepository) {
return username -> {
log.info("Finduser : {}",username);
Optional<User> findUser = userRepository.findById(username);
if (!findUser.isPresent()) {
return Mono.empty();
}
User user = findUser.get();
CustomUserDetails userDetails = new CustomUserDetails();
userDetails.setUsername(user.getUserId());
userDetails.setPassword(user.getUserPw());
userDetails.setEnabled(true);
userDetails.setAccountNonExpired(true);
userDetails.setCredentialsNonExpired(true);
userDetails.setAccountNonLocked(true);
userDetails.setPermissions(Arrays.asList(user.getRole()));
return Mono.just(userDetails);
};
}
@Bean
public ReactiveAuthenticationManager reactiveAuthenticationManager(ReactiveUserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
var authenticationManager = new UserDetailsRepositoryReactiveAuthenticationManager(userDetailsService);
authenticationManager.setPasswordEncoder(passwordEncoder);
return authenticationManager;
}
}
5. JwtTokenProvider
@Slf4j
@Component
public class JwtTokenProvider {
private static final String AUTHORITIES_KEY = "permissions";
@Value("${token.expiration_time}")
String expirationTime;
@Value("${token.secret}")
String secret;
/***
* jwt payload
* {
* "sub": "userId",
* "permissions": ["ROLE_USER","ROLE_ADMIN"],
* "iat": 1680778900,
* "exp": 1680865300
* }
*/
public String createToken(Authentication authentication) {
String username = authentication.getName();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
// Claims = sub + expiration + role
Claims claims = Jwts.claims().setSubject(username);
if (authorities != null) {
claims.put(AUTHORITIES_KEY
, authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(",")));
}
Long expirationTimeLong = Long.parseLong(expirationTime);
final Date createdDate = new Date();
final Date expirationDate = new Date(createdDate.getTime() + expirationTimeLong);
return Jwts.builder()
.setClaims(claims)
.setSubject(username)
.setIssuedAt(createdDate)
.setExpiration(expirationDate)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public Authentication getAuthentication(String token) {
Claims claims = Jwts.parserBuilder().setSigningKey(this.secret).build().parseClaimsJws(token).getBody();
Object authoritiesClaim = claims.get(AUTHORITIES_KEY);
// 토큰에서 permission 체크
Collection<? extends GrantedAuthority> authorities = authoritiesClaim == null ? AuthorityUtils.NO_AUTHORITIES
: AuthorityUtils.commaSeparatedStringToAuthorityList(authoritiesClaim.toString());
authorities.forEach(c->{
log.info("권한획득: {}",c.toString());
});
User principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
public boolean validateToken(String token) {
try {
Jws<Claims> claims = Jwts
.parserBuilder().setSigningKey(this.secret).build()
.parseClaimsJws(token);
// parseClaimsJws will check expiration date. No need do here.
log.info("JWT 토큰 만료 시간: {}", claims.getBody().getExpiration());
return true;
} catch (JwtException | IllegalArgumentException e) {
log.info("JWT 토큰 사용 불가능: {}", e.getMessage());
log.trace("TRACE", e);
}
return false;
}
}
6. JwtTokenAuthenticationFilter
@Slf4j
@RequiredArgsConstructor
public class JwtTokenAuthenticationFilter implements WebFilter {
public static final String HEADER_PREFIX = "Bearer ";
private final JwtTokenProvider jwtTokenProvider;
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
log.info("JWT 토큰 필터");
String token = resolveToken(exchange.getRequest());
if(StringUtils.hasText(token) && this.jwtTokenProvider.validateToken(token)) {
Authentication authentication = this.jwtTokenProvider.getAuthentication(token);
authentication.getAuthorities().forEach(a->{
log.info("JWT 토큰으로 부터 얻는 Authorities={}",a.getAuthority());
});
return chain.filter(exchange)
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication));
}
return chain.filter(exchange);
}
// Header에서 JWT 토큰을 Bear 프리픽스 떼서 가져옵니다
private String resolveToken(ServerHttpRequest request) {
String bearerToken = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(HEADER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
}