[과제 과정] Security 구현 과정 (@WithMockAuthUser 커스텀)

늘보·2025년 3월 21일

Spring

목록 보기
17/24

Spring Security

스프링 기반 애플리케이션에 강력한 인증(Authentication)인가(Authorization)기능을 제공하는 보안 프레임워크이다.

이러한 Security 보안을 통과하기 위해서는 SecurityContext의 AbstractAuthenticationToken을 set 해주어야한다.

💡 SecurityContext란?

SecurityContext는 보통 하나의 Authentication 객체를 포함하며, 이 객체는 사용자(Principal), 인증 방식, 인증 상태, 권한 목록(GrantedAuthorites)등을 포함한다.

➡️ 한 요청 내에서 현재 인증된 사용자가 누구인지, 어떤 권한을 가지는지 알려준다


🔔 JWT 기반의 Spring Security 구현 과정

1. 의존성 추가

// security
implementation 'org.springframework.boot:spring-boot-starter-security'

2. JWT 토큰 생성 (회원가입, 로그인)

회원가입로그인을 실행하면 해당 유저 정보를 JWT에 담고 클라이언트의 호출이 발생하면 토큰에 담겨있던 정보를 빼와서 setAuthentication에 set해준다.

🟢 JwtUtil

@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil {

    private static final String BEARER_PREFIX = "Bearer ";
    private static final long TOKEN_TIME = 60 * 60 * 1000L; // 60분

    @Value("${jwt.secret.key}")
    private String secretKey;
    private Key key;
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }

    public String createToken(Long userId, String nickname, String email, UserRole userRole) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(String.valueOf(userId))
                        .claim("nickname", nickname)
                        .claim("email", email)
                        .claim("userRole", userRole)
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME))
                        .setIssuedAt(date) // 발급일
                        .signWith(key, signatureAlgorithm) // 암호화 알고리즘
                        .compact();
    }

    public String substringToken(String tokenValue) {
        if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
            return tokenValue.substring(7);
        }
        throw new ServerException("Not Found Token");
    }

    public Claims extractClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
}

3. 인증 필터 구현하기

🟢 SecurityConfig

Spring Security 설정 파일이다.

본 포스팅에서는 stateless한 특성을 가지는 JWT를 기반으로 Security 적용을 하기 때문에 세션과 관련된 Filter들을 비활성화 해준다.

@Slf4j
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS) 
                ) 
                .addFilterBefore(jwtAuthenticationFilter, SecurityContextHolderAwareRequestFilter.class)
                .formLogin(AbstractHttpConfigurer::disable)
                .anonymous(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .logout(AbstractHttpConfigurer::disable)
                .rememberMe(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(request -> request.getRequestURI().startsWith("/auth")).permitAll()
                        .requestMatchers("/actuator/health").permitAll()
                        .anyRequest().authenticated()
                )
                .build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

1️⃣ JWT 인증 방식에서 서버가 Session을 관리하지 않기 때문에 STATELESS 설정을 해주어야한다.

.sessionManagement(session -> session
                       .sessionCreationPolicy(SessionCreationPolicy.STATELESS) 
               ) 

2️⃣ jwtAuthenticationFilter를 기존 필터 체인에 추가하고 SecurityContextHolderAwareRequestFilter보다 앞에서 실행되도록 한다.

.addFilterBefore(jwtAuthenticationFilter, SecurityContextHolderAwareRequestFilter.class)

➡️ 요청이 들어오면 우선적으로 JWT를 검증하고 검증이 통과되면 SecurityContext에 인증 정보를 설정할 수 있도록 한다.

3️⃣ 폼 기반 로그인 기능이 필요하지 않기 때문에 비활성화 해준다.

.formLogin(AbstractHttpConfigurer::disable)

💡 폼 기반 로그인?

4️⃣ 인증은 JWT를 진행하기 때문에 익명 사용자 권한은 필요없다.

.anonymous(AbstractHttpConfigurer::disable)

5️⃣ 커스텀 필터를 사용하기 때문에 필요없다.

.httpBasic(AbstractHttpConfigurer::disable)

6️⃣ 로그아웃은 세션 정보를 지우는 요청이기 때문에 비활성화 해준다.

.logout(AbstractHttpConfigurer::disable)

7️⃣ Stateless이기 때문에 Remember를 할 수 없다.

.rememberMe(AbstractHttpConfigurer::disable)

8️⃣ 요청 정보에서 /auth로 시작하는 URL 경로는 누구나 접근 가능

.authorizeHttpRequests(auth -> auth
                        .requestMatchers(request -> request.getRequestURI().startsWith("/auth")).permitAll() // 로그인 or 회원가입
                        .requestMatchers("/actuator/health").permitAll()
                        .anyRequest().authenticated() 
                )

➡️ .anyRequest().authenticated(): permitAll() 설정 외의 모든 요청은 인증이 필요하다.


4. 인증된 사용자 정보(로그인한 유저)를 Spring Security의 보안 컨텍스트에 저장하기

🟢 JwtAuthenticationFilter

💡 OncePerRequestFilter Spring Security 필터의 구현체모든 요청마다 한 번만 실행된다.

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain chain
    ) throws ServletException, IOException {

        String authorizationHeader = request.getHeader("Authorization");

        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            String jwt = jwtUtil.substringToken(authorizationHeader);
            try {
                Claims claims = jwtUtil.extractClaims(jwt);

                if (SecurityContextHolder.getContext().getAuthentication() == null) {
                    setAuthentication(claims); // 인증된 사용자 저장
                }
            } catch (SecurityException | MalformedJwtException e) {
                log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다.");
            } catch (ExpiredJwtException e) {
                log.error("Expired JWT token, 만료된 JWT token 입니다.", e);
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
            } catch (UnsupportedJwtException e) {
                log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e);
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다.");
            } catch (Exception e) {
                log.error("Internal server error", e);
                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
            }
        }
        chain.doFilter(request, response);
    }

    private void setAuthentication (Claims claims) {
        //JWT 토큰에 담긴 유저 정보 추출
        Long userId = Long.parseLong(claims.getSubject());
        String email = claims.get("email", String.class);
        UserRole userRole = UserRole.of(claims.get("userRole", String.class));

        //유저 저장
        AuthUser authUser = new AuthUser(userId, email, userRole);

        //SecurityContext에 인증 정보 저장
        JwtAuthenticationToken jwtAuthenticationToken = new JwtAuthenticationToken(authUser);
        SecurityContextHolder.getContext().setAuthentication(jwtAuthenticationToken); //ContextHolder에 인증 정보 저장
    }

🟢 JwtAuthenticationToken

public class JwtAuthenticationToken extends AbstractAuthenticationToken {

    private final AuthUser authUser;

    public JwtAuthenticationToken(AuthUser authUser) {
        super(authUser.getAuthorities());
        this.authUser = authUser;
        setAuthenticated(true); 
    }

    @Override
    public Object getCredentials() {
        return null;
    }

    @Override
    public Object getPrincipal() {
        return authUser;
    }
}

🟢 AuthUser 인증된 유저 정보 저장

@Getter
public class AuthUser {

   private final Long id;
   private final String email;
   private final Collection<? extends GrantedAuthority> authorities; //여러 권한을 가질 수 있기 때문에 컬랙션으로 선언

   public AuthUser(Long id, String email, UserRole userRole) {
       this.id = id;
       this.email = email;
       this.authorities = List.of(new SimpleGrantedAuthority(userRole.name())); //인증된 유저의 권환을 Security에서 사용할 수 있는 권한 객체로 변환
   }

}

💡 Security는 ROLE_USER 와 같은 방식으로 권한을 받기 때문에 아래와 같이 형식 변경

🟢 UserRole

@Getter
@RequiredArgsConstructor
public enum UserRole {
    ROLE_ADMIN(Authority.ADMIN),
    ROLE_USER(Authority.USER);

    private final String role;

    public static UserRole of(String role) {
        return Arrays.stream(UserRole.values())
                .filter(r -> r.name().equalsIgnoreCase(role))
                .findFirst()
                .orElseThrow(() -> new InvalidRequestException("유효하지 않은 UerRole"));
    }

    public static class Authority {
        public static final String USER = "ROLE_USER";
        public static final String ADMIN = "ROLE_ADMIN";
    }
}

AuthUser권한 필드 타입이 GrantedAuthority이기 때문에 해당 타입으로 변환을 해야 Security를 통해 검증된 유저의 Role을 얻을 수 있다.

@Getter
@Entity
@NoArgsConstructor
@Table(name = "users")
public class User extends Timestamped {

	필드 및 생성자 부분 생략
   	...

    public static User fromAuthUser(AuthUser authUser) {
        GrantedAuthority authorities = authUser.getAuthorities().iterator().next(); //AuthUser에 저장되어 있던 필드의 타입으로 변경 
        return new User(authUser.getId(), authUser.getEmail(), UserRole.of(authorities.getAuthority()));
    }

	...
}

Spring Security 어노테이션

@AuthenticationPrincipal

인증된 유저 객체를 가져온다.

@PostMapping("/todos/{todoId}/comments")
    public ResponseEntity<CommentSaveResponse> saveComment(
            @AuthenticationPrincipal AuthUser authUser,
            @PathVariable long todoId,
            @Valid @RequestBody CommentSaveRequest commentSaveRequest
    ) {
        return ResponseEntity.ok(commentService.saveComment(authUser, todoId, commentSaveRequest));
    }

@Secured

@Secured(UserRole.Authority.ADMIN) 적용 시 ADMIN 권한을 가진 사람만 해당 API에 인증된다.

@Secured(UserRole.Authority.ADMIN)
    @PatchMapping("/admin/users/{userId}")
    public void changeUserRole(@PathVariable long userId, @RequestBody UserRoleChangeRequest userRoleChangeRequest) {
        userAdminService.changeUserRole(userId, userRoleChangeRequest);
    }

Spring Security 테스트 코드

🟢 TestSecurityContextFactory

/**
 * 테스트 환경에서 SecurityContext를 설정하는 클래스
 *  WithMockAuthUser 어노테이션을 기반으로 가짜 인증 정보를 생성한다.
 */
public class TestSecurityContextFactory implements WithSecurityContextFactory<WithMockAuthUser> {

    /**
     * WithMockAuthUser 어노테이션을 통해 받은 유저 정보를 바탕으로 SecurityContext 생성
     *
     * @param customUser WithMockAuthUser를 통해 받아온 유저 정보(userId, email, role)
     * @return SecurityContext 가짜 유저 인증 정보를 담은 context
     */
    @Override
    public SecurityContext createSecurityContext(WithMockAuthUser customUser) {
        //비어있는 새로운 SecurityContext 객체 생성
        SecurityContext context = SecurityContextHolder.createEmptyContext();

        //유저 객체 생성
        AuthUser authUser = new AuthUser(customUser.userId(), customUser.email(), customUser.role());
        JwtAuthenticationToken authentication = new JwtAuthenticationToken(authUser);

        //SecurityContext에 유저 인증 정보 저장
        context.setAuthentication(authentication);
        return context;
    }
}

🟢 WithMockAuthUser

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = TestSecurityContextFactory.class)
public @interface WithMockAuthUser {
    long userId();
    String email();
    UserRole role();
}

@WithMockAuthUser를 통해 생성한 가짜 인증 유저Security Context에 저장하면 테스트에서는 해당 유저를 가져와서 활용하면 된다.


💡 Spring에서 기본적으로 제공하는 @WithMockUser@WithMockUser 어노테이션을 사용해도된다.


커스텀과 다른 점은 이미 어노테이션 내에 각 정보가 default로 정해져있기 때문에 테스트에서 따로 값을 넣어주지 않아도 된다.

profile
누워만 있지 말고 제발 뭐라도 하자.

0개의 댓글