304 lines
17 KiB
Java
304 lines
17 KiB
Java
package com.cleverthis.authservice.service.impl;
|
|
|
|
import com.cleverthis.authservice.dto.KeycloakRoleRepresentation;
|
|
import com.cleverthis.authservice.dto.RegistrationRequest;
|
|
import com.cleverthis.authservice.dto.TokenResponse;
|
|
import com.cleverthis.authservice.dto.VerificationResponse;
|
|
import com.cleverthis.authservice.exception.AuthenticationException;
|
|
import com.cleverthis.authservice.exception.LogoutException;
|
|
import com.cleverthis.authservice.exception.RegistrationException;
|
|
import com.cleverthis.authservice.exception.TokenVerificationException;
|
|
import com.cleverthis.authservice.exception.UserAlreadyExistsException;
|
|
import com.cleverthis.authservice.service.IdentityManagerService;
|
|
import java.util.Collections;
|
|
import java.util.HashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
import org.springframework.http.HttpHeaders;
|
|
import org.springframework.http.HttpStatus;
|
|
import org.springframework.http.MediaType;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.util.LinkedMultiValueMap;
|
|
import org.springframework.util.MultiValueMap;
|
|
import org.springframework.web.reactive.function.BodyInserters;
|
|
import org.springframework.web.reactive.function.client.WebClient;
|
|
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
|
import reactor.core.publisher.Mono;
|
|
|
|
/**
|
|
* Keycloak-specific implementation of the IdentityManagerService. Uses WebClient to interact with
|
|
* Keycloak's REST API endpoints.
|
|
*/
|
|
@Service
|
|
@Slf4j
|
|
public class KeycloakIdentityManagerService implements IdentityManagerService {
|
|
|
|
private final WebClient webClient;
|
|
private final KeycloakAdminClient keycloakAdminClient; // Inject KeycloakAdminClient
|
|
|
|
@Value("${keycloak.server-url}")
|
|
private String keycloakServerUrl;
|
|
@Value("${keycloak.realm}")
|
|
private String realm;
|
|
@Value("${keycloak.client-id}")
|
|
private String clientId;
|
|
@Value("${keycloak.client-secret}")
|
|
private String clientSecret;
|
|
@Value("${keycloak.grant-type}")
|
|
private String grantType; // Should be 'password' for ROPC
|
|
|
|
private String getTokenEndpoint() {
|
|
return String.format("%s/realms/%s/protocol/openid-connect/token", this.keycloakServerUrl,
|
|
this.realm);
|
|
}
|
|
|
|
private String getIntrospectionEndpoint() {
|
|
return String.format("%s/realms/%s/protocol/openid-connect/token/introspect",
|
|
this.keycloakServerUrl, this.realm);
|
|
}
|
|
|
|
private String getRevocationEndpoint() {
|
|
return String.format("%s/realms/%s/protocol/openid-connect/revoke", this.keycloakServerUrl,
|
|
this.realm);
|
|
}
|
|
|
|
private String getUsersAdminEndpoint() {
|
|
return String.format("%s/admin/realms/%s/users",
|
|
this.keycloakServerUrl.replaceAll("/+$", ""), this.realm);
|
|
}
|
|
|
|
|
|
@Autowired
|
|
public KeycloakIdentityManagerService(WebClient keycloakWebClient,
|
|
KeycloakAdminClient keycloakAdminClient) {
|
|
this.webClient = keycloakWebClient;
|
|
this.keycloakAdminClient = keycloakAdminClient;
|
|
}
|
|
|
|
@Override
|
|
public Mono<TokenResponse> login(String username, String password) {
|
|
log.debug("Attempting login for user: {}", username);
|
|
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
|
|
formData.add("client_id", this.clientId);
|
|
formData.add("client_secret", this.clientSecret);
|
|
formData.add("grant_type", this.grantType); // Use configured grant type (password)
|
|
formData.add("username", username);
|
|
formData.add("password", password);
|
|
formData.add("scope", "openid email profile"); // Request standard scopes
|
|
|
|
return this.webClient.post().uri(getTokenEndpoint())
|
|
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
|
.body(BodyInserters.fromFormData(formData)).retrieve()
|
|
// Handle specific HTTP status codes
|
|
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
|
|
clientResponse -> clientResponse.bodyToMono(String.class)
|
|
.flatMap(errorBody -> {
|
|
log.error("Keycloak login failed with status {}: {}",
|
|
clientResponse.statusCode(), errorBody);
|
|
return Mono.error(new AuthenticationException(
|
|
"Login failed: Invalid credentials or Keycloak error."
|
|
+ " Status: "
|
|
+ clientResponse.statusCode()));
|
|
}))
|
|
.bodyToMono(TokenResponse.class)
|
|
.doOnSuccess(response -> log.debug("Login successful for user: {}", username))
|
|
.doOnError(error -> log.error("Error during login for user {}: {}", username,
|
|
error.getMessage()));
|
|
}
|
|
|
|
@Override
|
|
public Mono<VerificationResponse> verifyToken(String accessToken) {
|
|
log.debug("Verifying access token");
|
|
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
|
|
formData.add("client_id", this.clientId);
|
|
formData.add("client_secret", this.clientSecret);
|
|
formData.add("token", accessToken);
|
|
|
|
return this.webClient.post().uri(getIntrospectionEndpoint())
|
|
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
|
.body(BodyInserters.fromFormData(formData)).retrieve()
|
|
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
|
|
clientResponse -> clientResponse.bodyToMono(String.class)
|
|
.flatMap(errorBody -> {
|
|
log.error(
|
|
"Keycloak token introspection failed with"
|
|
+ " status {}: {}",
|
|
clientResponse.statusCode(), errorBody);
|
|
return Mono.error(new TokenVerificationException(
|
|
"Token introspection failed. Status: "
|
|
+ clientResponse.statusCode()));
|
|
}))
|
|
.bodyToMono(VerificationResponse.class).flatMap(response -> {
|
|
if (!response.isActive()) {
|
|
log.warn("Token verification failed: Token is inactive.");
|
|
return Mono.error(
|
|
new TokenVerificationException("Token is invalid or expired."));
|
|
}
|
|
log.debug("Token verification successful. User: {}", response.getUsername());
|
|
return Mono.just(response);
|
|
}).doOnError(error -> log.error("Error during token verification: {}",
|
|
error.getMessage()));
|
|
}
|
|
|
|
@Override
|
|
public Mono<Void> logout(String refreshToken) {
|
|
log.debug("Attempting logout (token revocation)");
|
|
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
|
|
formData.add("client_id", this.clientId);
|
|
formData.add("client_secret", this.clientSecret);
|
|
formData.add("token", refreshToken);
|
|
formData.add("token_type_hint", "refresh_token"); // Hint for Keycloak
|
|
|
|
return this.webClient.post().uri(getRevocationEndpoint())
|
|
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
|
.body(BodyInserters.fromFormData(formData)).retrieve()
|
|
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
|
|
clientResponse -> clientResponse.bodyToMono(String.class)
|
|
.flatMap(errorBody -> {
|
|
// Keycloak might return 400 if token is already invalid, which
|
|
// is okay for logout
|
|
if (clientResponse.statusCode() == HttpStatus.BAD_REQUEST) {
|
|
log.warn(
|
|
"Token revocation returned 400 (possibly already"
|
|
+ " invalid/revoked): {}",
|
|
errorBody);
|
|
return Mono.empty(); // Treat as success in logout scenario
|
|
}
|
|
log.error("Keycloak token revocation failed with status {}: {}",
|
|
clientResponse.statusCode(), errorBody);
|
|
return Mono.error(new LogoutException("Logout failed. Status: "
|
|
+ clientResponse.statusCode()));
|
|
}))
|
|
.bodyToMono(Void.class) // Expecting empty body on success (2xx)
|
|
.doOnSuccess(v -> log.debug("Logout successful (token revoked)."))
|
|
.doOnError(error -> log.error("Error during logout: {}", error.getMessage()));
|
|
}
|
|
|
|
@Override
|
|
public Mono<Boolean> checkUserHasAnyClientRole(String userSub, String targetServiceClientId,
|
|
List<String> candidateRoleNames) {
|
|
log.info("Checking permission for userSub '{}', targetClient '{}', requiredRole '{}'",
|
|
userSub, targetServiceClientId, candidateRoleNames);
|
|
|
|
// Step 1: Get the internal UUID of the target Keycloak client
|
|
return this.keycloakAdminClient.getClientInternalId(targetServiceClientId)
|
|
.flatMap(clientInternalId -> {
|
|
// Step 2: Get the user's effective client roles for that client's internal ID
|
|
log.debug("Found internal client ID: {}. Fetching roles for userSub: {}",
|
|
clientInternalId, userSub);
|
|
return this.keycloakAdminClient.getUserEffectiveClientRoles(userSub,
|
|
clientInternalId);
|
|
}).map(roles -> {
|
|
// Step 3: Check if the required role name is present in the list of roles
|
|
boolean hasRole = roles.stream()
|
|
.anyMatch(role -> candidateRoleNames.contains(role.getName()));
|
|
if (hasRole) {
|
|
log.info("Permission GRANTED for userSub '{}' to role '{}' on client '{}'"
|
|
+ ". User roles: {}",
|
|
userSub, candidateRoleNames, targetServiceClientId,
|
|
roles.stream().map(KeycloakRoleRepresentation::getName).toList());
|
|
} else {
|
|
log.warn(
|
|
"Permission DENIED for userSub '{}' to role '{}' on"
|
|
+ " client '{}'. User roles: {}",
|
|
userSub, candidateRoleNames, targetServiceClientId,
|
|
roles.stream().map(KeycloakRoleRepresentation::getName).toList());
|
|
}
|
|
return hasRole;
|
|
})
|
|
.doOnError(e -> log.error(
|
|
"Error during permission check for userSub '{}', "
|
|
+ "client '{}', role '{}': {}",
|
|
userSub, targetServiceClientId, candidateRoleNames, e.getMessage(), e))
|
|
// If any error occurs during the admin calls (client not found, user roles fetch
|
|
// fail), treat as permission denied.
|
|
// Or, you could throw a specific PermissionCheckException to be handled by
|
|
// GlobalExceptionHandler.
|
|
.onErrorResume(e -> {
|
|
log.error("Permission check failed due to an error, defaulting to DENIED: {}",
|
|
e.getMessage());
|
|
return Mono.just(false); // Default to false on error to be safe
|
|
});
|
|
}
|
|
|
|
|
|
@Override
|
|
public Mono<Void> registerUser(RegistrationRequest registrationRequest) {
|
|
log.info("Registering new user with email: {}", registrationRequest.getEmail());
|
|
|
|
Map<String, Object> userRepresentation = new HashMap<>();
|
|
userRepresentation.put("username", registrationRequest.getEmail());
|
|
userRepresentation.put("email", registrationRequest.getEmail());
|
|
userRepresentation.put("firstName", registrationRequest.getFirstName());
|
|
userRepresentation.put("lastName", registrationRequest.getLastName());
|
|
userRepresentation.put("enabled", true);
|
|
userRepresentation.put("emailVerified", false); // Initially false
|
|
|
|
Map<String, Object> credential = new HashMap<>();
|
|
credential.put("type", "password");
|
|
credential.put("value", registrationRequest.getPassword());
|
|
credential.put("temporary", false);
|
|
userRepresentation.put("credentials", Collections.singletonList(credential));
|
|
|
|
// Tell Keycloak to initiate email verification,
|
|
// we may implement our email verification on future.
|
|
userRepresentation.put("requiredActions", Collections.singletonList("VERIFY_EMAIL"));
|
|
|
|
return this.keycloakAdminClient.getAdminAccessToken()
|
|
.flatMap(adminToken -> this.webClient.post().uri(getUsersAdminEndpoint())
|
|
.header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken)
|
|
.contentType(MediaType.APPLICATION_JSON).bodyValue(userRepresentation)
|
|
.exchangeToMono(response -> {
|
|
if (response.statusCode().is2xxSuccessful()) {
|
|
log.info(
|
|
"User {} created successfully in Keycloak."
|
|
+ " Keycloak will handle email verification.",
|
|
registrationRequest.getEmail());
|
|
return Mono.empty(); // Successfully initiated
|
|
} else if (response.statusCode() == HttpStatus.CONFLICT) {
|
|
log.warn(
|
|
"User registration failed for {}:"
|
|
+ " User already exists (Conflict).",
|
|
registrationRequest.getEmail());
|
|
return response.bodyToMono(String.class)
|
|
.flatMap(errorBody -> Mono.error(
|
|
new UserAlreadyExistsException("User with email "
|
|
+ registrationRequest.getEmail()
|
|
+ " already exists.")));
|
|
} else {
|
|
return response.bodyToMono(String.class).flatMap(errorBody -> {
|
|
log.error("Keycloak user creation failed for {}"
|
|
+ " with status {}: {}",
|
|
registrationRequest.getEmail(), response.statusCode(),
|
|
errorBody);
|
|
return Mono.error(new RegistrationException(
|
|
"User registration failed due to Keycloak error."
|
|
+ " Status: " + response.statusCode()));
|
|
});
|
|
}
|
|
}))
|
|
.doOnError(WebClientResponseException.class, e -> {
|
|
if (e.getStatusCode() == HttpStatus.CONFLICT) {
|
|
// Already handled by exchangeToMono, but good to log if it somehow gets
|
|
// here
|
|
log.warn(
|
|
"User registration conflict "
|
|
+ "(WebClientResponseException) for {}: {}",
|
|
registrationRequest.getEmail(), e.getMessage());
|
|
} else {
|
|
log.error("WebClientResponseException during user registration for {}: {}",
|
|
registrationRequest.getEmail(), e.getMessage());
|
|
}
|
|
})
|
|
.doOnError(
|
|
e -> !(e instanceof UserAlreadyExistsException
|
|
|| e instanceof RegistrationException),
|
|
e -> log.error("Generic error during user registration for {}: {}",
|
|
registrationRequest.getEmail(), e.getMessage()))
|
|
.then();
|
|
}
|
|
}
|