PSD( Private-Self-Development )

키클락( keycloak ) 본문

Backend/기타

키클락( keycloak )

chjysm 2024. 12. 17. 18:11

사용 배경

신규 프로젝트를 진행하며,
Auth 서비스를 일일히 구현하는 대신, 오픈 소스 Auth 서비스를 사용해보았다.

 

 

키클락 이란?

  • 인증 및 세션, 엑세스, 권한 관리를 해주는 ( IAM, SSO )오픈 소스 라이브러리 서버
  • Red Hat 에서 주도 개발

 

키클락의 장점

  1. OAuth2.0 지원
  2. 다양한 언어 지원( java, python, js, 안드로이드 등 )
  3. 외부 사용자 저장소 지원 ( AD, LDAP 등 )
  4. 토큰 기반 보안
  5. 개발자 가 구현하는 것 보다 검증된 보안 서비스인 키를록을 사용하는것이 좀더 간단하게 보안성을 향상 시킬 수 있는 방법이다.
  6. 확장성도 가지고 있다. (서버 하나 더 띄우는듯? 세션은 어캐??)

 

구현

사용 스택

  • java 21 + Spring boot 3.2.5 + security
  • AWS EC2( 우분투 ) + Docker
  • MariaDB

 

1. 키클락 서버 구축

Dockerfile.keycloak

FROM quay.io/keycloak/keycloak:26.0.5
COPY ./keycloak.conf /opt/keycloak/conf/keycloak.conf
ENTRYPOINT ["/bin/bash", "-c", "/opt/keycloak/bin/kc.sh show-config && /opt/keycloak/bin/kc.sh start"]

 

docker-compose.yml

  keycloak:
    build:
      context: .
      dockerfile: Dockerfile.keycloak
    image: test/test-keycloak
    container_name: keycloak
    environment:
      - KC_HEALTH_ENABLED=true
      - KC_BOOTSTRAP_ADMIN_USERNAME=${KC_BOOTSTRAP_ADMIN_USERNAME}
      - KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_PASSWORD}
    ports:
      - ${KEYCLOAK_EXTERNAL_PORT:-7080}:${KEYCLOAK_PORT:-8080}
    depends_on: # 컨테이너 시작 순서 제어
      mariadb:
        condition: service_healthy # 컨테이너의 상태 지정 가능
    networks:
      - test-net

 

2. 디펜던시 추가 

<dependency>
    <groupId>org.keycloak</groupId>
    <artifactId>keycloak-spring-boot-starter</artifactId>
    <version>${keycloak.spring.boot.version}</version>
</dependency>
<dependency>
    <groupId>org.keycloak</groupId>
    <artifactId>keycloak-admin-client</artifactId>
    <version>${keycloak.admin.client.version}</version>
</dependency>

 

3. 프로퍼티 설정

keycloak:
  realm: ${KEYCLOAK_REALM} # 저장소 명 
  auth-server-url: ${KEYCLOAK_SERVER_URL} # 키클락 서버 주소 
  resource: ${KEYCLOAK_CLIENT_ID} # 키클락 클라이언트 ID 
  credentials:
    secret: ${KEYCLOAK_CLIENT_SECRET} # 키클락 클라이언트 secret 키 
  use-resource-role-mappings: true # 역할 사용 여부  
  principal-attribute: sub # 주체 속성? 
  ssl-required: all # ssl 필요 여부
  bearer-only: true #
  
 spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: http://localhost:7080/realms/렐름명/protocol/openid-connect/certs
# security 에 oauth2 resourceServer 로 등록하면,
# 자동으로 security 가 jwt 토큰을 검사할 일이 있을 때, 
# 키클락에게 검증 요청한다.

 

4. Config 설정

@Profile("prod")
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
	private final JwtAuthenticationConverter jwtAuthenticationConverter;
	private final CorsConfigurationSource corsConfigurationSource;
	private final CustomJwtAuthenticationFilter customJwtAuthenticationFilter;

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		http.csrf(AbstractHttpConfigurer::disable)
				.cors(cors -> cors.configurationSource(corsConfigurationSource))
				.authorizeHttpRequests(authorize -> authorize
						.requestMatchers(
								"/api/v1/auth/**",
								"/api/v3/api-docs/**",
								"/swagger-ui/**",
								"/swagger-ui.html"
						).permitAll()
						.anyRequest().authenticated()
				)
				.oauth2ResourceServer(
						oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter)))
				.addFilterAfter(customJwtAuthenticationFilter, BearerTokenAuthenticationFilter.class);
		return http.build();
	}
}

5.  Java 구현

계정 생성

// 생성 요청 
Response response = adminKeycloakClient.realm(keycloakProperties.realm()).users().create(userRepresentation);
					
// 응답 데이터에서 authId 추출
authId = extractAuthIdFromResponse(response);

// 사용자에게 역할 할당
assignRolesToUser(authId, request.userRole());

---------------------------------------------------------------------------------

// 키클락 사용자 추가 요청 데이터 생성 
private UserRepresentation buildUserRepresentation(
			CreateAuthServerUserRequest request
) {
	UserRepresentation userRepresentation = new UserRepresentation();
	userRepresentation.setUsername(request.username().value());
	userRepresentation.setEmail(request.email().value());
	userRepresentation.setEnabled(true);

	CredentialRepresentation credential = new CredentialRepresentation();
	credential.setType(CredentialRepresentation.PASSWORD);
	credential.setValue(request.password());
	userRepresentation.setCredentials(Collections.singletonList(credential));

	return userRepresentation;
}

// 응답 데이터에서 authId 추출
private String extractAuthIdFromResponse(Response response) {
	return response.getLocation().getPath().replaceAll(".*/(.*)$", "$1");
}

// 사용자에게 역할 할당
private void assignRolesToUser(String authId, UserRole role) {
	List<RoleRepresentation> roleRepresentations = Collections.singletonList(
			adminKeycloakClient.realm(keycloakProperties.realm())
					.roles()
					.get(role.name())
					.toRepresentation()
	);

	adminKeycloakClient.realm(keycloakProperties.realm())
			.users()
			.get(authId)
			.roles()
			.realmLevel()
			.add(roleRepresentations);
}

 

토큰 생성 ( 로그인 )

// 로그인한 사용자로 키클락 클라이언트 생성
Keycloak userKeycloakClient = KeycloakBuilder.builder()
						.serverUrl(keycloakProperties.authServerUrl())
						.realm(keycloakProperties.realm())
						.clientId(keycloakProperties.resource())
						.clientSecret(keycloakProperties.credentials().secret())
						.username(request.username().value())
						.password(request.password())
						.grantType(OAuth2Constants.PASSWORD)
						.build();
						
// 토큰 발급
AccessTokenResponse accessTokenResponse = userKeycloakClient.tokenManager().getAccessToken();

 

토큰 리플래시 

// 토큰 리플래시 요청
String response = tokenRefresher.refreshToken(request.refreshToken());

// 응답 맵핑 
AccessTokenResponse accessTokenResponse = new ObjectMapper().readValue(response, AccessTokenResponse.class);

// AuthId 추출 
String sub = getSub(accessTokenResponse.getToken());


// 토큰 리플래시 요청 데이터 생성 
private HttpRequest buildRefreshRequest(String refreshToken) {
	String form = String.format(
			"grant_type=refresh_token&client_id=%s&client_secret=%s&refresh_token=%s",
			encode(keycloakProperties.resource()),
			encode(keycloakProperties.credentials().secret()),
			encode(refreshToken)
	);
	return HttpRequest.newBuilder()
			.uri(URI.create(buildTokenEndpoint()))
			.header("Content-Type", "application/x-www-form-urlencoded")
			.POST(HttpRequest.BodyPublishers.ofString(form))
			.build();
}

private String getSub(String jwtToken) throws JsonProcessingException {
	String[] parts = jwtToken.split("\\.");
	if (parts.length != 3) {
		throw new IllegalArgumentException("Invalid JWT token format");
	}

	String payload = new String(Base64.getUrlDecoder().decode(parts[1]));

	ObjectMapper objectMapper = new ObjectMapper();
	return objectMapper.readTree(payload).get("sub").asText();
}

 

사용자 정보 업데이트 

// 키클락에서 사용자 정보 조회
UserRepresentation userAuth =
				adminKeycloakClient.realm(keycloakProperties.realm()).users().get(authId)
						.toRepresentation();
						
// 정보 수정 
userAuth.setEmail(request.email().value());

CredentialRepresentation credential = new CredentialRepresentation();
			credential.setType(CredentialRepresentation.PASSWORD);
			credential.setValue(request.password());
			userAuth.setCredentials(Collections.singletonList(credential));
			
			
// 업데이트 요청
adminKeycloakClient.realm(keycloakProperties.realm()).users().get(authId).update(userAuth);

 

사용자 삭제

adminKeycloakClient.realm(keycloakProperties.realm()).users().delete(authId)

 

'Backend > 기타' 카테고리의 다른 글

Maven 다중 프로젝트 설정  (0) 2024.12.17
헥사고날 아키텍처(Hexagonal Architecture)  (0) 2024.07.15
도커( Doker )  (0) 2024.04.29
최소 지식 원칙( 데메테르의 법칙 )  (0) 2023.05.23
Nginx 란?  (0) 2023.01.19