created at 2023-04-13
인증/권한 설정
JWT 토큰을 강제로 expire 할 수 없다는 점이 까다로운것 같아요. 예로 비밀번호를 업데이트하면, 해당 비밀번호 이전의 토큰은 여전히 사용하능하다는 단점이 존재합니다. 따라서 어쩔수 없이 토큰을 refresh할 수 있는 수단이 필요한데, 수정이 빠른 redis db를 통해 refresh하도록 설정할 수 있어요. 하지만 일단 이는 제외하고, Netty reactor 기반 Spring-security를 설정하는 부분을 공유하고자 합니다.(Netty 기반 Spring-security 는 기존 Tomcat 기반과 설정이 다르기 떄문이죠)
Netty Reactor 기반 Spring-Security 설정
1. ReactiveSecurityConfig.java
/**
* 보안에 필요한 전반적인 것들을 여기에 작성합니다.
* <p>
* authentication manager, security context repository, 허용이 필요한 url등.
* <p>
*
* @EnableReactiveMethodSecurity은 DefaultMethodSecurityExpressionHandler클래스를 애플리케이션 컨텍스트에 등록해 줍니다.
* methodSecurityExpressionHandler
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class ReactiveSecurityConfig {
private final ApplicationContext applicationContext;
/**
* ServerHttpSecurity 는 Spring-Security 의 WebFlux 전용 클래스입니다.
* 해당 클래스를 이용하여 모든 요청에 대해 인증 여부 체크를 정의할 수 있습니다.
* 해당 클래스에 필터를 추가하여, 요청에 인증용 토큰이 존재할 경우 인증이 되도록 설정할 수 있습니다.
*
* @implNote
* [SecurityWebFilterChain클래스를 생성하기 전에 구성해야하는 부분]
*
* DefaultMethodSecurityExpressionHandler
* authenticationEntryPoint: 애플리케이션이 인증을 요청할 때 해야 할 일들을 정의
* accessDeniedHandler: 인증된 사용자가 필요한 권한을 가지고 있을 않을 때 헤야 할 일들을 정의
*
* @param http
* @return
*/
@Bean
@DependsOn({"methodSecurityExpressionHandler"})
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
JwtTokenProvider jwtTokenProvider,
ReactiveAuthenticationManager reactiveAuthenticationManager) {
DefaultMethodSecurityExpressionHandler defaultWebSecurityExpressionHandler = this.applicationContext.getBean(DefaultMethodSecurityExpressionHandler.class);
// 권한인증 필터 추가 : myPermissionEvaluator()
defaultWebSecurityExpressionHandler.setPermissionEvaluator(myPermissionEvaluator());
return http
// exchange 로 인증필터를 거칠 때, 에러가 throw 된다면,
// 아래의 예외 헤더를 전송합니다
.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())
// JwtToken 을 검증하는 필터
.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();
}
/**
* 퍼미션 정보들, 즉 'ROLE_ADMIN' 이냐 'ROLE_USER' 이냐를 ThreadLocal에서 값을 빼와서 실제로 비교하는 역할을 수행합니다.
* 즉, 아래의 ReactiveUserDetailsService 는 인증 및 권한을 저장하는 역할을 수행하고, PermissionEvaluator 는 hasRole() 메소드가 호출될 때 권한을 검증하는 역할을 수행하는 것이죠!
*
* @return
*/
@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;
}
};
}
/**
* ReactiveUserDetailsService 는 실제 validation 을 수행하는 로직으로써,
* DB에서 값을 BcryptEncryption을 통해 비교하고 user_role 및 기다 정보들을 ThreadLocal에 저장합니다.
*
* 여기서 SecurityContextHolder 에 저장된 ThreadLocal 값을 바탕으로,
* 위의 hasRole("ROLE_ADMIN") 와 같은 로직들을 controller 메소드에 접근 이전 수행하게 됩니다.
*
* @param userRepository
* @return
*/
@Bean
public ReactiveUserDetailsService userDetailsService(UserRepository userRepository) {
return 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);
};
}
/**
* ReactiveAuthenticationManager 은 위에서 Bean 으로 정의한 userDetailsService 와 연결되어 로직을 수행합니다.
*
*
* @param userDetailsService
* @param passwordEncoder
* @return
*/
@Bean
public ReactiveAuthenticationManager reactiveAuthenticationManager(ReactiveUserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
var authenticationManager = new UserDetailsRepositoryReactiveAuthenticationManager(userDetailsService);
authenticationManager.setPasswordEncoder(passwordEncoder);
return authenticationManager;
}
}
2. JwtTokenProvider.java
@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());
User principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
public boolean validateToken(String token, ServerWebExchange exchange) {
try {
Jws<Claims> claims = Jwts
.parserBuilder().setSigningKey(this.secret).build()
// parseClaimsJws 은 자동적으로 expiration을 체크해줍니다
.parseClaimsJws(token);
// 저는 해당 userId를 세션에 넣고 컨트롤러에서 빼서 쓸 거에요!
// 즉, jwt 토큰에 저장된 기본정보만을 가지고 여러 메소드가 쉽게 동작할 수 있도록 설정한 것이죠.
// (WebFlux 이벤트 방식으로 넣어줘야합니다)
exchange.getSession().subscribe(s -> {
s.getAttributes().put("userId", claims.getBody().getSubject());
});
return true;
} catch (JwtException | IllegalArgumentException e) {
log.debug("JWT 토큰 사용 불가능: {}", e.getMessage());
}
return false;
}
}
3. JwtTokenAuthenticationFilter.java
/**
* JWT 토큰이 http request header에 있는지 확인하기 위한 필터
* 토근이 있을 경우, 유효성 체크 후, 토큰을 이용하여 인증 정보를 만듭니다
*/
@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) {
String token = resolveToken(exchange.getRequest());
if(StringUtils.hasText(token) && this.jwtTokenProvider.validateToken(token, exchange)) {
Authentication authentication = this.jwtTokenProvider.getAuthentication(token);
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;
}
}
4. CustomUserDetails.java
@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;
}
}
5. LoginService.java
@Slf4j
@Service
@RequiredArgsConstructor
public class LoginService {
private final JwtTokenProvider jwtTokenProvider;
private final ReactiveAuthenticationManager authenticationManager;
// 드디어 Login!
// Bean에 저장된 ReactiveAuthenticationManager 를 가져와서 로직을 수행합니다
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());
// 매니저에서 authenticate 수행하면 이전에 우리가 만들었던 DB 체크 및 권한설정을 수행합니다.
// 그리고 이후, jwtTokenProvider::createToken 으로 토큰을 만들게 되죠. Payload 형식은 이미 코드에 기재를 해놓았습니다.
// 마지막으로 헤더의 Authorization 에 토큰을 집어넣고 반환해줍시다
return authenticationManager.authenticate(authentication)
.map(jwtTokenProvider::createToken)
.map(token -> {
response.getHeaders().add("Authorization","Bearer "+token);
return new LoginResponseDto(token);
});
}
}