Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/keycloak-callback-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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로 저장됩니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -69,11 +74,11 @@ public KeycloakIdTokenClaims extractClaims(String idToken) {
String authIdpType = getRequiredKeycloakClaim(payload, "auth_idp_type");
List<String> 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) {
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/com/acc/local/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ public class AuthController implements AuthDocs {
private final AuthServicePort authServicePort;

@Override
public ResponseEntity<LoginedUserProfileResponse> getLoginUserInformation(Authentication authentication, String projectId) {
public ResponseEntity<LoginedUserProfileResponse> 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);
}
}
5 changes: 1 addition & 4 deletions src/main/java/com/acc/local/controller/docs/AuthDocs.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@ public interface AuthDocs {
@GetMapping("/profile")
ResponseEntity<LoginedUserProfileResponse> getLoginUserInformation(
@Parameter(hidden = true)
Authentication authentication,
@RequestParam(required = false)
@Parameter(description = "프로젝트 ID", required = false)
String projectId
Authentication authentication
);
}
24 changes: 23 additions & 1 deletion src/main/java/com/acc/local/dto/auth/KeycloakIdTokenClaims.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,38 @@
* - 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에 기본 포함
* - 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)
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"
String ajouStudentId, // ajou_student_id → 학번 (Keycloak SPI 연동 시 채워짐)
String phoneNumber, // user_phone_number → 핸드폰 번호 (Keycloak SPI 연동 시 채워짐)
String authIdpType, // auth_idp_type → IDP 종류 (google/gitlab)
List<String> groups // groups → Keycloak 그룹 전체 경로 목록 (e.g. ["/Ajou_Univ/Aolda_Admin"])
) {}
) {
public String displayName() {
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;
}
if (name != null && !name.isBlank()) {
return name;
}
return preferredUsername;
}
}
Original file line number Diff line number Diff line change
@@ -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
) {}
8 changes: 4 additions & 4 deletions src/main/java/com/acc/local/dto/auth/UnivDepartBriefDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -17,22 +17,22 @@ public static UnivDepartBriefDto from(UserDepartDto userDepartDto) {

return UnivDepartBriefDto.builder()
.grade(userDepartDto.grade() + "학년")
.univDepartment(userDepartDto.department())
.department(userDepartDto.department())
.build();
}

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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@
import com.acc.local.domain.model.auth.User;
import com.acc.local.dto.auth.LoginedUserProfileResponse;
import com.acc.local.dto.auth.UnivDepartBriefDto;
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 com.acc.local.service.ports.AuthServicePort;
import lombok.RequiredArgsConstructor;
Expand All @@ -20,34 +17,18 @@ public class AuthServiceAdapter implements AuthServicePort {

private final AuthModule authModule;
private final UserModule userModule;
private final ProjectModule projectModule;
private final KeystoneTokenModule keystoneTokenModule;

@Override
public LoginedUserProfileResponse getUserLoginedProfile(String userId, String projectId) {
public LoginedUserProfileResponse getUserLoginedProfile(String userId) {
String adminToken = authModule.issueSystemAdminToken("ROOT_getUserLoginedProfile");

try {
// Module에서 User 도메인 모델 조회 (정합성 불일치 시 예외 발생)
//TODO: 추후 정합성 맞추는 Flow 필요시 진행
User user = userModule.getUserById(userId, adminToken);

// projectId가 존재하면 프로젝트 정보 조회
ProjectServiceDto projectServiceDto = null;
if (projectId != null && !projectId.isBlank()) {
String scopedToken = authModule.issueProjectScopeToken(projectId, userId);
try {
projectServiceDto = projectModule.getProjectDetail(projectId, scopedToken);
} finally {
keystoneTokenModule.revokeTokenQuietly(scopedToken);
}
}

return LoginedUserProfileResponse.builder()
.userName(user.getUsername())
.univ(UnivDepartBriefDto.from(user))
.project(projectServiceDto)
.build();
.userName(user.getUsername())
.univ(UnivDepartBriefDto.from(user))
.build();
} finally {
authModule.invalidateSystemAdminToken(adminToken);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
}

Expand Down Expand Up @@ -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())
Expand All @@ -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());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
import com.acc.local.dto.auth.LoginedUserProfileResponse;

public interface AuthServicePort {
LoginedUserProfileResponse getUserLoginedProfile(String userId, String projectId);
LoginedUserProfileResponse getUserLoginedProfile(String userId);
}
Original file line number Diff line number Diff line change
@@ -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 displayNameUsesFamilyAndGivenNameBeforeKeycloakFullName() throws Exception {
Map<String, Object> 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<String, Object> 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<String, Object> payload) throws Exception {
String header = encode(Map.of("alg", "none"));
return header + "." + encode(payload) + ".";
}

private Map<String, Object> basePayload() {
Map<String, Object> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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");
}
}