감사 로깅/Audit: 로그인 실패·권한거부 탐지 규격
인증/인가는 성공보다 실패가 더 중요합니다. 로그인 실패(401)와 권한 거부(403)는 침해 징후를 가장 먼저 알려주는 신호입니다. 이 글은 Spring Security 6 기준으로 감사 로깅 표준과 구현 코드(핸들러/이벤트/필터/로거 분리)를 제시합니다.
1) 로거 분리: 보안 감사를 별도 파일로
<!-- logback-spring.xml -->
<configuration>
<appender name="SECURITY_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/security-audit.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/security-audit.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{ISO8601} %X{traceId} %-5level %logger - %msg%n</pattern>
</encoder>
</appender>
<logger name="SECURITY_AUDIT" level="INFO" additivity="false">
<appender-ref ref="SECURITY_FILE"/>
</logger>
<root level="INFO"> </root>
</configuration>
운영팀이 security-audit.log만 별도로 수집/알림할 수 있게 분리합니다. MDC(traceId)를 함께 남겨 요청 흐름을 추적합니다(게이트웨이와 동일 ID 사용 권장).
2) 401/403 핸들러에서 컨텍스트 로깅
@Component
public class AuditHandlers {
private static final Logger AUDIT = LoggerFactory.getLogger("SECURITY_AUDIT");
public AuthenticationEntryPoint entryPoint401() {
return (req, res, ex) -> {
AUDIT.info("401 UNAUTHORIZED ip={} ua={} uri={} msg={}",
req.getRemoteAddr(), req.getHeader("User-Agent"), req.getRequestURI(), ex.getMessage());
res.sendError(HttpServletResponse.SC_UNAUTHORIZED);
};
}
public AccessDeniedHandler accessDenied403() {
return (req, res, ex) -> {
String user = Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.map(Authentication::getName).orElse("anonymous");
AUDIT.info("403 FORBIDDEN user={} ip={} uri={} msg={}", user, req.getRemoteAddr(), req.getRequestURI(), ex.getMessage());
res.sendError(HttpServletResponse.SC_FORBIDDEN);
};
}
}
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityCfg {
private final AuditHandlers audit;
@Bean
SecurityFilterChain http(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt())
.exceptionHandling(ex -> ex
.authenticationEntryPoint(audit.entryPoint401())
.accessDeniedHandler(audit.accessDenied403()));
return http.build();
}
}
3) 로그인 실패/성공 이벤트 청취
Spring Security는 인증 이벤트를 발행합니다. 실패 유형별로 빈번한 패턴을 잡아낼 수 있습니다.
@Component
public class AuthEventListener {
private static final Logger AUDIT = LoggerFactory.getLogger("SECURITY_AUDIT");
@EventListener
public void onFailure(AbstractAuthenticationFailureEvent e) {
String user = Objects.toString(e.getAuthentication().getPrincipal(), "unknown");
String reason = e.getException().getClass().getSimpleName();
AUDIT.info("AUTH_FAIL user={} reason={}", user, reason);
}
@EventListener
public void onSuccess(AuthenticationSuccessEvent e) {
String user = e.getAuthentication().getName();
AUDIT.info("AUTH_SUCCESS user={}", user);
}
}
4) 권한 거부 이벤트(Method Security)
메서드 보안에서 거부가 발생하면 Authorization 이벤트를 받아 추가 정보를 기록할 수 있습니다.
@Component
public class AuthorizationAudit {
private static final Logger AUDIT = LoggerFactory.getLogger("SECURITY_AUDIT");
@EventListener
public void onDenied(AuthorizationDeniedEvent e) {
String user = Optional.ofNullable(e.getAuthentication()).map(Authentication::getName).orElse("anonymous");
String expr = e.getAuthorizationDecision().toString(); // 표현식/결정
AUDIT.info("AUTHZ_DENIED user={} details={}", user, expr);
}
}
5) 요청 감사 필터: URI/메서드/지연시간
@Component
public class RequestAuditFilter extends OncePerRequestFilter {
private static final Logger AUDIT = LoggerFactory.getLogger("SECURITY_AUDIT");
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
long start = System.currentTimeMillis();
String traceId = Optional.ofNullable(req.getHeader("X-Request-Id"))
.orElse(UUID.randomUUID().toString());
MDC.put("traceId", traceId);
try {
chain.doFilter(req, res);
} finally {
long took = System.currentTimeMillis() - start;
String user = Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.map(Authentication::getName).orElse("anonymous");
AUDIT.info("REQ user={} ip={} {} {} status={} took={}ms",
user, req.getRemoteAddr(), req.getMethod(), req.getRequestURI(), res.getStatus(), took);
MDC.clear();
}
}
}
6) 탐지 규칙(운영)
- IP/계정별 실패 횟수가 5분 내 임계치 초과 → 경보(Brute Force 의심).
- 한 계정이 짧은 시간 여러 국가/ASN에서 로그인 시도 → 경보.
- 403 빈발 엔드포인트(관리 기능) → 권한 설계/UX 점검.
7) 개인정보·보안 고려
- 로그에 민감 데이터(토큰/비밀번호/주민번호 등) 기록 금지. 마스킹 규칙 적용.
- 보관 주기(예: 90일)와 암호화 저장 요건 점검.
- 감사 로그 접근 권한은 최소화, 별도 스토리지/권한 체계 권장.
8) 대시보드/알림
- Prometheus/Loki + Grafana로 로그인 실패율, 403 비율, 지연시간 시계열 시각화.
- Alertmanager/Slack 연동으로 임계치 초과 즉시 알림.
👉 1편: SecurityFilterChain 완전정복: 요청 매칭/인가 룰 제대로 이해하기
👉 2편: PasswordEncoder와 회원가입: BCrypt·Pepper·비밀번호 정책
👉 3편: AuthenticationProvider & UserDetailsService 커스터마이징
👉 4편: 세션 기반 로그인 vs Stateless: CSRF/Remember-me/세션관리
👉 5편: JWT 심화: 만료/클레임/키 회전 + 4편과의 연결 전략
👉 6편: OAuth2 로그인(Google/GitHub) + JWT 브릿지
👉 7편: 권한 모델링: ROLE vs 권한 문자열, @PreAuthorize, 도메인 권한
👉 8편: CORS·XSS·헤더 보안: SPA/REST 현실 설정
'개발자 기초 & 실무' 카테고리의 다른 글
| Git 시작하기 — Push, Pull, Merge 충돌 해결까지 초보자를 위한 3단계 (0) | 2025.11.09 |
|---|---|
| 가장 흔한 자바 에러 10가지, 초보자도 쉽게 해결하는 디버깅 가이드 (0) | 2025.11.08 |
| JSP 코드 자동 정렬 단축키와 포맷 설정으로 가독성 높이기 (0) | 2025.11.06 |
| Java 초보 개발자를 위한 코드 줄맞춤과 주석 정리의 모든 것 (0) | 2025.11.05 |
| IntelliJ 생산성: 라이브 템플릿 10개 (0) | 2025.10.01 |