From 9d52484fa7c4f93c9fb53facaaffabafcd2d448b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=98=84=EC=A0=9C?= Date: Sun, 14 Jun 2026 16:00:45 +0900 Subject: [PATCH 1/5] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=9D=98=EC=A1=B4=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../acc/local/controller/AuthController.java | 5 +- .../acc/local/controller/docs/AuthDocs.java | 5 +- .../dto/auth/LoginedUserProfileResponse.java | 4 +- .../local/dto/auth/UnivDepartBriefDto.java | 8 +-- .../adapters/auth/AuthServiceAdapter.java | 13 +---- .../local/service/ports/AuthServicePort.java | 2 +- .../adapters/auth/AuthServiceAdapterTest.java | 56 +++++++++++++++++++ 7 files changed, 66 insertions(+), 27 deletions(-) create mode 100644 src/test/java/com/acc/local/service/adapters/auth/AuthServiceAdapterTest.java diff --git a/src/main/java/com/acc/local/controller/AuthController.java b/src/main/java/com/acc/local/controller/AuthController.java index 78dd1745..271749b6 100644 --- a/src/main/java/com/acc/local/controller/AuthController.java +++ b/src/main/java/com/acc/local/controller/AuthController.java @@ -144,11 +144,11 @@ public ResponseEntity signup( } @Override - public ResponseEntity getLoginUserInformation(Authentication authentication, String projectId) { + public ResponseEntity getLoginUserInformation(Authentication authentication) { SessionPrincipal principal = (SessionPrincipal) authentication.getPrincipal(); String userId = principal.getKeystoneUserId(); - LoginedUserProfileResponse loginedUserProfileResponse = authServicePort.getUserLoginedProfile(userId, projectId); + LoginedUserProfileResponse loginedUserProfileResponse = authServicePort.getUserLoginedProfile(userId); return ResponseEntity.ok(loginedUserProfileResponse); } @@ -179,4 +179,3 @@ public ResponseEntity logout(Authentication authentication, Http return ResponseEntity.ok(LogoutResponse.success()); } } - diff --git a/src/main/java/com/acc/local/controller/docs/AuthDocs.java b/src/main/java/com/acc/local/controller/docs/AuthDocs.java index c3d85a6d..5aba0aa4 100644 --- a/src/main/java/com/acc/local/controller/docs/AuthDocs.java +++ b/src/main/java/com/acc/local/controller/docs/AuthDocs.java @@ -99,10 +99,7 @@ ResponseEntity signup( @GetMapping("/profile") ResponseEntity getLoginUserInformation( @Parameter(hidden = true) - Authentication authentication, - @RequestParam(required = false) - @Parameter(description = "프로젝트 ID", required = false) - String projectId + Authentication authentication ); diff --git a/src/main/java/com/acc/local/dto/auth/LoginedUserProfileResponse.java b/src/main/java/com/acc/local/dto/auth/LoginedUserProfileResponse.java index 66a599f7..331362a1 100644 --- a/src/main/java/com/acc/local/dto/auth/LoginedUserProfileResponse.java +++ b/src/main/java/com/acc/local/dto/auth/LoginedUserProfileResponse.java @@ -1,11 +1,9 @@ package com.acc.local.dto.auth; -import com.acc.local.dto.project.ProjectServiceDto; import lombok.Builder; @Builder public record LoginedUserProfileResponse( String userName, - UnivDepartBriefDto univ, - ProjectServiceDto project + UnivDepartBriefDto univ ) {} diff --git a/src/main/java/com/acc/local/dto/auth/UnivDepartBriefDto.java b/src/main/java/com/acc/local/dto/auth/UnivDepartBriefDto.java index c398098c..9b89ef77 100644 --- a/src/main/java/com/acc/local/dto/auth/UnivDepartBriefDto.java +++ b/src/main/java/com/acc/local/dto/auth/UnivDepartBriefDto.java @@ -8,7 +8,7 @@ @Builder public record UnivDepartBriefDto( String grade, - String univDepartment + String department ) { public static UnivDepartBriefDto from(UserDepartDto userDepartDto) { if (userDepartDto.univAccountType() != UnivAccountType.UNDERGRADUATE) { @@ -17,7 +17,7 @@ public static UnivDepartBriefDto from(UserDepartDto userDepartDto) { return UnivDepartBriefDto.builder() .grade(userDepartDto.grade() + "학년") - .univDepartment(userDepartDto.department()) + .department(userDepartDto.department()) .build(); } @@ -25,14 +25,14 @@ public static UnivDepartBriefDto from(AdminGetUserResponse adminGetUserResponse) return UnivDepartBriefDto.builder() // .grade(adminGetUserResponse.) .grade(1 + "학년") // TODO: 회원가입 시 나이 저장되도록 수정 필요 - .univDepartment(adminGetUserResponse.department()) + .department(adminGetUserResponse.department()) .build(); } public static UnivDepartBriefDto from(User user) { return UnivDepartBriefDto.builder() .grade(1 + "학년") // TODO: 회원가입 시 나이 저장되도록 수정 필요 - .univDepartment(user.getDepartment()) + .department(user.getDepartment()) .build(); } } diff --git a/src/main/java/com/acc/local/service/adapters/auth/AuthServiceAdapter.java b/src/main/java/com/acc/local/service/adapters/auth/AuthServiceAdapter.java index 5b12a7e3..e24478f4 100644 --- a/src/main/java/com/acc/local/service/adapters/auth/AuthServiceAdapter.java +++ b/src/main/java/com/acc/local/service/adapters/auth/AuthServiceAdapter.java @@ -6,10 +6,8 @@ import com.acc.local.dto.auth.UserKeystoneDto; import com.acc.local.domain.model.auth.UserToken; import com.acc.local.dto.auth.*; -import com.acc.local.dto.project.ProjectServiceDto; import com.acc.local.dto.project.UserPermissionResponse; import com.acc.local.service.modules.auth.AuthModule; -import com.acc.local.service.modules.auth.ProjectModule; import com.acc.local.service.modules.auth.UserModule; import com.acc.local.service.ports.AuthServicePort; import lombok.RequiredArgsConstructor; @@ -25,7 +23,6 @@ public class AuthServiceAdapter implements AuthServicePort { private final AuthModule authModule; private final UserModule userModule; - private final ProjectModule projectModule; // keycloak 로그인 이후 redirect URL 엔드포인트에서 사용될 메서드 @Override @@ -119,7 +116,7 @@ public SignupResponse signup(SignupRequest request, String verificationToken) { } @Override - public LoginedUserProfileResponse getUserLoginedProfile(String userId, String projectId) { + public LoginedUserProfileResponse getUserLoginedProfile(String userId) { String adminToken = authModule.issueSystemAdminToken("ROOT_getUserLoginedProfile"); try { @@ -127,17 +124,9 @@ public LoginedUserProfileResponse getUserLoginedProfile(String userId, String pr //TODO: 추후 정합성 맞추는 Flow 필요시 진행 User user = userModule.getUserById(userId, adminToken); - // projectId가 존재하면 프로젝트 정보 조회 - ProjectServiceDto projectServiceDto = null; - if (projectId != null && !projectId.isBlank()) { - String scopedToken = authModule.issueProjectScopeToken(projectId, userId); - projectServiceDto = projectModule.getProjectDetail(projectId, scopedToken); - } - return LoginedUserProfileResponse.builder() .userName(user.getUsername()) .univ(UnivDepartBriefDto.from(user)) - .project(projectServiceDto) .build(); } finally { authModule.invalidateSystemAdminToken(adminToken); diff --git a/src/main/java/com/acc/local/service/ports/AuthServicePort.java b/src/main/java/com/acc/local/service/ports/AuthServicePort.java index e2b5b497..d2849596 100644 --- a/src/main/java/com/acc/local/service/ports/AuthServicePort.java +++ b/src/main/java/com/acc/local/service/ports/AuthServicePort.java @@ -17,7 +17,7 @@ public interface AuthServicePort { LoginTokens refreshToken(String refreshToken); SignupResponse signup(SignupRequest request, String verificationToken); - LoginedUserProfileResponse getUserLoginedProfile(String userId, String projectId); + LoginedUserProfileResponse getUserLoginedProfile(String userId); void logout(String userId); } diff --git a/src/test/java/com/acc/local/service/adapters/auth/AuthServiceAdapterTest.java b/src/test/java/com/acc/local/service/adapters/auth/AuthServiceAdapterTest.java new file mode 100644 index 00000000..3e2b937e --- /dev/null +++ b/src/test/java/com/acc/local/service/adapters/auth/AuthServiceAdapterTest.java @@ -0,0 +1,56 @@ +package com.acc.local.service.adapters.auth; + +import com.acc.local.domain.model.auth.User; +import com.acc.local.dto.auth.LoginedUserProfileResponse; +import com.acc.local.service.modules.auth.AuthModule; +import com.acc.local.service.modules.auth.UserModule; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuthServiceAdapterTest { + + @Mock + private AuthModule authModule; + + @Mock + private UserModule userModule; + + @InjectMocks + private AuthServiceAdapter authServiceAdapter; + + @Test + void getUserLoginedProfileReturnsProfileWithoutProject() throws Exception { + String userId = "user-id"; + String adminToken = "admin-token"; + User user = User.builder() + .userId(userId) + .username("현제 이") + .department("소프트웨어및컴퓨터공학전공") + .build(); + + when(authModule.issueSystemAdminToken("ROOT_getUserLoginedProfile")).thenReturn(adminToken); + when(userModule.getUserById(userId, adminToken)).thenReturn(user); + + LoginedUserProfileResponse response = authServiceAdapter.getUserLoginedProfile(userId); + String json = new ObjectMapper().writeValueAsString(response); + + assertEquals("현제 이", response.userName()); + assertEquals("소프트웨어및컴퓨터공학전공", response.univ().department()); + assertFalse(json.contains("project")); + assertFalse(json.contains("univDepartment")); + verify(authModule, never()).issueProjectScopeToken(anyString(), anyString()); + verify(authModule).invalidateSystemAdminToken(adminToken); + } +} From b1371c126c94b97f1b014064d2afb2628af58a71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=98=84=EC=A0=9C?= Date: Sun, 14 Jun 2026 16:02:11 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20=20Keycloak=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EC=9D=B4=EB=A6=84=20=EA=B8=B0=EB=B0=98=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=9D=B4=EB=A6=84=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/keycloak-callback-flow.md | 3 +- .../keycloak/KeycloakIdTokenParser.java | 11 ++- .../local/dto/auth/KeycloakIdTokenClaims.java | 23 ++++- .../modules/keycloak/KeycloakUserModule.java | 19 ++-- .../keycloak/KeycloakIdTokenParserTest.java | 78 +++++++++++++++ .../keycloak/KeycloakUserModuleTest.java | 94 +++++++++++++++++++ 6 files changed, 216 insertions(+), 12 deletions(-) create mode 100644 src/test/java/com/acc/global/security/keycloak/KeycloakIdTokenParserTest.java create mode 100644 src/test/java/com/acc/local/service/modules/keycloak/KeycloakUserModuleTest.java diff --git a/docs/keycloak-callback-flow.md b/docs/keycloak-callback-flow.md index 019ad58f..059ba691 100644 --- a/docs/keycloak-callback-flow.md +++ b/docs/keycloak-callback-flow.md @@ -42,7 +42,8 @@ keycloak_user_id도, 이메일 일치도 없는 완전히 새로운 사용자입 - `sub` → keycloakUserId. Branch 1~3 분기 기준 및 세션 저장에 사용됩니다. - `email` → 계정 연결(Branch 2) 조회 기준입니다. 없으면 빈 문자열로 처리합니다. -- `preferred_username` → 신규 가입 시 userName 초기값입니다. +- `name` / `given_name` / `family_name` → ACC 표시 이름(userName) 초기값입니다. `name`이 없으면 `family_name + given_name`을 사용합니다. 표준 이름 클레임이 없으면 `ajou_lastName + ajou_firstName`을 fallback으로 사용합니다. +- `preferred_username` → 표시 이름 클레임이 없을 때의 fallback 값입니다. - `ajou_major` → 학적 검증 및 department 저장에 사용됩니다. `univ_depart_info` 테이블에 매핑이 없으면 ajou_major 값 자체를 department로 저장합니다(신설학과 대응). - `ajou_status` → 재학 상태 코드입니다. `SS0001(학생(학부))` 형태로 오며 괄호 이하는 파싱 시 제거됩니다. UNDERGRADUATE 여부 검증에 사용됩니다. - `ajou_grade` → 학년입니다. 정수 파싱에 실패하면 -1로 저장됩니다. diff --git a/src/main/java/com/acc/global/security/keycloak/KeycloakIdTokenParser.java b/src/main/java/com/acc/global/security/keycloak/KeycloakIdTokenParser.java index a11c6a78..a393b63b 100644 --- a/src/main/java/com/acc/global/security/keycloak/KeycloakIdTokenParser.java +++ b/src/main/java/com/acc/global/security/keycloak/KeycloakIdTokenParser.java @@ -61,6 +61,11 @@ public KeycloakIdTokenClaims extractClaims(String idToken) { String email = getRequiredKeycloakClaim(payload, "email"); String preferredUsername = getOptionalClaim(payload, "preferred_username", email.isBlank() ? "" : email.split("@")[0]); + String name = getOptionalClaim(payload, "name", null); + String givenName = getOptionalClaim(payload, "given_name", + getOptionalClaim(payload, "ajou_firstName", null)); + String familyName = getOptionalClaim(payload, "family_name", + getOptionalClaim(payload, "ajou_lastName", null)); String ajouMajor = getRequiredKeycloakClaim(payload, "ajou_major"); String ajouStatus = getRequiredKeycloakClaim(payload, "ajou_status"); String ajouGrade = getRequiredKeycloakClaim(payload, "ajou_grade"); @@ -69,11 +74,11 @@ public KeycloakIdTokenClaims extractClaims(String idToken) { String authIdpType = getRequiredKeycloakClaim(payload, "auth_idp_type"); List groups = getOptionalArrayClaim(payload, "groups"); - log.info("Keycloak ID Token claims - sub={}, email={}, ajouMajor='{}', ajouStatus='{}', ajouGrade='{}', ajouStudentId='{}', phoneNumber='{}', authIdpType='{}', groups={}", - sub, email, ajouMajor, ajouStatus, ajouGrade, ajouStudentId, phoneNumber, authIdpType, groups); + log.info("Keycloak ID Token claims - sub={}, email={}, name='{}', ajouMajor='{}', ajouStatus='{}', ajouGrade='{}', ajouStudentId='{}', phoneNumber='{}', authIdpType='{}', groups={}", + sub, email, name, ajouMajor, ajouStatus, ajouGrade, ajouStudentId, phoneNumber, authIdpType, groups); return new KeycloakIdTokenClaims(sub, email, preferredUsername, - ajouMajor, ajouStatus, ajouGrade, ajouStudentId, phoneNumber, authIdpType, groups); + name, givenName, familyName, ajouMajor, ajouStatus, ajouGrade, ajouStudentId, phoneNumber, authIdpType, groups); } catch (KeycloakException e) { throw e; } catch (Exception e) { diff --git a/src/main/java/com/acc/local/dto/auth/KeycloakIdTokenClaims.java b/src/main/java/com/acc/local/dto/auth/KeycloakIdTokenClaims.java index 2c592afd..6e50b144 100644 --- a/src/main/java/com/acc/local/dto/auth/KeycloakIdTokenClaims.java +++ b/src/main/java/com/acc/local/dto/auth/KeycloakIdTokenClaims.java @@ -14,11 +14,16 @@ * - auth_idp_type : User Attribute → Token Claim Name "auth_idp_type" (String, google/gitlab) * - groups : Group Membership → Token Claim Name "groups", Full group path ON * - email, preferred_username은 표준 클레임으로 openid scope에 기본 포함 + * - name, given_name, family_name은 표준 profile scope에 포함 + * - ajou_firstName, ajou_lastName은 표준 이름 클레임이 없을 때 표시 이름 fallback으로 사용 */ public record KeycloakIdTokenClaims( String subject, // sub → keycloakUserId (Keycloak 내부 UUID) String email, // email → 계정 연결/조회 기준 String preferredUsername, // preferred_username → 신규 가입 시 userName 초기값 + String name, // name → Keycloak 표시 이름 + String givenName, // given_name → 이름 + String familyName, // family_name → 성 String ajouMajor, // ajou_major → "소프트웨어및컴퓨터공학전공" String ajouStatus, // ajou_status → "SS0001(학생(학부))" String ajouGrade, // ajou_grade → "4" @@ -26,4 +31,20 @@ public record KeycloakIdTokenClaims( String phoneNumber, // user_phone_number → 핸드폰 번호 (Keycloak SPI 연동 시 채워짐) String authIdpType, // auth_idp_type → IDP 종류 (google/gitlab) List groups // groups → Keycloak 그룹 전체 경로 목록 (e.g. ["/Ajou_Univ/Aolda_Admin"]) -) {} +) { + public String displayName() { + if (name != null && !name.isBlank()) { + return name; + } + if (familyName != null && !familyName.isBlank() && givenName != null && !givenName.isBlank()) { + return familyName + givenName; + } + if (givenName != null && !givenName.isBlank()) { + return givenName; + } + if (familyName != null && !familyName.isBlank()) { + return familyName; + } + return preferredUsername; + } +} diff --git a/src/main/java/com/acc/local/service/modules/keycloak/KeycloakUserModule.java b/src/main/java/com/acc/local/service/modules/keycloak/KeycloakUserModule.java index d6c8b1e1..a0f91067 100644 --- a/src/main/java/com/acc/local/service/modules/keycloak/KeycloakUserModule.java +++ b/src/main/java/com/acc/local/service/modules/keycloak/KeycloakUserModule.java @@ -63,13 +63,18 @@ public KeycloakUserResult findOrRegisterKeycloakUser(KeycloakIdTokenClaims claim if (linkedUser.isPresent()) { UserDbExtraEntity entity = linkedUser.get(); + String displayName = claims.displayName(); - // 그룹 변경으로 admin 상태가 달라졌으면 DB 동기화 - if (isAdminByGroup != Boolean.TRUE.equals(entity.getIsAdmin())) { - log.info("Keycloak 그룹 변경 감지 - admin 상태 동기화: keystoneUserId={}, isAdmin={}→{}", - entity.getUserId(), entity.getIsAdmin(), isAdminByGroup); + // Keycloak의 권한/프로필 변경사항을 ACC DB에 동기화 + if (isAdminByGroup != Boolean.TRUE.equals(entity.getIsAdmin()) + || !displayName.equals(entity.getUserName())) { + log.info("Keycloak 사용자 정보 변경 감지 - DB 동기화: keystoneUserId={}, isAdmin={}→{}, userName={}→{}", + entity.getUserId(), entity.getIsAdmin(), isAdminByGroup, entity.getUserName(), displayName); entity = userRepositoryPort.saveUserDetail( - entity.toBuilder().isAdmin(isAdminByGroup).build() + entity.toBuilder() + .isAdmin(isAdminByGroup) + .userName(displayName) + .build() ); } @@ -110,7 +115,7 @@ private KeycloakUserResult registerNewKeycloakUser(KeycloakIdTokenClaims claims, // user_detail 저장 (Keycloak 그룹 기반 admin 여부 반영) UserDbExtraEntity userDbExtraEntity = UserDbExtraEntity.builder() .userId(keystoneUserId) - .userName(claims.preferredUsername()) + .userName(claims.displayName()) .userPhoneNumber(claims.phoneNumber()) .isAdmin(isAdminByGroup) .keycloakUserId(claims.subject()) @@ -131,7 +136,7 @@ private KeycloakUserResult registerNewKeycloakUser(KeycloakIdTokenClaims claims, log.info("신규 Keycloak 사용자 등록 완료 - keystoneUserId={}, keycloakUserId={}", keystoneUserId, claims.subject()); - return new KeycloakUserResult(keystoneUserId, keystoneUsername, newPassword, claims.preferredUsername()); + return new KeycloakUserResult(keystoneUserId, keystoneUsername, newPassword, claims.displayName()); } /** diff --git a/src/test/java/com/acc/global/security/keycloak/KeycloakIdTokenParserTest.java b/src/test/java/com/acc/global/security/keycloak/KeycloakIdTokenParserTest.java new file mode 100644 index 00000000..d1d97b6f --- /dev/null +++ b/src/test/java/com/acc/global/security/keycloak/KeycloakIdTokenParserTest.java @@ -0,0 +1,78 @@ +package com.acc.global.security.keycloak; + +import com.acc.local.dto.auth.KeycloakIdTokenClaims; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class KeycloakIdTokenParserTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final KeycloakIdTokenParser parser = new KeycloakIdTokenParser(objectMapper); + + @Test + void displayNameUsesKeycloakFullName() throws Exception { + Map payload = basePayload(); + payload.put("name", "현제 이"); + String idToken = token(payload); + + KeycloakIdTokenClaims claims = parser.extractClaims(idToken); + + assertEquals("현제 이", claims.displayName()); + } + + @Test + void displayNameFallsBackToFamilyAndGivenName() throws Exception { + String idToken = token(basePayload()); + + KeycloakIdTokenClaims claims = parser.extractClaims(idToken); + + assertEquals("이현제", claims.displayName()); + } + + @Test + void displayNameFallsBackToAjouNameAttributes() throws Exception { + Map payload = basePayload(); + payload.remove("given_name"); + payload.remove("family_name"); + payload.put("ajou_firstName", "현제"); + payload.put("ajou_lastName", "이"); + String idToken = token(payload); + + KeycloakIdTokenClaims claims = parser.extractClaims(idToken); + + assertEquals("이현제", claims.displayName()); + } + + private String token(Map payload) throws Exception { + String header = encode(Map.of("alg", "none")); + return header + "." + encode(payload) + "."; + } + + private Map basePayload() { + Map payload = new HashMap<>(); + payload.put("sub", "keycloak-user-id"); + payload.put("email", "hyeonje@example.com"); + payload.put("preferred_username", "hyeonje"); + payload.put("given_name", "현제"); + payload.put("family_name", "이"); + payload.put("ajou_major", "소프트웨어및컴퓨터공학전공"); + payload.put("ajou_status", "SS0001(학생(학부))"); + payload.put("ajou_grade", "1"); + payload.put("ajou_student_id", "202012345"); + payload.put("user_phone_number", "010-0000-0000"); + payload.put("auth_idp_type", "google"); + return payload; + } + + private String encode(Object value) throws Exception { + byte[] json = objectMapper.writeValueAsString(value).getBytes(StandardCharsets.UTF_8); + return Base64.getUrlEncoder().withoutPadding().encodeToString(json); + } +} diff --git a/src/test/java/com/acc/local/service/modules/keycloak/KeycloakUserModuleTest.java b/src/test/java/com/acc/local/service/modules/keycloak/KeycloakUserModuleTest.java new file mode 100644 index 00000000..c0b34be0 --- /dev/null +++ b/src/test/java/com/acc/local/service/modules/keycloak/KeycloakUserModuleTest.java @@ -0,0 +1,94 @@ +package com.acc.local.service.modules.keycloak; + +import com.acc.global.security.crypto.KeystonePasswordEncryptor; +import com.acc.local.dto.auth.KeycloakIdTokenClaims; +import com.acc.local.dto.auth.KeycloakUserResult; +import com.acc.local.dto.auth.UserDepartDto; +import com.acc.local.entity.UserDbExtraEntity; +import com.acc.local.external.ports.KeystoneAPIExternalPort; +import com.acc.local.repository.ports.UserRepositoryPort; +import com.acc.local.service.modules.auth.AuthModule; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class KeycloakUserModuleTest { + + @Mock + private UserRepositoryPort userRepositoryPort; + + @Mock + private KeystoneAPIExternalPort keystoneAPIExternalPort; + + @Mock + private KeystonePasswordEncryptor keystonePasswordEncryptor; + + @Mock + private AuthModule authModule; + + @InjectMocks + private KeycloakUserModule keycloakUserModule; + + @Test + void linkedUserNameIsSyncedFromKeycloakDisplayName() { + UserDbExtraEntity existingUser = UserDbExtraEntity.builder() + .userId("keystone-user-id") + .userName("hando1220@ajou.ac.kr") + .userPhoneNumber("010-0000-0000") + .isAdmin(false) + .keycloakUserId("keycloak-user-id") + .keystoneUsername("hando1220") + .keystonePassword("encrypted-password") + .build(); + KeycloakIdTokenClaims claims = new KeycloakIdTokenClaims( + "keycloak-user-id", + "hyeonje@example.com", + "hyeonje", + "현제 이", + "현제", + "이", + "소프트웨어및컴퓨터공학전공", + "SS0001(학생(학부))", + "1", + "202012345", + "010-0000-0000", + "google", + List.of() + ); + + when(userRepositoryPort.findUserDetailByKeycloakUserId("keycloak-user-id")) + .thenReturn(Optional.of(existingUser)); + when(userRepositoryPort.saveUserDetail(org.mockito.ArgumentMatchers.any())) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(keystonePasswordEncryptor.decrypt("encrypted-password")).thenReturn("plain-password"); + + KeycloakUserResult result = keycloakUserModule.findOrRegisterKeycloakUser(claims, departDto(), false); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserDbExtraEntity.class); + verify(userRepositoryPort).saveUserDetail(captor.capture()); + assertEquals("현제 이", captor.getValue().getUserName()); + assertEquals("현제 이", result.userName()); + } + + private UserDepartDto departDto() { + return new UserDepartDto( + "소프트웨어및컴퓨터공학전공", + "정보통신대학", + "소프트웨어및컴퓨터공학전공", + 1, + null, + null + ); + } +} From 79556095605c3aa5cc3d074ca457a5d595da5555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=98=84=EC=A0=9C?= Date: Sun, 14 Jun 2026 16:10:29 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[Refactor]=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=94=84=EB=A1=9C=ED=95=84=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/auth/AuthServiceAdapterTest.java | 56 ----------- .../keycloak/KeycloakUserModuleTest.java | 94 ------------------- 2 files changed, 150 deletions(-) delete mode 100644 src/test/java/com/acc/local/service/adapters/auth/AuthServiceAdapterTest.java delete mode 100644 src/test/java/com/acc/local/service/modules/keycloak/KeycloakUserModuleTest.java diff --git a/src/test/java/com/acc/local/service/adapters/auth/AuthServiceAdapterTest.java b/src/test/java/com/acc/local/service/adapters/auth/AuthServiceAdapterTest.java deleted file mode 100644 index 3e2b937e..00000000 --- a/src/test/java/com/acc/local/service/adapters/auth/AuthServiceAdapterTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.acc.local.service.adapters.auth; - -import com.acc.local.domain.model.auth.User; -import com.acc.local.dto.auth.LoginedUserProfileResponse; -import com.acc.local.service.modules.auth.AuthModule; -import com.acc.local.service.modules.auth.UserModule; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class AuthServiceAdapterTest { - - @Mock - private AuthModule authModule; - - @Mock - private UserModule userModule; - - @InjectMocks - private AuthServiceAdapter authServiceAdapter; - - @Test - void getUserLoginedProfileReturnsProfileWithoutProject() throws Exception { - String userId = "user-id"; - String adminToken = "admin-token"; - User user = User.builder() - .userId(userId) - .username("현제 이") - .department("소프트웨어및컴퓨터공학전공") - .build(); - - when(authModule.issueSystemAdminToken("ROOT_getUserLoginedProfile")).thenReturn(adminToken); - when(userModule.getUserById(userId, adminToken)).thenReturn(user); - - LoginedUserProfileResponse response = authServiceAdapter.getUserLoginedProfile(userId); - String json = new ObjectMapper().writeValueAsString(response); - - assertEquals("현제 이", response.userName()); - assertEquals("소프트웨어및컴퓨터공학전공", response.univ().department()); - assertFalse(json.contains("project")); - assertFalse(json.contains("univDepartment")); - verify(authModule, never()).issueProjectScopeToken(anyString(), anyString()); - verify(authModule).invalidateSystemAdminToken(adminToken); - } -} diff --git a/src/test/java/com/acc/local/service/modules/keycloak/KeycloakUserModuleTest.java b/src/test/java/com/acc/local/service/modules/keycloak/KeycloakUserModuleTest.java deleted file mode 100644 index c0b34be0..00000000 --- a/src/test/java/com/acc/local/service/modules/keycloak/KeycloakUserModuleTest.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.acc.local.service.modules.keycloak; - -import com.acc.global.security.crypto.KeystonePasswordEncryptor; -import com.acc.local.dto.auth.KeycloakIdTokenClaims; -import com.acc.local.dto.auth.KeycloakUserResult; -import com.acc.local.dto.auth.UserDepartDto; -import com.acc.local.entity.UserDbExtraEntity; -import com.acc.local.external.ports.KeystoneAPIExternalPort; -import com.acc.local.repository.ports.UserRepositoryPort; -import com.acc.local.service.modules.auth.AuthModule; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.List; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class KeycloakUserModuleTest { - - @Mock - private UserRepositoryPort userRepositoryPort; - - @Mock - private KeystoneAPIExternalPort keystoneAPIExternalPort; - - @Mock - private KeystonePasswordEncryptor keystonePasswordEncryptor; - - @Mock - private AuthModule authModule; - - @InjectMocks - private KeycloakUserModule keycloakUserModule; - - @Test - void linkedUserNameIsSyncedFromKeycloakDisplayName() { - UserDbExtraEntity existingUser = UserDbExtraEntity.builder() - .userId("keystone-user-id") - .userName("hando1220@ajou.ac.kr") - .userPhoneNumber("010-0000-0000") - .isAdmin(false) - .keycloakUserId("keycloak-user-id") - .keystoneUsername("hando1220") - .keystonePassword("encrypted-password") - .build(); - KeycloakIdTokenClaims claims = new KeycloakIdTokenClaims( - "keycloak-user-id", - "hyeonje@example.com", - "hyeonje", - "현제 이", - "현제", - "이", - "소프트웨어및컴퓨터공학전공", - "SS0001(학생(학부))", - "1", - "202012345", - "010-0000-0000", - "google", - List.of() - ); - - when(userRepositoryPort.findUserDetailByKeycloakUserId("keycloak-user-id")) - .thenReturn(Optional.of(existingUser)); - when(userRepositoryPort.saveUserDetail(org.mockito.ArgumentMatchers.any())) - .thenAnswer(invocation -> invocation.getArgument(0)); - when(keystonePasswordEncryptor.decrypt("encrypted-password")).thenReturn("plain-password"); - - KeycloakUserResult result = keycloakUserModule.findOrRegisterKeycloakUser(claims, departDto(), false); - - ArgumentCaptor captor = ArgumentCaptor.forClass(UserDbExtraEntity.class); - verify(userRepositoryPort).saveUserDetail(captor.capture()); - assertEquals("현제 이", captor.getValue().getUserName()); - assertEquals("현제 이", result.userName()); - } - - private UserDepartDto departDto() { - return new UserDepartDto( - "소프트웨어및컴퓨터공학전공", - "정보통신대학", - "소프트웨어및컴퓨터공학전공", - 1, - null, - null - ); - } -} From 3217da96a9c18ccb1b152fdf1b64d91737590c91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=98=84=EC=A0=9C?= Date: Sun, 14 Jun 2026 18:57:58 +0900 Subject: [PATCH 4/5] =?UTF-8?q?Refactor:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EC=9D=B4=EB=A6=84=20=EC=A1=B0=ED=95=A9=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../local/dto/auth/KeycloakIdTokenClaims.java | 23 ++++++++++--------- .../keycloak/KeycloakIdTokenParserTest.java | 4 ++-- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/acc/local/dto/auth/KeycloakIdTokenClaims.java b/src/main/java/com/acc/local/dto/auth/KeycloakIdTokenClaims.java index 6e50b144..13e6a973 100644 --- a/src/main/java/com/acc/local/dto/auth/KeycloakIdTokenClaims.java +++ b/src/main/java/com/acc/local/dto/auth/KeycloakIdTokenClaims.java @@ -14,8 +14,9 @@ * - auth_idp_type : User Attribute → Token Claim Name "auth_idp_type" (String, google/gitlab) * - groups : Group Membership → Token Claim Name "groups", Full group path ON * - email, preferred_username은 표준 클레임으로 openid scope에 기본 포함 - * - name, given_name, family_name은 표준 profile scope에 포함 - * - ajou_firstName, ajou_lastName은 표준 이름 클레임이 없을 때 표시 이름 fallback으로 사용 + * - given_name, family_name은 표준 profile scope에 포함 + * - ajou_firstName, ajou_lastName은 표준 이름 클레임이 없을 때 표시 이름 fallback으로 사용 + * - name은 Keycloak full name mapper가 생성한 값으로, 성/이름 조합이 없을 때만 fallback으로 사용 */ public record KeycloakIdTokenClaims( String subject, // sub → keycloakUserId (Keycloak 내부 UUID) @@ -33,18 +34,18 @@ public record KeycloakIdTokenClaims( List groups // groups → Keycloak 그룹 전체 경로 목록 (e.g. ["/Ajou_Univ/Aolda_Admin"]) ) { public String displayName() { - if (name != null && !name.isBlank()) { - return name; - } if (familyName != null && !familyName.isBlank() && givenName != null && !givenName.isBlank()) { return familyName + givenName; } if (givenName != null && !givenName.isBlank()) { return givenName; } - if (familyName != null && !familyName.isBlank()) { - return familyName; - } - return preferredUsername; - } -} + if (familyName != null && !familyName.isBlank()) { + return familyName; + } + if (name != null && !name.isBlank()) { + return name; + } + return preferredUsername; + } +} diff --git a/src/test/java/com/acc/global/security/keycloak/KeycloakIdTokenParserTest.java b/src/test/java/com/acc/global/security/keycloak/KeycloakIdTokenParserTest.java index d1d97b6f..564bba05 100644 --- a/src/test/java/com/acc/global/security/keycloak/KeycloakIdTokenParserTest.java +++ b/src/test/java/com/acc/global/security/keycloak/KeycloakIdTokenParserTest.java @@ -17,14 +17,14 @@ class KeycloakIdTokenParserTest { private final KeycloakIdTokenParser parser = new KeycloakIdTokenParser(objectMapper); @Test - void displayNameUsesKeycloakFullName() throws Exception { + void displayNameUsesFamilyAndGivenNameBeforeKeycloakFullName() throws Exception { Map payload = basePayload(); payload.put("name", "현제 이"); String idToken = token(payload); KeycloakIdTokenClaims claims = parser.extractClaims(idToken); - assertEquals("현제 이", claims.displayName()); + assertEquals("이현제", claims.displayName()); } @Test From ef7e5db5bf14e186095b12d94d47063ceca37f95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=98=84=EC=A0=9C?= Date: Mon, 15 Jun 2026 22:03:51 +0900 Subject: [PATCH 5/5] =?UTF-8?q?Refactor:=20main=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EC=B9=98=20=EB=B3=80=EA=B2=BD=EC=82=AC=ED=95=AD=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=EB=B0=8F=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/auth/AuthServiceAdapterTest.java | 25 +++---------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/src/test/java/com/acc/local/service/adapters/auth/AuthServiceAdapterTest.java b/src/test/java/com/acc/local/service/adapters/auth/AuthServiceAdapterTest.java index fa1c5e8f..33af2ed2 100644 --- a/src/test/java/com/acc/local/service/adapters/auth/AuthServiceAdapterTest.java +++ b/src/test/java/com/acc/local/service/adapters/auth/AuthServiceAdapterTest.java @@ -1,10 +1,7 @@ package com.acc.local.service.adapters.auth; import com.acc.local.domain.model.auth.User; -import com.acc.local.dto.project.ProjectServiceDto; import com.acc.local.service.modules.auth.AuthModule; -import com.acc.local.service.modules.auth.KeystoneTokenModule; -import com.acc.local.service.modules.auth.ProjectModule; import com.acc.local.service.modules.auth.UserModule; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -25,37 +22,21 @@ class AuthServiceAdapterTest { @Mock private UserModule userModule; - @Mock - private ProjectModule projectModule; - - @Mock - private KeystoneTokenModule keystoneTokenModule; - @InjectMocks private AuthServiceAdapter authServiceAdapter; @Test - @DisplayName("레거시 프로필 조회에서 발급한 프로젝트 스코프 토큰은 사용 후 즉시 폐기한다.") - void givenProjectId_whenGetUserLoginedProfile_thenRevokeScopedToken() { - // given + @DisplayName("프로필 조회는 사용자 기본 정보만 반환한다.") + void getUserLoginedProfileReturnsUserProfile() { String userId = "user-id"; - String projectId = "project-id"; given(authModule.issueSystemAdminToken("ROOT_getUserLoginedProfile")).willReturn("admin-token"); given(userModule.getUserById(userId, "admin-token")).willReturn(User.builder() .userId(userId) .username("user") .department("software") .build()); - given(authModule.issueProjectScopeToken(projectId, userId)).willReturn("scoped-token"); - given(projectModule.getProjectDetail(projectId, "scoped-token")).willReturn(ProjectServiceDto.builder() - .projectId(projectId) - .build()); - - // when - authServiceAdapter.getUserLoginedProfile(userId, projectId); + authServiceAdapter.getUserLoginedProfile(userId); - // then - then(keystoneTokenModule).should().revokeTokenQuietly("scoped-token"); then(authModule).should().invalidateSystemAdminToken("admin-token"); } }