Skip to content

Commit

Permalink
feature/compromised-passwords (#26)
Browse files Browse the repository at this point in the history
  • Loading branch information
hardikSinghBehl authored May 27, 2024
1 parent 0eb43cf commit b31bf39
Show file tree
Hide file tree
Showing 14 changed files with 289 additions and 66 deletions.
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
## JWT Authentication and Authorization Flow using Spring Security
##### A reference proof-of-concept that leverages Spring-security to implement JWT based authentication, API access control and Token revocation.
##### A reference proof-of-concept that leverages Spring-security to implement JWT based authentication, API access control, Token revocation and Compromised password detection.
##### 🛠 upgraded to Spring Boot 3 and Spring Security 6 🛠

### Key Components
Expand Down Expand Up @@ -79,6 +79,34 @@ The below API response is returned when authentication succeeds i.e Access Token
```
If the user's permissions have changed, the client can leverage available refresh token to request a new JWT, reflecting the new permissions that the user has obtained.

### Compromised Password Detection

To protect user accounts from the use of vulnerable passwords that have been exposed in data breaches, the project uses the new compromised password detection feature added in `spring-security:6.3`. The default implementation provided uses the [Have I Been Pwned API](https://haveibeenpwned.com/API/v3#PwnedPasswords) under the hood.

The compromised password check is performed during two key scenarios:
* **User creation**: When a new user is being registered via the user creation API, the provided password is checked.
* **User Login**: Even if a password was not compromised at the time of user creation, it may become compromised at a later point. To address this, the login API also incorporates the compromised password check.

In case of compromised password detection in any of the above scenarios, the server responds with the below error:

```json
{
"Status": "422 UNPROCESSABLE_ENTITY",
"Description": "The provided password is compromised and cannot be used for account creation."
}
```

To recover from a compromised password situation during login, a new API endpoint PUT `/users/reset-password` is exposed. This endpoint accepts the below request body payload:

```json
{
"EmailId": "hardik@behl.com",
"CurrentPassword": "somethingCompromised",
"NewPassword": "somethingSecured"
}
```
The new password is also checked for compromise before allowing the password reset.

---
### Local Setup
The below given commands can be executed in the project's base directory to build an image and start required container(s). Docker compose will initiate a MySQL and Redis container as well, with the backend swagger-ui accessible at `http://localhost:8080/swagger-ui.html`
Expand Down
57 changes: 34 additions & 23 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.3</version>
<version>3.3.0</version>
<relativePath />
</parent>

Expand All @@ -20,7 +20,7 @@
<properties>
<java.version>21</java.version>
<jjwt.version>0.12.5</jjwt.version>
<springdoc.version>2.3.0</springdoc.version>
<springdoc.version>2.5.0</springdoc.version>
</properties>

<developers>
Expand All @@ -38,34 +38,20 @@
</developers>

<dependencies>
<!-- API dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<!-- Database dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
Expand All @@ -75,10 +61,11 @@
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>

<!-- Security dependencies -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
Expand All @@ -90,6 +77,30 @@
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
</dependency>

<!-- Cache dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- Devtools dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>

<!-- Test dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.password.CompromisedPasswordChecker;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.password.HaveIBeenPwnedRestApiPasswordChecker;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
Expand Down Expand Up @@ -73,6 +75,11 @@ public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
public CompromisedPasswordChecker compromisedPasswordChecker() {
return new HaveIBeenPwnedRestApiPasswordChecker();
}

private CorsConfigurationSource corsConfigurationSource() {
final var corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowedOrigins(List.of("*"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ public class AuthenticationController {
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Authentication successfull"),
@ApiResponse(responseCode = "401", description = "Bad credentials provided. Failed to authenticate user",
content = @Content(schema = @Schema(implementation = ExceptionResponseDto.class))),
@ApiResponse(responseCode = "422", description = "Password has been compromised",
content = @Content(schema = @Schema(implementation = ExceptionResponseDto.class))) })
public ResponseEntity<TokenSuccessResponseDto> login(@Valid @RequestBody final UserLoginRequestDto userLoginRequest) {
final var tokenResponse = authenticationService.login(userLoginRequest);
Expand All @@ -59,4 +61,4 @@ public ResponseEntity<TokenSuccessResponseDto> refreshToken() {
return ResponseEntity.ok(tokenResponse);
}

}
}
20 changes: 19 additions & 1 deletion src/main/java/com/behl/cerberus/controller/UserController.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import com.behl.cerberus.configuration.PublicEndpoint;
import com.behl.cerberus.dto.ExceptionResponseDto;
import com.behl.cerberus.dto.ResetPasswordRequestDto;
import com.behl.cerberus.dto.UserCreationRequestDto;
import com.behl.cerberus.dto.UserDetailDto;
import com.behl.cerberus.dto.UserUpdationRequestDto;
Expand Down Expand Up @@ -45,6 +46,8 @@ public class UserController {
@ApiResponse(responseCode = "201", description = "User account created successfully",
content = @Content(schema = @Schema(implementation = Void.class))),
@ApiResponse(responseCode = "409", description = "User account with provided email-id already exists",
content = @Content(schema = @Schema(implementation = ExceptionResponseDto.class))),
@ApiResponse(responseCode = "422", description = "Provided password is compromised",
content = @Content(schema = @Schema(implementation = ExceptionResponseDto.class))) })
public ResponseEntity<HttpStatus> createUser(@Valid @RequestBody final UserCreationRequestDto userCreationRequest) {
userService.create(userCreationRequest);
Expand Down Expand Up @@ -72,6 +75,21 @@ public ResponseEntity<UserDetailDto> retrieveUser() {
return ResponseEntity.ok(userDetail);
}

@PublicEndpoint
@PutMapping(value = "/reset-password", consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "Resets user's current password", description = "Non secured endpoint to help user reset their current password with a new password of choosing.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Password reset successfully",
content = @Content(schema = @Schema(implementation = Void.class))),
@ApiResponse(responseCode = "401", description = "No user account exists with given email/current-password combination.",
content = @Content(schema = @Schema(implementation = ExceptionResponseDto.class))),
@ApiResponse(responseCode = "422", description = "Provided new password is compromised",
content = @Content(schema = @Schema(implementation = ExceptionResponseDto.class))) })
public ResponseEntity<HttpStatus> resetPassword(@Valid @RequestBody final ResetPasswordRequestDto resetPasswordRequest) {
userService.resetPassword(resetPasswordRequest);
return ResponseEntity.status(HttpStatus.OK).build();
}

@DeleteMapping(value = "/deactivate")
@Operation(summary = "Deactivates current logged-in user's profile", description = "Deactivates user's profile: can only be undone by praying to a higher power or contacting our vanished customer support.")
@ApiResponse(responseCode = "204", description = "User profile successfully deactivated",
Expand All @@ -83,4 +101,4 @@ public ResponseEntity<HttpStatus> deactivateUser(){
return ResponseEntity.noContent().build();
}

}
}
32 changes: 32 additions & 0 deletions src/main/java/com/behl/cerberus/dto/ResetPasswordRequestDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.behl.cerberus.dto;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;

import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@JsonNaming(value = PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@Schema(title = "ResetPasswordRequest", accessMode = Schema.AccessMode.WRITE_ONLY)
public class ResetPasswordRequestDto {

@NotBlank(message = "email-id must not be empty")
@Email(message = "email-id must be of valid format")
@Schema(requiredMode = RequiredMode.REQUIRED, description = "email-id of user", example = "behl@gmail.com")
private String emailId;

@NotBlank(message = "current-password must not be empty")
@Schema(requiredMode = RequiredMode.REQUIRED, description = "current-password of the user")
private String currentPassword;

@NotBlank(message = "new-password must not be empty")
@Schema(requiredMode = RequiredMode.REQUIRED, description = "new-password of the user")
private String newPassword;

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.password.CompromisedPasswordException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
Expand All @@ -34,7 +34,6 @@ public class ExceptionResponseHandler extends ResponseEntityExceptionHandler {
private static final String FORBIDDEN_ERROR_MESSAGE = "Access Denied: You do not have sufficient privileges to access this resource.";
private static final String NOT_READABLE_REQUEST_ERROR_MESSAGE = "The request is malformed. Ensure the JSON structure is correct.";

@ResponseBody
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ExceptionResponseDto<String>> responseStatusExceptionHandler(final ResponseStatusException exception) {
logException(exception);
Expand All @@ -44,6 +43,16 @@ public ResponseEntity<ExceptionResponseDto<String>> responseStatusExceptionHandl
return ResponseEntity.status(exception.getStatusCode()).body(exceptionResponse);
}

@ExceptionHandler(CompromisedPasswordException.class)
public ResponseEntity<ExceptionResponseDto<String>> compromisedPasswordExceptionHandler(final CompromisedPasswordException exception) {
logException(exception);
final var statusCode = HttpStatus.UNPROCESSABLE_ENTITY;
final var exceptionResponse = new ExceptionResponseDto<String>();
exceptionResponse.setStatus(statusCode.toString());
exceptionResponse.setDescription(exception.getMessage());
return ResponseEntity.status(statusCode).body(exceptionResponse);
}

@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ExceptionResponseDto<String>> accessDeniedExceptionHandler(final AccessDeniedException exception) {
logException(exception);
Expand Down Expand Up @@ -97,7 +106,6 @@ protected ResponseEntity<Object> handleHttpMessageNotReadable(HttpMessageNotRead
return ResponseEntity.badRequest().body(exceptionResponse);
}

@ResponseBody
@ExceptionHandler(Exception.class)
public ResponseEntity<?> serverExceptionHandler(final Exception exception) {
logException(exception);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.behl.cerberus.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;

import lombok.NonNull;

public class InvalidCredentialsException extends ResponseStatusException {

private static final long serialVersionUID = 7439642984069939024L;

public InvalidCredentialsException(@NonNull final String message) {
super(HttpStatus.UNAUTHORIZED, message);
}

}

This file was deleted.

16 changes: 12 additions & 4 deletions src/main/java/com/behl/cerberus/service/AuthenticationService.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
import java.util.UUID;

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.security.authentication.password.CompromisedPasswordChecker;
import org.springframework.security.authentication.password.CompromisedPasswordException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import com.behl.cerberus.configuration.TokenConfigurationProperties;
import com.behl.cerberus.dto.TokenSuccessResponseDto;
import com.behl.cerberus.dto.UserLoginRequestDto;
import com.behl.cerberus.exception.InvalidLoginCredentialsException;
import com.behl.cerberus.exception.InvalidCredentialsException;
import com.behl.cerberus.exception.TokenVerificationException;
import com.behl.cerberus.repository.UserRepository;
import com.behl.cerberus.utility.CacheManager;
Expand All @@ -30,17 +32,23 @@ public class AuthenticationService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final RefreshTokenGenerator refreshTokenGenerator;
private final CompromisedPasswordChecker compromisedPasswordChecker;
private final TokenConfigurationProperties tokenConfigurationProperties;

public TokenSuccessResponseDto login(@NonNull final UserLoginRequestDto userLoginRequestDto) {
final var user = userRepository.findByEmailId(userLoginRequestDto.getEmailId())
.orElseThrow(InvalidLoginCredentialsException::new);
.orElseThrow(() -> new InvalidCredentialsException("Invalid login credentials provided."));

final var encodedPassword = user.getPassword();
final var plainTextPassword = userLoginRequestDto.getPassword();
final var isCorrectPassword = passwordEncoder.matches(plainTextPassword, encodedPassword);
if (Boolean.FALSE.equals(isCorrectPassword)) {
throw new InvalidLoginCredentialsException();
throw new InvalidCredentialsException("Invalid login credentials provided.");
}

final var isPasswordCompromised = compromisedPasswordChecker.check(plainTextPassword).isCompromised();
if (Boolean.TRUE.equals(isPasswordCompromised)) {
throw new CompromisedPasswordException("Password has been compromised. Password reset required.");
}

final var accessToken = jwtUtility.generateAccessToken(user);
Expand All @@ -64,4 +72,4 @@ public TokenSuccessResponseDto refreshToken(@NonNull final String refreshToken)
.build();
}

}
}
Loading

0 comments on commit b31bf39

Please sign in to comment.