목차
1. Filter의 개념 및 Spring Security Filter의 역할
2. Reactive에서 Spring Security 클래스 구조 및 역할
3. 구현(Java)
필터는 웹 어플리케이션에서 요청과 응답을 가로채어 공통적인 작업을 수행하는 객체이다. 이는 핵심 비즈니스 로직에 들어가기 전후에 필요한 기능을 분리하여 코드를 깔끔하게 만든다.
필터의 가장 중요한 개념은 필터 체인(Filter Chain) 이다. 웹 요청은 마치 공장 생산 라인처럼 여러 필터들을 순서대로 통과하며 처리된다.
시큐리티 필터는 필터의 여러 역할 중 특히 인증(Authentication) 과 인가(Authorization) 에 특화된 필터들의 집합이다. Spring Security는 이 필터들을 하나의 필터 체인으로 구성하여 보안 관련 작업을 전담하게 된다.
WebFilter
를 거친다. WebFilter
는 모든 HTTP 요청에 대해 공통적인 전처리 로직을 수행한다. User-Agent
나 IP 주소 같은 정보를 추출하여 Context에 담는다. 여기서 추출되는 정보들은 JWT 에서 Refresh Token
을 저장할 때, 메타데이터로 활용된다. Authorization
헤더가 있는지 확인하고 토큰을 추출하는 역할도 할 수 있지만, 스프링 시큐리티로 함께 개발하기 때문에 이러한 역할은 Spring Security Filter가 담당하도록 한다.ServerHttpSecurity : 스프링 시큐리티에서 리액티브 웹 보안 설정을 구성하는 핵심 클래스이다. 이 클래스는 전통적인 서블릿 기반 어플리케이션의 HttpSecurity
와 유사하지만, 리액티브 환경에 맞게 비동기적이고 논블로킹 방식으로 동작한다.
SecurityWebFilterChain : 스프링 시큐리티의 리액티브 웹 보안 필터 체인을 정의하는 핵심 인터페이스이다. 전통적인 서블릿 기반 어플리케이션의 FilterChain
과 유사하지만, 리액티브 컨텍스트에서 비동기 및 논블로킹 방식으로 작동한다. 이 인터페이스는 웹 요청이 들어왔을 때, 어떤 경로에 어떤 보안 필터들(예: 인증, 인가, CSRF)이 어떤 순서로 적용될지 설정하는 역할을 한다. 개발자는 보통 ServerHttpSecurity
를 사용하여 특정 URL 패턴에 따른 보안 규칙을 Mono
나 Flux
를 반환하는 리액티브 스트림으로 구성한다.
UserDetails : 스프링 시큐리티에서 사용자 정보를 캡슐화하는 인터페이스이다. 이 객체는 사용자의 아이디, 비밀번호, 권한 목록, 계정 만료 여부 등 인증 및 인가에 필요한 모든 데이터를 포함한다. 리액티브 관점에서는 ReactiveUserDetailsService
가 비동기적으로 데이터베이스에서 사용자 정보를 조회한 후, 이 UserDetails
객체를 Mono<UserDetails>
형태로 반환하여 리액티브 스트림을 따라 흐르게 한다.
ServerAuthenticationConverter : HTTP 요청에서 인증 정보를 비동기적으로 추출하여 Authenticaton
객체로 변환하는 인터페이스이다. 이 객체는 Mono<Authentication>
을 반환하여 논블로킹 방식으로 동작한다. 예를 들어, Authorization
헤더에서 JWT 토큰을 추출하거나, 세션 쿠키에서 사용자 ID를 읽어와 인증 객체를 생성하는 작업을 수행한다. 이 과정에서 발생할 수 있는 오류(예: 토큰 부재)는 Mono.error()
를 통해 리액티브 스트림에 오류 신호를 전달한다.
ReactiveAuthenticationManager : 인증 로직을 비동기적으로 처리하는 인터페이스이다. ServerAuthenticationConverter
가 만든 Authentication
객체를 Mono<Authentication>
형태로 받아 실제 인증 로직을 수행한다. 예를 들어, ReactiveUserDetailsService
를 통해 사용자 정보를 조회하고 비밀번호를 확인하는 등의 작업을 리액티브 스트림 내에서 처리한다. 인증이 성공하면 Mono<Authentication>
을 반환하고, 실패하면 Mono.error(AuthenticationException)
를 통해 오류를 전파한다.
ReactiveUserDetailsService : 사용자 정보를 비동기적으로 로드하는 인터페이스이다. 전통적인 UserDetailsService
의 리액티브 버전으로, 데이터베이스나 캐시에서 사용자 정보를 조회할 때 블로킹 없이 Mono<UserDetails>
형태로 결과를 반환한다. 이 덕분에 I/O 작업이 주를 이루는 사용자 정보 조회 시 애플리케이션의 스레드가 차단되지 않아 리액티브 시스템의 성능을 유지할 수 있다.
ErrorWebExceptionHandler : 웹 요청 처리 중 발생하는 모든 예외를 전역적으로 비동기 처리하는 인터페이스이다. 이 핸들러는 리액티브 스트림에서 전파된 오류 신호를 받아, 클라이언트에게 적절한 HTTP 응답을 생성하고 반환한다. 이 인터페이스는 애플리케이션의 모든 필터에서 발생하는 예외를 캐치하는 가장 상위의 예외 처리 레이어 역할을 수행한다.
ServerAuthenticationFailureHandler : 인증 실패 시에만 호출되는 특화된 비동기 핸들러이다. ReactiveAuthenticationManager
가 AuthenticationException
을 던지면, 이 핸들러가 해당 예외를 받아 클라이언트에게 맞춤형 오류 응답을 Mono<Void>
형태로 반환한다. ErrorWebExceptionHandler
가 모든 종류의 예외를 처리하는 것에 비해, 이 핸들러는 오직 인증 실패와 관련된 예외에만 반응한다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.5'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
description = 'Demo project for Spring Boot'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'io.jsonwebtoken:jjwt-api:0.12.7'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.7'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.7'
implementation("nl.basjes.parse.useragent:yauaa:7.31.0")
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
runtimeOnly 'org.postgresql:r2dbc-postgresql'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.projectreactor:reactor-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
User-Agent
의 값들을 읽어온다. 클라이언트의 기기명, 운영체제, 웹 브라우저 등의 메타정보를 가져온다. yauaa외에도 이러한 기능들을 하는 라이브러리들이 다양하게 있는데, 예시 코드를 쉽게 찾을 수 있어서 yauaa를 선택하였다.jwt:
secret: U29tZUJhc2U2NEVuY29kZWRSYW5kb21LZXlGb3JKV1Q=
access-token:
expiration: 30 # 30분(분단위)
refresh-token:
expiration: 1440 # 한달(분단위)
만료시간을 설정파일에 담았다. 데이터 액세스와 관련된 설정은 제외하였다.
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table("users")
public class User {
@Id
private Long id;
@Column("username")
private String username;
@Column("password")
private String password;
@Column("authorities")
private String authorities;
@Column("email")
private String email;
@Column("created_at")
private LocalDateTime createdAt;
}
기본적으로 사용자에 대한 테이블에는 username
, password
, authorities
를 저장하고, 그 외에 다른 정보들을 추가하였다. 이러한 컬럼명의 설정은 Spring Security의 UserDetails
에서 사용되는 시그니처를 그대로 가져온 것이다.
@Repository
public interface ReactiveUserRepository extends ReactiveCrudRepository<User, Long> {
Mono<User> findByUsername(String username);
@Query("SELECT * FROM \"user\" WHERE username = :username OR email = :email LIMIT 1")
Mono<User> findByUsernameOrEmail(String username, String email);
}
}
비동기, 논블로킹으로 데이터베이스와의 통신을 위해 ReactiveCrudRepository
를 상속받는다.
@Service
@RequiredArgsConstructor
public class CustomReactiveUserDetailsService implements ReactiveUserDetailsService {
private final ReactiveUserRepository reactiveUserRepository;
@Override
public Mono<UserDetails> findByUsername(String username) {
return reactiveUserRepository.findByUsername(username)
.switchIfEmpty(Mono.error(new UsernameNotFoundException("User not found: "+username)))
.map(user ->
new CustomUserDetails(
user.getId(),
user.getUsername(),
user.getPassword(),
user.getAuthorities(),
user.getEmail()
)
);
}
}
ReactiveUserDetailsService
의 구현체이다. Spring MVC의 Spring Security에서 제공하는 UserDetailsService
대신 내부적으로 비동기, 논블로킹으로 동작하도록 구현된 인터페이스이다.
잠시 뒤에 다루게 되는 UserService.java와는 담당하는 로직이 다르다. UserService.java 는 오직 사용자의 요청(예: 회원가입, 로그인, 로그아웃 등...)에 대한 비즈니스 로직을 담당하기 위한 클래스이며, 위의 ReactiveUserDetailsService
인터페이스의 구현체인 CustomReactiveUserDetailsService.java
는 사용자의 다양한 요청에 대하여 Spring Security의 인증, 인가를 처리할 때 데이터베이스와 통신하여 데이터를 다루기 위한 클래스이다. 두 클래스의 목적성이 조금 다르기 때문에 분리하였다.
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table("refresh_token")
public class RefreshToken {
@Id
private Long id;
@Column("refresh_token")
private String refreshToken;
@Column("username")
private String username;
@Column("ip_address")
private String ipAddress;
@Column("device_name")
private String deviceName;
@Column("os_name")
private String osName;
@Column("browser_name")
private String browserName;
@Column("expiration_date")
private LocalDateTime expirationDate;
@Column("created_at")
private LocalDateTime createdAt;
}
클라이언트에서는 JWT 기반 인증방식에서 Access Token
을 담아 요청을 보내는데, Access Token
이 만료되면, Refresh Token
을 이용해 Access Token
을 재발급 받는다. Refresh Token
과 그 외에 메타정보들을 저장해두는 엔티티 클래스이다. HTTP Header로부터 가져온 IP 정보, 기기명, 운영체제, 웹 브라우저 정보 등의 메타 정보들을 저장한다. 다양한 기기(개인 노트북, PC, 모바일)에서 웹 어플리케이션을 사용하는 경우를 고려하여 Refresh Token
을 구분하기 위해 여러 메타정보들을 함께 저장한다.
@Repository
public interface ReactiveRefreshTokenRepository extends ReactiveCrudRepository<RefreshToken, Long> {
Mono<Void> deleteByRefreshToken(String refreshToken);
}
@Getter
public class CustomUserDetails implements UserDetails {
private final Long id;
private final String username;
private final String password;
private final List<GrantedAuthority> authorities;
private final String email;
public CustomUserDetails(Long id, String username, String password, String authorities, String email) {
this.id = id;
this.username = username;
this.password = password;
this.authorities = parseAuthorities(authorities);
this.email = email;
}
private List<GrantedAuthority> parseAuthorities(String authorities) {
if (authorities == null || authorities.isEmpty()) {
return Collections.emptyList();
}
return Arrays.stream(authorities.split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.username;
}
@Override
public boolean isAccountNonExpired() {
return UserDetails.super.isAccountNonExpired();
}
@Override
public boolean isAccountNonLocked() {
return UserDetails.super.isAccountNonLocked();
}
@Override
public boolean isCredentialsNonExpired() {
return UserDetails.super.isCredentialsNonExpired();
}
@Override
public boolean isEnabled() {
return UserDetails.super.isEnabled();
}
}
UserDetails
의 구현체이다. 이 객체는 Spring Security에서 DTO 클래스로 사용된다. DTO 객체 자체로는 MVC, WebFlux 환경에서 동기/비동기, 블로킹/논블로킹 동작에 영향을 주지 않기 때문에 Spring Security에서는 리액티브 환경을 고려한 다른 인터페이스를 제공하지 않는 듯하다.
@Slf4j
@Component
public class JwtTokenProvider {
private final SecretKey secretKey;
private final long accessTokenExpiration;
private final long refreshTokenExpiration;
public JwtTokenProvider(@Value("${jwt.secret}") String key,
@Value("${jwt.access-token.expiration}") long accessTokenExpiration,
@Value("${jwt.refresh-token.expiration}") long refreshTokenExpiration) {
byte[] bytes = Decoders.BASE64.decode(key);
this.secretKey = Keys.hmacShaKeyFor(bytes);
this.accessTokenExpiration = accessTokenExpiration;
this.refreshTokenExpiration = refreshTokenExpiration;
}
public String createAccessToken(Authentication authentication) {
return createToken(authentication, accessTokenExpiration);
}
public String createRefreshToken(Authentication authentication) {
return createToken(authentication, refreshTokenExpiration);
}
/**
* Authentication 객체로부터 JWT Access Token 생성
*
* @param authentication 현재 인증된 사용자의 정보
* @return 생성된 토큰 정보(TokenInfoDto
*/
private String createToken(Authentication authentication, long expirationHours) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
Long userId = userDetails.getId();
String username = userDetails.getUsername();
String email = userDetails.getEmail();
Instant now = Instant.now();
Instant expirationInstant = now.plus(Duration.ofMinutes(expirationHours));
Date expirationDate = Date.from(expirationInstant);
return Jwts.builder()
.subject(authentication.getName())
.claim("userId", userId)
.claim("username", username)
.claim("email", email)
.claim("authorities", authorities)
.issuedAt(Date.from(now))
.expiration(expirationDate)
.signWith(secretKey)
.compact();
}
public Claims getClaims(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
public boolean validateToken(String token) {
try {
Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token);
return true;
} catch (ExpiredJwtException e) {
log.error("JWT token is expired: {}", e.getMessage());
throw new JwtAuthenticationException(ErrorCode.EXPIRED_TOKEN);
} catch (SignatureException e) {
log.error("Invalid JWT signature: {}", e.getMessage());
throw new JwtAuthenticationException(ErrorCode.INVALID_SIGNATURE);
} catch (MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
log.error("Invalid JWT token: {}", e.getMessage());
throw new JwtAuthenticationException(ErrorCode.INVALID_TOKEN);
}
}
public Authentication getAuthentication(String token) {
Claims claims = getClaims(token);
Long userId = claims.get("userId", Long.class);
String username = claims.get("username", String.class);
String email = claims.get("email", String.class);
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("authorities", String.class).split(","))
.map(SimpleGrantedAuthority::new)
.toList();
CustomUserDetails userDetails = new CustomUserDetails(
userId,
username,
null,
authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(",")),
email
);
return new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
}
}
토큰 생성, 토큰 유효성 검증 등의 서포터 역할을 하는 클래스이다. 만료시간은 yaml 파일로 관리하고 주입받는다.
👉🏻 CustomReactiveUserDetailsService.java로 돌아가기
@Service
@RequiredArgsConstructor
public class UserService {
private final ReactiveUserRepository reactiveUserRepository;
private final ReactiveRefreshTokenRepository reactiveRefreshTokenRepository;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
public Mono<RegisterResponseDto> register(RegisterRequestDto request) {
return reactiveUserRepository.findByUsernameOrEmail(request.getUsername(),request.getEmail())
.flatMap(existingUser -> {
if (existingUser.getUsername().equals(request.getUsername())) {
return Mono.error(new UserRegistrationException(ErrorCode.DUPLICATE_USERNAME));
}
if (existingUser.getEmail().equals(request.getEmail())) {
return Mono.error(new UserRegistrationException(ErrorCode.DUPLICATE_EMAIL));
}
return null;
})
.switchIfEmpty(Mono.defer(() -> {
User user = User.builder()
.username(request.getUsername())
.password(passwordEncoder.encode(request.getPassword()))
.email(request.getEmail())
.authorities("ROLE_USER")
.createdAt(LocalDateTime.now())
.build();
return reactiveUserRepository.save(user)
.map(savedUser -> RegisterResponseDto.builder()
.message("회원가입이 완료되었습니다.")
.username(savedUser.getUsername())
.email(savedUser.getEmail())
.build());
}))
.cast(RegisterResponseDto.class);
}
public Mono<LoginResponseDto> loginAndGenerateToken(LoginRequestDto request) {
return Mono.deferContextual(ctx -> {
String clientIp = ctx.get("clientIp");
String deviceName = ctx.get("deviceName");
String osName = ctx.get("osName");
String browserName = ctx.get("browserName");
return reactiveUserRepository.findByUsername(request.getUsername())
.switchIfEmpty(Mono.error(new UserLoginException(ErrorCode.USER_NOT_FOUND)))
.flatMap(user -> {
if (passwordEncoder.matches(request.getPassword(), user.getPassword())) {
List<SimpleGrantedAuthority> authorities = Collections.singletonList(new SimpleGrantedAuthority(user.getAuthorities()));
Authentication authentication = new UsernamePasswordAuthenticationToken(
new CustomUserDetails(user.getId(), user.getUsername(), user.getPassword(), user.getAuthorities(), user.getEmail()),
null,
authorities
);
String accessToken = jwtTokenProvider.createAccessToken(authentication);
String refreshToken = jwtTokenProvider.createRefreshToken(authentication);
RefreshToken refreshTokenEntity = RefreshToken.builder()
.refreshToken(refreshToken)
.username(user.getUsername())
.expirationDate(LocalDateTime.now().plusHours(720))
.ipAddress(clientIp)
.deviceName(deviceName)
.osName(osName)
.browserName(browserName)
.build();
return reactiveRefreshTokenRepository.save(refreshTokenEntity)
.thenReturn(LoginResponseDto.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build());
} else {
return Mono.error(new UserLoginException(ErrorCode.INVALID_PASSWORD));
}
});
});
}
public Mono<Void> logout(String refreshToken) {
return reactiveRefreshTokenRepository.deleteByRefreshToken(refreshToken);
}
}
(예외처리와 관련된 코드들은 포스트의 가장 아래에 있다.)
회원가입, 로그인, 로그아웃의 비즈니스 로직을 구현한다. 웹플럭스를 활용하여 비동기, 논블로킹으로 동작하도록 하기 위해 반환 타입은 모두 Mono
또는 Flux
를 사용한다.
로그인 기능을 수행할 때에는 Access Token
, Refresh Token
을 함께 생성하고, DB에는 Refresh Token
만 저장한다. 클라이언트에게는 2개의 토큰을 모두 반환하여서 저장하도록 하고, 기본 요청을 할 때에는 Access Token
을 사용하도록 한다. Access Token
이 만료되면 Refresh Token
으로 서버로 요청을 보내 다시 발급받도록 한다. Access Token
을 DB에 저장하지 않는 이유는 토큰이 탈취되었을 때를 대비한 보안 때문이다.
로그아웃 요청을 받을 때는 클라이언트로부터 Refresh Token
을 받아서 해당 토큰을 DB에서 완전히 제거하도록 한다.
@Getter
@Builder
public class RegisterRequestDto {
private String username;
private String password;
private String roles;
private String email;
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class RegisterResponseDto {
private String message;
private String username;
private String email;
}
@Getter
@Builder
public class LoginRequestDto {
private String username;
private String password;
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class LoginResponseDto {
private String accessToken;
private String refreshToken;
}
@Getter
@Builder
public class LogoutRequestDto {
@JsonProperty("refresh_token")
@NotBlank(message = "리프레시 토큰은 필수 값입니다.")
private String refreshToken;
}
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping("/register")
public Mono<ResponseEntity<RegisterResponseDto>> register(@RequestBody RegisterRequestDto request) {
return userService.register(request)
.map(responseDto -> new ResponseEntity<>(responseDto, HttpStatus.CREATED));
}
@PostMapping("/login")
public Mono<ResponseEntity<LoginResponseDto>> login(@RequestBody LoginRequestDto request) {
return userService.loginAndGenerateToken(request)
.map(responseDto -> new ResponseEntity<>(responseDto, HttpStatus.OK));
}
@PostMapping("/logout")
public Mono<ResponseEntity<Void>> logout(@Valid @RequestBody LogoutRequestDto request) {
return userService.logout(request.getRefreshToken())
.thenReturn(ResponseEntity.ok().build());
}
}
@Component
public class RequestMetadataFilter implements WebFilter {
private static final UserAgentAnalyzer userAgentAnalyzer;
static {
userAgentAnalyzer = UserAgentAnalyzer
.newBuilder()
.hideMatcherLoadStats()
.withCache(1000)
.build();
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String clientIp = Optional.ofNullable(exchange.getRequest().getRemoteAddress())
.map(addr -> addr.getAddress().getHostAddress())
.orElse("unknown");
String userAgentString = Optional.ofNullable(exchange.getRequest().getHeaders().getFirst("User-Agent"))
.orElse("unknown");
UserAgent agent = userAgentAnalyzer.parse(userAgentString);
String deviceName = Optional.ofNullable(agent.getValue("DeviceName")).orElse("unknown");
String osName = Optional.ofNullable(agent.getValue("OperatingSystemName")).orElse("unknown");
String browserName = Optional.ofNullable(agent.getValue("AgentName")).orElse("unknown");
return chain.filter(exchange)
.contextWrite(ctx -> ctx.put("clientIp", clientIp))
.contextWrite(ctx -> ctx.put("deviceName", deviceName))
.contextWrite(ctx -> ctx.put("osName", osName))
.contextWrite(ctx -> ctx.put("browserName", browserName));
}
}
WebFilter
의 구현체이다. 클라이언트로부터 API 요청이 들어올 때, 스프링 시큐리티 필터체인보다 앞에서 요청을 가로챈다. HTTP Header로부터 클라이언트 기기의 메타정보들을 추출하여 Context
객체에 임시로 저장하고, UserService
에서는 로그인 기능을 수행할 때, 이 Context
객체로부터 메타정보들을 가져와 DB에 저장한다.
@Component
@RequiredArgsConstructor
public class JwtServerAuthenticationConverter implements ServerAuthenticationConverter {
private final JwtTokenProvider jwtTokenProvider;
private static final String BEARER_PREFIX = "Bearer ";
/**
* ServerWebExchange 에서 Authorization 헤더를 파싱하여 JWT 를 추출
* 추출된 JWT 의 유효성 검사(서명이 올바른지, 만료되지 않았는지) -> validateToken 이 이를 처리함.
*
* 유효한 경우 사용자 정보를 기반으로 Mono<Authentication>을 생성
*
* @param exchange
* @return
*/
@Override
public Mono<Authentication> convert(ServerWebExchange exchange) {
return Mono.justOrEmpty(exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION))
.filter(authValue -> authValue.startsWith(BEARER_PREFIX))
.flatMap(authValue -> {
try {
String token = authValue.substring(BEARER_PREFIX.length());
jwtTokenProvider.validateToken(token);
Authentication authentication = jwtTokenProvider.getAuthentication(token);
return Mono.just(authentication);
} catch (JwtAuthenticationException e) {
return Mono.error(e);
} catch (Exception e) {
return Mono.error(new JwtAuthenticationException(ErrorCode.UNEXPECTED_ERROR));
}
});
}
}
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtReactiveAuthenticationManager implements ReactiveAuthenticationManager {
private final CustomReactiveUserDetailsService customReactiveUserDetailsService;
/**
* ServerAuthenticationConverter 의 구현체인 JwtServerAuthenticationConverter 에서 생성된
* Authentication 객체를 받아 인증을 수행
*
* 이미 JwtServerAuthenticationConverter에서 토큰을 통해 사용자 정보를 가져왔으므로
* 별도의 추가 검증이 필요하지 않는다.
* 여기서는 토큰에서 가져온 정보가 유효한지 최종적으로 확인만 한다.
* 실직적인 인증 로직은 이미 컨버터에서 처리하였다.
* 예를 들어, username이 비어있는지 확인하는 등의 로직을 추가할 수 있다.
*
* @param authentication
* @return
*/
@Override
public Mono<Authentication> authenticate(Authentication authentication) {
if (authentication.getPrincipal() == null || authentication.getPrincipal().toString().isEmpty()) {
return Mono.error(new AuthenticationFailedException(ErrorCode.AUTHENTICATION_FAILED));
}
return Mono.just(authentication);
}
}
@Component
@Order(-1)
@RequiredArgsConstructor
public class CustomErrorWebExceptionHandler implements ErrorWebExceptionHandler {
private final ObjectMapper objectMapper;
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
if (ex instanceof JwtAuthenticationException) {
JwtAuthenticationException jwtEx = (JwtAuthenticationException) ex;
ErrorCode errorCode = jwtEx.getErrorCode();
HttpStatus httpStatus = HttpStatus.UNAUTHORIZED;
ErrorResponseDto errorResponse = ErrorResponseDto.builder()
.message(errorCode.getMessage())
.status(httpStatus)
.code(errorCode.getCode())
.timestamp(LocalDateTime.now())
.build();
byte[] bytes;
try {
bytes = objectMapper.writeValueAsBytes(errorResponse);
} catch (JsonProcessingException e) {
return Mono.error(e);
}
exchange.getResponse().setStatusCode(httpStatus);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(bytes)));
}
return Mono.error(ex);
}
}
여기서 응답 객체를 byte
로 다루는 이유는 비동기적으로 데이터를 클라이언트에 응답하기 위함이다.
@Slf4j
@Component
@RequiredArgsConstructor
public class CustomServerAuthenticationFailureHandler implements ServerAuthenticationFailureHandler {
private final ObjectMapper objectMapper;
@Override
public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange, AuthenticationException exception) {
ServerWebExchange exchange = webFilterExchange.getExchange();
ErrorCode errorCode;
if (exception instanceof AuthenticationFailedException) {
errorCode = ((AuthenticationFailedException) exception).getErrorCode();
} else if (exception instanceof BadCredentialsException) {
errorCode = ErrorCode.AUTHENTICATION_FAILED;
} else {
errorCode = ErrorCode.UNEXPECTED_ERROR;
}
return buildErrorResponse(exchange, errorCode, HttpStatus.UNAUTHORIZED);
}
private Mono<Void> buildErrorResponse(ServerWebExchange exchange, ErrorCode errorCode, HttpStatus status) {
exchange.getResponse().setStatusCode(status);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
ErrorResponseDto errorResponse = ErrorResponseDto.builder()
.message(errorCode.getMessage())
.status(status)
.code(errorCode.getCode())
.timestamp(LocalDateTime.now())
.build();
try {
byte[] bytes = objectMapper.writeValueAsBytes(errorResponse);
return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(bytes)));
} catch (JsonProcessingException e) {
return Mono.error(e);
}
}
}
@Slf4j
@Component
public class JwtServerAuthenticationEntryPoint implements ServerAuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException ex) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> errorDetails = new HashMap<>();
errorDetails.put("status", HttpStatus.UNAUTHORIZED.value());
errorDetails.put("error","Unauthorized");
errorDetails.put("message",ex.getMessage());
try {
byte[] jsonBytes = objectMapper.writeValueAsBytes(errorDetails);
DataBuffer buffer = response.bufferFactory().wrap(jsonBytes);
return response.writeWith(Mono.just(buffer));
} catch (JsonProcessingException e) {
return Mono.error(e);
}
}
}
@Component
public class JwtServerAccessDeniedHandler implements ServerAccessDeniedHandler {
@Override
public Mono<Void> handle(ServerWebExchange exchange, AccessDeniedException denied) {
return Mono.fromRunnable(() -> {
exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
});
}
}
@Configuration
@EnableWebFluxSecurity
@RequiredArgsConstructor
public class ReactiveSecurityConfig {
private final ReactiveAuthenticationManager reactiveAuthenticationManager;
private final ServerAuthenticationConverter serverAuthenticationConverter;
private final ServerAuthenticationEntryPoint serverAuthenticationEntryPoint;
private final ServerAccessDeniedHandler serverAccessDeniedHandler;
private final ServerAuthenticationFailureHandler serverAuthenticationFailureHandler;
@Bean
public SecurityWebFilterChain securityWebFilterChain(
ServerHttpSecurity http) {
AuthenticationWebFilter authenticationWebFilter = new AuthenticationWebFilter(reactiveAuthenticationManager);
authenticationWebFilter.setServerAuthenticationConverter(serverAuthenticationConverter);
authenticationWebFilter.setAuthenticationFailureHandler(serverAuthenticationFailureHandler);
http
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.cors(ServerHttpSecurity.CorsSpec::disable)
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
.securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
.addFilterAt(authenticationWebFilter,SecurityWebFiltersOrder.AUTHENTICATION)
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/auth/**").permitAll()
.anyExchange().authenticated())
.exceptionHandling(exceptionHandlingSpec -> exceptionHandlingSpec
.authenticationEntryPoint(serverAuthenticationEntryPoint)
.accessDeniedHandler(serverAccessDeniedHandler));
return http.build();
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
DUPLICATE_USERNAME("USER_001", "이미 사용 중인 아이디입니다."),
DUPLICATE_EMAIL("USER_002","이미 사용 중인 이메일입니다."),
USER_NOT_FOUND("AUTH_001", "사용자를 찾을 수 없습니다."),
INVALID_PASSWORD("AUTH_002", "잘못된 비밀번호입니다."),
AUTHENTICATION_FAILED("AUTH_003", "인증에 실패했습니다."),
INVALID_TOKEN("TOKEN_001","유효하지 않은 토큰입니다."),
EXPIRED_TOKEN("TOKEN_002", "토큰이 만료되었습니다."),
INVALID_SIGNATURE("TOKEN_003", "토큰 서명이 유효하지 않습니다."),
UNEXPECTED_ERROR("COMMON_001", "예기치 않은 오류가 발생했습니다.");
private final String code;
private final String message;
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ErrorResponseDto {
private HttpStatus status;
private String code;
private String message;
private LocalDateTime timestamp;
}
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UserRegistrationException.class)
public ResponseEntity<ErrorResponseDto> handleUserRegistrationException(UserRegistrationException ex) {
ErrorCode errorCode = ex.getErrorCode();
HttpStatus httpStatus = HttpStatus.CONFLICT;
ErrorResponseDto errorResponse = ErrorResponseDto.builder()
.message(errorCode.getMessage())
.status(httpStatus)
.code(errorCode.getCode())
.timestamp(LocalDateTime.now())
.build();
return new ResponseEntity<>(errorResponse, httpStatus);
}
@ExceptionHandler(UserLoginException.class)
public ResponseEntity<ErrorResponseDto> handleUserLoginException(UserLoginException ex) {
ErrorCode errorCode = ex.getErrorCode();
HttpStatus httpStatus = HttpStatus.UNAUTHORIZED;
ErrorResponseDto errorResponse = ErrorResponseDto.builder()
.message(errorCode.getMessage())
.status(httpStatus)
.code(errorCode.getCode())
.timestamp(LocalDateTime.now())
.build();
return new ResponseEntity<>(errorResponse, httpStatus);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponseDto> handleValidationExceptions(MethodArgumentNotValidException ex) {
String errorMessage = ex.getBindingResult().getFieldError().getDefaultMessage();
HttpStatus httpStatus = HttpStatus.BAD_REQUEST;
ErrorResponseDto errorResponse = ErrorResponseDto.builder()
.message(errorMessage)
.status(httpStatus)
.code(ErrorCode.UNEXPECTED_ERROR.getCode())
.timestamp(LocalDateTime.now())
.build();
return new ResponseEntity<>(errorResponse, httpStatus);
}
}
@Getter
@RequiredArgsConstructor
public class UserRegistrationException extends RuntimeException {
private final ErrorCode errorCode;
}
@Getter
@RequiredArgsConstructor
public class UserLoginException extends RuntimeException {
private final ErrorCode errorCode;
}
@Getter
@RequiredArgsConstructor
public class JwtAuthenticationException extends RuntimeException {
private final ErrorCode errorCode;
}
@Getter
public class AuthenticationFailedException extends BadCredentialsException {
private final ErrorCode errorCode;
public AuthenticationFailedException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
}
위의 예외 코드는 RuntimeException
이 아닌 BadCredentialsException
을 상속받고 있다. 이 예외는 BadCredentilasException
을 발생시킴으로써, ReactiveAuthenticationManager
의 구현체인 JwtReactiveAuthenticationManager
가 해당 예외를 전달받아서 클라이언트 응답을 처리하기 위함이다.