Reactive Spring Security의 구조와 동작 및 구현[JWT 인증방식]

eggggg·2025년 8월 29일
0
post-thumbnail

목차

1. Filter의 개념 및 Spring Security Filter의 역할
2. Reactive에서 Spring Security 클래스 구조 및 역할
3. 구현(Java)

1. Filter의 개념 및 Spring Security Filter의 역할

필터는 웹 어플리케이션에서 요청과 응답을 가로채어 공통적인 작업을 수행하는 객체이다. 이는 핵심 비즈니스 로직에 들어가기 전후에 필요한 기능을 분리하여 코드를 깔끔하게 만든다.

Filter의 일반적인 개념과 역할

필터의 가장 중요한 개념은 필터 체인(Filter Chain) 이다. 웹 요청은 마치 공장 생산 라인처럼 여러 필터들을 순서대로 통과하며 처리된다.

  • 요청/응답 변환: 요청의 인코딩을 변경하거나, 응답 데이터를 압축하는 등 데이터를 가공한다.
  • 부가 정보 추출 및 삽입: 모든 요청에 대해 IP 주소, User-Agent 등 부가적인 정보를 추출하거나, 응답에 보안 헤더(예: Content-Security-Policy)를 추가한다.
  • 로깅 및 모니터링: 요청이 들어오고 나가는 시간을 기록하거나, 요청 URL을 로깅하여 시스템의 활동을 추적한다.
  • 자원 관리: 캐싱을 통해 정적 자원에 대한 응답 속도를 높이거나, 불필요한 요청을 차단하여 서버의 부하를 줄인다.

Security Filter의 역할

시큐리티 필터는 필터의 여러 역할 중 특히 인증(Authentication)인가(Authorization) 에 특화된 필터들의 집합이다. Spring Security는 이 필터들을 하나의 필터 체인으로 구성하여 보안 관련 작업을 전담하게 된다.

  • 인증(Authentication): 사용자의 신원을 확인합니다.예를 들어, 로그인 폼에서 사용자 이름과 비밀번호를 검증하거나, JWT 토큰의 유효성을 확인하여 사용자가 누구인지 식별합니다.
  • 인가(Authorization): 인증된 사용자가 특정 자원에 접근할 권한이 있는지 확인합니다. 예를 들어, "관리자" 권한을 가진 사용자만 특정 API에 접근할 수 있도록 제한하는 역할을 합니다.

2. Reactive에서 Spring Security 클래스 구조 및 역할

  • WebFilter : 서블릿 컨테이너 또는 리액티브 프레임워크에서 웹 요청과 응답을 가로채서 처리하는 객체이다. 웹 어플리케이션의 핵심 비즈니스 로직에 들어가기 전에 공통적인 작업을 처리하는데 사용된다. 클라이언트로부터 요청이 들어오면 가장 먼저 WebFilter를 거친다. WebFilter는 모든 HTTP 요청에 대해 공통적인 전처리 로직을 수행한다. User-Agent나 IP 주소 같은 정보를 추출하여 Context에 담는다. 여기서 추출되는 정보들은 JWT 에서 Refresh Token을 저장할 때, 메타데이터로 활용된다. Authorization 헤더가 있는지 확인하고 토큰을 추출하는 역할도 할 수 있지만, 스프링 시큐리티로 함께 개발하기 때문에 이러한 역할은 Spring Security Filter가 담당하도록 한다.
  • ServerHttpSecurity : 스프링 시큐리티에서 리액티브 웹 보안 설정을 구성하는 핵심 클래스이다. 이 클래스는 전통적인 서블릿 기반 어플리케이션의 HttpSecurity와 유사하지만, 리액티브 환경에 맞게 비동기적이고 논블로킹 방식으로 동작한다.

  • SecurityWebFilterChain : 스프링 시큐리티의 리액티브 웹 보안 필터 체인을 정의하는 핵심 인터페이스이다. 전통적인 서블릿 기반 어플리케이션의 FilterChain과 유사하지만, 리액티브 컨텍스트에서 비동기 및 논블로킹 방식으로 작동한다. 이 인터페이스는 웹 요청이 들어왔을 때, 어떤 경로에 어떤 보안 필터들(예: 인증, 인가, CSRF)이 어떤 순서로 적용될지 설정하는 역할을 한다. 개발자는 보통 ServerHttpSecurity를 사용하여 특정 URL 패턴에 따른 보안 규칙을 MonoFlux를 반환하는 리액티브 스트림으로 구성한다.

  • 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 : 인증 실패 시에만 호출되는 특화된 비동기 핸들러이다. ReactiveAuthenticationManagerAuthenticationException을 던지면, 이 핸들러가 해당 예외를 받아 클라이언트에게 맞춤형 오류 응답을 Mono<Void> 형태로 반환한다. ErrorWebExceptionHandler가 모든 종류의 예외를 처리하는 것에 비해, 이 핸들러는 오직 인증 실패와 관련된 예외에만 반응한다.


3. 구현(Java)

build.gradle

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()
}
  • Spring Boot 3.5.5
  • JJWT 0.12.7 (버전에 따라 지원하는 메서드가 조금씩 달라짐)
  • Spring Security는 따로 Reactive에 대한 라이브러리가 존재하지 않고, Spring Security 하나에 MVC, Reactive에 대한 모든 기능들이 포함되어 있다.
  • yauaa : 라이브러리는 HTTP Header로부터 User-Agent의 값들을 읽어온다. 클라이언트의 기기명, 운영체제, 웹 브라우저 등의 메타정보를 가져온다. yauaa외에도 이러한 기능들을 하는 라이브러리들이 다양하게 있는데, 예시 코드를 쉽게 찾을 수 있어서 yauaa를 선택하였다.
  • r2dbc : 내부적으로 데이터베이스와 리액티브하게 CRUD를 지원하는 라이브러리이다.

application.yaml

jwt:
  secret: U29tZUJhc2U2NEVuY29kZWRSYW5kb21LZXlGb3JKV1Q=
  access-token:
    expiration: 30 # 30분(분단위)
  refresh-token:
    expiration: 1440 # 한달(분단위)

만료시간을 설정파일에 담았다. 데이터 액세스와 관련된 설정은 제외하였다.


데이터 액세스 코드

User.java

@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에서 사용되는 시그니처를 그대로 가져온 것이다.

ReactiveUserRepository.java

@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를 상속받는다.

CustomReactiveUserDetailsService.java

@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의 인증, 인가를 처리할 때 데이터베이스와 통신하여 데이터를 다루기 위한 클래스이다. 두 클래스의 목적성이 조금 다르기 때문에 분리하였다.

RefreshToken.java

@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을 구분하기 위해 여러 메타정보들을 함께 저장한다.

ReactiveRefreshTokenRepository.java

@Repository
public interface ReactiveRefreshTokenRepository extends ReactiveCrudRepository<RefreshToken, Long> {
    Mono<Void> deleteByRefreshToken(String refreshToken);
}

CustomUserDetails.java

@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에서는 리액티브 환경을 고려한 다른 인터페이스를 제공하지 않는 듯하다.


토큰 관리 코드

JwtTokenProvider.java

@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 파일로 관리하고 주입받는다.


비즈니스 코드

UserService.java

👉🏻 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에서 완전히 제거하도록 한다.

RegisterRequestDto.java, RegisterResponseDto.java

@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;
}

LoginRequestDto.java, LoginResponseDto.java

@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;
}

LogoutRequestDto.java

@Getter
@Builder
public class LogoutRequestDto {
    @JsonProperty("refresh_token")
    @NotBlank(message = "리프레시 토큰은 필수 값입니다.")
    private String refreshToken;
}

UserController.java

@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());
    }
}

시큐리티 관련 코드

RequestMetadataFilter.java

@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에 저장한다.

JwtServerAuthenticationConverter.java

@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));
                    }
                });
    }
}

JwtReactiveAuthenticationManager.java

@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);
    }
}

CustomErrorWebExceptionHandler.java

@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로 다루는 이유는 비동기적으로 데이터를 클라이언트에 응답하기 위함이다.

CustomServerAuthenticationFailureHandler.java

@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);
        }
    }
}

JwtServerAuthenticationEntryPoint.java

@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);
        }
    }
}

JwtServerAccessDeniedHandler

@Component
public class JwtServerAccessDeniedHandler implements ServerAccessDeniedHandler {
    @Override
    public Mono<Void> handle(ServerWebExchange exchange, AccessDeniedException denied) {
        return Mono.fromRunnable(() -> {
            exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
        });
    }
}

ReactiveSecurityConfig

@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();
    }
}

예외 처리 코드

ErrorCode.java

@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;
}

ErrorResponseDto.java

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ErrorResponseDto {
    private HttpStatus status;
    private String code;
    private String message;
    private LocalDateTime timestamp;
}

GlobalExceptionHandler.java

@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);
    }
}

UserRegistrationException.java

@Getter
@RequiredArgsConstructor
public class UserRegistrationException extends RuntimeException {
    private final ErrorCode errorCode;
}

UserLoginException.java

@Getter
@RequiredArgsConstructor
public class UserLoginException extends RuntimeException {
    private final ErrorCode errorCode;
}

JwtAuthenticationException.java

@Getter
@RequiredArgsConstructor
public class JwtAuthenticationException extends RuntimeException {
    private final ErrorCode errorCode;
}

AuthenticationFailedException.java

@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가 해당 예외를 전달받아서 클라이언트 응답을 처리하기 위함이다.

0개의 댓글