Conversation
- 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); |
|
코파일럿 리뷰 반영 먼저 진행해주세용!! |
callme-waffle
left a comment
There was a problem hiding this comment.
진행주신 리팩토링에 대한 플로우검토 및 로직검토를 완료했습니다.
코드 내 삼항연산자 사용과 단일메서드 내 다중책임 등이 확인되어 추후 리팩토링은 필요할 것 같으나, 전반적인 플로우 및 구현에서는 큰 문제는 없는 것 같습니다.
다만 몇가지 확인이 필요한 사항이 있어 확인 후 코멘트 추가 요청드립니다. 확인 후 코멘트 부탁드립니다!
| `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 직접 패스워드 로그인은 불가합니다. |
There was a problem hiding this comment.
해당 플로우는 기존 테스트하던 ACC프로젝트 내에서의 하위호환성을 위함으로 이해했는데, 혹시 맞을까요?
ADMIN계정 등록의 경우, 관리자의 개인계정과는 무관하게 별도 계정을 생성하여 사용하는 것으로 이해했어 해당 플로우의 필요성이 운영환경에서도 존재하는지 궁금하여 문의드립니다.
| private final OAuth2Properties oAuth2Properties; | ||
|
|
||
| @Override | ||
| public void login(HttpServletResponse response) { |
There was a problem hiding this comment.
해당 엔드포인트에 대하여, 이미 로그인한 사용자가 해당 엔드포인트로 재접속 시에도 로그인 플로우가 다시 진행되는 것 같은데, 혹시 의도하신 바가 맞으신지 궁금합니다
| GITLAB(1, "GitLab 인증"), | ||
| ADMIN(2, "관리자 직접 생성"); | ||
| ADMIN(2, "관리자 직접 생성"), | ||
| KEYCLOAK(3, "Keycloak OIDC 인증"); |
There was a problem hiding this comment.
GOOGLE과 GITLAB 인증의 구현이 Keycloak 경유를 기반으로 구현되는거라 별도의 KEYCLOAK 기반 인증임을 구분할 필요는 없다고 생각했는데, 혹시 위 2개의 연동 flow는 다르게 구현되는건가요?
| dept != null ? dept.getCollege() : null, | ||
| dept != null ? dept.getDepartment() : claims.ajouMajor(), |
There was a problem hiding this comment.
삼항연산자 처리보다는 별도 조건분기나 Optional 사용은 어떨지 제안드립니다..!
| /** | ||
| * Keycloak OIDC 콜백 처리: code → 세션 생성 → sessionId 반환. | ||
| */ | ||
| public String processCallback(String code) { |
There was a problem hiding this comment.
후순위라 이번 리뷰에서는 반영되지 않아도 될 것 같습니다만, 추후헤는 메서드 내 담당기능이 많아 목적 별 메서드 분할하여 정리하면 조금 더 가시성이 높아질 것 같습니다 :)
📌 변경 요약 (Summary)
acc-session-id세션 쿠키만으로 모든 API를 호출할 수 있게 됩니다.String token파라미터를SessionModule로부터 전달 받게 됩니다.🔗 관련 이슈 (Related Issue)
Closes #18
🛠 작업 내용 / 작업 순서 (Implementation Details)
예시:
UserEntity에keycloak_user_id컬럼 추가 및 Keystone 비밀번호 AES-256 암호화 적용KeycloakAuthController,KeycloakAuthServicePort)SessionAuthenticationFilter,SessionModule)SessionAuthenticationFilter에서 토큰 유효성 검사 후 자동 갱신JwtAuthenticationFilter·JwtInfo제거 및SecurityConfig정리✨ 주요 변경 사항 (Key Changes)
인증 방식 전환
JwtAuthenticationFilter,JwtInfo,/auth/login/refresh엔드포인트GET /api/v1/auth/keycloak/login), 로그아웃 (DELETE /api/v1/auth/keycloak/logout)acc-session-id(HttpOnly / Secure / SameSite=None) 발급Redis 세션 저장소
{ keystoneUnscopedToken, keystoneScopedTokens, keycloakAccessToken, keycloakRefreshToken }구조로 Redis 저장전 도메인 서비스 시그니처 변경
someMethod(String token, ...)→ 변경:someMethod(String sessionId, ...)SessionModule.getKeystoneUserId(sessionId)/getKeystoneScopedToken(sessionId, projectId)등으로 토큰 조회 일원화보안 강화
KEYSTONE_PASSWORD_ENCRYPTION_KEY)UserEntity에keycloak_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)
테스트 환경
테스트 방법
테스트 결과