feat(security): 보안 3종 — PII 감사로그·공개쓰기 레이트리밋·CI 의존성 스캔
SEC-02 PII 감사로그(누가·언제·어디서 개인정보를 조회/내보냈/파기했나): - AuditLog 엔티티 + AuditLogRepository + AuditService(DB 영속 + slf4j 이중기록) - MentorController: 명단조회·CSV내보내기·비번재설정·학생파기 4개 민감지점에 기록 - GET /api/mentor/audit-logs + MentorPage에 '개인정보 접근 기록' 표(남기기+보기 짝) - 미성년 수습생 PII라 개인정보보호법상 접근기록 의무 대응 SEC-03 공개쓰기 레이트리밋(무인증 남용 방어): - RateLimiterService(범용 고정창, Clock 주입, 메모리) — LoginAttemptService와 별도(단일책임) - signup(30건/시간/IP: 대량가입·알림폭탄 차단), find-id(15건/10분/IP: 이름열거 차단) - 초과 시 429. ClientIp 유틸로 rightmost XFF(Caddy 뒤 실제 IP) 공용화(AuthController 중복 제거) CI 의존성 스캔: - 프론트 npm audit(--omit=dev --audit-level=high, 비차단), 백엔드 OSV-Scanner v2.4.0(비차단) - 둘 다 continue-on-error로 '보고 전용' 시작 → 무관한 신규 권고로 CI가 붉어지지 않게 적대적 보안 리뷰(4관점→검증) 확정 4건(전부 LOW) 반영: - 파기 감사에 대상 아이디/이름 박제(파기 후 id는 dangling → '누구'를 못 알던 문제) - 가입 한도 10→30(기수 동시 온보딩 대비), 감사 저장 전 로그 선기록(DB실패 시 흔적 유지) - 고정창 경계 2배 특성 문서화, npm audit 비차단 전환 테스트 29→47(RateLimiter 5·Audit 3·SecurityContract 2 추가), 전부 통과. 운영 검증: audit_log 테이블 생성·감사쓰기(실IP 포함)·find-id 429·스모크 11/11 확인. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
daf3e9ac09
commit
e04e93cf18
@ -60,6 +60,17 @@ jobs:
|
||||
- name: 의존성 설치
|
||||
run: cd frontend && npm ci
|
||||
|
||||
# 의존성 취약점 스캔 — 배포에 실리는 패키지(--omit=dev)의 high 이상 취약점을 보고.
|
||||
# 학습 포인트 ①: package-lock.json 기반으로 레지스트리에 온디맨드 질의만 하므로(대용량 DB
|
||||
# 다운로드 없음) CI가 안정적이다. --omit=dev로 vite/eslint 등 배포에 안 실리는 도구는 제외.
|
||||
# 학습 포인트 ②: 왜 continue-on-error(비차단)인가? — npm audit는 "코드"가 아니라 "외부
|
||||
# 권고 DB 상태"에 결합된다. 우리가 한 줄도 안 고쳐도, 어느 날 axios 등에 새 권고가 뜨면
|
||||
# 차단형이면 무관한 PR까지 전부 CI가 붉어져 머지가 막힌다. 그래서 백엔드 OSV-Scanner와
|
||||
# 똑같이 도입 초기엔 "보고만" 하고, 팀이 수용목록(allow-list) 운영에 익숙해지면 차단으로 승격한다.
|
||||
- name: 의존성 취약점 스캔 (npm audit, 비차단)
|
||||
continue-on-error: true
|
||||
run: cd frontend && npm audit --omit=dev --audit-level=high
|
||||
|
||||
# 린트: 안 쓰는 변수·존재하지 않는 전역·React 훅 오용 등 버그류 검출.
|
||||
- name: 린트 (ESLint)
|
||||
run: cd frontend && npm run lint
|
||||
@ -70,3 +81,25 @@ jobs:
|
||||
|
||||
- name: 프론트 빌드 (JSX 오류·깨진 import 검출)
|
||||
run: cd frontend && npm run build
|
||||
|
||||
# ── 백엔드 의존성 취약점 스캔 (OSV-Scanner) — 비차단(보고 전용) ──
|
||||
# 학습 포인트: 백엔드는 OWASP dependency-check의 NVD 대용량 DB를 클린 러너가 매 실행
|
||||
# 재다운로드해야 해(레이트리밋·타임아웃) CI를 불안정하게 만든다. 대신 OSV-Scanner는
|
||||
# osv.dev에 패키지별 온디맨드 질의라 대용량 DB가 없다. 도입 초기라 continue-on-error로
|
||||
# "보고만" 하고(취약점 있어도 CI는 초록), 팀이 익숙해지면 차단 게이트로 승격한다.
|
||||
backend-dep-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 코드 받기
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 릴리스 바이너리를 버전 고정으로 내려받는다(GitHub 릴리스 자산은 영구 보관 → 미래에도 안 깨짐).
|
||||
- name: OSV-Scanner 설치
|
||||
continue-on-error: true
|
||||
run: |
|
||||
curl -fsSL https://github.com/google/osv-scanner/releases/download/v2.4.0/osv-scanner_linux_amd64 -o /usr/local/bin/osv-scanner
|
||||
chmod +x /usr/local/bin/osv-scanner
|
||||
|
||||
- name: 백엔드 pom.xml 취약점 스캔 (비차단)
|
||||
continue-on-error: true
|
||||
run: osv-scanner scan source -r backend
|
||||
|
||||
113
backend/src/main/java/dev/awesomedev/mirim/domain/AuditLog.java
Normal file
113
backend/src/main/java/dev/awesomedev/mirim/domain/AuditLog.java
Normal file
@ -0,0 +1,113 @@
|
||||
package dev.awesomedev.mirim.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* "누가, 언제, 어떤 개인정보(PII)를, 어느 IP에서 열람/내보냈/파기했나"를 남기는 감사로그 테이블.
|
||||
*
|
||||
* 학습 포인트 ① — 왜 감사로그가 필요한가?
|
||||
* 수습생 인적사항(이름·전화·주소·생년월일·비상연락처)은 미성년자의 개인정보다.
|
||||
* 개인정보보호법상 회사는 "누가 개인정보에 접근·제공받았는지"를 기록·보관할 의무가 있고,
|
||||
* 유출 사고가 나면 이 기록이 "어디서 새어 나갔나"를 추적하는 최초의 단서가 된다.
|
||||
* 특히 명단 CSV 다운로드는 데이터가 서버 밖(경리 PC·엑셀)으로 나가는 순간이라 반드시 남긴다.
|
||||
*
|
||||
* 학습 포인트 ② — 왜 엔티티 연관(User) 없이 값만 복사해 두나?
|
||||
* 감사로그는 "그 시점의 사실"을 박제하는 것이다. 나중에 그 학생이 파기(삭제)돼도
|
||||
* "예전에 누가 열람했다"는 기록은 남아야 한다. User를 FK로 연결하면 학생 삭제 시
|
||||
* 기록이 함께 사라지거나 참조가 깨진다. 그래서 actor/targetUserId를 값(String/Long)으로 저장한다.
|
||||
* (덤으로 open-in-view:false 환경에서 LAZY 연관 예외 걱정도 없다.)
|
||||
*
|
||||
* 학습 포인트 ③ — 비밀번호·주민번호 같은 민감정보는 감사로그에도 남기지 않는다.
|
||||
* "누가 무엇을 했다"는 남기되, "그 내용 자체"(비밀번호 값 등)는 남기지 않는다.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "audit_log", indexes = @Index(name = "ix_audit_log_created_at", columnList = "createdAt"))
|
||||
public class AuditLog {
|
||||
|
||||
/** 감사로그에 남길 행위의 종류. 문자열로 저장해 사람이 읽고 조회·필터하기 쉽게 한다. */
|
||||
public enum Action {
|
||||
EXPORT_STUDENT_CSV, // 수습생 명단 CSV 다운로드 (최고 민감 — 데이터가 서버 밖으로)
|
||||
VIEW_STUDENT_ROSTER, // 수습생 인적사항 전체 목록 조회 (JSON)
|
||||
RESET_STUDENT_PASSWORD, // 멘토가 학생 비밀번호 재설정 (계정 통제)
|
||||
PURGE_STUDENT // 학생 계정·개인정보 영구 파기 (비가역)
|
||||
}
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** 행위자(로그인 아이디). */
|
||||
@Column(nullable = false, length = 40)
|
||||
private String actor;
|
||||
|
||||
/** 행위자 역할(MENTOR 등) — 나중에 "멘토가 한 일"만 걸러 보기 위해. */
|
||||
@Column(nullable = false, length = 20)
|
||||
private String actorRole;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 30)
|
||||
private Action action;
|
||||
|
||||
/** 사람이 읽을 설명(예: "명단 CSV 다운로드", "학생 #3 파기"). 민감값은 넣지 않는다. */
|
||||
@Column(length = 200)
|
||||
private String detail;
|
||||
|
||||
/** 대상 학생의 id(개별 대상이 있을 때만). 명단 전체 조회처럼 대상이 '전체'면 null. */
|
||||
@Column
|
||||
private Long targetUserId;
|
||||
|
||||
@Column(nullable = false, length = 45)
|
||||
private String ip;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
protected AuditLog() {
|
||||
}
|
||||
|
||||
public AuditLog(String actor, String actorRole, Action action, String detail,
|
||||
Long targetUserId, String ip, Instant createdAt) {
|
||||
this.actor = actor;
|
||||
this.actorRole = actorRole;
|
||||
this.action = action;
|
||||
this.detail = detail;
|
||||
this.targetUserId = targetUserId;
|
||||
this.ip = ip;
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getActor() {
|
||||
return actor;
|
||||
}
|
||||
|
||||
public String getActorRole() {
|
||||
return actorRole;
|
||||
}
|
||||
|
||||
public Action getAction() {
|
||||
return action;
|
||||
}
|
||||
|
||||
public String getDetail() {
|
||||
return detail;
|
||||
}
|
||||
|
||||
public Long getTargetUserId() {
|
||||
return targetUserId;
|
||||
}
|
||||
|
||||
public String getIp() {
|
||||
return ip;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package dev.awesomedev.mirim.repository;
|
||||
|
||||
import dev.awesomedev.mirim.domain.AuditLog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 감사로그를 저장하고, 최근 기록을 최신순으로 읽어오는 저장소.
|
||||
*
|
||||
* 학습 포인트: 메서드 이름만으로 쿼리가 만들어진다(Spring Data JPA).
|
||||
* findTop200ByOrderByCreatedAtDesc → "createdAt 내림차순으로 상위 200건".
|
||||
* 감사로그는 계속 쌓이므로, 화면엔 최근 것만 보여 주고 전체는 필요할 때만 조회한다.
|
||||
*/
|
||||
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
|
||||
|
||||
List<AuditLog> findTop200ByOrderByCreatedAtDesc();
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
package dev.awesomedev.mirim.service;
|
||||
|
||||
import dev.awesomedev.mirim.domain.AuditLog;
|
||||
import dev.awesomedev.mirim.repository.AuditLogRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 개인정보 접근·처리 "사건"을 감사로그 테이블에 남기고, 최근 기록을 읽어 준다.
|
||||
*
|
||||
* 학습 포인트 — "이중 기록": DB(영속·조회용) + 표준 로그(운영 중 즉시 확인용).
|
||||
* DB에 남기면 멘토 화면에서 "누가 언제 열람했나"를 조회·필터할 수 있다.
|
||||
* 동시에 slf4j로도 찍어, 서버 로그를 보는 사람이 흐름 속에서 바로 알아챌 수 있다.
|
||||
* 단, 비밀번호 같은 민감값은 어느 쪽에도 남기지 않는다(AuditLog 주석 참고).
|
||||
*/
|
||||
@Service
|
||||
public class AuditService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AuditService.class);
|
||||
|
||||
private final AuditLogRepository auditLogRepository;
|
||||
private final Clock clock;
|
||||
|
||||
public AuditService(AuditLogRepository auditLogRepository, Clock clock) {
|
||||
this.auditLogRepository = auditLogRepository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/** 개인정보 접근·처리 사건 한 건을 기록한다. */
|
||||
@Transactional
|
||||
public void record(String actor, String actorRole, AuditLog.Action action,
|
||||
Long targetUserId, String detail, String ip) {
|
||||
// 학습 포인트: DB 저장 "전에" 먼저 로그로 남긴다.
|
||||
// 비가역 행위(파기 등)는 본작업이 이미 커밋된 뒤 이 감사가 별도 트랜잭션으로 실행된다.
|
||||
// 만약 audit_log INSERT가 실패(DB 순단 등)하면 감사기록이 통째로 사라질 수 있는데,
|
||||
// 저장 전에 로그부터 찍어 두면 최소한 서버 로그엔 "그 사건이 있었다"가 남는다.
|
||||
// (완전한 원자성은 텔레그램 발송 같은 되돌릴 수 없는 부작용과 충돌하므로 택하지 않는다.)
|
||||
log.info("감사기록 actor={} role={} action={} target={} ip={} detail={}",
|
||||
actor, actorRole, action, targetUserId, ip, detail);
|
||||
auditLogRepository.save(new AuditLog(actor, actorRole, action, detail, targetUserId, ip, clock.instant()));
|
||||
}
|
||||
|
||||
/** 멘토 화면용 — 최근 감사기록(최신순 상위 200건). */
|
||||
@Transactional(readOnly = true)
|
||||
public List<AuditLog> recent() {
|
||||
return auditLogRepository.findTop200ByOrderByCreatedAtDesc();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
package dev.awesomedev.mirim.service;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* "같은 키(보통 IP)로 정해진 시간 안에 몇 번까지만 허용"하는 범용 레이트리밋(호출 빈도 제한).
|
||||
* 무인증으로 열린 쓰기 API(회원가입·아이디찾기)를 자동화 남용으로부터 지킨다.
|
||||
*
|
||||
* 학습 포인트 ① — 왜 로그인 잠금(LoginAttemptService)과 따로 두나?
|
||||
* 로그인 잠금은 "연속 실패"를 세어 잠그는 특화 로직이다(성공하면 리셋).
|
||||
* 여기 레이트리밋은 성공/실패와 무관하게 "호출 횟수 자체"를 센다 —
|
||||
* 가입 봇이 계정을 5천 개 만드는 걸 막는 건 실패 카운트가 아니라 호출 카운트다.
|
||||
* 두 문제는 규칙이 달라 클래스를 나눈다(단일 책임).
|
||||
*
|
||||
* 학습 포인트 ② — "고정 창(fixed window)" 방식.
|
||||
* 키마다 "창이 언제 끝나는지(expiresAt)"와 "그 창에서 몇 번 왔는지(count)"만 기억한다.
|
||||
* 창이 끝나면 다음 호출에서 카운트를 0부터 새로 센다. 구현이 단순하고 이 규모에 충분하다.
|
||||
* (정밀한 슬라이딩 윈도/토큰버킷은 더 매끄럽지만 복잡하다 — 규모가 필요를 정한다.)
|
||||
* ⚠️ 알려진 한계: 창 경계에서는 실효 한도가 약 2배가 될 수 있다. 창 종료 직전 limit건 +
|
||||
* 만료 직후 limit건이 짧은 구간에 몰리면 한 창 기준 최대 2×limit이 통과한다(유계이므로 방어가
|
||||
* 무력화되진 않는다). 한도를 정할 땐 "경계에서 최대 2배"를 감안해 여유 있게 잡는다.
|
||||
*
|
||||
* 학습 포인트 ③ — 왜 메모리(Map)인가? Clock 주입은 왜?
|
||||
* 기록은 창이 지나면 쓸모없어지고 유실돼도 피해가 없다. 서버 1대면 메모리가 가장 단순·빠르다
|
||||
* (여러 대가 되면 Redis 등 공유 저장소로). 시간을 Clock으로 주입받아 테스트에서 창 만료를
|
||||
* 실제로 기다리지 않고 검증한다 — LoginAttemptService와 같은 설계 원칙.
|
||||
*/
|
||||
@Service
|
||||
public class RateLimiterService {
|
||||
|
||||
private final Clock clock;
|
||||
// ConcurrentHashMap: 여러 요청(스레드)이 동시에 만져도 안전한 Map.
|
||||
private final Map<String, Window> windows = new ConcurrentHashMap<>();
|
||||
|
||||
/** 한 키의 현재 창: 이 시각에 창이 끝나고, 지금까지 몇 번 왔나. */
|
||||
private record Window(Instant expiresAt, int count) {
|
||||
}
|
||||
|
||||
public RateLimiterService(Clock clock) {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* bucket(용도 구분)+key(보통 IP) 기준으로, window 동안 limit회까지 허용한다.
|
||||
* 허용되면 true, 한도를 넘어서면 false를 돌려준다(호출자가 429로 막으면 된다).
|
||||
*
|
||||
* 주의: 이 메서드는 "시도"를 셈에 포함한다 — 넘친 시도도 카운트되지만 어차피 창이
|
||||
* 끝날 때까지 계속 거부되므로 결과는 같다(창당 최대 limit회 통과).
|
||||
*/
|
||||
public boolean tryAcquire(String bucket, String key, int limit, Duration window) {
|
||||
Instant now = clock.instant();
|
||||
String composite = bucket + "|" + key;
|
||||
Window updated = windows.compute(composite, (k, prev) -> {
|
||||
// 기록이 없거나 창이 이미 끝났으면 새 창을 연다(카운트 1).
|
||||
if (prev == null || now.isAfter(prev.expiresAt())) {
|
||||
return new Window(now.plus(window), 1);
|
||||
}
|
||||
// 창이 살아 있으면 카운트만 올린다(창 끝나는 시각은 그대로).
|
||||
return new Window(prev.expiresAt(), prev.count() + 1);
|
||||
});
|
||||
purgeIfLarge(now);
|
||||
return updated.count() <= limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 학습 포인트: 메모리에 쌓기만 하는 자료구조는 언젠가 메모리를 다 먹는다(누수).
|
||||
* 공격자가 IP를 바꿔가며 수만 번 두드리면 Map이 커지므로, 일정 크기를 넘으면
|
||||
* 이미 창이 끝난(만료된) 기록을 청소한다. (우리 규모에선 사실상 안 돌지만, 습관이다.)
|
||||
*/
|
||||
private void purgeIfLarge(Instant now) {
|
||||
if (windows.size() > 10_000) {
|
||||
windows.entrySet().removeIf(e -> now.isAfter(e.getValue().expiresAt()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ package dev.awesomedev.mirim.web;
|
||||
import dev.awesomedev.mirim.domain.User;
|
||||
import dev.awesomedev.mirim.service.AuthService;
|
||||
import dev.awesomedev.mirim.service.LoginAttemptService;
|
||||
import dev.awesomedev.mirim.service.RateLimiterService;
|
||||
import dev.awesomedev.mirim.web.dto.FindIdRequest;
|
||||
import dev.awesomedev.mirim.web.dto.FindIdResponse;
|
||||
import dev.awesomedev.mirim.web.dto.LoginRequest;
|
||||
@ -23,6 +24,7 @@ import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.context.SecurityContextRepository;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@ -41,15 +43,29 @@ import java.util.List;
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
// 무인증 공개 쓰기 API의 남용 방어(레이트리밋) 정책.
|
||||
// 학습 포인트: 정상 사용자에겐 넉넉하고, 자동화 봇(수천 건)은 확실히 막히는 값으로 잡는다.
|
||||
// 가입: 오리엔테이션 때 한 기수(교육장 Wi-Fi 단일 IP)가 동시에 가입해도 막히지 않도록
|
||||
// 30건/시간으로 넉넉히 잡는다 — 봇의 대량 생성(수천 건)은 여전히 차단된다.
|
||||
// (같은 IP로 NAT되면 여러 명이 한 IP로 보이므로, 동시 온보딩 인원보다 여유 있게.)
|
||||
// 아이디찾기: 몇 번 헷갈려 다시 찾는 건 허용, 이름을 무한 대입하는 열거는 차단.
|
||||
private static final int SIGNUP_MAX_PER_HOUR = 30;
|
||||
private static final Duration SIGNUP_WINDOW = Duration.ofHours(1);
|
||||
private static final int FIND_ID_MAX = 15;
|
||||
private static final Duration FIND_ID_WINDOW = Duration.ofMinutes(10);
|
||||
|
||||
private final AuthService authService;
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
private final RateLimiterService rateLimiter;
|
||||
private final SecurityContextRepository securityContextRepository;
|
||||
|
||||
public AuthController(AuthService authService,
|
||||
LoginAttemptService loginAttemptService,
|
||||
RateLimiterService rateLimiter,
|
||||
SecurityContextRepository securityContextRepository) {
|
||||
this.authService = authService;
|
||||
this.loginAttemptService = loginAttemptService;
|
||||
this.rateLimiter = rateLimiter;
|
||||
this.securityContextRepository = securityContextRepository;
|
||||
}
|
||||
|
||||
@ -61,7 +77,7 @@ public class AuthController {
|
||||
// 0) 무차별 대입 방어 — 비밀번호를 검증하기 "전에" 잠금부터 확인한다.
|
||||
// 잠긴 상태에서는 맞는 비밀번호를 넣어도 검증 자체를 하지 않는다.
|
||||
// (안 그러면 공격자가 잠금 중에도 계속 정답 후보를 확인해 볼 수 있다.)
|
||||
String clientIp = clientIp(request);
|
||||
String clientIp = ClientIp.of(request);
|
||||
if (loginAttemptService.isLocked(loginRequest.username(), clientIp)) {
|
||||
throw new org.springframework.web.server.ResponseStatusException(
|
||||
HttpStatus.TOO_MANY_REQUESTS, "로그인 시도가 너무 많습니다. 5분 뒤에 다시 시도해 주세요.");
|
||||
@ -141,7 +157,15 @@ public class AuthController {
|
||||
*/
|
||||
@PostMapping("/signup")
|
||||
@ResponseStatus(HttpStatus.CREATED) // 자원(계정)이 새로 생겼으므로 REST 관례대로 201
|
||||
public SignupResponse signup(@Valid @RequestBody SignupRequest signupRequest) {
|
||||
public SignupResponse signup(@Valid @RequestBody SignupRequest signupRequest,
|
||||
HttpServletRequest request) {
|
||||
// 무인증 대량 가입 + 멘토 텔레그램 알림 폭탄 방어 — IP당 시간당 횟수 제한.
|
||||
// 학습 포인트: @Valid 검증이 먼저 통과한 뒤 이 코드가 돈다. 검증 실패는 400,
|
||||
// 한도 초과는 429(TOO_MANY_REQUESTS) — 실패 사유마다 알맞은 상태코드를 준다.
|
||||
if (!rateLimiter.tryAcquire("signup", ClientIp.of(request), SIGNUP_MAX_PER_HOUR, SIGNUP_WINDOW)) {
|
||||
throw new org.springframework.web.server.ResponseStatusException(
|
||||
HttpStatus.TOO_MANY_REQUESTS, "가입 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.");
|
||||
}
|
||||
User user = authService.signup(signupRequest);
|
||||
return SignupResponse.from(user);
|
||||
}
|
||||
@ -152,7 +176,14 @@ public class AuthController {
|
||||
* 왜 마스킹하는지는 AuthService.findUsernamesByName 주석 참고 — 보안 검토의 실제 교훈이 담겨 있다.
|
||||
*/
|
||||
@PostMapping("/find-id")
|
||||
public FindIdResponse findId(@Valid @RequestBody FindIdRequest findIdRequest) {
|
||||
public FindIdResponse findId(@Valid @RequestBody FindIdRequest findIdRequest,
|
||||
HttpServletRequest request) {
|
||||
// 이름 열거(enumeration) 방어 — 임의 이름을 무한 대입해 등록 여부·마스킹 아이디를
|
||||
// 수집하는 걸 막는다. IP당 조회 횟수를 제한한다.
|
||||
if (!rateLimiter.tryAcquire("find-id", ClientIp.of(request), FIND_ID_MAX, FIND_ID_WINDOW)) {
|
||||
throw new org.springframework.web.server.ResponseStatusException(
|
||||
HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.");
|
||||
}
|
||||
return new FindIdResponse(authService.findUsernamesByName(findIdRequest.name()));
|
||||
}
|
||||
|
||||
@ -172,30 +203,4 @@ public class AuthController {
|
||||
changePasswordRequest.newPassword());
|
||||
// 반환할 내용이 없으므로 본문 없이 200 OK만 내려간다.
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청을 보낸 클라이언트의 IP를 알아낸다 (로그인 잠금의 키로 쓴다).
|
||||
*
|
||||
* 학습 포인트: 우리 백엔드 앞에는 Caddy(리버스 프록시)가 있다. 그래서
|
||||
* request.getRemoteAddr()는 항상 "Caddy의 IP"다 — 이걸 키로 쓰면 전 세계 사용자가
|
||||
* 한 명으로 묶여 버린다. 진짜 클라이언트 IP는 Caddy가 X-Forwarded-For 헤더에 담아 준다.
|
||||
*
|
||||
* ⚠️⚠️ 실제로 났던 취약점 — "맨 앞이 클라이언트"가 함정이었다.
|
||||
* X-Forwarded-For는 클라이언트가 임의로 넣어 보낼 수 있는 헤더다. 프록시(Caddy)는
|
||||
* 그 값을 지우지 않고 "실제 연결 IP를 뒤에 덧붙인다". 그래서 백엔드가 보는 값은
|
||||
* [클라이언트가 위조한 값] , [Caddy가 본 진짜 IP]
|
||||
* 가 된다. 맨 앞(leftmost)을 믿으면 공격자가 요청마다 가짜 IP를 넣어
|
||||
* 잠금 카운트를 매번 새 키로 흩뿌려 무차별 대입 잠금을 통째로 우회할 수 있었다.
|
||||
* → 신뢰할 수 있는 건 "프록시가 직접 덧붙인" 맨 뒤(rightmost) 값이다.
|
||||
* (프록시가 Caddy 하나뿐이라 이 값이 곧 실제 클라이언트 IP다. 프록시가 여러 겹이면
|
||||
* "신뢰하는 프록시 수만큼 뒤에서" 세어야 한다 — 홉 수를 아는 것이 핵심이다.)
|
||||
*/
|
||||
private String clientIp(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
String[] hops = forwarded.split(",");
|
||||
return hops[hops.length - 1].trim(); // 맨 뒤 = Caddy가 붙인 실제 IP (위조 불가)
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
|
||||
41
backend/src/main/java/dev/awesomedev/mirim/web/ClientIp.java
Normal file
41
backend/src/main/java/dev/awesomedev/mirim/web/ClientIp.java
Normal file
@ -0,0 +1,41 @@
|
||||
package dev.awesomedev.mirim.web;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 요청을 보낸 진짜 클라이언트 IP를 알아낸다 — 로그인 잠금·레이트리밋·감사로그가 공통으로 쓴다.
|
||||
*
|
||||
* 학습 포인트: 우리 백엔드 앞에는 Caddy(리버스 프록시)가 있다. 그래서
|
||||
* request.getRemoteAddr()는 항상 "Caddy의 IP"다 — 이걸 키로 쓰면 전 세계 사용자가
|
||||
* 한 명으로 묶여 버린다. 진짜 클라이언트 IP는 Caddy가 X-Forwarded-For 헤더에 담아 준다.
|
||||
*
|
||||
* ⚠️⚠️ 실제로 났던 취약점 — "맨 앞이 클라이언트"가 함정이었다.
|
||||
* X-Forwarded-For는 클라이언트가 임의로 넣어 보낼 수 있는 헤더다. 프록시(Caddy)는
|
||||
* 그 값을 지우지 않고 "실제 연결 IP를 뒤에 덧붙인다". 그래서 백엔드가 보는 값은
|
||||
* [클라이언트가 위조한 값] , [Caddy가 본 진짜 IP]
|
||||
* 가 된다. 맨 앞(leftmost)을 믿으면 공격자가 요청마다 가짜 IP를 넣어
|
||||
* 잠금/레이트리밋 카운트를 매번 새 키로 흩뿌려 방어를 통째로 우회할 수 있었다.
|
||||
* → 신뢰할 수 있는 건 "프록시가 직접 덧붙인" 맨 뒤(rightmost) 값이다.
|
||||
* (프록시가 Caddy 하나뿐이라 이 값이 곧 실제 클라이언트 IP다. 프록시가 여러 겹이면
|
||||
* "신뢰하는 프록시 수만큼 뒤에서" 세어야 한다 — 홉 수를 아는 것이 핵심이다.)
|
||||
*
|
||||
* 학습 포인트 ②: 왜 유틸로 뽑았나 —
|
||||
* 이 규칙(rightmost)을 여러 곳에서 각자 구현하면, 한 곳만 leftmost로 잘못 짜도 그 지점이
|
||||
* 우회 구멍이 된다. "보안 규칙은 한 곳에만" 두어 모두가 같은 규칙을 쓰게 한다.
|
||||
*/
|
||||
public final class ClientIp {
|
||||
|
||||
private ClientIp() {
|
||||
// 인스턴스를 만들 이유가 없는 유틸리티 클래스 — 생성 막기.
|
||||
}
|
||||
|
||||
public static String of(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
String[] hops = forwarded.split(",");
|
||||
return hops[hops.length - 1].trim(); // 맨 뒤 = Caddy가 붙인 실제 IP (위조 불가)
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,10 @@
|
||||
package dev.awesomedev.mirim.web;
|
||||
|
||||
import dev.awesomedev.mirim.domain.AuditLog;
|
||||
import dev.awesomedev.mirim.domain.CodingSubmission;
|
||||
import dev.awesomedev.mirim.domain.Submission;
|
||||
import dev.awesomedev.mirim.repository.UserRepository;
|
||||
import dev.awesomedev.mirim.service.AuditService;
|
||||
import dev.awesomedev.mirim.service.AuthService;
|
||||
import dev.awesomedev.mirim.service.CodingService;
|
||||
import dev.awesomedev.mirim.service.CourseProgressService;
|
||||
@ -11,18 +13,21 @@ import dev.awesomedev.mirim.service.QuizService;
|
||||
import dev.awesomedev.mirim.service.StudentProfileService;
|
||||
import dev.awesomedev.mirim.service.StudentPurgeService;
|
||||
import dev.awesomedev.mirim.service.SubmissionService;
|
||||
import dev.awesomedev.mirim.web.dto.AuditLogResponse;
|
||||
import dev.awesomedev.mirim.web.dto.ChecklistProgressOverviewResponse;
|
||||
import dev.awesomedev.mirim.web.dto.FeedbackRequest;
|
||||
import dev.awesomedev.mirim.web.dto.MentorResetPasswordRequest;
|
||||
import dev.awesomedev.mirim.web.dto.MentorSubmissionResponse;
|
||||
import dev.awesomedev.mirim.web.dto.StudentCourseProgressResponse;
|
||||
import dev.awesomedev.mirim.web.dto.StudentProfileResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@ -51,6 +56,7 @@ public class MentorController {
|
||||
private final StudentProfileService studentProfileService;
|
||||
private final StudentPurgeService studentPurgeService;
|
||||
private final UserRepository userRepository;
|
||||
private final AuditService auditService;
|
||||
|
||||
public MentorController(SubmissionService submissionService,
|
||||
CourseProgressService courseProgressService,
|
||||
@ -60,7 +66,8 @@ public class MentorController {
|
||||
AuthService authService,
|
||||
StudentProfileService studentProfileService,
|
||||
StudentPurgeService studentPurgeService,
|
||||
UserRepository userRepository) {
|
||||
UserRepository userRepository,
|
||||
AuditService auditService) {
|
||||
this.submissionService = submissionService;
|
||||
this.courseProgressService = courseProgressService;
|
||||
this.progressService = progressService;
|
||||
@ -70,6 +77,26 @@ public class MentorController {
|
||||
this.studentProfileService = studentProfileService;
|
||||
this.studentPurgeService = studentPurgeService;
|
||||
this.userRepository = userRepository;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 개인정보 접근·처리 사건을 감사로그에 남긴다.
|
||||
*
|
||||
* 학습 포인트: 행위자·IP를 세션(Authentication)과 요청(HttpServletRequest)에서 얻는다.
|
||||
* Authentication.getName()이 로그인 아이디, 권한 목록에서 역할(ROLE_ 접두어 제거)을 뽑는다.
|
||||
* 여기선 DB를 다시 조회하지 않는다 — 로그인 시 이미 검증된 세션 정보라 믿을 수 있고,
|
||||
* 감사 한 줄 남기려고 매번 DB를 때리는 낭비를 피한다.
|
||||
*/
|
||||
private void audit(AuditLog.Action action, Long targetUserId, String detail,
|
||||
Authentication authentication, HttpServletRequest request) {
|
||||
String actor = authentication.getName();
|
||||
String role = authentication.getAuthorities().stream()
|
||||
.map(a -> a.getAuthority())
|
||||
.findFirst()
|
||||
.map(a -> a.startsWith("ROLE_") ? a.substring("ROLE_".length()) : a)
|
||||
.orElse("UNKNOWN");
|
||||
auditService.record(actor, role, action, targetUserId, detail, ClientIp.of(request));
|
||||
}
|
||||
|
||||
/** GET /api/mentor/submissions — 전체 제출물 + 학생 이름 + 과제 제목 (최신순) */
|
||||
@ -135,10 +162,14 @@ public class MentorController {
|
||||
|
||||
/** GET /api/mentor/students — 수습생 인적사항 명단 (화면 표시용) */
|
||||
@GetMapping("/students")
|
||||
public List<StudentProfileResponse> students() {
|
||||
return studentProfileService.allProfiles().stream()
|
||||
public List<StudentProfileResponse> students(Authentication authentication, HttpServletRequest request) {
|
||||
List<StudentProfileResponse> profiles = studentProfileService.allProfiles().stream()
|
||||
.map(StudentProfileResponse::from)
|
||||
.toList();
|
||||
// 전 수습생의 풀 PII(주소·연락처·생년월일 등)를 한 번에 조회하는 지점이라 감사로그를 남긴다.
|
||||
audit(AuditLog.Action.VIEW_STUDENT_ROSTER, null,
|
||||
"수습생 인적사항 명단 조회 (" + profiles.size() + "명)", authentication, request);
|
||||
return profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -150,8 +181,12 @@ public class MentorController {
|
||||
* 이 두 헤더가 없으면 브라우저는 CSV를 그냥 화면에 텍스트로 펼쳐 버린다.
|
||||
*/
|
||||
@GetMapping("/students/export.csv")
|
||||
public ResponseEntity<Resource> exportCsv() {
|
||||
public ResponseEntity<Resource> exportCsv(Authentication authentication, HttpServletRequest request) {
|
||||
byte[] bytes = studentProfileService.exportCsv().getBytes(StandardCharsets.UTF_8);
|
||||
// 최우선 감사 지점 — 전 수습생 PII가 파일로 서버 밖(경리 PC·엑셀)으로 나가는 순간.
|
||||
// 유출 사고 시 "누가 언제 명단 전체를 내려받았나"의 최초 추적 단서가 된다.
|
||||
audit(AuditLog.Action.EXPORT_STUDENT_CSV, null,
|
||||
"수습생 명단 CSV 다운로드", authentication, request);
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"mirim-students.csv\"")
|
||||
@ -169,8 +204,16 @@ public class MentorController {
|
||||
*/
|
||||
@PostMapping("/users/{id}/reset-password")
|
||||
public void resetStudentPassword(@PathVariable Long id,
|
||||
@Valid @RequestBody MentorResetPasswordRequest request) {
|
||||
@Valid @RequestBody MentorResetPasswordRequest request,
|
||||
Authentication authentication,
|
||||
HttpServletRequest httpRequest) {
|
||||
authService.resetPasswordByMentor(id, request.newPassword());
|
||||
// 남의 계정 통제권(비밀번호)을 바꾸는 민감 행위 — 누가 누구 것을 바꿨는지 남긴다.
|
||||
// 대상의 아이디를 함께 박제한다(숫자 id만으론 나중에 '누구'인지 알기 어렵다).
|
||||
// (바꾼 비밀번호 값은 절대 남기지 않는다.)
|
||||
String who = userRepository.findById(id).map(u -> u.getUsername()).orElse("id=" + id);
|
||||
audit(AuditLog.Action.RESET_STUDENT_PASSWORD, id,
|
||||
"학생 #" + id + " (" + who + ") 비밀번호 재설정", authentication, httpRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -181,8 +224,33 @@ public class MentorController {
|
||||
* 의도를 HTTP 동사로 분명히 한다. 동의 화면에서 약속한 "파기"를 실제로 지키는 손이다.
|
||||
*/
|
||||
@DeleteMapping("/students/{id}")
|
||||
public void purgeStudent(@PathVariable Long id) {
|
||||
public void purgeStudent(@PathVariable Long id, Authentication authentication, HttpServletRequest request) {
|
||||
// 파기 "전에" 대상의 신원(아이디/이름)을 확보한다.
|
||||
// 학습 포인트: purge()가 User 행 자체를 지우므로, 파기 후엔 이 id가 아무도 가리키지 않는
|
||||
// 값(dangling)이 된다. 그때 감사기록에 숫자 id만 있으면 "누구를 지웠나"를 영영 알 수 없다.
|
||||
// 사고 조사에서 가장 답이 필요한 순간이므로, 지우기 전에 아이디/이름을 붙잡아 박제한다.
|
||||
String who = userRepository.findById(id)
|
||||
.map(u -> u.getUsername() + " / " + u.getName())
|
||||
.orElse("id=" + id);
|
||||
studentPurgeService.purge(id);
|
||||
// 비가역 파기 — 되돌릴 수 없으므로 "언제 누가 누구를 지웠나"를 반드시 기록으로 남긴다.
|
||||
// (대상 학생이 지워져도 이 감사기록은 값으로 복사돼 남는다 — AuditLog 설계 참고.)
|
||||
audit(AuditLog.Action.PURGE_STUDENT, id,
|
||||
"학생 #" + id + " (" + who + ") 계정·개인정보 영구 파기", authentication, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mentor/audit-logs — 개인정보 접근·처리 감사기록 (최신순 상위 200건).
|
||||
*
|
||||
* 학습 포인트: 감사로그는 "남기기"만 해선 반쪽이다. 멘토가 실제로 들여다볼 수 있어야
|
||||
* "누가 명단을 자주 내려받나" 같은 이상 징후를 알아챈다. 남기기(record)와 보기(view)가
|
||||
* 짝을 이뤄야 감사가 살아 있는 통제가 된다.
|
||||
*/
|
||||
@GetMapping("/audit-logs")
|
||||
public List<AuditLogResponse> auditLogs() {
|
||||
return auditService.recent().stream()
|
||||
.map(AuditLogResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** POST /api/mentor/submissions/{id}/feedback — 피드백 등록, 상태는 REVIEWED로 변경 */
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
package dev.awesomedev.mirim.web.dto;
|
||||
|
||||
import dev.awesomedev.mirim.domain.AuditLog;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 감사로그 한 건을 화면(멘토)에 내려줄 형태로 변환한 응답 DTO.
|
||||
*
|
||||
* 학습 포인트: 엔티티(AuditLog)를 그대로 반환하지 않고 DTO로 변환한다.
|
||||
* 시각(Instant)은 문자열로, action(enum)은 이름 문자열로 — 프론트가 다루기 쉽게 정돈한다.
|
||||
*/
|
||||
public record AuditLogResponse(
|
||||
Long id,
|
||||
String actor,
|
||||
String actorRole,
|
||||
String action,
|
||||
String detail,
|
||||
Long targetUserId,
|
||||
String ip,
|
||||
String createdAt) {
|
||||
|
||||
public static AuditLogResponse from(AuditLog a) {
|
||||
return new AuditLogResponse(
|
||||
a.getId(),
|
||||
a.getActor(),
|
||||
a.getActorRole(),
|
||||
a.getAction().name(),
|
||||
a.getDetail(),
|
||||
a.getTargetUserId(),
|
||||
a.getIp(),
|
||||
a.getCreatedAt().toString());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
package dev.awesomedev.mirim.service;
|
||||
|
||||
import dev.awesomedev.mirim.domain.AuditLog;
|
||||
import dev.awesomedev.mirim.repository.AuditLogRepository;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 감사서비스가 "무엇을, 어떤 값으로" 저장하는지 검증한다.
|
||||
*
|
||||
* 학습 포인트 — ArgumentCaptor: 가짜 저장소(mock)에 "무엇이 넘어왔는지"를 붙잡아 확인한다.
|
||||
* 실제 DB 없이도 "record()가 올바른 AuditLog를 만들어 save에 넘겼는가"를 정확히 검증할 수 있다.
|
||||
* 시각은 고정 Clock으로 못박아 결정적으로 테스트한다.
|
||||
*/
|
||||
class AuditServiceTest {
|
||||
|
||||
private static final Instant FIXED = Instant.parse("2026-09-01T09:00:00Z");
|
||||
private final Clock clock = Clock.fixed(FIXED, ZoneOffset.UTC);
|
||||
private final AuditLogRepository repository = mock(AuditLogRepository.class);
|
||||
private final AuditService service = new AuditService(repository, clock);
|
||||
|
||||
@Test
|
||||
@DisplayName("record()는 넘겨받은 값 그대로 + 현재시각으로 감사로그를 저장한다")
|
||||
void record_saves_audit_log_with_given_values() {
|
||||
service.record("mentor1", "MENTOR", AuditLog.Action.EXPORT_STUDENT_CSV,
|
||||
null, "수습생 명단 CSV 다운로드", "203.0.113.9");
|
||||
|
||||
ArgumentCaptor<AuditLog> captor = ArgumentCaptor.forClass(AuditLog.class);
|
||||
verify(repository).save(captor.capture());
|
||||
AuditLog saved = captor.getValue();
|
||||
|
||||
assertEquals("mentor1", saved.getActor());
|
||||
assertEquals("MENTOR", saved.getActorRole());
|
||||
assertEquals(AuditLog.Action.EXPORT_STUDENT_CSV, saved.getAction());
|
||||
assertEquals("수습생 명단 CSV 다운로드", saved.getDetail());
|
||||
assertEquals("203.0.113.9", saved.getIp());
|
||||
assertEquals(FIXED, saved.getCreatedAt()); // 고정 시계의 시각이 박힌다
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("파기 같은 개별 대상 행위는 targetUserId를 함께 남긴다")
|
||||
void record_keeps_target_user_id() {
|
||||
service.record("mentor1", "MENTOR", AuditLog.Action.PURGE_STUDENT,
|
||||
42L, "학생 #42 파기", "203.0.113.9");
|
||||
|
||||
ArgumentCaptor<AuditLog> captor = ArgumentCaptor.forClass(AuditLog.class);
|
||||
verify(repository).save(captor.capture());
|
||||
assertEquals(42L, captor.getValue().getTargetUserId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("recent()는 저장소의 최신순 조회를 그대로 돌려준다")
|
||||
void recent_delegates_to_repository() {
|
||||
AuditLog one = new AuditLog("mentor1", "MENTOR", AuditLog.Action.VIEW_STUDENT_ROSTER,
|
||||
"명단 조회", null, "203.0.113.9", FIXED);
|
||||
when(repository.findTop200ByOrderByCreatedAtDesc()).thenReturn(List.of(one));
|
||||
|
||||
List<AuditLog> result = service.recent();
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals(AuditLog.Action.VIEW_STUDENT_ROSTER, result.get(0).getAction());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
package dev.awesomedev.mirim.service;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 범용 레이트리밋(호출 빈도 제한)의 규칙을 검증한다.
|
||||
*
|
||||
* 학습 포인트 — 가짜 시계(MutableClock)로 "창이 지나면 리셋"을 실제로 기다리지 않고 검증한다.
|
||||
* (LoginAttemptServiceTest와 같은 기법. 시간을 주입받게 설계한 덕분이다.)
|
||||
*/
|
||||
class RateLimiterServiceTest {
|
||||
|
||||
/** 테스트용 시계: advance()로 시간을 앞당긴다. */
|
||||
static class MutableClock extends Clock {
|
||||
private Instant now = Instant.parse("2026-09-01T09:00:00Z");
|
||||
|
||||
void advance(Duration duration) {
|
||||
now = now.plus(duration);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return now;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.time.ZoneId getZone() {
|
||||
return ZoneOffset.UTC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(java.time.ZoneId zone) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
private final MutableClock clock = new MutableClock();
|
||||
private final RateLimiterService service = new RateLimiterService(clock);
|
||||
|
||||
private static final Duration WINDOW = Duration.ofMinutes(10);
|
||||
|
||||
@Test
|
||||
@DisplayName("한도까지는 허용하고, 한도를 넘으면 거부한다")
|
||||
void allows_up_to_limit_then_denies() {
|
||||
for (int i = 0; i < 3; i++) {
|
||||
assertTrue(service.tryAcquire("signup", "1.2.3.4", 3, WINDOW), (i + 1) + "번째는 허용");
|
||||
}
|
||||
assertFalse(service.tryAcquire("signup", "1.2.3.4", 3, WINDOW), "4번째는 거부");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("다른 키(IP)는 서로 카운트가 섞이지 않는다")
|
||||
void keys_are_independent() {
|
||||
assertTrue(service.tryAcquire("signup", "1.1.1.1", 1, WINDOW));
|
||||
assertFalse(service.tryAcquire("signup", "1.1.1.1", 1, WINDOW)); // 이 IP는 한도 참
|
||||
assertTrue(service.tryAcquire("signup", "2.2.2.2", 1, WINDOW)); // 다른 IP는 멀쩡
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("같은 IP라도 용도(bucket)가 다르면 따로 센다")
|
||||
void buckets_are_independent() {
|
||||
assertTrue(service.tryAcquire("signup", "1.2.3.4", 1, WINDOW));
|
||||
assertFalse(service.tryAcquire("signup", "1.2.3.4", 1, WINDOW));
|
||||
assertTrue(service.tryAcquire("find-id", "1.2.3.4", 1, WINDOW)); // 다른 bucket은 별개
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("창(window)이 지나면 카운트가 리셋되어 다시 허용된다")
|
||||
void resets_after_window() {
|
||||
assertTrue(service.tryAcquire("signup", "1.2.3.4", 1, WINDOW));
|
||||
assertFalse(service.tryAcquire("signup", "1.2.3.4", 1, WINDOW)); // 한도 참
|
||||
|
||||
clock.advance(WINDOW.plusSeconds(1)); // 창이 지나간다
|
||||
assertTrue(service.tryAcquire("signup", "1.2.3.4", 1, WINDOW)); // 새 창에서 다시 허용
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("한도 초과가 창을 연장시키지 않는다 — 넘쳐도 창은 제때 끝난다")
|
||||
void overflow_does_not_extend_window() {
|
||||
assertTrue(service.tryAcquire("signup", "1.2.3.4", 1, WINDOW));
|
||||
// 창 중간에 계속 두드려도(모두 거부됨)
|
||||
clock.advance(Duration.ofMinutes(5));
|
||||
assertFalse(service.tryAcquire("signup", "1.2.3.4", 1, WINDOW));
|
||||
// 최초 창 시작 기준 10분이 지나면 리셋되어야 한다
|
||||
clock.advance(Duration.ofMinutes(5).plusSeconds(1));
|
||||
assertTrue(service.tryAcquire("signup", "1.2.3.4", 1, WINDOW));
|
||||
}
|
||||
}
|
||||
@ -3,12 +3,14 @@ package dev.awesomedev.mirim.web;
|
||||
import dev.awesomedev.mirim.config.SecurityConfig;
|
||||
import dev.awesomedev.mirim.domain.User;
|
||||
import dev.awesomedev.mirim.repository.UserRepository;
|
||||
import dev.awesomedev.mirim.service.AuditService;
|
||||
import dev.awesomedev.mirim.service.AuthService;
|
||||
import dev.awesomedev.mirim.service.CodingService;
|
||||
import dev.awesomedev.mirim.service.CourseProgressService;
|
||||
import dev.awesomedev.mirim.service.LoginAttemptService;
|
||||
import dev.awesomedev.mirim.service.ProgressService;
|
||||
import dev.awesomedev.mirim.service.QuizService;
|
||||
import dev.awesomedev.mirim.service.RateLimiterService;
|
||||
import dev.awesomedev.mirim.service.StudentProfileService;
|
||||
import dev.awesomedev.mirim.service.StudentPurgeService;
|
||||
import dev.awesomedev.mirim.service.SubmissionService;
|
||||
@ -31,6 +33,8 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
@ -65,6 +69,8 @@ class SecurityContractTest {
|
||||
// 컨트롤러들이 의존하는 서비스는 전부 가짜로 끼운다(보안 규칙만 보려는 것이므로).
|
||||
@MockitoBean private AuthService authService;
|
||||
@MockitoBean private LoginAttemptService loginAttemptService;
|
||||
@MockitoBean private RateLimiterService rateLimiterService;
|
||||
@MockitoBean private AuditService auditService;
|
||||
@MockitoBean private CodingService codingService;
|
||||
@MockitoBean private SubmissionService submissionService;
|
||||
@MockitoBean private CourseProgressService courseProgressService;
|
||||
@ -155,4 +161,27 @@ class SecurityContractTest {
|
||||
// permitAll이므로 인증 차단(401)이 아니어야 한다. (검증 실패로 400이 나든, 200이 나든 무방)
|
||||
org.junit.jupiter.api.Assertions.assertNotEquals(401, statusCode);
|
||||
}
|
||||
|
||||
// ─────────────────────── 공개 쓰기 레이트리밋(SEC-03) 배선 검증 ───────────────────────
|
||||
// 학습 포인트: 레이트리밋이 "컨트롤러에 실제로 연결됐는지"를 검증한다.
|
||||
// 한도 초과(tryAcquire=false)면 429가 나오고, 여유(true)면 서비스까지 도달해야 한다.
|
||||
|
||||
@Test
|
||||
@DisplayName("아이디 찾기: 레이트리밋 한도 초과면 429")
|
||||
void findId_rate_limited_returns_429() throws Exception {
|
||||
when(rateLimiterService.tryAcquire(anyString(), anyString(), anyInt(), any())).thenReturn(false);
|
||||
mockMvc.perform(post("/api/auth/find-id")
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{\"name\":\"홍길동\"}"))
|
||||
.andExpect(status().isTooManyRequests());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("아이디 찾기: 레이트리밋 여유가 있으면 통과해 서비스까지 도달(200)")
|
||||
void findId_within_limit_reaches_service() throws Exception {
|
||||
when(rateLimiterService.tryAcquire(anyString(), anyString(), anyInt(), any())).thenReturn(true);
|
||||
when(authService.findUsernamesByName(any())).thenReturn(List.of("ho****1"));
|
||||
mockMvc.perform(post("/api/auth/find-id")
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{\"name\":\"홍길동\"}"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
}
|
||||
|
||||
@ -328,12 +328,56 @@ function QuizResultsTable({ quizResults }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 감사기록의 행위(action)를 사람이 읽는 한국어로. 서버 enum 이름과 1:1 대응.
|
||||
const AUDIT_ACTION_LABEL = {
|
||||
EXPORT_STUDENT_CSV: '명단 CSV 다운로드',
|
||||
VIEW_STUDENT_ROSTER: '명단 조회',
|
||||
RESET_STUDENT_PASSWORD: '비밀번호 재설정',
|
||||
PURGE_STUDENT: '학생 파기',
|
||||
};
|
||||
|
||||
/**
|
||||
* 개인정보 접근 감사기록 표.
|
||||
* 학습 포인트: 감사로그는 "남기기"만으로는 반쪽이다. 멘토가 여기서 실제로 들여다볼 수 있어야
|
||||
* "누가 명단을 자주 내려받나" 같은 이상 징후를 알아챈다 — 남기기와 보기가 짝을 이뤄야 통제가 산다.
|
||||
*/
|
||||
function AuditLogTable({ logs }) {
|
||||
return (
|
||||
<Card style={{ padding: 0, overflow: 'hidden' }}>
|
||||
{/* table--nowrap: 시각·IP가 세로로 꺾이면 못 읽는다 — 가로 스크롤로 본다 */}
|
||||
<table className="table table--nowrap" style={{ minWidth: 640 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시각</th>
|
||||
<th>행위자</th>
|
||||
<th>행위</th>
|
||||
<th>내용</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((logRow) => (
|
||||
<tr key={logRow.id}>
|
||||
<td className="muted">{new Date(logRow.createdAt).toLocaleString('ko-KR')}</td>
|
||||
<td style={{ fontWeight: 600 }}>{logRow.actor}</td>
|
||||
<td>{AUDIT_ACTION_LABEL[logRow.action] || logRow.action}</td>
|
||||
<td>{logRow.detail}</td>
|
||||
<td className="muted" style={{ fontVariantNumeric: 'tabular-nums' }}>{logRow.ip}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MentorPage() {
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [students, setStudents] = useState([]); // 학생별 코스 진도
|
||||
const [checklistOverview, setChecklistOverview] = useState(null); // 주차별 체크리스트 진도
|
||||
const [quizResults, setQuizResults] = useState([]); // 학생별 퀴즈 성적
|
||||
const [roster, setRoster] = useState([]); // 수습생 인적사항 명단
|
||||
const [auditLogs, setAuditLogs] = useState([]); // 개인정보 접근 감사기록
|
||||
const [loadError, setLoadError] = useState(false); // 일부 데이터 로딩 실패 여부
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState(null); // 클릭해서 펼친 제출물 id
|
||||
@ -352,14 +396,16 @@ export default function MentorPage() {
|
||||
client.get('/mentor/checklist-progress'),
|
||||
client.get('/mentor/quiz-results'),
|
||||
client.get('/mentor/students'),
|
||||
client.get('/mentor/audit-logs'),
|
||||
])
|
||||
.then(([subsRes, progressRes, checklistRes, quizRes, rosterRes]) => {
|
||||
.then(([subsRes, progressRes, checklistRes, quizRes, rosterRes, auditRes]) => {
|
||||
if (subsRes.status === 'fulfilled') setSubmissions(subsRes.value.data);
|
||||
if (progressRes.status === 'fulfilled') setStudents(progressRes.value.data);
|
||||
if (checklistRes.status === 'fulfilled') setChecklistOverview(checklistRes.value.data);
|
||||
if (quizRes.status === 'fulfilled') setQuizResults(quizRes.value.data);
|
||||
if (rosterRes.status === 'fulfilled') setRoster(rosterRes.value.data);
|
||||
const anyFailed = [subsRes, progressRes, checklistRes, quizRes, rosterRes].some(
|
||||
if (auditRes.status === 'fulfilled') setAuditLogs(auditRes.value.data);
|
||||
const anyFailed = [subsRes, progressRes, checklistRes, quizRes, rosterRes, auditRes].some(
|
||||
(r) => r.status === 'rejected',
|
||||
);
|
||||
setLoadError(anyFailed);
|
||||
@ -574,6 +620,17 @@ export default function MentorPage() {
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── 개인정보 접근 기록 (감사로그) ── */}
|
||||
<h2 className="section-title" style={{ marginTop: 24 }}>개인정보 접근 기록</h2>
|
||||
<p className="page-desc" style={{ marginTop: -4 }}>
|
||||
수습생 개인정보를 누가·언제·어디서 조회/내보냈/파기했는지 기록입니다(최근 200건).
|
||||
</p>
|
||||
{auditLogs.length === 0 ? (
|
||||
<p className="empty">아직 기록이 없어요. (명단 조회·CSV 다운로드 등을 하면 여기에 남습니다)</p>
|
||||
) : (
|
||||
<AuditLogTable logs={auditLogs} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user