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