- Red Hat에서 개발한 오픈소스 Identity and Access Management(IAM) 솔루션입니다. 현대적인 애플리케이션과 서비스를 위한 인증 및 권한 부여 기능을 제공하는 인증 서버(Authentication Server)의 기능을 수행합니다. - Keycloack에서는 여러 플랫폼에서 중앙 집중식 인증 서버로 동작을 합니다. 주요한 기능은 서로 다른 도메인에서 실행되는 애플리케이션 간의 SSO를 지원하거나 REST API 기반에 접근제어 토큰에 대한 인증 제공 및 세션 타임아웃, 동시 로그인 제한과 같은 다양한 세션 기능을 담당합니다.
여러 애플리케이션에 대한 인증을 한 곳에서 관리할 수 있어 보안 정책 적용과 유지보수가 용이합니다.
SSO(Single Sign-On) 지원
사용자가 한 번의 로그인으로 여러 애플리케이션에 접근할 수 있어 사용자 경험이 향상됩니다.
다양한 인증 프로토콜
OpenID Connect, SAML 2.0 등 표준 프로토콜을 지원하여 다양한 시스템과의 통합이 가능합니다.
소셜 로그인 통합
Google, Facebook 등 소셜 로그인을 쉽게 구현할 수 있습니다.
강력한 보안 기능
2단계 인증, 비밀번호 정책, 세션 관리 등 다양한 보안 기능을 제공합니다.
오픈소스
무료로 사용 가능하며, 활발한 커뮤니티 지원과 지속적인 업데이트가 이루어집니다.
확장성
REST API를 통한 통합이 용이하며, 커스터마이징이 가능한 유연한 아키텍처를 제공합니다.
2) Keycloak API 통신 방법
💡 Keycloak API 통신 방법
- Keycloak 서버와 API 통신을 하는 방법으로는 OIDC 기반의 통신 방법이나 Keycloak Admin REST Client를 이용하는 방법이 있습니다.
- OIDC의 경우는 일반 사용자의 ‘인증 절차’를 통해서 접근 토큰(Access Token)을 발급받습니다. 그리고, 이를 기반으로 API 통신 시 Header에 토큰을 추가하여 Keycloak 서버에 접근이 가능합니다. - Keycloak Admin REST Client의 경우는 API 통신 시 client_id, client_secret만으로 Keycloak 서버에 접근이 가능합니다. 토큰 발급 과정 없이 API를 호출할 수 있어서 더 간단한 방식으로 관리할 수 있습니다.
구분
인증 방식
사용 목적
주요 기능
OIDC(OpenID Connect) 기반 통신
Access Token 기반 인증
일반 사용자의 인증 및 권한 관리
- 표준화된 엔드포인트를 통한 인증/인가 - 사용자 정보 조회 - 권한 검증
Keycloak Admin REST Client 기반 통신
client_id/secret 기반 직접 접근
관리자 권한이 필요한 시스템 관리 작업
- 사용자 관리 - 그룹 관리 - 역할 관리
1. OIDC(OpenID Connect) 기반 통신
💡 OIDC(OpenID Connect) 기반 통신
- OIDC는 OAuth 2.0 프로토콜을 기반으로 하는 인증 레이어로, 사용자 인증을 위한 표준 프로토콜입니다. - 이 프로토콜을 기반으로 사용자는 ‘인증 절차’를 통해서 ‘접근 토큰(Access Token)’을 발급받아서 API 통신 시 Header에 해당 토큰을 추가하여 Keycloak 서버에 접근이 가능합니다.
1.1. OIDC 기반 통신 특징
💡 OIDC 기반 통신 특징
- 사용자 인증 후 발급받은 Access Token을 사용하여 API 통신을 수행합니다. - Authorization 헤더에 Bearer 토큰을 포함하여 요청합니다. - 표준화된 엔드포인트를 통한 인증 및 인가 처리를 수행합니다. 사용자 정보 조회, 권한 검증 등의 기능 제공합니다.
💡 [참고] OIDC 기반의 인가 코드 및 토큰 발급 과정에 대한 API가 궁금하시면 아래의 글을 참고하시면 도움이 됩니다.
- Keycloak 서버를 프로그래밍 방식으로 관리할 수 있는 Java 기반의 공식 라이브러리입니다. - client_id와 client_secret만으로 Keycloak 서버에 직접 접근이 가능하여 별도의 토큰 발급 과정이 필요하지 않습니다. - 사용자 관리, 그룹 관리, 역할 관리 등 Keycloak의 관리자 기능을 API로 제공합니다. - 주로 관리자 권한이 필요한 작업을 수행할 때 사용됩니다.
- 테스트를 하다 보면 짧은 토큰 시간에 따라 매번 토큰을 발급받아야 하는 문제가 있기에, 임시로 토큰 시간을 늘려줍니다. - Realm settings > Tokens > Access tokens > Access Token Lifespan을 선택하여 1분마다 발급해야 하는 토큰 시간을 테스트를 위해 늘려줍니다.
2. 사용자 생성
💡 사용자 생성
- 위에 대한 접근이 가능한 Role을 모두 부여하기 위한 사용자를 생성합니다.
3. 비밀번호 지정
💡 비밀번호 지정
- Users > Credentials > Set Password를 선택하여 비밀번호를 지정합니다. - Direct Access Grants 방식을 통해서 토큰을 발급받아올 예정이기에 비밀번호까지 지정합니다.
💡 [참고] Direct Access Grants 방식에 대해 궁금하시면 아래의 글을 참고하시면 도움이 됩니다.
- OIDC 기반의 통신 처리이기에 접근 토큰(AccessToken)을 발급받아서 이를 기반으로 Keycloak 서버에 리소스를 요청하여 처리를 수행합니다.
1. 기능 설명
💡 기능 설명
- 해당 기능은 Keycloak의 사용자를 다루기 위한 Endpoint를 관리하고 있습니다. - RESTful 아키텍처 원칙을 따르는 표준 HTTP 메서드 사용하며 JSON 형식의 요청/응답 데이터 구조를 가지고 있습니다. - Bearer 토큰 기반의 인증 방식이며 페이지네이션과 필터링을 통한 효율적인 데이터 조회를 수행합니다.
- OpenFeign로 구성된 인터페이스를 호출하여 비즈니스 로직 처리를 수행하며 데이터를 재 가공하여 반환하는 역할을 수행합니다. - 해당 서비스의 주요한 특징은 클라이언트에서 user id 값을 찾기가 어렵기에, 아이디 기반의 조회를 수행하도록 구성하였습니다. - 또한, 기본적으로 전달되는 Bearer 토큰에 대해서 유효성 검증을 수행한 뒤 비즈니스 로직을 처리합니다.
메서드 종류
메서드 명
설명
Public 메서드
selectKeycloakUserList()
Keycloak 사용자를 전체 조회하는 메서드입니다. 토큰 유효성을 체크한 후 사용자 목록을 반환합니다.
Public 메서드
createUser()
새로운 사용자를 등록하는 메서드입니다. 토큰 유효성 검사 후 사용자를 생성하며, 중복 사용자, 잘못된 요청 등의 예외를 처리합니다.
Public 메서드
updateUser()
사용자 정보를 수정하는 메서드입니다. username을 기반으로 ID를 조회한 후 사용자 정보를 업데이트합니다.
Public 메서드
deleteUser()
사용자를 삭제하는 메서드입니다. username으로 ID를 조회한 후 해당 사용자를 삭제합니다.
Public 메서드
resetPassword()
사용자의 비밀번호를 재설정하는 메서드입니다. username으로 ID를 조회한 후 새로운 비밀번호로 설정합니다.
Private 메서드
getKeycloakUserId()
username을 기반으로 Keycloak 사용자 ID를 조회하는 메서드입니다.
Private 메서드
validateToken()
Bearer 토큰의 유효성을 검사하는 메서드입니다.
Private 메서드
isValidToken()
실제 토큰 유효성 검사를 수행하는 메서드로, Keycloak 서버와 통신하여 토큰의 유효성을 확인합니다.
package com.blog.springbootkeycloak.service;
import com.blog.springbootkeycloak.config.properties.KeycloakProperties;
import com.blog.springbootkeycloak.dto.*;
import com.blog.springbootkeycloak.service.feign.KeycloakAuthFeignClient;
import com.blog.springbootkeycloak.service.feign.KeycloakUserFeignClient;
import feign.FeignException;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.MappingsRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import java.util.List;
/**
* Keycloak 비즈니스 관리를 수행합니다.
*
* @author : leejonghoon
* @fileName : KeycloakUserService
* @since : 2025. 2. 10.
*/@Slf4j@Service@RequiredArgsConstructorpublicclassKeycloakUserService{
privatefinal KeycloakProperties properties; // Keycloak 설정 정보privatefinal KeycloakUserFeignClient keycloakUserFeignClient; // Keycloak User 통신privatefinal KeycloakAuthFeignClient keycloakAuthFeignClient; // Keycloak Auth 통신/**
* Keycloak 사용자를 전체 조회합니다.
*
* @param bearerToken
* @return
*/public List<UserRepresentation> selectKeycloakUserList(String bearerToken, KeycloakUserSearchDto kus){
// 1. [Keycloak] 토큰 유효성 체크this.validateToken(bearerToken);
// 2. [Keycloak] 사용자 조회return keycloakUserFeignClient.selectKeycloakUserDetail(
bearerToken,
kus.getFirst(),
kus.getMax(),
kus.getSearch(),
kus.getUsername(),
kus.getEmail(),
kus.getEnabled(),
true
);
}
/**
* 사용자 등록
*
* @param bearerToken
* @param ur
*/publicintcreateUser(String bearerToken, UserRepresentation ur){
int result = 0;
// 1. [Keycloak] 토큰 유효성 체크this.validateToken(bearerToken);
try {
// 2. [Keycloak] 사용자 생성
keycloakUserFeignClient.createUser(bearerToken, ur);
result = 1;
} catch (FeignException.Conflict e) {
thrownew IllegalStateException("이미 존재하는 사용자입니다: " + e.getMessage());
} catch (FeignException.BadRequest e) {
thrownew IllegalArgumentException("잘못된 사용자 정보입니다: " + e.getMessage());
} catch (FeignException.Unauthorized | FeignException.Forbidden e) {
thrownew SecurityException("인증/인가 오류가 발생했습니다: " + e.getMessage());
} catch (Exception e) {
thrownew RuntimeException("사용자 생성 중 예기치 않은 오류가 발생했습니다: " + e.getMessage());
}
return result;
}
/**
* 사용자 수정
*
* @param bearerToken
* @param ur
*/publicintupdateUser(String bearerToken, UserRepresentation ur){
int result = 0;
// 1. [Keycloak] 토큰 유효성 체크this.validateToken(bearerToken);
// 2. [Keycloak] username 기반 ID 조회
String id = this.getKeycloakUserId(bearerToken, ur.getUsername());
try {
// 3. [Keycloak] 사용자 수정
keycloakUserFeignClient.updateUser(bearerToken, id, ur);
result = 1;
} catch (FeignException.NotFound e) {
thrownew IllegalArgumentException("존재하지 않는 사용자입니다: " + id);
} catch (FeignException.BadRequest e) {
thrownew IllegalArgumentException("잘못된 사용자 정보입니다: " + e.getMessage());
} catch (FeignException.Unauthorized | FeignException.Forbidden e) {
thrownew SecurityException("인증/인가 오류가 발생했습니다: " + e.getMessage());
} catch (Exception e) {
thrownew RuntimeException("사용자 정보 수정 중 예기치 않은 오류가 발생했습니다: " + e.getMessage());
}
return result;
}
/**
* 사용자 삭제
*
* @param bearerToken
* @param bearerToken
* @param ur
*/publicintdeleteUser(String bearerToken, UserRepresentation ur){
int result = 0;
// 1. [Keycloak] 토큰 유효성 체크this.validateToken(bearerToken);
// 2. [Keycloak] username 기반 ID 조회
String id = this.getKeycloakUserId(bearerToken, ur.getUsername());
try {
// 3. [Keycloak] 사용자 삭제
keycloakUserFeignClient.deleteUser(bearerToken, id);
result = 1;
} catch (FeignException.NotFound e) {
thrownew IllegalArgumentException("존재하지 않는 사용자입니다: " + id);
} catch (FeignException.Unauthorized | FeignException.Forbidden e) {
thrownew SecurityException("인증/인가 오류가 발생했습니다: " + e.getMessage());
} catch (Exception e) {
thrownew RuntimeException("사용자 삭제 중 예기치 않은 오류가 발생했습니다: " + e.getMessage());
}
return result;
}
/**
* 비밀번호를 재설정합니다.
*
* @param bearerToken
* @param keycloakUserResetPwDto
* @return
*/publicintresetPassword(String bearerToken, KeycloakUserResetPwDto keycloakUserResetPwDto){
int intResult = 0;
// 1. [Keycloak] 토큰 유효성 체크this.validateToken(bearerToken);
// 2. [Keycloak] username 기반 ID 조회
String id = this.getKeycloakUserId(bearerToken, keycloakUserResetPwDto.getUsername());
// 3. [Keycloak] username 기반의 사용자 조회
CredentialRepresentation result = new CredentialRepresentation();
result.setValue(keycloakUserResetPwDto.getValue());
result.setType("password");
result.setTemporary(false);
result.setId(id);
try {
// 3. [Keycloak] 비밀번호 재설정
keycloakUserFeignClient.resetPassword(bearerToken, id, result);
intResult = 1;
} catch (FeignException e) {
// 기타 Feign 예외thrownew RuntimeException("Failed to reset password: " + e.getMessage());
}
return intResult;
}
// ***********************************************************************************************************************************// *************************************************** private Method ****************************************************************// ***********************************************************************************************************************************/**
* username 기반 id 조회
*
* @param bearerToken
* @param username
* @return
*/private String getKeycloakUserId(String bearerToken, String username){
// [Validation] 빈 값 체크if (username == null) {
thrownew IllegalArgumentException("username이 존재하지 않습니다.: ");
}
// [Validation] 빈 값 체크if (username.isEmpty()) {
thrownew IllegalArgumentException("username이 존재하지 않습니다.: ");
}
// [Keycloak] username 기반의 사용자 조회
List<UserRepresentation> result = keycloakUserFeignClient.selectKeycloakUserDetail(
bearerToken,
null,
null,
null,
username,
null,
null,
true
);
// [Validation] 유효하지 않은 사용자if (result.isEmpty()) {
thrownew IllegalArgumentException("해당 username으로 등록된 사용자를 찾을 수 없습니다: " + username);
}
return result.get(0).getId();
}
/**
* 토큰 유효성을 검사하고 예외를 발생시킵니다.
*
* @param bearerToken
*/privatevoidvalidateToken(String bearerToken){
log.debug("bearerToken :: {}", bearerToken);
if (!isValidToken(bearerToken)) {
log.error("토큰이 유효하지 않습니다.");
thrownew IllegalArgumentException("토큰이 유효하지 않습니다.");
}
}
/**
* 토큰이 유효한지 체크를 수행합니다.
*
* @param bearerToken
* @return
*/privatebooleanisValidToken(String bearerToken){
String accessToken = bearerToken.split(" ")[1]; // Bearer를 제외하고 토큰 값만 전달
TokenIntrospectionReqDto tokenIntrospectionReqDto = TokenIntrospectionReqDto.builder()
.token(accessToken)
.client_id(properties.getResource())
.client_secret(properties.getCredentials().getSecret())
.build();
TokenIntrospectionResDto validTokenDto = keycloakAuthFeignClient.tokenIntrospect(tokenIntrospectionReqDto); // Keycloak : 토큰 유효성 검증return validTokenDto.getActive();
}
}
4. KeycloakUserController
💡 KeycloakUserController
- 구성한 서비스를 호출하여서 최종적으로 클라이언트에게 반환해 주는 Controller입니다.