← 개발 로그 목록

website: 어드민 인증 파운데이션 — 로그인·초대·비밀번호재설정·운영진관리

/ 12분 분량 / 개발 로그

멋사 경희대 사이트 프로젝트에서 `/api/admin/**`가 전부 `permitAll()`로 열려 있던 상태를 닫고, 이메일 로그인부터 초대·비밀번호 재설정·운영진 관리까지 어드민 인증의 공통 기반을 한 번에 구현한 PR입니다.

요약

이슈 #90의 실제 구현체로, 2026년 7월 10일에 시작해 다음날인 11일 dev 브랜치에 병합됐다. 변경 규모는 파일 76개, 추가 2940줄에 삭제는 8줄뿐 — 사실상 통째로 새로 만든 모듈이다. 로그인, 초대, 비밀번호 재설정, 운영진 관리, 그리고 이 과정에서 나온 실제 버그 수정까지 한 PR 안에 다 들어가 있다.

배경 및 목적

지금까지는 어드민 관련 엔드포인트에 로그인도 역할도 토큰도 없이 전부 뚫려 있었다. PR 설명에 따르면 목적은 명확했다 — 도메인마다 인증을 급조하지 않고, 공통 로그인 기반을 한 번에 만들어서 닫는 것. 이 작업은 선행 PR인 #85(이메일 발송 모듈)가 있었기에 가능했는데, 초대 메일과 비밀번호 재설정 메일을 그 위에 바로 얹어 쓸 수 있었다.

닫아야 할 기존 TODO도 명확했다. /api/admin/posts/**, /api/admin/comments/**가 permitAll()로 열려 있던 걸 authenticated()로 전환하는 것도 이번 PR의 범위였다.

구현 내용

인증 흐름

이메일을 아이디로 쓰는 로그인 방식을 택했고, JWT는 응답 바디가 아니라 HttpOnly 쿠키로만 내려준다. access_token은 15분, refresh_token은 7일 — 토큰이 JSON에 노출되지 않는다는 점은 shared/types/admin.ts에도 명시해뒀다.

// access_token/refresh_token은 Set-Cookie(HttpOnly)로만 전달되며 JSON 바디엔 노출되지 않는다.
export interface AdminLoginResponse {
  admin: AdminAccount;
}

역할은 SUPER_ADMIN/ADMIN 2단계로 나눴고, @EnableMethodSecurity + @PreAuthorize 기반 권한 체크는 이 코드베이스에서 처음 도입됐다.

초대·비밀번호 재설정

초대는 khu.ac.kr 도메인으로 제한하고 72시간짜리 토큰을 발급, 초대받은 사람이 이름과 비밀번호를 설정하면 가입이 완료되는 흐름이다. 비밀번호 재설정은 30분짜리 토큰을 쓰고, 존재하지 않는 이메일로 요청해도 동일한 응답을 준다 — 이메일 존재 여부가 노출되지 않도록 한 부분이다.

계정 보호 장치

5회 연속 로그인 실패 시 15분간 계정을 잠그는 로직(423 응답)과, 최소 1명의 SUPER_ADMIN은 항상 남아있어야 한다는 불변식(마지막 한 명은 삭제·강등 불가, 409 응답)을 구현했다. 초기 SUPER_ADMIN은 env 기반으로 시드하되 실제 이메일은 소스에 남기지 않고, 가입 후 비밀번호 재설정 메일로 온보딩하는 방식을 택했다.

스펙에는 없었지만 refresh 토큰을 실제로 쓰려면 필요해서 /api/admin/auth/refresh 엔드포인트도 새로 추가했다.

변경된 파일

admin/, admin/auth/, admin/invitation/, admin/password/, admin/management/, admin/exception/ 등 도메인별로 패키지를 쪼갠 구조로 총 76개 파일이 바뀌었다. 주요 축은 다음과 같다.

  • AdminAuthController/AdminAuthService/JwtProvider/JwtAuthenticationFilter — 로그인·로그아웃·리프레시
  • AdminInvitationController/AdminInvitationService — 초대 발급·취소·검증·수락
  • AdminPasswordController/AdminPasswordResetService — 비밀번호 재설정
  • AdminManagementController/AdminManagementService — 운영진 목록·삭제·역할변경
  • SecurityConfig, GlobalExceptionHandler — 시큐리티 배선, 예외 응답 통일
  • shared/types/admin.ts — FE·BE 계약
  • 테스트 파일들 (컨트롤러 통합 테스트만 각 200줄 안팎)

기술적 의사결정

기존 컨벤션을 최대한 재사용했다. 엔티티는 Post 패턴대로 정적 팩토리 메서드 + 수동 타임스탬프를 썼고, 토큰 생성 방식도 기존 MagicLinkToken과 동일하게 SecureRandom + Base64를 그대로 가져다 썼다. 이메일 발송도 신규 EmailType을 만들지 않고 EmailService.sendInviteEmail/sendPasswordResetEmail을 그대로 호출했다. 새 모듈이라고 새 패턴을 만들지 않고, 기존 것에 맞춘 셈이다.

에러 응답에 code 필드를 추가했다. 기존 GlobalExceptionHandler는 {success, message} 2키 컨벤션이었는데, 이 모듈만 FE가 분기할 수 있도록 code 필드를 얹었다. shared/types/admin.ts에 AdminErrorCode 유니언 타입으로 전체 에러코드를 정의해뒀다.

export type AdminErrorCode =
  | 'INVALID_CREDENTIALS'
  | 'ACCOUNT_LOCKED'
  | 'INVALID_REFRESH_TOKEN'
  | 'UNAUTHENTICATED'
  | 'FORBIDDEN'
  | 'INVALID_EMAIL_DOMAIN'
  | 'ALREADY_MEMBER'
  | 'NOT_FOUND'
  | 'INVALID_TOKEN'
  | 'EXPIRED_TOKEN'
  | 'WEAK_PASSWORD'
  | 'LAST_SUPER_ADMIN';

다만 401/403은 @RestControllerAdvice가 아니라 SecurityConfig의 exceptionHandling에서 직접 처리했다. @PreAuthorize가 던지는 예외는 시큐리티 필터 레벨에서 가로채져서 컨트롤러 advice까지 도달하지 않기 때문이다.

refresh 토큰은 원문이 아니라 SHA-256 해시로 저장했다. 로그아웃, 비밀번호 재설정, 역할변경, 운영진 제거 시 토큰을 폐기할 수 있어야 했기 때문인데, AdminAuthService의 hash() 메서드에서 확인할 수 있다.

private String hash(String token) {
    try {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] bytes = digest.digest(token.getBytes(StandardCharsets.UTF_8));
        return HexFormat.of().formatHex(bytes);
    } catch (NoSuchAlgorithmException e) {
        throw new IllegalStateException("SHA-256을 사용할 수 없어요.", e);
    }
}

시행착오: 코드리뷰 중 발견한 실제 버그

가장 흥미로운 부분은 코드리뷰 과정에서 발견한 트랜잭션 롤백 버그다. 로그인 실패 시 recordFailedLogin()으로 실패 횟수를 늘리는데, 그 직후 InvalidCredentialsException이나 AccountLockedException을 던지면 Spring의 기본 롤백 정책 때문에 방금 늘린 카운터까지 통째로 롤백돼버린다. 즉 5번을 틀려도 카운트가 매번 리셋되니 423(계정 잠금)이 영원히 안 뜨는 상황이었다.

// noRollbackFor 필수: 실패 로그인 시 recordFailedLogin()으로 늘린 시도횟수·잠금을 남긴 채로
// InvalidCredentialsException/AccountLockedException을 던져야 하는데, 기본 정책은 unchecked
// 예외에서 전체 롤백이라 이 카운터 증가 자체가 롤백돼 무차별 대입 방어가 무력화된다.
@Transactional(noRollbackFor = {InvalidCredentialsException.class, AccountLockedException.class})
public LoginResult login(String email, String rawPassword) {
    ...
}

noRollbackFor를 명시하는 것으로 해결했다. 이런 종류의 버그는 테스트를 짜다가 "어, 5번째 실패에도 423이 안 뜨네" 하고 발견하는 경우가 많은데, 이번에도 마찬가지였던 것으로 보인다.

또 하나는 초대 취소(DELETE /invitations/:id)가 존재하지 않는 id에 대해 스펙과 다르게 400을 내던 문제였다. 토큰 조회 실패(400 INVALID_TOKEN)와 id 조회 실패(404 NOT_FOUND)가 섞여 있던 걸 별개 예외로 분리해서 고쳤다. AdminInvitationIdNotFoundException과 AdminInvitationNotFoundException을 나눈 것도 이 때문이다.

우당탕: gitleaks와의 싸움

커밋 히스토리를 보면 마지막 세 커밋이 전부 gitleaks 오탐 처리다. 비-khu.ac.kr 도메인을 거부하는지 확인하는 테스트에 someone@gmail.com 같은 placeholder 이메일을 썼는데, .gitleaks.toml의 personal-email 규칙이 이걸 진짜 개인정보로 오탐한 것이다. example.com(RFC 예약 테스트 도메인)으로 바꿔서 1차 수정했는데, PR 범위 커밋 히스토리를 스캔하는 gitleaks 특성상 이미 지나간 커밋의 diff에는 여전히 gmail.com이 남아있어 계속 걸렸다. fingerprint 단위로 무시 처리했더니, 이번엔 그 무시 처리를 설명하며 커밋 메시지에 적어둔 예시 문자열이 또 도메인 패턴처럼 보여서 자기참조로 재발탐지되는 상황까지 벌어졌다. 결국 주석 표현 자체를 도메인 형태가 아니게 다시 고치고서야 정리됐다. 작은 일이지만 시큐리티 스캐너를 상대로 세 번이나 왔다 갔다 한 흐름이 커밋 로그에 그대로 남아 있다.

테스트

단위 테스트로 AdminTest, AdminInvitationTest, PasswordResetTokenTest, RefreshTokenTest, JwtProviderTest를 짰고, 컨트롤러 통합 테스트는 로그인 성공 시 쿠키 2개가 세팅되고 바디엔 토큰이 없는지, 5회 실패 시 423이 뜨는지, 로그아웃 후 refresh가 401을 내는지, /api/admin/posts/**가 구 permitAll에서 401로 바뀌었는지(회귀 테스트) 등을 확인했다.

테스트를 짜면서 @WithMockUser의 기본 principal 타입이 @AuthenticationPrincipal AdminPrincipal과 안 맞아서 조용히 null이 되는 문제도 만났다고 한다. 실제 JwtAuthenticationFilter가 채우는 것과 동일한 SecurityContext를 만드는 @WithMockAdminUser라는 전용 애노테이션을 만들어 해결했는데, 이 내용은 pm/docs/learnings.md에도 별도로 기록해뒀다.

배운 점 및 개선점

이 PR 자체가 noRollbackFor의 필요성과 @WithMockUser의 함정이라는 두 가지 실전 교훈을 남겼다. 둘 다 "당연히 될 줄 알았는데 조용히 안 되는" 케이스라서, 테스트를 촘촘히 짜지 않았으면 놓쳤을 법한 버그다.

PR 설명에 정리된 "아직 못 메꾼 빈틈"도 솔직하게 남아있다. IP 기반 요청 제한(429)은 신규 인프라 없이 이번 범위에서 만들면 오버엔지니어링이라 보류했고, refresh 토큰은 재사용 로테이션 없이 만료까지 그대로 쓰는 구조라 탈취 시 대응이 약하다. 스테이트리스 access 토큰이라 즉시 무효화가 안 되는 것도 트레이드오프로 남겨뒀다 — 제거·역할변경·비밀번호재설정을 해도 이미 나간 access 토큰은 최대 15분 살아있다. 재초대 시 기존 PENDING 초대를 자동으로 대체할지, FE 라우트 경로를 어떻게 잡을지도 PM/FE 확인이 필요한 부분으로 남겨둔 채 마무리했다.