Skip to content

feat: 어드민 API 인증/인가 및 OWNER 권한 관리 구현 - #59

Open
psm1st wants to merge 7 commits into
kusitms-com:mainfrom
psm1st:feature/#58-admin-authorization
Open

psm1st wants to merge 7 commits into
kusitms-com:mainfrom
psm1st:feature/#58-admin-authorization

Conversation

@psm1st

@psm1st psm1st commented Oct 12, 2026

Copy link
Copy Markdown

📌 작업 개요

관련 이슈

작업 배경 및 목적

  • /admin/**가 로그인 여부만 확인하고 있어, 일반 학회원(YB/OB)도 학회 소개·프로젝트·후기를 등록/수정/삭제할 수 있었습니다.
  • MemberRole {YB, OB, ADMIN}에 학회원 구분과 어드민 권한이 섞여 있어, 로그인 시 기수 기반 role 재계산이 ADMIN을 덮어썼습니다. 기수가 있는 운영진은 로그인할 때마다 관리자 권한을 잃었습니다.
  • 권한 거부(AccessDeniedException)가 RuntimeException 핸들러에 잡혀 HTTP 200 + code 500으로 응답돼, FE가 권한 오류를 구분할 수 없었습니다.
  • 운영진(MANAGER)과 학회원(MEMBER)을 분리하고, 운영진 권한을 조정할 수 있는 OWNER 권한을 추가합니다.

🛠️ 주요 변경 사항

1. 학회원 구분과 어드민 권한 분리

  • MemberAuthority { MEMBER, MANAGER, OWNER } 추가, Member.authority 컬럼(member_authority) 추가
  • role은 YB/OB만 담당하며 기수 기준 자동 판별을 유지합니다. authority는 OWNER만 변경할 수 있습니다.
  • 로그인/토큰 갱신 응답에 authority 필드를 추가했습니다.

2. 역할 기반 인가

  • RoleHierarchy: OWNER > MANAGER > MEMBER
  • URL 인가: /admin/owner/**는 OWNER, /admin/**는 MANAGER 이상
  • 메서드 인가: AdminController에 @PreAuthorize("hasRole('MANAGER')"), OwnerController에 @PreAuthorize("hasRole('OWNER')")
  • /api/mypage/**, /api/chat/**, /api/auth/logout은 인증 필수로 변경했습니다. 기존에는 컨트롤러에서 400을 반환했고, 이제 401을 반환합니다.
  • 권한은 매 요청 DB에서 조회하므로 권한 변경이나 탈퇴가 토큰 재발급 없이 즉시 반영됩니다.
  • 승인되지 않았거나 탈퇴한 회원의 토큰은 401(INACTIVE_MEMBER)로 처리합니다.

3. 신규 API

메서드 경로 권한 설명
GET /admin/members?status= MANAGER 이상 회원 목록 조회 (authority 포함)
PATCH /admin/owner/members/{userId}/authority OWNER 회원 권한 변경

권한 변경 규칙

  • 자기 자신의 권한은 변경할 수 없습니다.
  • APPROVED 상태인 회원에게만 권한을 부여할 수 있습니다.
  • 마지막 OWNER는 강등할 수 없습니다. OWNER 행에 비관적 락을 걸어 동시 강등 상황에서도 보호합니다.

4. 에러 응답 정리

  • 권한 부족은 403 FORBIDDEN, 미인증은 401입니다.
  • 처리되지 않은 예외는 실제 HTTP 500으로 응답하고 에러 로그를 남깁니다.

5. 기존 데이터 이전 (AdminAuthorityInitializer)

  • 서버 기동 시 member_role = 'ADMIN'인 회원을 authority = MANAGER로 이전하고, role은 YB/OB로 재계산합니다.
  • admin.owner-ids에 지정한 로그인 ID를 OWNER로 승격합니다. 최초 OWNER를 만들기 위한 설정입니다.
  • 두 작업 모두 멱등이라 재시작해도 안전합니다.

6. 기타

  • 중복된 CORS 설정(WebConfig)을 SecurityConfig로 통합했습니다. 허용 origin은 cors.allowed-origins 설정으로 분리했고, PATCH/OPTIONS를 허용했습니다.
  • Swagger Admin 태그의 "테스트 단계" 문구를 제거했습니다.
  • 구조 설명 문서를 추가했습니다: docs/admin-authorization.md

✅ 완료 조건

  • YB/OB(MEMBER)는 어드민 API 호출 시 403
  • MANAGER/OWNER는 어드민 API 사용 가능
  • OWNER만 회원 권한 변경 가능, 마지막 OWNER 보호
  • 기수가 있는 운영진이 로그인해도 권한 유지
  • 권한 없음 403 / 미인증 401 구분 응답
  • 어드민 경로 /admin/** 유지

🧪 테스트 및 검증 결과

테스트 항목 검증 방법 결과
어드민 API: 비로그인 / YB / MANAGER / OWNER 통합 테스트 + 로컬 서버 API 호출 성공 (401 / 403 / 200 / 200)
회원 목록 API 권한 및 authority 포함 통합 테스트 + API 호출 성공
권한 변경 API: MANAGER 호출 통합 테스트 + API 호출 성공 (403)
권한 변경 API: OWNER가 YB를 MANAGER로 승격 후 기존 토큰으로 어드민 API 호출 통합 테스트 + API 호출 성공 (200, 즉시 반영)
OWNER 자기 자신 변경 통합 테스트 + API 호출 성공 (400)
마지막 OWNER 강등 / OWNER 2명일 때 강등 통합 테스트 성공 (거부 / 허용)
미승인 회원 권한 부여 통합 테스트 성공 (거부)
탈퇴 회원의 미만료 토큰 통합 테스트 성공 (401)
로그인 필요 API 비로그인 / 공개 API 통합 테스트 + API 호출 성공 (401 / 200)
기수 있는 운영진 로그인 통합 테스트 + API 호출 성공 (role=YB/OB, authority=MANAGER)
레거시 ADMIN 데이터 이전 통합 테스트 + 로컬 DB 재기동 성공 (ADMIN → OB + MANAGER)

테스트 환경

  • 환경: 로컬 (JDK 17, Spring Boot 2.7.8), PostgreSQL 15 (Docker) / 테스트는 H2 PostgreSQL 모드
  • 테스트 도구: JUnit5, MockMvc, spring-security-test, curl, Swagger UI

테스트 증빙

./gradlew test
WebsiteApplicationTests       tests=1  failures=0 errors=0
AdminAuthorizationTest        tests=10 failures=0 errors=0

로컬 서버 기동 로그 (레거시 ADMIN 회원이 있는 DB로 기동):

AdminAuthorityInitializer : 레거시 ADMIN 회원 권한 이전: userId=1, authority=MANAGER
AdminAuthorityInitializer : OWNER 권한 부여: id=owner

로컬 API 호출 결과:

토큰 없이 GET /admin/review                     → 401 인증되지 않은 사용자입니다.
YB: DELETE /admin/review/1                     → 403 접근 권한이 없습니다.
MANAGER: GET /admin/review                     → 200
OWNER: GET /admin/review                       → 200
MANAGER: PATCH /admin/owner/members/3/authority → 403
OWNER: PATCH /admin/owner/members/3/authority   → 200
YB(승격 후, 기존 토큰): GET /admin/review        → 200
OWNER: 자기 자신 권한 변경                       → 400 자기 자신의 권한은 변경할 수 없습니다.
토큰 없이 GET /api/mypage/account               → 401

미검증 항목

  • 운영(Heroku) 환경 배포 및 실제 운영 DB 데이터 이전은 검증하지 않았습니다. 로컬 DB에 운영과 같은 형태의 레거시 데이터를 넣어 검증했습니다.
  • 사이트 FE와 어드민 FE의 실제 연동은 검증하지 않았습니다. 어드민 FE는 아직 API 연동 전입니다.
  • 동시 강등 시나리오는 락 로직만 구현했고, 멀티 스레드 테스트는 하지 않았습니다.

🔍 리뷰 요청 사항

  • 역할 모델 분리: role(YB/OB)과 authority(MEMBER/MANAGER/OWNER)를 분리한 방향이 적절한지 봐 주세요. MemberRole.ADMIN은 기존 데이터를 읽기 위해 @Deprecated로만 남겼습니다.
  • 권한을 매 요청 DB에서 조회: 즉시 반영되는 장점과 요청당 PK 조회 1회 비용 사이의 트레이드오프를 봐 주세요. 기존 구조에서도 매 요청 조회하고 있었습니다.
  • /api/** 인증 범위: 인증 필수로 바꾼 경로(mypage, chat, logout)가 맞는지 확인 부탁드립니다. 멘토링 상세/신청은 기존처럼 컨트롤러에서 검사합니다.
  • RuntimeException 응답 변경: 200에서 500으로 바뀐 것이 사이트 FE에 영향이 없는지 확인이 필요합니다.

⚠️ 배포 영향 및 참고 사항

  • DB 스키마/데이터 변경
    • member.member_authority 컬럼 추가 (ddl-auto=update로 자동 생성)
    • 기동 시 기존 member_role = 'ADMIN' 회원을 member_authority = 'MANAGER'로 이전하고, member_role은 YB/OB로 재계산
  • 환경 변수 및 설정 변경
    • admin.owner-ids=<OWNER 로그인 ID 목록>: 최초 OWNER 지정용으로 배포 시 필요합니다.
    • cors.allowed-origins=<사이트 FE>,<어드민 FE>: 선택. 미설정 시 기존처럼 *을 허용합니다.
  • API 명세 변경
    • 로그인/토큰 갱신 응답: role은 YB/OB만 내려가고, authority가 추가됩니다. 어드민 FE는 authority !== 'MEMBER'로 접근을 판별합니다.
    • 권한 부족 403, 미인증 401, 서버 오류 500(HTTP 상태)
    • /api/mypage/**, /api/chat/**, /api/auth/logout 비로그인 시 400에서 401로 변경
    • 신규: GET /admin/members, PATCH /admin/owner/members/{userId}/authority
  • 기타: 테스트 의존성 spring-security-test, h2(testRuntimeOnly)를 추가했습니다.

☑️ PR 체크리스트

  • 관련 이슈를 연결했습니다.
  • 코드 및 Git 컨벤션을 준수했습니다.
  • 불필요한 코드와 디버깅 로그를 제거했습니다.
  • 필요한 테스트를 수행하고 결과를 기록했습니다.
  • 미검증 항목이나 알려진 문제를 기록했습니다.
  • API, DB, 설정 변경 시 관련 문서를 업데이트했습니다.
  • 민감 정보가 포함되지 않았는지 확인했습니다.

🤖 Generated with Claude Code

- AccessDeniedException이 RuntimeException 핸들러에 잡혀 HTTP 200으로 내려가던 문제 수정
- AccessDeniedHandler가 401 대신 403(FORBIDDEN)을 반환하도록 변경
- INACTIVE_MEMBER(401), FORBIDDEN(403) 상태 추가
- MemberAuthority(MEMBER/MANAGER/OWNER) 추가, role은 YB/OB만 담당
- 로그인 시 기수 기반 role 재계산이 어드민 권한을 덮어쓰던 문제 해결
- 로그인/토큰 갱신 응답에 authority 필드 추가
- 기동 시 기존 role=ADMIN 회원을 MANAGER로 이전, admin.owner-ids로 최초 OWNER 지정
- RoleHierarchy(OWNER > MANAGER > MEMBER) 적용
- /admin/owner/** OWNER, /admin/** MANAGER 이상 URL 인가 + 컨트롤러 @PreAuthorize 이중 적용
- 마이페이지/채팅/로그아웃 API는 인증 필수로 변경
- 승인되지 않았거나 탈퇴한 회원의 토큰은 401(INACTIVE_MEMBER) 처리
- CORS 허용 origin을 cors.allowed-origins 설정으로 분리하고 PATCH/OPTIONS 허용
- GET /admin/members 회원 목록 조회 API 추가
- PATCH /admin/owner/members/{userId}/authority
- 자기 자신 변경 금지, 승인된 회원만 권한 부여 가능
- OWNER 행 비관적 락으로 동시 강등 시에도 마지막 OWNER 보호
- 권한별(비로그인/MEMBER/MANAGER/OWNER) API 접근 검증
- 권한 변경 규칙, 탈퇴 회원 토큰, 레거시 ADMIN 이전 검증
- 테스트 DB를 H2(PostgreSQL 모드)로 구성
@psm1st psm1st self-assigned this Oct 12, 2026
@psm1st
psm1st force-pushed the feature/#58-admin-authorization branch from 7fba847 to 04c92f8 Compare October 12, 2026 06:33
Comment on lines +47 to +59
if (ownerIds.isEmpty()) {
return;
}
for (Member member : memberRepository.findAllByIdIn(ownerIds)) {
if (member.getStatus() != MemberStatus.APPROVED) {
log.warn("OWNER 지정 실패 (승인되지 않은 회원): id={}", member.getId());
continue;
}
if (member.getAuthority() != MemberAuthority.OWNER) {
member.updateAuthority(MemberAuthority.OWNER);
log.info("OWNER 권한 부여: id={}", member.getId());
}
}

@hyunbin7664 hyunbin7664 Oct 15, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서버가 켜질 때마다 admin.owner-ids에 있는 회원을 다시 OWNER로 올리고 있어서 API로 OWNER를 강등해도 재배포하면 다시 OWNER가 될 것 같아요

OWNER가 한 명도 없을 때만 실행하면 어떨까요?

if (ownerIds.isEmpty() || !memberRepository.findAllByAuthority(MemberAuthority.OWNER).isEmpty()) {
    return;
}

이렇게 하면 마지막 OWNER가 탈퇴해도 재시작만 하면 다시 복구될 것 같습니다!

.body(BaseResponse.error(BaseExceptionStatus.FORBIDDEN));
}

@ExceptionHandler(RuntimeException.class)

@hyunbin7664 hyunbin7664 Oct 15, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금은 잘못된 요청도 이 핸들러에 잡혀서 400이 아닌 500으로 응답되는 것 같습니다

  • GET /admin/members?status=FOO
  • PATCH .../authority에 {"authority": "ADMIN"}

어드민 FE 연동을 시작하면 자주 생길 것 같아서 아래처럼 400으로 따로 처리하면 좋을 것 같아요!

@ExceptionHandler({MethodArgumentTypeMismatchException.class, HttpMessageNotReadableException.class})
public ResponseEntity<BaseResponse<BaseException>> handleBadRequest(Exception ex) {
    return ResponseEntity.badRequest()
            .body(BaseResponse.error(BaseExceptionStatus.BAD_REQUEST));
}

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: 어드민 API 인증/인가 및 OWNER 권한 관리 구현

2 participants