Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import vaultweb.apigateway.dto.request.ChangePasswordRequest;
import vaultweb.apigateway.dto.request.LoginRequest;
import vaultweb.apigateway.dto.request.UserRegistrationRequest;
import vaultweb.apigateway.dto.response.AuthResponse;
Expand Down Expand Up @@ -70,8 +71,8 @@ public String changeEmail() {
}

@PostMapping("change-password")
public String changePassword() {
return "changePassword";
public Mono<Void> changePassword(@Valid @RequestBody ChangePasswordRequest request) {
return authService.changePassword(request);
}

@PostMapping("/reset-password")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package vaultweb.apigateway.dto.request;

import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;

public record ChangePasswordRequest(
@NotEmpty(message = "Your old password logic that it cannot be empty") String oldPassword,
@NotEmpty(message = "Your new password logic that it cannot be empty")
@Pattern(
regexp = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\\S+$).{8,}$",
message =
"password must be at least 8 characters long and include at least one uppercase letter, one lowercase letter, one digit, and one special character")
String newPassword) {}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import org.springframework.stereotype.Service;

import vaultweb.apigateway.dto.request.ChangePasswordRequest;
import vaultweb.apigateway.dto.request.LoginRequest;
import vaultweb.apigateway.dto.request.UserRegistrationRequest;
import vaultweb.apigateway.dto.response.AuthResponse;
Expand Down Expand Up @@ -105,6 +106,28 @@ public Mono<AuthResponse> login(LoginRequest request) {
});
}

public Mono<Void> changePassword(ChangePasswordRequest request) {
return securityContextUtil
.getAuthenticatedUsername()
.flatMap(username -> userRepository.findByUsername(username))
.switchIfEmpty(
Mono.error(
new DefaultException(
"username from token has no registered user",
DefaultExceptionLevels.AUTHENTICATION_EXCEPTION)))
.flatMap(
user -> {
if (!BcryptUtil.matches(request.oldPassword(), user.getPassword())) {
return Mono.error(
new DefaultException(
"Invalid current password",
DefaultExceptionLevels.AUTHENTICATION_EXCEPTION));
}
user.setPassword(BcryptUtil.encode(request.newPassword()));
return userRepository.save(user).then();
});
}

public Mono<AuthResponse> switchToken(String token) {
// find refresh
return refreshTokenRepository
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package vaultweb.apigateway.controller;

import java.util.Map;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.reactive.server.WebTestClient;

/**
* Integration tests for the protected {@code /auth/change-password} endpoint exposed by {@link
* GatewayAuthController}. Each test registers and logs in a fresh user to obtain a valid access
* token before exercising the endpoint, so the full controller -> service -> repository path is
* covered end to end.
*/
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
@ActiveProfiles("test")
class GatewayAuthControllerChangePasswordTest {

private static final String VALID_PASSWORD = "Test@1234";
private static final String NEW_VALID_PASSWORD = "NewTest@5678";

@Autowired private WebTestClient webTestClient;

private void register(String name, String username, String email, String password) {
webTestClient
.post()
.uri("/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("name", name, "username", username, "email", email, "password", password))
.exchange()
.expectStatus()
.isCreated();
}

private String loginAndGetAccessToken(String emailUsername, String password) {
byte[] responseBody =
webTestClient
.post()
.uri("/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("emailUsername", emailUsername, "password", password))
.exchange()
.expectStatus()
.isOk()
.expectBody()
.returnResult()
.getResponseBody();

return readJsonField(responseBody, "accessToken");
}

private WebTestClient.ResponseSpec changePassword(
String accessToken, String oldPassword, String newPassword) {
WebTestClient.RequestBodySpec request =
webTestClient.post().uri("/auth/change-password").contentType(MediaType.APPLICATION_JSON);

if (accessToken != null) {
request = request.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
}

return request
.bodyValue(Map.of("oldPassword", oldPassword, "newPassword", newPassword))
.exchange();
}

@Test
void changePassword_withValidCredentials_returnsOk() {
register("Karl", "karl", "karl@example.com", VALID_PASSWORD);
String accessToken = loginAndGetAccessToken("karl@example.com", VALID_PASSWORD);

changePassword(accessToken, VALID_PASSWORD, NEW_VALID_PASSWORD).expectStatus().isOk();
}

@Test
void changePassword_withoutToken_isUnauthorized() {
changePassword(null, VALID_PASSWORD, NEW_VALID_PASSWORD).expectStatus().isUnauthorized();
}

@Test
void changePassword_withWrongOldPassword_isUnauthorized() {
register("Liam", "liam", "liam@example.com", VALID_PASSWORD);
String accessToken = loginAndGetAccessToken("liam@example.com", VALID_PASSWORD);

changePassword(accessToken, "Wrong@1234", NEW_VALID_PASSWORD).expectStatus().isUnauthorized();
}

@Test
void changePassword_withWeakNewPassword_isRejected() {
register("Mona", "mona", "mona@example.com", VALID_PASSWORD);
String accessToken = loginAndGetAccessToken("mona@example.com", VALID_PASSWORD);

changePassword(accessToken, VALID_PASSWORD, "weak").expectStatus().isBadRequest();
}

@Test
void changePassword_persistsNewPassword_oldPasswordNoLongerWorks() {
register("Nina", "nina", "nina@example.com", VALID_PASSWORD);
String accessToken = loginAndGetAccessToken("nina@example.com", VALID_PASSWORD);

changePassword(accessToken, VALID_PASSWORD, NEW_VALID_PASSWORD).expectStatus().isOk();

webTestClient
.post()
.uri("/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("emailUsername", "nina@example.com", "password", NEW_VALID_PASSWORD))
.exchange()
.expectStatus()
.isOk();

webTestClient
.post()
.uri("/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("emailUsername", "nina@example.com", "password", VALID_PASSWORD))
.exchange()
.expectStatus()
.isUnauthorized();
}

private static String readJsonField(byte[] json, String field) {
try {
return new com.fasterxml.jackson.databind.ObjectMapper().readTree(json).get(field).asText();
} catch (Exception e) {
throw new IllegalStateException("Could not read field '" + field + "' from response", e);
}
}
}
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ services:
POSTGRES_PASSWORD: password
POSTGRES_DB: auth_gateway_db
ports:
- "5432:5432"
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data

Expand Down