Skip to content

Refactor/#18/jwt to session#19

Open
LEE-HYUN-JE wants to merge 18 commits intomainfrom
refactor/#18/jwt-to-session
Open

Refactor/#18/jwt to session#19
LEE-HYUN-JE wants to merge 18 commits intomainfrom
refactor/#18/jwt-to-session

Conversation

@LEE-HYUN-JE
Copy link
Collaborator

@LEE-HYUN-JE LEE-HYUN-JE commented Mar 10, 2026

📌 변경 요약 (Summary)

이 PR에서 무엇을 변경했는지 간단히 설명해주세요.

  • JWT(Access Token + Refresh Token) 기반 인증을 Keycloak OIDC Authorization Code Flow + Redis 세션 방식으로 전환합니다.
  • 프론트엔드는 토큰을 직접 관리하지 않고, 백엔드가 발급한 acc-session-id 세션 쿠키만으로 모든 API를 호출할 수 있게 됩니다.
  • 또한 기존에 서비스 레이어 전체에 분산되어 있던 String token 파라미터를 SessionModule 로부터 전달 받게 됩니다.

🔗 관련 이슈 (Related Issue)

이 PR과 관련된 이슈를 작성해주세요.

Closes #18


🛠 작업 내용 / 작업 순서 (Implementation Details)

이번 작업에서 수행한 내용이나 작업 순서를 작성해주세요.

예시:

  1. DB 재설계 — UserEntitykeycloak_user_id 컬럼 추가 및 Keystone 비밀번호 AES-256 암호화 적용
  2. Keycloak OIDC Authorization Code Flow 구현 (KeycloakAuthController, KeycloakAuthServicePort)
  3. Redis 세션 저장소 구현 — 세션 ID를 키로 Keycloak 토큰·Keystone 토큰 캐싱 (SessionAuthenticationFilter, SessionModule)
  4. Keycloak 토큰 갱신 및 만료 처리 — 매 요청마다 SessionAuthenticationFilter에서 토큰 유효성 검사 후 자동 갱신
  5. JwtAuthenticationFilter·JwtInfo 제거 및 SecurityConfig 정리
  6. 전체 도메인(10개) Port/Adapter/Controller 세션 기반으로 전환
  7. SuperAdmin 초기화 시 Keycloak 계정 자동 매핑, 관리자 학적검증 우회 처리 추가

✨ 주요 변경 사항 (Key Changes)

인증 방식 전환

  • 제거: JwtAuthenticationFilter, JwtInfo, /auth/login/refresh 엔드포인트
  • 추가: Keycloak OIDC 로그인 (GET /api/v1/auth/keycloak/login), 로그아웃 (DELETE /api/v1/auth/keycloak/logout)
  • 세션 쿠키 acc-session-id (HttpOnly / Secure / SameSite=None) 발급

Redis 세션 저장소

  • 세션 ID → { keystoneUnscopedToken, keystoneScopedTokens, keycloakAccessToken, keycloakRefreshToken } 구조로 Redis 저장
  • TTL 30분, 슬라이딩 방식 (매 요청마다 갱신)
  • Keycloak refresh token 만료 시 세션 강제 폐기 → 401 반환

전 도메인 서비스 시그니처 변경

  • 기존: someMethod(String token, ...) → 변경: someMethod(String sessionId, ...)
  • SessionModule.getKeystoneUserId(sessionId) / getKeystoneScopedToken(sessionId, projectId) 등으로 토큰 조회 일원화
  • 영향 범위: Instance / Network / Image / Keypair / QuickStart / Project / User / Role / Notice / Auth / Volume

보안 강화

  • DB에 평문 저장되던 Keystone 비밀번호 → AES-256 암호화 저장 (KEYSTONE_PASSWORD_ENCRYPTION_KEY)
  • UserEntitykeycloak_user_id 추가 — Keycloak ↔ Keystone 계정 연결

환경변수 변경

구분 항목
신규 추가 KEYCLOAK_ISSUER_URI, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_REDIRECT_URI, KEYCLOAK_FRONTEND_REDIRECT_URL, OAUTH_COOKIE_DOMAIN, KEYSTONE_PASSWORD_ENCRYPTION_KEY

|
| 제거 가능 | JWT_SECRET, JWT_EXPIRATION_MS, JWT_REFRESH_EXPIRATION_MS, JWT_OAUTH_VERIFICATION_EXPIRATION_MS |


🧪 테스트 결과 (Test Results)

테스트 방법 및 결과를 작성해주세요.

테스트 환경

  • 로컬 환경에서 개발환경의 Keycloak에 local client를 활용하여 진행했습니다.
  • 추후 merge 시, 개발환경의 keycloak client에 매핑할 필요가 있습니다. (개발환경 도메인 추가 등등)

테스트 방법

테스트 결과

필요한 경우 로그 또는 스크린샷 첨부

  • 스크린샷도 노션에 함께 첨부되어 있습니다.

  - AuthType에 KEYCLOAK(3) 추가
  - UserDbExtraEntity에 keycloak_user_id, keystone_username, keystone_password 컬럼 추가
  - UserDetailJpaRepository.findByKeycloakUserId() 추가
  - UserIdentityJpaRepository.findByUserEmail() 추가
  - UserRepositoryPort/Adapter: findUserDetailByKeycloakUserId, findUserIdentityByEmail 추가
  - KeystonePasswordEncryptor: AES-256-CBC 기반 Keystone 패스워드 암호화 모듈 추가
  - KeycloakOidcExternalPort/Adapter/Module: code→token 교환, token revoke 구현
  - KeycloakIdTokenParser: ID Token 클레임 추출 (email, department, studentId 등)
  - KeycloakUserModule: 3-way 분기 처리
      Branch 1) keycloakUserId 연결된 기존 사용자 → 일반 로그인
      Branch 2) 이메일 일치 기존 사용자 → Account Linking (Keystone 패스워드 재설정)
      Branch 3) 신규 사용자 → Keystone 생성 + user_detail/user_auth_detail INSERT
  - KeycloakAuthModule.processCallback(): 전체 플로우 오케스트레이션
  - KeycloakAuthController: /login, /callback, /logout 엔드포인트 추가
  - SessionConstants: 세션 키/필드명 상수 정의 (global/security/session으로 배치)
  - SessionPrincipal: SecurityContext principal 클래스 (sessionId, keycloakUserId, keystoneUserId)
  - SessionAuthenticationFilter: acc-session-id 쿠키 → SecurityContext 인증 처리
      - Redis 세션 조회 후 SessionPrincipal 설정
      - 슬라이딩 세션 (요청마다 TTL 30분 연장)
  - SecurityConfig: SessionAuthenticationFilter를 JwtAuthenticationFilter 뒤에 등록
  - RedisSessionRepositoryAdapter: SessionConstants import global로 변경
  - SessionModule: 세션 기반 토큰 접근 모듈
      - getKeystoneUnscopedToken(): 만료 시 DB credentials로 자동 재발급
      - getKeycloakAccessToken(): 만료 시 refresh_token으로 자동 갱신
      - getKeystoneScopedToken(): 프로젝트 스코프 토큰 발급
  - AuthModule: getUnscopedTokenByUserId, issueProjectScopeToken, getProjectPermission @deprecated 처리
  - docker-compose.yml: Redis 서비스 추가
- JwtAuthenticationFilter 삭제 → SessionAuthenticationFilter로 대체
- JwtInfo 삭제 → SessionPrincipal로 대체
- SessionConstants 삭제
- SecurityConfig에서 JwtAuthenticationFilter 제거
- userId → sessionId 변경
- authModule → sessionModule 전환
- JwtInfo → SessionPrincipal 교체
- userId → sessionId 변경
- authModule → sessionModule 전환
- JwtInfo → SessionPrincipal 교체
- userId → sessionId 변경
- authModule → sessionModule 전환
- JwtInfo → SessionPrincipal 교체
- userId → sessionId 변경
- sessionModule 기반 Keystone 처리
- userId → sessionId 변경
- SessionModule + AuthModule 병행 구조
- requesterId → sessionId 변경
- SessionModule 기반 사용자 식별 전환
- JwtInfo → SessionPrincipal 교체
- userId → sessionId 변경
- authModule → sessionModule 전환
- JwtInfo → SessionPrincipal 교체
  - Keycloak accessToken 만료 시 refreshToken으로 자동 갱신 (HTTP 1회, ~수명마다)
  - refreshToken까지 만료 시 Redis 세션 삭제 → 401 (재로그인 유도)
  - Keycloak 서버 설정 기반 세션 수명이 실제로 적용되도록 수정
  - 대부분 요청: 로컬 timestamp 체크만 수행 (HTTP 없음)
- SuperAdminProperties에 keycloakUserId/keystoneUsername/keystonePassword 필드 추가
- SuperAdminInitializer: 기동 시 user_detail + user_auth_detail 함께 생성
- keycloakUserId 미설정 계정 재기동 시 자동 업데이트
- KeycloakUserModule: isLinkedAdmin() 추가, Branch 3 departDto null 방어
- KeycloakAuthModule: isLinkedAdmin() 확인 후 관리자 학적검증 skip
- KeycloakIdTokenClaims: studentId 제거, ajouStudentId(ajou_student_id) 단일화
- KeycloakIdTokenParser: ajou_student_id 클레임 추출 추가, email optional 처리
- AjouUnivModule: univ_depart_info 미매핑 시 ajouMajor를 department fallback으로 사용
void deleteProject(String projectId, String sessionId);

List<ProjectRole> getAssignableRoleTypes(String requesterId);
List<ProjectRole> getAssignableRoleTypes(String sessionId);
void deletePolicy(Long policyId, String sessionId, String projectId);
void activatePolicy(Long policyId, String sessionId, String projectId);
void deactivatePolicy(Long policyId, String sessionId, String projectId);
PageResponse<SnapshotTaskResponse> getPolicyRuns(Long policyId, LocalDate since, PageRequest page, String sessionId, String projectId);
SnapshotPolicyResponse updatePolicy(Long policyId, SnapshotPolicyRequest request, String sessionId, String projectId);
void deletePolicy(Long policyId, String sessionId, String projectId);
void activatePolicy(Long policyId, String sessionId, String projectId);
void deactivatePolicy(Long policyId, String sessionId, String projectId);
SnapshotPolicyResponse createPolicy(SnapshotPolicyRequest request, String sessionId, String projectId);
SnapshotPolicyResponse updatePolicy(Long policyId, SnapshotPolicyRequest request, String sessionId, String projectId);
void deletePolicy(Long policyId, String sessionId, String projectId);
void activatePolicy(Long policyId, String sessionId, String projectId);
SnapshotPolicyResponse getPolicyDetails(Long policyId, String sessionId, String projectId);
SnapshotPolicyResponse createPolicy(SnapshotPolicyRequest request, String sessionId, String projectId);
SnapshotPolicyResponse updatePolicy(Long policyId, SnapshotPolicyRequest request, String sessionId, String projectId);
void deletePolicy(Long policyId, String sessionId, String projectId);
PageResponse<SnapshotPolicyResponse> getPolicies(PageRequest page, String sessionId, String projectId);
SnapshotPolicyResponse getPolicyDetails(Long policyId, String sessionId, String projectId);
SnapshotPolicyResponse createPolicy(SnapshotPolicyRequest request, String sessionId, String projectId);
SnapshotPolicyResponse updatePolicy(Long policyId, SnapshotPolicyRequest request, String sessionId, String projectId);
void deactivatePolicy(Long policyId, String userId, String projectId);
PageResponse<SnapshotTaskResponse> getPolicyRuns(Long policyId, LocalDate since, PageRequest page, String userId, String projectId);
PageResponse<SnapshotPolicyResponse> getPolicies(PageRequest page, String sessionId, String projectId);
SnapshotPolicyResponse getPolicyDetails(Long policyId, String sessionId, String projectId);
void activatePolicy(Long policyId, String userId, String projectId);
void deactivatePolicy(Long policyId, String userId, String projectId);
PageResponse<SnapshotTaskResponse> getPolicyRuns(Long policyId, LocalDate since, PageRequest page, String userId, String projectId);
PageResponse<SnapshotPolicyResponse> getPolicies(PageRequest page, String sessionId, String projectId);
String clientId,
String clientSecret
) implements KeycloakFormRequest {
public MultiValueMap<String, String> toFormData() {
JwtInfo jwtInfo = (JwtInfo) authentication.getPrincipal();
String id = interfaceServicePort.createInterface(jwtInfo.getUserId(), projectId, request);
SessionPrincipal principal = (SessionPrincipal) authentication.getPrincipal();
String id = interfaceServicePort.createInterface(principal.getSessionId(), projectId, request);
@jalju0804
Copy link
Collaborator

코파일럿 리뷰 반영 먼저 진행해주세용!!

Copy link
Collaborator

@callme-waffle callme-waffle left a comment

Choose a reason for hiding this comment

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

진행주신 리팩토링에 대한 플로우검토 및 로직검토를 완료했습니다.

코드 내 삼항연산자 사용과 단일메서드 내 다중책임 등이 확인되어 추후 리팩토링은 필요할 것 같으나, 전반적인 플로우 및 구현에서는 큰 문제는 없는 것 같습니다.

다만 몇가지 확인이 필요한 사항이 있어 확인 후 코멘트 추가 요청드립니다. 확인 후 코멘트 부탁드립니다!

`user_detail.keycloak_user_id = claims.subject()`인 레코드가 있으면 저장된 keystoneUsername과 AES-256 복호화된 keystonePassword를 그대로 반환합니다. 외부 API 호출 없이 DB 조회와 복호화만으로 처리됩니다.

**Branch 2. 이메일 일치 기존 사용자 (Account Linking)**
keycloak_user_id는 없지만 `user_auth_detail.user_email = claims.email()`인 레코드가 있을 때 진입합니다. 기존에 ADMIN 또는 GOOGLE 방식으로 가입한 사용자가 Keycloak으로 최초 로그인하는 경우입니다. 기존 Keystone 패스워드를 알 수 없으므로 시스템 어드민 토큰으로 패스워드를 재설정하고, `user_detail`에 keycloak_user_id, keystoneUsername, keystonePassword(암호화)를 업데이트합니다. 이 시점 이후 Keystone 직접 패스워드 로그인은 불가합니다.
Copy link
Collaborator

Choose a reason for hiding this comment

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

해당 플로우는 기존 테스트하던 ACC프로젝트 내에서의 하위호환성을 위함으로 이해했는데, 혹시 맞을까요?

ADMIN계정 등록의 경우, 관리자의 개인계정과는 무관하게 별도 계정을 생성하여 사용하는 것으로 이해했어 해당 플로우의 필요성이 운영환경에서도 존재하는지 궁금하여 문의드립니다.

private final OAuth2Properties oAuth2Properties;

@Override
public void login(HttpServletResponse response) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

해당 엔드포인트에 대하여, 이미 로그인한 사용자가 해당 엔드포인트로 재접속 시에도 로그인 플로우가 다시 진행되는 것 같은데, 혹시 의도하신 바가 맞으신지 궁금합니다

GITLAB(1, "GitLab 인증"),
ADMIN(2, "관리자 직접 생성");
ADMIN(2, "관리자 직접 생성"),
KEYCLOAK(3, "Keycloak OIDC 인증");
Copy link
Collaborator

Choose a reason for hiding this comment

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

GOOGLE과 GITLAB 인증의 구현이 Keycloak 경유를 기반으로 구현되는거라 별도의 KEYCLOAK 기반 인증임을 구분할 필요는 없다고 생각했는데, 혹시 위 2개의 연동 flow는 다르게 구현되는건가요?

Comment on lines +48 to +49
dept != null ? dept.getCollege() : null,
dept != null ? dept.getDepartment() : claims.ajouMajor(),
Copy link
Collaborator

Choose a reason for hiding this comment

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

삼항연산자 처리보다는 별도 조건분기나 Optional 사용은 어떨지 제안드립니다..!

/**
* Keycloak OIDC 콜백 처리: code → 세션 생성 → sessionId 반환.
*/
public String processCallback(String code) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

후순위라 이번 리뷰에서는 반영되지 않아도 될 것 같습니다만, 추후헤는 메서드 내 담당기능이 많아 목적 별 메서드 분할하여 정리하면 조금 더 가시성이 높아질 것 같습니다 :)

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.

[Refactor] JWT 기반 인증을 Keycloak OIDC + Redis 세션 방식으로 전환

3 participants