Files
project-auth-server/coverage-history/v0.0.1-SNAPSHOT/2026-05-04-aggregate-after/aggregate/html/com.project.auth.config.web/SecurityResponseExceptionHandler.java.html
T

144 lines
10 KiB
HTML

<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"><html xmlns="http://www.w3.org/1999/xhtml" lang="en"><head><meta http-equiv="Content-Type" content="text/html;charset=UTF-8"/><link rel="stylesheet" href="../jacoco-resources/report.css" type="text/css"/><link rel="shortcut icon" href="../jacoco-resources/report.gif" type="image/gif"/><title>SecurityResponseExceptionHandler.java</title><link rel="stylesheet" href="../jacoco-resources/prettify.css" type="text/css"/><script type="text/javascript" src="../jacoco-resources/prettify.js"></script></head><body onload="window['PR_TAB_WIDTH']=4;prettyPrint()"><div class="breadcrumb" id="breadcrumb"><span class="info"><a href="../jacoco-sessions.html" class="el_session">Sessions</a></span><a href="../index.html" class="el_report">project-auth-server</a> &gt; <a href="index.source.html" class="el_package">com.project.auth.config.web</a> &gt; <span class="el_source">SecurityResponseExceptionHandler.java</span></div><h1>SecurityResponseExceptionHandler.java</h1><pre class="source lang-java linenums">package com.project.auth.config.web;
import com.project.auth.application.support.audit.AuthAuditEventType;
import com.project.auth.application.support.exception.AuthErrorCode;
import com.project.auth.application.support.logging.LogSanitizer;
import com.project.auth.config.auth.security.SecurityAuditTrailWriter;
import com.project.auth.presentation.support.exception.ApiErrorHttpStatusMapper;
import com.project.auth.presentation.support.response.ApiResult;
import com.project.auth.presentation.support.response.ApiResultFactory;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Objects;
/**
* Spring Security 예외를 처리하는 bootstrap 계층 advice.
*
* presentation 모듈은 ArchUnit 규칙에 의해 org.springframework.security.core / context에
* 직접 의존할 수 없으므로(LayerDependencyArchitectureTest 참고), 이 advice는 bootstrap에 둔다.
*
* AccessDeniedException은 현재 인증 상태에 따라 401/403으로 분기한다. 익명 principal이
* 보호된 리소스에 접근한 경우는 인증이 필요하다는 의미로 401을 반환하고, 인증된 principal이
* 권한이 부족한 경우에만 403을 반환한다. Spring Security의 ExceptionTranslationFilter가
* 필터 단계에서 던지는 예외에 대해 적용하는 분기 로직을 컨트롤러 단(@PreAuthorize 등 메서드
* 보안)에서 던져진 동일 예외에도 일관되게 적용한 것이다.
*
* Audit 정책: 이 advice의 모든 분기(인증 필요, 익명 401, 인증된 사용자 403)는
* SecurityAuditTrailWriter로 audit를 기록한다. 필터 단의 SecurityExceptionHandler가
* 기록하는 audit 채널과 동일 채널을 사용하므로, &quot;필터 단/컨트롤러 단&quot; 어느 경로로 들어와도
* 보안 이벤트가 누락 없이 동일 형태로 적재된다.
*
* 스레드/익명 판정: SecurityContextHolder의 ThreadLocal 컨텍스트를 우선 보지만,
* 비동기 컨트롤러로 컨텍스트가 워커 스레드로 전파되지 않은 케이스를 대비해
* HttpServletRequest.getUserPrincipal()을 보조 신호로 사용한다. principal이 명시적으로
* 존재하면 익명으로 분류하지 않는다 — 컨텍스트가 비어 있더라도 인증된 사용자가 잘못
* 401로 분류되지 않도록 보호하는 방어적 폴백이다.
*/
@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE + 5)
public class SecurityResponseExceptionHandler {
<span class="fc" id="L53"> private static final Logger log = LoggerFactory.getLogger(SecurityResponseExceptionHandler.class);</span>
private final ApiResultFactory apiResultFactory;
private final SecurityAuditTrailWriter securityAuditTrailWriter;
public SecurityResponseExceptionHandler(
ApiResultFactory apiResultFactory,
SecurityAuditTrailWriter securityAuditTrailWriter
<span class="fc" id="L61"> ) {</span>
<span class="fc" id="L62"> this.apiResultFactory = Objects.requireNonNull(apiResultFactory, &quot;apiResultFactory must not be null&quot;);</span>
<span class="fc" id="L63"> this.securityAuditTrailWriter = Objects.requireNonNull(</span>
securityAuditTrailWriter, &quot;securityAuditTrailWriter must not be null&quot;);
<span class="fc" id="L65"> }</span>
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity&lt;ApiResult&lt;Void&gt;&gt; handleAuthenticationException(
AuthenticationException exception,
HttpServletRequest request
) {
<span class="fc" id="L72"> log.warn(&quot;Authentication required. exceptionType={} method={} requestPath={} errorCode={}&quot;,</span>
<span class="fc" id="L73"> exception.getClass().getSimpleName(),</span>
<span class="fc" id="L74"> request.getMethod(),</span>
<span class="fc" id="L75"> LogSanitizer.requestPath(request.getRequestURI()),</span>
<span class="fc" id="L76"> AuthErrorCode.AUTHENTICATION_REQUIRED.code());</span>
<span class="fc" id="L77"> recordAudit(request, AuthAuditEventType.AUTHENTICATION_REQUIRED, &quot;Authentication required.&quot;);</span>
<span class="fc" id="L79"> return ResponseEntity.status(ApiErrorHttpStatusMapper.map(AuthErrorCode.AUTHENTICATION_REQUIRED))</span>
<span class="fc" id="L80"> .body(apiResultFactory.failure(</span>
<span class="fc" id="L81"> AuthErrorCode.AUTHENTICATION_REQUIRED.code(),</span>
<span class="fc" id="L82"> AuthErrorCode.AUTHENTICATION_REQUIRED.message()</span>
));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity&lt;ApiResult&lt;Void&gt;&gt; handleAccessDeniedException(
AccessDeniedException exception,
HttpServletRequest request
) {
<span class="pc bpc" id="L91" title="1 of 2 branches missed."> if (isAnonymous(SecurityContextHolder.getContext().getAuthentication(), request)) {</span>
<span class="nc" id="L92"> log.warn(&quot;AccessDenied for anonymous principal -&gt; 401. exceptionType={} method={} requestPath={} errorCode={}&quot;,</span>
<span class="nc" id="L93"> exception.getClass().getSimpleName(),</span>
<span class="nc" id="L94"> request.getMethod(),</span>
<span class="nc" id="L95"> LogSanitizer.requestPath(request.getRequestURI()),</span>
<span class="nc" id="L96"> AuthErrorCode.AUTHENTICATION_REQUIRED.code());</span>
<span class="nc" id="L97"> recordAudit(request, AuthAuditEventType.AUTHENTICATION_REQUIRED,</span>
&quot;Authentication required (AccessDenied for anonymous).&quot;);
<span class="nc" id="L100"> return ResponseEntity.status(ApiErrorHttpStatusMapper.map(AuthErrorCode.AUTHENTICATION_REQUIRED))</span>
<span class="nc" id="L101"> .body(apiResultFactory.failure(</span>
<span class="nc" id="L102"> AuthErrorCode.AUTHENTICATION_REQUIRED.code(),</span>
<span class="nc" id="L103"> AuthErrorCode.AUTHENTICATION_REQUIRED.message()</span>
));
}
<span class="fc" id="L107"> log.warn(&quot;Access denied for authenticated principal. exceptionType={} method={} requestPath={} errorCode={}&quot;,</span>
<span class="fc" id="L108"> exception.getClass().getSimpleName(),</span>
<span class="fc" id="L109"> request.getMethod(),</span>
<span class="fc" id="L110"> LogSanitizer.requestPath(request.getRequestURI()),</span>
<span class="fc" id="L111"> AuthErrorCode.ACCESS_DENIED.code());</span>
<span class="fc" id="L112"> recordAudit(request, AuthAuditEventType.ACCESS_DENIED, &quot;Access denied.&quot;);</span>
<span class="fc" id="L114"> return ResponseEntity.status(ApiErrorHttpStatusMapper.map(AuthErrorCode.ACCESS_DENIED))</span>
<span class="fc" id="L115"> .body(apiResultFactory.failure(</span>
<span class="fc" id="L116"> AuthErrorCode.ACCESS_DENIED.code(),</span>
<span class="fc" id="L117"> AuthErrorCode.ACCESS_DENIED.message()</span>
));
}
/**
* SecurityContext가 비어 있어도 서블릿 principal이 명시적으로 존재하면 익명이 아니다.
* ThreadLocal 컨텍스트가 비동기 워커 스레드로 전파되지 않은 케이스에서 인증된 사용자가
* 잘못 401로 분류되는 사고를 막기 위한 방어적 폴백.
*/
private static boolean isAnonymous(Authentication authentication, HttpServletRequest request) {
<span class="pc bpc" id="L127" title="1 of 2 branches missed."> if (request.getUserPrincipal() != null) {</span>
<span class="fc" id="L128"> return false;</span>
}
<span class="nc bnc" id="L130" title="All 2 branches missed."> return authentication == null</span>
<span class="nc bnc" id="L131" title="All 4 branches missed."> || !authentication.isAuthenticated()</span>
|| authentication instanceof AnonymousAuthenticationToken;
}
private void recordAudit(HttpServletRequest request, AuthAuditEventType type, String description) {
try {
<span class="fc" id="L137"> securityAuditTrailWriter.record(request, type, description);</span>
<span class="nc" id="L138"> } catch (RuntimeException auditFailure) {</span>
<span class="nc" id="L139"> log.warn(&quot;Security audit write failed; continuing with response rendering. type={}&quot;,</span>
type, auditFailure);
<span class="fc" id="L141"> }</span>
<span class="fc" id="L142"> }</span>
}
</pre><div class="footer"><span class="right">Created with <a href="http://www.jacoco.org/jacoco">JaCoCo</a> 0.8.14.202510111229</span></div></body></html>