From e04e93cf183ce542cf0e70e638d1189aa8d9e0f6 Mon Sep 17 00:00:00 2001 From: AWESOMEDEV Date: Fri, 17 Jul 2026 18:52:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(security):=20=EB=B3=B4=EC=95=88=203?= =?UTF-8?q?=EC=A2=85=20=E2=80=94=20PII=20=EA=B0=90=EC=82=AC=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=C2=B7=EA=B3=B5=EA=B0=9C=EC=93=B0=EA=B8=B0=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=EB=A6=AC=EB=B0=8B=C2=B7CI=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=8A=A4=EC=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitea/workflows/ci.yml | 33 +++++ .../dev/awesomedev/mirim/domain/AuditLog.java | 113 ++++++++++++++++++ .../mirim/repository/AuditLogRepository.java | 19 +++ .../mirim/service/AuditService.java | 54 +++++++++ .../mirim/service/RateLimiterService.java | 82 +++++++++++++ .../awesomedev/mirim/web/AuthController.java | 63 +++++----- .../dev/awesomedev/mirim/web/ClientIp.java | 41 +++++++ .../mirim/web/MentorController.java | 80 ++++++++++++- .../mirim/web/dto/AuditLogResponse.java | 33 +++++ .../mirim/service/AuditServiceTest.java | 75 ++++++++++++ .../mirim/service/RateLimiterServiceTest.java | 97 +++++++++++++++ .../mirim/web/SecurityContractTest.java | 29 +++++ frontend/src/pages/MentorPage.jsx | 61 +++++++++- 13 files changed, 743 insertions(+), 37 deletions(-) create mode 100644 backend/src/main/java/dev/awesomedev/mirim/domain/AuditLog.java create mode 100644 backend/src/main/java/dev/awesomedev/mirim/repository/AuditLogRepository.java create mode 100644 backend/src/main/java/dev/awesomedev/mirim/service/AuditService.java create mode 100644 backend/src/main/java/dev/awesomedev/mirim/service/RateLimiterService.java create mode 100644 backend/src/main/java/dev/awesomedev/mirim/web/ClientIp.java create mode 100644 backend/src/main/java/dev/awesomedev/mirim/web/dto/AuditLogResponse.java create mode 100644 backend/src/test/java/dev/awesomedev/mirim/service/AuditServiceTest.java create mode 100644 backend/src/test/java/dev/awesomedev/mirim/service/RateLimiterServiceTest.java diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 1fc55ba..9b3fe45 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/backend/src/main/java/dev/awesomedev/mirim/domain/AuditLog.java b/backend/src/main/java/dev/awesomedev/mirim/domain/AuditLog.java new file mode 100644 index 0000000..f58aaf9 --- /dev/null +++ b/backend/src/main/java/dev/awesomedev/mirim/domain/AuditLog.java @@ -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; + } +} diff --git a/backend/src/main/java/dev/awesomedev/mirim/repository/AuditLogRepository.java b/backend/src/main/java/dev/awesomedev/mirim/repository/AuditLogRepository.java new file mode 100644 index 0000000..8319aac --- /dev/null +++ b/backend/src/main/java/dev/awesomedev/mirim/repository/AuditLogRepository.java @@ -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 { + + List findTop200ByOrderByCreatedAtDesc(); +} diff --git a/backend/src/main/java/dev/awesomedev/mirim/service/AuditService.java b/backend/src/main/java/dev/awesomedev/mirim/service/AuditService.java new file mode 100644 index 0000000..42d54bd --- /dev/null +++ b/backend/src/main/java/dev/awesomedev/mirim/service/AuditService.java @@ -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 recent() { + return auditLogRepository.findTop200ByOrderByCreatedAtDesc(); + } +} diff --git a/backend/src/main/java/dev/awesomedev/mirim/service/RateLimiterService.java b/backend/src/main/java/dev/awesomedev/mirim/service/RateLimiterService.java new file mode 100644 index 0000000..662b790 --- /dev/null +++ b/backend/src/main/java/dev/awesomedev/mirim/service/RateLimiterService.java @@ -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 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())); + } + } +} diff --git a/backend/src/main/java/dev/awesomedev/mirim/web/AuthController.java b/backend/src/main/java/dev/awesomedev/mirim/web/AuthController.java index 58a3a09..8207b70 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/web/AuthController.java +++ b/backend/src/main/java/dev/awesomedev/mirim/web/AuthController.java @@ -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(); - } } diff --git a/backend/src/main/java/dev/awesomedev/mirim/web/ClientIp.java b/backend/src/main/java/dev/awesomedev/mirim/web/ClientIp.java new file mode 100644 index 0000000..b32269b --- /dev/null +++ b/backend/src/main/java/dev/awesomedev/mirim/web/ClientIp.java @@ -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(); + } +} diff --git a/backend/src/main/java/dev/awesomedev/mirim/web/MentorController.java b/backend/src/main/java/dev/awesomedev/mirim/web/MentorController.java index ca5192b..e7a749b 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/web/MentorController.java +++ b/backend/src/main/java/dev/awesomedev/mirim/web/MentorController.java @@ -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 students() { - return studentProfileService.allProfiles().stream() + public List students(Authentication authentication, HttpServletRequest request) { + List 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 exportCsv() { + public ResponseEntity 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 auditLogs() { + return auditService.recent().stream() + .map(AuditLogResponse::from) + .toList(); } /** POST /api/mentor/submissions/{id}/feedback — 피드백 등록, 상태는 REVIEWED로 변경 */ diff --git a/backend/src/main/java/dev/awesomedev/mirim/web/dto/AuditLogResponse.java b/backend/src/main/java/dev/awesomedev/mirim/web/dto/AuditLogResponse.java new file mode 100644 index 0000000..41bc0ca --- /dev/null +++ b/backend/src/main/java/dev/awesomedev/mirim/web/dto/AuditLogResponse.java @@ -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()); + } +} diff --git a/backend/src/test/java/dev/awesomedev/mirim/service/AuditServiceTest.java b/backend/src/test/java/dev/awesomedev/mirim/service/AuditServiceTest.java new file mode 100644 index 0000000..a9fb961 --- /dev/null +++ b/backend/src/test/java/dev/awesomedev/mirim/service/AuditServiceTest.java @@ -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 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 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 result = service.recent(); + + assertEquals(1, result.size()); + assertEquals(AuditLog.Action.VIEW_STUDENT_ROSTER, result.get(0).getAction()); + } +} diff --git a/backend/src/test/java/dev/awesomedev/mirim/service/RateLimiterServiceTest.java b/backend/src/test/java/dev/awesomedev/mirim/service/RateLimiterServiceTest.java new file mode 100644 index 0000000..b420fae --- /dev/null +++ b/backend/src/test/java/dev/awesomedev/mirim/service/RateLimiterServiceTest.java @@ -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)); + } +} diff --git a/backend/src/test/java/dev/awesomedev/mirim/web/SecurityContractTest.java b/backend/src/test/java/dev/awesomedev/mirim/web/SecurityContractTest.java index a2d895c..78caba9 100644 --- a/backend/src/test/java/dev/awesomedev/mirim/web/SecurityContractTest.java +++ b/backend/src/test/java/dev/awesomedev/mirim/web/SecurityContractTest.java @@ -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()); + } } diff --git a/frontend/src/pages/MentorPage.jsx b/frontend/src/pages/MentorPage.jsx index a572856..4eae4ab 100644 --- a/frontend/src/pages/MentorPage.jsx +++ b/frontend/src/pages/MentorPage.jsx @@ -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 ( + + {/* table--nowrap: 시각·IP가 세로로 꺾이면 못 읽는다 — 가로 스크롤로 본다 */} + + + + + + + + + + + + {logs.map((logRow) => ( + + + + + + + + ))} + +
시각행위자행위내용IP
{new Date(logRow.createdAt).toLocaleString('ko-KR')}{logRow.actor}{AUDIT_ACTION_LABEL[logRow.action] || logRow.action}{logRow.detail}{logRow.ip}
+
+ ); +} + 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() { )} + + {/* ── 개인정보 접근 기록 (감사로그) ── */} +

개인정보 접근 기록

+

+ 수습생 개인정보를 누가·언제·어디서 조회/내보냈/파기했는지 기록입니다(최근 200건). +

+ {auditLogs.length === 0 ? ( +

아직 기록이 없어요. (명단 조회·CSV 다운로드 등을 하면 여기에 남습니다)

+ ) : ( + + )} ); }