
자세한 프로젝트 내용은 포트폴리오 — 모니 - 초보 투자자를 위한 AI 모의투자 플랫폼 에서 확인할 수 있습니다.
처음엔 어드민 기능을 user-service 안에 그냥 넣으려고 했다. 근데 Role 하나로 관리자를 구분하는 게 마음에 걸렸다. 그게 결국 전체 설계를 바꾸는 출발점이 됐다.
초기에는 user-service 안에 어드민 기능을 같이 뒀다. UserRole에 ADMIN을 추가하고, 헤더의 Role 값으로 관리자를 구분하는 방식이었다.
근데 이게 구조적으로 불안했다.
X-User-Role: ADMIN 헤더 하나로 관리자 권한을 판단하면, 이 헤더가 어디서든 위조될 수 있다는 게 전제로 깔린다. 게이트웨이에서 JWT로 덮어쓴다고 해도, 인증 체계 자체가 동일한 흐름 위에 올라가 있다는 게 문제였다. 사용자와 관리자가 같은 인증 경로를 공유하면 공격 표면이 넓어진다.
그래서 그냥 분리하기로 했다. 어드민은 별도 서비스로 빼고, 인증도 따로 가져가는 구조로.
결국 어드민이 유저 서비스 안에 얹혀있는 느낌 자체를 없애고 싶었다.
인증 방식은 Okta OIDC를 선택했다. 자체 RBAC과 MFA를 직접 구현하면 권한 정책과 다단계 인증까지 만들고 유지해야 하는데, 인증/인가는 지속적인 관리와 점검이 필요한 영역이라 관리형 IdP에 맡기는 게 맞다고 판단했다. 초대 기반 접근 모델이라 무단 계정 생성 자체가 불가능하고, MFA도 자체 구현 없이 확보된다.
다만 Okta는 SaaS형 IdP라 규모가 커지면 비용과 운영 방식을 다시 봐야 한다. 사내 인프라로 가져가야 하는 시점이 오면 규모에 따라Keycloak 같은 자체 호스팅 IdP로 전환하는 게 현실적인 선택지가 될수있다.
관리자 인증은 Okta를 통한 OIDC 세션 방식으로 구성했다. 일반 사용자의 JWT 인증과 완전히 분리된 흐름이다.
admin-service의 SecurityConfig는 이렇게 생겼다:
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/css/**", "/js/**", "/actuator/health", "/admin/login").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/admin/login")
.defaultSuccessUrl("/admin/dashboard", true)
)
.logout(logout -> logout
.logoutUrl("/admin/logout")
.logoutSuccessHandler(oidcLogoutSuccessHandler(clientRegistrationRepository))
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
);http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/css/**", "/js/**", "/actuator/health", "/admin/login").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/admin/login")
.defaultSuccessUrl("/admin/dashboard", true)
)
.logout(logout -> logout
.logoutUrl("/admin/logout")
.logoutSuccessHandler(oidcLogoutSuccessHandler(clientRegistrationRepository))
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
);로그인 페이지는 /admin/login이고, Okta 인증을 통과하면 세션이 생성된다. 로그아웃 시엔 Okta 측 세션도 같이 종료되도록 OidcClientInitiatedLogoutSuccessHandler를 달았다.
어드민 페이지는 React 같은 별도 프론트 없이 Thymeleaf로 서버사이드 렌더링했다. 어드민 기능은 내부 운영 도구라 SPA가 굳이 필요 없었다. 세션 기반 인증이랑도 궁합이 잘 맞는다.
admin-service가 실제 데이터는 각 서비스에서 Feign으로 가져온다. user-service에는 어드민 전용 내부 API(/api/v1/admin/users)가 따로 있고, X-Gateway-Secret 헤더로만 접근 가능하다.
@Tag(name = "Admin User API", description = "admin-service Feign 전용 내부 API (X-Gateway-Secret 필수)")
@RestController
@RequestMapping("/api/v1/admin/users")
public class AdminController { ... }@Tag(name = "Admin User API", description = "admin-service Feign 전용 내부 API (X-Gateway-Secret 필수)")
@RestController
@RequestMapping("/api/v1/admin/users")
public class AdminController { ... }이 엔드포인트는 외부에서 직접 호출할 수 없다. Gateway를 통하지 않으면 X-Gateway-Secret이 없어서 막힌다.
코드를 보면 user-service 안에 admin 패키지가 있다. 처음엔 여기에 어드민 기능을 넣으려고 만들었다가, 분리하면서 실제 비즈니스 로직은 admin-service로 옮겼다.
user-service의 AdminController는 admin-service에서 Feign으로 호출하는 내부 API 엔드포인트다. 어드민 서비스가 "유저 목록 줘", "이 유저 정지해줘" 하고 요청하면 응답하는 역할이다. 이름이 AdminController라 혼란스럽게 보이지만, 어드민이 쓰는 API가 아니라 어드민 서비스가 호출하는 user-service 내부 API다.
나중에 유저 기능이 확장될 때 이 구조를 활용할 수 있도록 남겨둔 거다.
발표 중에 외부 심사진에서 이런 질문이 나왔다.
"X-User-Id, X-User-Role 헤더가 제거되지 않아서 위조가 가능한 거 아니냐?"
사실 이런 의심 자체가 생기지 않도록 설계한 게 이 구조였다. 어드민 인증을 JWT Role 체계에서 완전히 분리한 이유가 바로 그거였으니까.
일단 헤더 위조 자체가 불가능하다. Gateway의 JwtAuthenticationFilter에는 stripTrustedHeaders()가 있어서, 클라이언트가 X-User-Id나 X-User-Role을 위조해서 보내도 Gateway 통과 시점에 전부 제거하고 JWT에서 뽑은 실제 값으로 덮어쓴다:
private void stripTrustedHeaders(HttpHeaders headers) {
headers.remove("X-Gateway-Secret");
headers.remove("X-User-Id");
headers.remove("X-User-Email");
headers.remove("X-User-Role");
}private void stripTrustedHeaders(HttpHeaders headers) {
headers.remove("X-Gateway-Secret");
headers.remove("X-User-Id");
headers.remove("X-User-Email");
headers.remove("X-User-Role");
}그리고 설령 X-User-Role을 위조할 수 있다고 가정해도 의미가 없다. UserRole은 USER 하나뿐이라 승격할 권한 자체가 존재하지 않는다:
public enum UserRole {
USER
}public enum UserRole {
USER
}관리자 권한은 이 Role 체계와 완전히 무관하다. Okta 인증을 통과한 세션이 있어야만 admin-service에 접근할 수 있다. 헤더를 어떻게 조작해도 어드민 기능에 닿을 경로 자체가 없다.
아마 오인이 생긴 건 user-service 코드에 UserRole enum과 X-User-Role 헤더가 남아있어서인 것 같다. 원래 Role 기반으로 어드민을 구분하던 구조에서 Okta로 전환하면서, 나중에 유저 기능 확장에 쓸 수 있도록 Role 값 자체는 남겨뒀다. 그 흔적이 보안 문제처럼 보였던 거다.
비슷한 맥락에서 배포 주소도 오해를 사기 쉬운 구성이었다.
배포 웹: http://moni.my/ ← 접근 가능
백엔드 API: https://api.moni.my/ ← 접근 가능 (브라우저는 CORS 제한)
모니터링: https://grafana.moni.my/ ← Okta 인증 필요
어드민: https://admin.moni.my/ ← Okta 인증 필요배포 웹: http://moni.my/ ← 접근 가능
백엔드 API: https://api.moni.my/ ← 접근 가능 (브라우저는 CORS 제한)
모니터링: https://grafana.moni.my/ ← Okta 인증 필요
어드민: https://admin.moni.my/ ← Okta 인증 필요백엔드 API는 주소 자체는 열려있다. curl이나 Postman으로는 직접 호출 가능하고, 브라우저에서는 허용된 origin(moni.my)에서만 응답한다. Grafana와 어드민은 주소로 접속은 되지만 Okta 인증 없이는 로그인 페이지로 튕겨진다.
제출 당시 이 주소들을 "접근 불가"로 표기했는데, 튜터님은 "올바른 보안 구성"이라고 했다. 의도한 대로 동작하고 있었다.