Feat#1 creating auth-service, with JUnit test #14
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.4.5</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>com.cleverthis</groupId>
|
||||
<artifactId>auth-service</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>auth-service</name>
|
||||
<description>User management project for CleverThis</description>
|
||||
<url/>
|
||||
<licenses>
|
||||
<license/>
|
||||
</licenses>
|
||||
<developers>
|
||||
<developer/>
|
||||
</developers>
|
||||
<scm>
|
||||
<connection/>
|
||||
<developerConnection/>
|
||||
<tag/>
|
||||
<url/>
|
||||
</scm>
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.cleverthis.authservice;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* Auth-Service starter
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class AuthServiceApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(AuthServiceApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.cleverthis.authservice.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import io.netty.channel.ChannelOption;
|
||||
import io.netty.handler.timeout.ReadTimeoutHandler;
|
||||
import io.netty.handler.timeout.WriteTimeoutHandler;
|
||||
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
|
||||
import reactor.netty.http.client.HttpClient;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Configuration class for creating and configuring the WebClient bean
|
||||
* used for making HTTP requests to Keycloak.
|
||||
*/
|
||||
@Configuration
|
||||
public class WebClientConfig {
|
||||
|
||||
// Timeout values in milliseconds
|
||||
private static final int CONNECT_TIMEOUT = 5000; // 5 seconds
|
||||
private static final int READ_TIMEOUT = 10000; // 10 seconds
|
||||
private static final int WRITE_TIMEOUT = 10000; // 10 seconds
|
||||
|
||||
@Bean
|
||||
public WebClient keycloakWebClient() {
|
||||
// Configure HttpClient with timeouts
|
||||
HttpClient httpClient = HttpClient.create()
|
||||
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, CONNECT_TIMEOUT)
|
||||
.responseTimeout(Duration.ofMillis(READ_TIMEOUT)) // Max time to wait for response
|
||||
.doOnConnected(conn ->
|
||||
conn.addHandlerLast(new ReadTimeoutHandler(READ_TIMEOUT, TimeUnit.MILLISECONDS))
|
||||
.addHandlerLast(new WriteTimeoutHandler(WRITE_TIMEOUT, TimeUnit.MILLISECONDS)));
|
||||
|
||||
// Build WebClient with the configured HttpClient
|
||||
return WebClient.builder()
|
||||
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.cleverthis.authservice.controller;
|
||||
|
||||
import com.cleverthis.authservice.dto.LoginRequest;
|
||||
import com.cleverthis.authservice.dto.LogoutRequest;
|
||||
import com.cleverthis.authservice.dto.TokenResponse;
|
||||
import com.cleverthis.authservice.dto.VerificationResponse;
|
||||
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* REST Controller for handling authentication related requests. Delegates the actual identity
|
||||
* provider interaction to the IdentityManagerService.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/")
|
||||
@Slf4j public class AuthController {
|
||||
|
||||
private final IdentityManagerService identityManagerService;
|
||||
|
||||
@Autowired
|
||||
public AuthController(IdentityManagerService identityManagerService) {
|
||||
this.identityManagerService = identityManagerService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles user login requests using username and password.
|
||||
*
|
||||
* @param loginRequest DTO containing username and password.
|
||||
* @return ResponseEntity containing the TokenResponse on success, or an error status handled
|
||||
* globally.
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
public Mono<ResponseEntity<TokenResponse>> login(
|
||||
@Valid @RequestBody LoginRequest loginRequest) {
|
||||
log.info("Received login request for user: {}", loginRequest.getUsername());
|
||||
return this.identityManagerService
|
||||
.login(loginRequest.getUsername(), loginRequest.getPassword())
|
||||
.map(ResponseEntity::ok) // Map successful response to ResponseEntity<TokenResponse>
|
||||
.doOnError(e -> log.error("Login failed for user {}: " + "{}",
|
||||
loginRequest.getUsername(), e.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles token verification requests. Expects the access token in the Authorization header
|
||||
* (Bearer scheme).
|
||||
*
|
||||
* @param authorizationHeader The full Authorization header value.
|
||||
* @return ResponseEntity containing the VerificationResponse on success, or an error status
|
||||
* handled globally.
|
||||
*/
|
||||
@PostMapping("/verify")
|
||||
public Mono<ResponseEntity<VerificationResponse>> verify(
|
||||
@RequestHeader(HttpHeaders.AUTHORIZATION) String authorizationHeader) {
|
||||
log.info("Received token verification request");
|
||||
if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
|
||||
log.warn("Verification request missing or invalid Bearer token format. {}",
|
||||
|
stanislav.hejny marked this conversation as resolved
Outdated
|
||||
authorizationHeader);
|
||||
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
|
||||
}
|
||||
String accessToken = authorizationHeader.substring(7);
|
||||
return this.identityManagerService.verifyToken(accessToken).map(ResponseEntity::ok)
|
||||
.doOnError(e -> log.error("Token verification failed: {}", e.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles user logout requests using a refresh token.
|
||||
*
|
||||
* @param logoutRequest DTO containing the refresh token.
|
||||
* @return ResponseEntity with 204 No Content on success, or an error status handled globally.
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
public Mono<ResponseEntity<Void>> logout(@Valid @RequestBody LogoutRequest logoutRequest) {
|
||||
log.info("Received logout request");
|
||||
return this.identityManagerService.logout(logoutRequest.getRefreshToken())
|
||||
.thenReturn(ResponseEntity.noContent().<Void>build())
|
||||
.doOnError(e -> log.error("Logout failed: {}", e.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.cleverthis.authservice.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for the /login request body.
|
||||
*/
|
||||
@Data // Lombok annotation for getters, setters, toString, equals, hashCode
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class LoginRequest {
|
||||
@NotBlank(message = "Username cannot be blank")
|
||||
private String username;
|
||||
|
||||
@NotBlank(message = "Password cannot be blank")
|
||||
private String password;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.cleverthis.authservice.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for the /logout request body.
|
||||
*/
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class LogoutRequest {
|
||||
@NotBlank(message = "Refresh token cannot be blank")
|
||||
private String refreshToken;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.cleverthis.authservice.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO representing a successful token response from Keycloak (or similar). Contains the fields
|
||||
* typically returned by an OAuth2 token endpoint.
|
||||
*/
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor public class TokenResponse {
|
||||
|
||||
@JsonProperty("access_token")
|
||||
private String accessToken;
|
||||
|
||||
@JsonProperty("expires_in")
|
||||
private int expiresIn;
|
||||
|
||||
@JsonProperty("refresh_expires_in")
|
||||
private int refreshExpiresIn;
|
||||
|
||||
@JsonProperty("refresh_token")
|
||||
private String refreshToken;
|
||||
|
||||
@JsonProperty("token_type")
|
||||
private String tokenType;
|
||||
|
||||
@JsonProperty("id_token")
|
||||
private String idToken;
|
||||
|
||||
@JsonProperty("not-before-policy")
|
||||
private int notBeforePolicy;
|
||||
|
||||
@JsonProperty("session_state")
|
||||
private String sessionState;
|
||||
|
||||
@JsonProperty("scope")
|
||||
private String scope;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.cleverthis.authservice.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO representing the response from Keycloak's token introspection endpoint.
|
||||
* Also serves as the response for our /verify endpoint and basis for /auth headers.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class VerificationResponse {
|
||||
private boolean active; // Standard introspection field: is the token active?
|
||||
private String clientId;
|
||||
private String username; // Often same as preferred_username
|
||||
private String scope;
|
||||
private String sub; // Subject (user ID) - Use this for X-User-Id
|
||||
private long exp; // Expiration timestamp
|
||||
private long iat; // Issued at timestamp
|
||||
private long nbf; // Not before timestamp
|
||||
private String aud; // Audience
|
||||
private String iss; // Issuer
|
||||
private String typ; // Type (e.g., Bearer)
|
||||
private String email; // Standard OIDC claim
|
||||
private Boolean emailVerified; // Standard OIDC claim
|
||||
private String preferredUsername; // Standard OIDC claim - Use this for X-User-Name
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.cleverthis.authservice.exception;
|
||||
|
||||
/**
|
||||
* Custom exception for authentication failures.
|
||||
*/
|
||||
public class AuthenticationException extends RuntimeException {
|
||||
|
||||
public AuthenticationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public AuthenticationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.cleverthis.authservice.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler;
|
||||
|
||||
/**
|
||||
* Global Exception Handler to translate custom exceptions into appropriate HTTP responses using
|
||||
* Problem Details (RFC 7807).
|
||||
*/
|
||||
|
||||
@RestControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
||||
|
||||
@ExceptionHandler(AuthenticationException.class)
|
||||
public ProblemDetail handleAuthenticationException(AuthenticationException ex) {
|
||||
ProblemDetail problemDetail =
|
||||
ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage());
|
||||
problemDetail.setTitle("Authentication Failed");
|
||||
// Add more details if needed
|
||||
// problemDetail.setProperty("details", "Check username/password or Keycloak status.");
|
||||
return problemDetail;
|
||||
}
|
||||
|
||||
@ExceptionHandler(TokenVerificationException.class)
|
||||
public ProblemDetail handleTokenVerificationException(TokenVerificationException ex) {
|
||||
ProblemDetail problemDetail =
|
||||
ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage());
|
||||
problemDetail.setTitle("Token Verification Failed");
|
||||
return problemDetail;
|
||||
}
|
||||
|
||||
@ExceptionHandler(LogoutException.class)
|
||||
public ProblemDetail handleLogoutException(LogoutException ex) {
|
||||
// Depending on the cause, might return 400 or 500
|
||||
ProblemDetail problemDetail =
|
||||
ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage());
|
||||
problemDetail.setTitle("Logout Failed");
|
||||
return problemDetail;
|
||||
}
|
||||
|
||||
|
||||
// Catch-all for other unexpected exceptions
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ProblemDetail handleGenericException(Exception ex) {
|
||||
logger.error("An internal error occurred.: ", ex); // Log the full stack trace
|
||||
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR, "An internal error occurred.");
|
||||
problemDetail.setTitle("Internal Server Error");
|
||||
return problemDetail;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.cleverthis.authservice.exception;
|
||||
|
||||
/**
|
||||
* Custom exception for logout failures.
|
||||
*/
|
||||
public class LogoutException extends RuntimeException {
|
||||
|
||||
public LogoutException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public LogoutException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.cleverthis.authservice.exception;
|
||||
|
||||
/**
|
||||
* Custom exception for token verification failures.
|
||||
*/
|
||||
public class TokenVerificationException extends RuntimeException {
|
||||
|
||||
public TokenVerificationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public TokenVerificationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.cleverthis.authservice.service;
|
||||
|
||||
import com.cleverthis.authservice.dto.TokenResponse;
|
||||
import com.cleverthis.authservice.dto.VerificationResponse;
|
||||
import reactor.core.publisher.Mono; // Using Mono for asynchronous operations with WebClient
|
||||
|
||||
/**
|
||||
* Interface defining operations for interacting with an identity provider. This abstraction allows
|
||||
* swapping the underlying implementation (e.g., Keycloak, Okta) without changing the controller
|
||||
* logic.
|
||||
*/
|
||||
public interface IdentityManagerService {
|
||||
|
||||
/**
|
||||
* Authenticates a user using username and password (Resource Owner Password Credentials Grant).
|
||||
*
|
||||
* @param username The user's username.
|
||||
* @param password The user's password.
|
||||
* @return A Mono emitting the TokenResponse upon successful authentication. Emits an error
|
||||
* (e.g., AuthenticationException) if authentication fails.
|
||||
*/
|
||||
Mono<TokenResponse> login(String username, String password);
|
||||
|
||||
/**
|
||||
* Verifies the validity of an access token and retrieves associated user information.
|
||||
*
|
||||
* @param accessToken The access token (without "Bearer " prefix).
|
||||
* @return A Mono emitting the VerificationResponse containing token details and user info.
|
||||
* Emits an error if the token is invalid, expired, or introspection fails.
|
||||
*/
|
||||
Mono<VerificationResponse> verifyToken(String accessToken);
|
||||
|
||||
/**
|
||||
* Logs out a user session by revoking the refresh token.
|
||||
*
|
||||
* @param refreshToken The refresh token associated with the user session.
|
||||
* @return A Mono completing successfully upon successful logout/revocation. Emits an error if
|
||||
* revocation fails.
|
||||
*/
|
||||
Mono<Void> logout(String refreshToken);
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package com.cleverthis.authservice.service.impl;
|
||||
|
||||
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.TokenVerificationException;
|
||||
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
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 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;
|
||||
|
||||
|
||||
@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",
|
||||
keycloakServerUrl, this.realm);
|
||||
}
|
||||
|
||||
private String getRevocationEndpoint() {
|
||||
return String.format("%s/realms/%s/protocol/openid-connect/revoke", this.keycloakServerUrl,
|
||||
this.realm);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public KeycloakIdentityManagerService(WebClient keycloakWebClient) {
|
||||
this.webClient = keycloakWebClient;
|
||||
}
|
||||
|
||||
@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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Spring Boot application configuration
|
||||
server:
|
||||
port: ${AUTH_SERVICE_PORT:8099} # Port the auth-service will run on
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: auth-service
|
||||
|
||||
# Keycloak Configuration (adjust values as needed)
|
||||
keycloak:
|
||||
server-url: ${KEYCLOAK_AUTH_SERVER_URL:http://localhost:9100} # Base URL of your Keycloak instance (NO trailing slash)
|
||||
realm: ${KEYCLOAK_AUTH_REALM:myrealm} # The realm name you are using
|
||||
client-id: ${KEYCLOAK_AUTH_SERVICE_Client:test-client} # Client ID created in Keycloak for this service
|
||||
client-secret: ${KEYCLOAK_AUTH_SERVICE_SECRET:7Xyh7M6Tc1FvwznY265KcLzcmXoWmjs6} # Client Secret from Keycloak (use secrets management!)
|
||||
# Grant type specific settings (ROPC for /login)
|
||||
grant-type: password
|
||||
|
||||
logging:
|
||||
level:
|
||||
# Set log levels as needed, e.g., DEBUG for Keycloak interactions
|
||||
com.clevermicro.authservice.service.impl: DEBUG
|
||||
org.springframework.web.client.RestTemplate: DEBUG
|
||||
reactor.netty.http.client: DEBUG
|
||||
|
||||
|
||||
# --- Security Note ---
|
||||
# Storing secrets like client-secret directly in application.yml is NOT recommended for production.
|
||||
# Use environment variables, Docker secrets, Vault, or Spring Cloud Config Server.
|
||||
# Example using environment variables:
|
||||
# keycloak:
|
||||
# client-secret: ${KEYCLOAK_AUTH_SERVICE_SECRET}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.cleverthis.authservice; // Assuming correct package
|
||||
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.cleverthis.authservice.controller.AuthController;
|
||||
import com.cleverthis.authservice.dto.LoginRequest;
|
||||
import com.cleverthis.authservice.dto.LogoutRequest;
|
||||
import com.cleverthis.authservice.dto.TokenResponse;
|
||||
import com.cleverthis.authservice.dto.VerificationResponse;
|
||||
import com.cleverthis.authservice.exception.AuthenticationException;
|
||||
import com.cleverthis.authservice.exception.GlobalExceptionHandler;
|
||||
import com.cleverthis.authservice.exception.LogoutException;
|
||||
import com.cleverthis.authservice.exception.TokenVerificationException;
|
||||
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
|
||||
import org.springframework.context.annotation.Import; // Import annotation
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Unit tests for the AuthController using WebFluxTest and Mockito. Focuses on testing the
|
||||
* controller logic and request/response handling.
|
||||
*/
|
||||
@WebFluxTest(AuthController.class)
|
||||
@Import(GlobalExceptionHandler.class) class AuthControllerTest {
|
||||
|
||||
@Autowired
|
||||
private WebTestClient webTestClient; // Test client for making requests to the controller
|
||||
|
||||
@MockitoBean // Creates a Mockito mock for the service layer
|
||||
private IdentityManagerService identityManagerService;
|
||||
|
||||
private TokenResponse mockTokenResponse;
|
||||
private VerificationResponse mockVerificationResponse;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// Setup mock responses for successful scenarios
|
||||
this.mockTokenResponse = new TokenResponse("access-123", 300, 1800, "refresh-456", "Bearer",
|
||||
"id-789", 0, "state", "openid");
|
||||
this.mockVerificationResponse = new VerificationResponse(true, "test-client", "testuser",
|
||||
"openid", "sub-123", System.currentTimeMillis() / 1000 + 300,
|
||||
System.currentTimeMillis() / 1000, System.currentTimeMillis() / 1000, "aud", "iss",
|
||||
"Bearer", "test@example.com", true, "testuser");
|
||||
}
|
||||
|
||||
// --- /login Tests ---
|
||||
|
||||
@Test
|
||||
void login_Success_ReturnsTokenResponse() {
|
||||
LoginRequest loginRequest = new LoginRequest("testuser", "password");
|
||||
when(this.identityManagerService.login("testuser", "password"))
|
||||
.thenReturn(Mono.just(this.mockTokenResponse));
|
||||
|
||||
this.webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(loginRequest).exchange().expectStatus().isOk() // Expect 200
|
||||
.expectBody(TokenResponse.class)
|
||||
.value(TokenResponse::getAccessToken, equalTo("access-123"))
|
||||
.value(TokenResponse::getRefreshToken, equalTo("refresh-456"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_ServiceThrowsAuthException_ReturnsUnauthorized() {
|
||||
LoginRequest loginRequest = new LoginRequest("wronguser", "wrongpass");
|
||||
// Mock the service to throw the specific exception handled by GlobalExceptionHandler
|
||||
when(this.identityManagerService.login("wronguser", "wrongpass"))
|
||||
.thenReturn(Mono.error(new AuthenticationException("Invalid credentials")));
|
||||
|
||||
this. webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(loginRequest).exchange().expectStatus().isUnauthorized(); // Expect 401
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_InvalidRequest_ReturnsBadRequest() {
|
||||
LoginRequest loginRequest = new LoginRequest("", "");
|
||||
|
||||
// No need to mock service for validation errors
|
||||
// The framework and GlobalExceptionHandler (via parent) should handle this
|
||||
|
||||
this.webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(loginRequest).exchange().expectStatus().isBadRequest(); // Expect 400
|
||||
}
|
||||
|
||||
// --- /verify Tests ---
|
||||
|
||||
@Test
|
||||
void verify_Success_ReturnsVerificationResponse() {
|
||||
String validToken = "valid-token-123";
|
||||
when(this.identityManagerService.verifyToken(validToken))
|
||||
.thenReturn(Mono.just(this.mockVerificationResponse));
|
||||
|
||||
this.webTestClient.post().uri("/verify")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken).exchange().expectStatus()
|
||||
.isOk() // Expect 200
|
||||
.expectBody(VerificationResponse.class)
|
||||
.value(VerificationResponse::isActive, equalTo(true))
|
||||
.value(VerificationResponse::getUsername, equalTo("testuser"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void verify_ServiceThrowsVerificationException_ReturnsUnauthorized() {
|
||||
String invalidToken = "invalid-token-456";
|
||||
// Mock the service to throw the specific exception
|
||||
when(this.identityManagerService.verifyToken(invalidToken))
|
||||
.thenReturn(Mono.error(new TokenVerificationException("Token expired")));
|
||||
|
||||
this.webTestClient.post().uri("/verify")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidToken).exchange()
|
||||
.expectStatus().isUnauthorized(); // Expect 401 (handled by GlobalExceptionHandler)
|
||||
}
|
||||
|
||||
@Test
|
||||
void verify_MissingAuthHeader_ReturnsBadRequest() { // Renamed test slightly for clarity
|
||||
// No service mocking needed, framework handles missing required header
|
||||
this.webTestClient.post().uri("/verify").exchange().expectStatus().isBadRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void verify_InvalidAuthHeaderFormat_ReturnsUnauthorized() {
|
||||
// No service mocking needed, controller handles this directly
|
||||
this.webTestClient.post().uri("/verify")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Basic somecredentials") // Wrong scheme
|
||||
.exchange().expectStatus().isUnauthorized(); // Expect 401
|
||||
}
|
||||
|
||||
// --- /logout Tests ---
|
||||
|
||||
@Test
|
||||
void logout_Success_ReturnsNoContent() {
|
||||
LogoutRequest logoutRequest = new LogoutRequest("refresh-token-to-revoke");
|
||||
when(this.identityManagerService.logout(logoutRequest.getRefreshToken()))
|
||||
.thenReturn(Mono.empty()); // Mono<Void> completes successfully
|
||||
|
||||
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(logoutRequest).exchange().expectStatus().isNoContent(); // Expect 204
|
||||
}
|
||||
|
||||
@Test
|
||||
void logout_ServiceThrowsException_ReturnsInternalServerError() {
|
||||
LogoutRequest logoutRequest = new LogoutRequest("refresh-token-error");
|
||||
// Mock service to throw the specific exception
|
||||
when(this.identityManagerService.logout(logoutRequest.getRefreshToken()))
|
||||
.thenReturn(Mono.error(new LogoutException("Keycloak unavailable")));
|
||||
|
||||
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(logoutRequest).exchange().expectStatus().is5xxServerError();
|
||||
}
|
||||
|
||||
@Test
|
||||
void logout_InvalidRequest_ReturnsBadRequest() {
|
||||
LogoutRequest logoutRequest = new LogoutRequest("");
|
||||
// Invalid request (blank token)
|
||||
// No service mocking needed
|
||||
|
||||
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(logoutRequest).exchange().expectStatus().isBadRequest();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user
Please add the actual token string to the log message, this will help greatly with debugging (and invalid token leaks no security info)
Added