From d1562d319e5909794661185f4c8b846d637a1ef3 Mon Sep 17 00:00:00 2001 From: edk12564 Date: Sat, 13 Jun 2026 19:21:33 -0500 Subject: [PATCH 1/3] FINERACT-2641: Implement Authentication Feature - implemented jwt auth - used ES256 for asymmetric jwt generation - 2FA implemented with OTP - device fingerprints checked in login - password and username columns added - passwords hashed with bcrypt - generate jwt key script --- .github/workflows/build-tests-backend.yml | 3 + .github/workflows/build-tests-frontend.yml | 4 + consumer/.gitignore | 3 + consumer/build.gradle | 2 +- consumer/compose.yaml | 3 + consumer/scripts/generate-dev-jwt-key.sh | 39 +++ .../api/AuthenticationCommandController.java | 134 ++++++++++ .../command/data/AuthenticationConstants.java | 30 +++ .../data/EstablishedSessionCommandData.java | 42 ++++ .../data/LoginChallengeCommandData.java | 41 ++++ .../command/data/LoginCommand.java | 36 +++ .../command/data/LoginCommandRequest.java | 41 ++++ .../command/data/LogoutCommand.java | 34 +++ .../command/data/RefreshSessionCommand.java | 35 +++ .../command/data/SessionCommandData.java | 42 ++++ .../command/data/VerifyTwoFactorCommand.java | 36 +++ .../data/VerifyTwoFactorCommandRequest.java | 39 +++ .../command/domain/RefreshToken.java | 84 +++++++ .../InvalidCredentialsException.java | 32 +++ .../RefreshTokenInvalidException.java | 32 +++ .../exception/TwoFactorInvalidException.java | 38 +++ .../RefreshTokenCommandRepository.java | 32 +++ .../service/AuthenticationCommandService.java | 38 +++ .../AuthenticationCommandServiceImpl.java | 228 ++++++++++++++++++ .../infrastructure/command/Command.java | 3 +- .../configs/AuthenticationConfig.java | 28 +++ .../configs/AuthenticationProperties.java | 36 +++ .../infrastructure/configs/JwtConfig.java | 132 ++++++++++ .../infrastructure/configs/JwtProperties.java | 34 +++ .../configs/PasswordEncoderConfig.java | 34 +++ .../configs/SecurityConfig.java | 15 +- .../infrastructure/jwt/IssuedJwt.java | 39 +++ .../infrastructure/jwt/JwtClaims.java | 30 +++ .../infrastructure/jwt/JwtIssuer.java | 58 +++++ .../consumer/infrastructure/query/Query.java | 3 +- .../infrastructure/web/ConsumerHeaders.java | 28 +++ ...eliveryMethod.java => OtpDestination.java} | 4 +- .../otp/command/data/OtpMetadata.java | 2 +- .../consumer/otp/command/data/PendingOtp.java | 4 +- ...va => OtpDestinationInvalidException.java} | 4 +- .../command/service/OtpCommandService.java | 4 +- .../service/OtpCommandServiceImpl.java | 18 +- .../api/RegistrationCommandController.java | 4 +- .../data/SubmitRegistrationCommand.java | 1 + .../SubmitRegistrationCommandRequest.java | 7 + .../RegistrationCommandServiceImpl.java | 13 +- .../user/command/data/CreateUserCommand.java | 3 +- .../consumer/user/command/domain/User.java | 15 +- .../user/command/domain/UserStatus.java | 1 - .../command/service/UserCommandService.java | 2 - .../service/UserCommandServiceImpl.java | 9 +- .../query/data/UserCredentialsQueryData.java | 40 +++ .../query/repository/UserQueryRepository.java | 15 ++ .../user/query/service/UserQueryService.java | 6 + .../query/service/UserQueryServiceImpl.java | 15 ++ .../src/main/resources/application.properties | 12 + .../changes/001-create-users-table.yaml | 7 +- .../changes/002-create-refresh-tokens.yaml | 78 ++++++ .../db/changelog/db.changelog-master.yaml | 2 + .../MailpitClient.java} | 27 ++- .../cucumber/steps/RegistrationSteps.java | 42 +++- .../resources/features/registration.feature | 14 +- 62 files changed, 1773 insertions(+), 64 deletions(-) create mode 100755 consumer/scripts/generate-dev-jwt-key.sh create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/authentication/command/api/AuthenticationCommandController.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/AuthenticationConstants.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/EstablishedSessionCommandData.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/LoginChallengeCommandData.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/LoginCommand.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/LoginCommandRequest.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/LogoutCommand.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/RefreshSessionCommand.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/SessionCommandData.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/VerifyTwoFactorCommand.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/VerifyTwoFactorCommandRequest.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/authentication/command/domain/RefreshToken.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/authentication/command/exception/InvalidCredentialsException.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/authentication/command/exception/RefreshTokenInvalidException.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/authentication/command/exception/TwoFactorInvalidException.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/authentication/command/repository/RefreshTokenCommandRepository.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/authentication/command/service/AuthenticationCommandService.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/authentication/command/service/AuthenticationCommandServiceImpl.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/AuthenticationConfig.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/AuthenticationProperties.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/JwtConfig.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/JwtProperties.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/PasswordEncoderConfig.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/infrastructure/jwt/IssuedJwt.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/infrastructure/jwt/JwtClaims.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/infrastructure/jwt/JwtIssuer.java create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/infrastructure/web/ConsumerHeaders.java rename consumer/src/main/java/org/apache/fineract/consumer/otp/command/data/{OtpDeliveryMethod.java => OtpDestination.java} (93%) rename consumer/src/main/java/org/apache/fineract/consumer/otp/command/exception/{OtpDeliveryMethodInvalidException.java => OtpDestinationInvalidException.java} (89%) create mode 100644 consumer/src/main/java/org/apache/fineract/consumer/user/query/data/UserCredentialsQueryData.java create mode 100644 consumer/src/main/resources/db/changelog/changes/002-create-refresh-tokens.yaml rename consumer/src/test/java/org/apache/fineract/consumer/cucumber/{helpers/MailpitProbe.java => clients/MailpitClient.java} (81%) diff --git a/.github/workflows/build-tests-backend.yml b/.github/workflows/build-tests-backend.yml index 50c52d4..d98f1e1 100644 --- a/.github/workflows/build-tests-backend.yml +++ b/.github/workflows/build-tests-backend.yml @@ -65,6 +65,9 @@ jobs: with: validate-wrappers: true + - name: Generate dev JWT signing key + run: ./scripts/generate-dev-jwt-key.sh + - name: Start application stack run: docker compose up -d --build --wait --wait-timeout 180 diff --git a/.github/workflows/build-tests-frontend.yml b/.github/workflows/build-tests-frontend.yml index 9488803..ddc6bb9 100644 --- a/.github/workflows/build-tests-frontend.yml +++ b/.github/workflows/build-tests-frontend.yml @@ -103,6 +103,10 @@ jobs: if: steps.pw-cache.outputs.cache-hit == 'true' run: npx playwright install-deps chromium + - name: Generate dev JWT signing key + working-directory: ${{ github.workspace }} + run: ./consumer/scripts/generate-dev-jwt-key.sh + - name: Bring up e2e stack working-directory: ${{ github.workspace }} run: docker compose -f docker-compose.e2e.yml up -d --build --wait --wait-timeout 360 diff --git a/consumer/.gitignore b/consumer/.gitignore index 1415ffb..424bb6b 100644 --- a/consumer/.gitignore +++ b/consumer/.gitignore @@ -52,3 +52,6 @@ out/ ### VS Code ### .vscode/ + +### Local secrets — never commit ### +dev-jwt-key.pem diff --git a/consumer/build.gradle b/consumer/build.gradle index 84b0c75..e709874 100644 --- a/consumer/build.gradle +++ b/consumer/build.gradle @@ -91,7 +91,7 @@ tasks.named('test') { cucumber { featurePath = 'src/test/resources/features' - glue = 'org.apache.fineract.consumer.cucumber.steps' + glue = 'org.apache.fineract.consumer.cucumber' plugin = ['pretty'] } diff --git a/consumer/compose.yaml b/consumer/compose.yaml index 547fb8d..67a43fa 100644 --- a/consumer/compose.yaml +++ b/consumer/compose.yaml @@ -100,7 +100,10 @@ services: FINERACT_BASE_URL: http://fineract:8080/fineract-provider/api/v1 SPRING_MAIL_HOST: mailpit SERVER_FORWARD_HEADERS_STRATEGY: framework + JWT_KEY_LOCATION: file:/etc/bff/jwt-key.pem JAVA_TOOL_OPTIONS: "-Xmx768m -Xms256m" + volumes: + - ./dev-jwt-key.pem:/etc/bff/jwt-key.pem:ro ports: - "8080:8080" healthcheck: diff --git a/consumer/scripts/generate-dev-jwt-key.sh b/consumer/scripts/generate-dev-jwt-key.sh new file mode 100755 index 0000000..9936bad --- /dev/null +++ b/consumer/scripts/generate-dev-jwt-key.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Generates the dev/CI JWT signing keypair (EC P-256, for ES256) as a PEM file. +# Dev-only convenience — prod injects the PEM from a secret manager instead. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +KEY_FILE="dev-jwt-key.pem" + +if [ -f "$KEY_FILE" ]; then + echo "Dev JWT key already exists at consumer/$KEY_FILE — leaving it untouched." + exit 0 +fi + +umask 077 + +openssl ecparam -name prime256v1 -genkey -noout \ + | openssl pkcs8 -topk8 -nocrypt -out "$KEY_FILE" +openssl ec -in "$KEY_FILE" -pubout >> "$KEY_FILE" 2>/dev/null + +echo "Generated dev JWT signing key at consumer/$KEY_FILE" diff --git a/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/api/AuthenticationCommandController.java b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/api/AuthenticationCommandController.java new file mode 100644 index 0000000..786726e --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/api/AuthenticationCommandController.java @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.authentication.command.api; + +import jakarta.validation.Valid; +import java.time.Duration; +import java.time.Instant; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.consumer.authentication.command.data.AuthenticationConstants; +import org.apache.fineract.consumer.authentication.command.data.EstablishedSessionCommandData; +import org.apache.fineract.consumer.authentication.command.data.LoginChallengeCommandData; +import org.apache.fineract.consumer.authentication.command.data.LoginCommand; +import org.apache.fineract.consumer.authentication.command.data.LoginCommandRequest; +import org.apache.fineract.consumer.authentication.command.data.LogoutCommand; +import org.apache.fineract.consumer.authentication.command.data.RefreshSessionCommand; +import org.apache.fineract.consumer.authentication.command.data.SessionCommandData; +import org.apache.fineract.consumer.authentication.command.data.VerifyTwoFactorCommand; +import org.apache.fineract.consumer.authentication.command.data.VerifyTwoFactorCommandRequest; +import org.apache.fineract.consumer.authentication.command.exception.RefreshTokenInvalidException; +import org.apache.fineract.consumer.authentication.command.service.AuthenticationCommandService; +import org.apache.fineract.consumer.infrastructure.configs.AuthenticationProperties; +import org.apache.fineract.consumer.infrastructure.web.ConsumerHeaders; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +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; + +@RestController +@RequestMapping("/api/v1/authentication") +@RequiredArgsConstructor +public class AuthenticationCommandController { + + private static final String REFRESH_COOKIE_PATH = "/api/v1/authentication"; + private static final String SAME_SITE_STRICT = "Strict"; + + private final AuthenticationCommandService authenticationCommandService; + private final AuthenticationProperties authenticationProperties; + + @PostMapping("/login") + public ResponseEntity login( + @Valid @RequestBody LoginCommandRequest request, + @RequestHeader(ConsumerHeaders.DEVICE_FINGERPRINT) String deviceFingerprint) { + LoginCommand command = LoginCommand.builder() + .email(request.getEmail()) + .password(request.getPassword()) + .deviceFingerprint(deviceFingerprint) + .build(); + return ResponseEntity.ok(authenticationCommandService.login(command)); + } + + @PostMapping("/2fa") + public ResponseEntity verifyTwoFactor( + @Valid @RequestBody VerifyTwoFactorCommandRequest request, + @RequestHeader(ConsumerHeaders.DEVICE_FINGERPRINT) String deviceFingerprint) { + VerifyTwoFactorCommand command = VerifyTwoFactorCommand.builder() + .challengeToken(request.getChallengeToken()) + .token(request.getToken()) + .deviceFingerprint(deviceFingerprint) + .build(); + return sessionResponse(authenticationCommandService.verifyTwoFactor(command)); + } + + @PostMapping("/refresh") + public ResponseEntity refresh( + @CookieValue(value = AuthenticationConstants.REFRESH_TOKEN_COOKIE_NAME, required = false) String refreshToken, + @RequestHeader(ConsumerHeaders.DEVICE_FINGERPRINT) String deviceFingerprint) { + if (refreshToken == null) { + throw new RefreshTokenInvalidException(); + } + RefreshSessionCommand command = RefreshSessionCommand.builder() + .refreshToken(refreshToken) + .deviceFingerprint(deviceFingerprint) + .build(); + return sessionResponse(authenticationCommandService.refresh(command)); + } + + @PostMapping("/logout") + public ResponseEntity logout( + @CookieValue(value = AuthenticationConstants.REFRESH_TOKEN_COOKIE_NAME, required = false) String refreshToken) { + if (refreshToken != null) { + authenticationCommandService.logout(LogoutCommand.builder() + .refreshToken(refreshToken) + .build()); + } + return ResponseEntity.noContent() + .header(HttpHeaders.SET_COOKIE, refreshCookie("", Duration.ZERO).toString()) + .build(); + } + + private ResponseEntity sessionResponse(EstablishedSessionCommandData session) { + ResponseCookie cookie = refreshCookie( + session.getRefreshToken(), + Duration.between(Instant.now(), session.getRefreshTokenExpiresAt())); + SessionCommandData body = SessionCommandData.builder() + .accessToken(session.getAccessToken()) + .expiresAt(session.getAccessTokenExpiresAt()) + .build(); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .body(body); + } + + private ResponseCookie refreshCookie(String value, Duration maxAge) { + return ResponseCookie.from(AuthenticationConstants.REFRESH_TOKEN_COOKIE_NAME, value) + .httpOnly(true) + .secure(authenticationProperties.isRefreshCookieSecure()) + .sameSite(SAME_SITE_STRICT) + .path(REFRESH_COOKIE_PATH) + .maxAge(maxAge) + .build(); + } +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/AuthenticationConstants.java b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/AuthenticationConstants.java new file mode 100644 index 0000000..5f05fe2 --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/AuthenticationConstants.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.authentication.command.data; + +public final class AuthenticationConstants { + + private AuthenticationConstants() { + } + + public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token"; + public static final String CHALLENGE_PURPOSE_VALUE = "2fa_challenge"; + public static final String BEARER_TOKEN_TYPE = "Bearer"; +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/EstablishedSessionCommandData.java b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/EstablishedSessionCommandData.java new file mode 100644 index 0000000..7551cfd --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/EstablishedSessionCommandData.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.authentication.command.data; + +import java.time.Instant; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +@Getter +@RequiredArgsConstructor +@Builder +@EqualsAndHashCode +@ToString(onlyExplicitlyIncluded = true) +public final class EstablishedSessionCommandData { + + private final String accessToken; + @ToString.Include + private final Instant accessTokenExpiresAt; + private final String refreshToken; + @ToString.Include + private final Instant refreshTokenExpiresAt; +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/LoginChallengeCommandData.java b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/LoginChallengeCommandData.java new file mode 100644 index 0000000..e4d75a2 --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/LoginChallengeCommandData.java @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.authentication.command.data; + +import java.time.Instant; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +@Getter +@RequiredArgsConstructor +@Builder +@EqualsAndHashCode +@ToString(onlyExplicitlyIncluded = true) +public final class LoginChallengeCommandData { + + private final String challengeToken; + @ToString.Include + private final Instant expiresAt; + @ToString.Include + private final String sentTo; +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/LoginCommand.java b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/LoginCommand.java new file mode 100644 index 0000000..58c22d3 --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/LoginCommand.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.authentication.command.data; + +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +@Getter +@RequiredArgsConstructor +@Builder +@ToString(onlyExplicitlyIncluded = true) +public final class LoginCommand { + + private final String email; + private final String password; + private final String deviceFingerprint; +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/LoginCommandRequest.java b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/LoginCommandRequest.java new file mode 100644 index 0000000..7725c8c --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/LoginCommandRequest.java @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.authentication.command.data; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +@Getter +@RequiredArgsConstructor +@Builder +@ToString(onlyExplicitlyIncluded = true) +public final class LoginCommandRequest { + + @NotBlank + @Email + private final String email; + + @NotBlank + private final String password; +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/LogoutCommand.java b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/LogoutCommand.java new file mode 100644 index 0000000..00e79b0 --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/LogoutCommand.java @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.authentication.command.data; + +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +@Getter +@RequiredArgsConstructor +@Builder +@ToString(onlyExplicitlyIncluded = true) +public final class LogoutCommand { + + private final String refreshToken; +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/RefreshSessionCommand.java b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/RefreshSessionCommand.java new file mode 100644 index 0000000..a606960 --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/RefreshSessionCommand.java @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.authentication.command.data; + +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +@Getter +@RequiredArgsConstructor +@Builder +@ToString(onlyExplicitlyIncluded = true) +public final class RefreshSessionCommand { + + private final String refreshToken; + private final String deviceFingerprint; +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/SessionCommandData.java b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/SessionCommandData.java new file mode 100644 index 0000000..5affc9b --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/SessionCommandData.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.authentication.command.data; + +import java.time.Instant; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +@Getter +@RequiredArgsConstructor +@Builder +@EqualsAndHashCode +@ToString(onlyExplicitlyIncluded = true) +public final class SessionCommandData { + + private final String accessToken; + @ToString.Include + private final Instant expiresAt; + @ToString.Include + @Builder.Default + private final String tokenType = AuthenticationConstants.BEARER_TOKEN_TYPE; +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/VerifyTwoFactorCommand.java b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/VerifyTwoFactorCommand.java new file mode 100644 index 0000000..636cfd1 --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/VerifyTwoFactorCommand.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.authentication.command.data; + +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +@Getter +@RequiredArgsConstructor +@Builder +@ToString(onlyExplicitlyIncluded = true) +public final class VerifyTwoFactorCommand { + + private final String challengeToken; + private final String token; + private final String deviceFingerprint; +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/VerifyTwoFactorCommandRequest.java b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/VerifyTwoFactorCommandRequest.java new file mode 100644 index 0000000..22c8e4c --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/data/VerifyTwoFactorCommandRequest.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.authentication.command.data; + +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +@Getter +@RequiredArgsConstructor +@Builder +@ToString(onlyExplicitlyIncluded = true) +public final class VerifyTwoFactorCommandRequest { + + @NotBlank + private final String challengeToken; + + @NotBlank + private final String token; +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/domain/RefreshToken.java b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/domain/RefreshToken.java new file mode 100644 index 0000000..211172e --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/domain/RefreshToken.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.authentication.command.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "refresh_tokens") +@Getter +@NoArgsConstructor +public class RefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "user_id", nullable = false, updatable = false) + private Long userId; + + @Column(name = "token_hash", nullable = false, unique = true, updatable = false, length = 64) + private String tokenHash; + + @Column(name = "device_fingerprint", nullable = false, updatable = false) + private String deviceFingerprint; + + @Column(name = "issued_at", nullable = false, updatable = false) + private Instant issuedAt; + + @Column(name = "expires_at", nullable = false, updatable = false) + private Instant expiresAt; + + @Column(name = "revoked_at") + private Instant revokedAt; + + @Column(name = "rotated_to") + private Long rotatedTo; + + public static RefreshToken issue(Long userId, String tokenHash, String deviceFingerprint, Instant expiresAt) { + RefreshToken token = new RefreshToken(); + token.userId = userId; + token.tokenHash = tokenHash; + token.deviceFingerprint = deviceFingerprint; + token.issuedAt = Instant.now(); + token.expiresAt = expiresAt; + return token; + } + + public void revoke() { + if (revokedAt == null) { + revokedAt = Instant.now(); + } + } + + public void rotateTo(Long successorId) { + rotatedTo = successorId; + revoke(); + } +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/exception/InvalidCredentialsException.java b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/exception/InvalidCredentialsException.java new file mode 100644 index 0000000..2a5ea38 --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/exception/InvalidCredentialsException.java @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.authentication.command.exception; + +import org.apache.fineract.consumer.infrastructure.exception.AbstractConsumerException; +import org.springframework.http.HttpStatus; + +public class InvalidCredentialsException extends AbstractConsumerException { + + public static final String CODE = "error.msg.consumer.auth.invalid.credentials"; + + public InvalidCredentialsException() { + super(HttpStatus.UNAUTHORIZED, CODE, "invalid email or password"); + } +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/exception/RefreshTokenInvalidException.java b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/exception/RefreshTokenInvalidException.java new file mode 100644 index 0000000..88fc754 --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/exception/RefreshTokenInvalidException.java @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.authentication.command.exception; + +import org.apache.fineract.consumer.infrastructure.exception.AbstractConsumerException; +import org.springframework.http.HttpStatus; + +public class RefreshTokenInvalidException extends AbstractConsumerException { + + public static final String CODE = "error.msg.consumer.auth.refresh.token.invalid"; + + public RefreshTokenInvalidException() { + super(HttpStatus.UNAUTHORIZED, CODE, "invalid refresh token"); + } +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/exception/TwoFactorInvalidException.java b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/exception/TwoFactorInvalidException.java new file mode 100644 index 0000000..7cd2980 --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/exception/TwoFactorInvalidException.java @@ -0,0 +1,38 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.authentication.command.exception; + +import org.apache.fineract.consumer.infrastructure.exception.AbstractConsumerException; +import org.springframework.http.HttpStatus; + +public class TwoFactorInvalidException extends AbstractConsumerException { + + public static final String CODE = "error.msg.consumer.auth.two.factor.invalid"; + + private static final String MESSAGE = "invalid two-factor challenge"; + + public TwoFactorInvalidException() { + super(HttpStatus.UNAUTHORIZED, CODE, MESSAGE); + } + + public TwoFactorInvalidException(Throwable cause) { + super(HttpStatus.UNAUTHORIZED, CODE, MESSAGE, cause); + } +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/repository/RefreshTokenCommandRepository.java b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/repository/RefreshTokenCommandRepository.java new file mode 100644 index 0000000..f3f6a34 --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/repository/RefreshTokenCommandRepository.java @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.authentication.command.repository; + +import java.util.List; +import java.util.Optional; +import org.apache.fineract.consumer.authentication.command.domain.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RefreshTokenCommandRepository extends JpaRepository { + + Optional findByTokenHash(String tokenHash); + + List findByUserId(Long userId); +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/service/AuthenticationCommandService.java b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/service/AuthenticationCommandService.java new file mode 100644 index 0000000..4918066 --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/service/AuthenticationCommandService.java @@ -0,0 +1,38 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.authentication.command.service; + +import org.apache.fineract.consumer.authentication.command.data.EstablishedSessionCommandData; +import org.apache.fineract.consumer.authentication.command.data.LoginChallengeCommandData; +import org.apache.fineract.consumer.authentication.command.data.LoginCommand; +import org.apache.fineract.consumer.authentication.command.data.LogoutCommand; +import org.apache.fineract.consumer.authentication.command.data.RefreshSessionCommand; +import org.apache.fineract.consumer.authentication.command.data.VerifyTwoFactorCommand; + +public interface AuthenticationCommandService { + + LoginChallengeCommandData login(LoginCommand command); + + EstablishedSessionCommandData verifyTwoFactor(VerifyTwoFactorCommand command); + + EstablishedSessionCommandData refresh(RefreshSessionCommand command); + + void logout(LogoutCommand command); +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/service/AuthenticationCommandServiceImpl.java b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/service/AuthenticationCommandServiceImpl.java new file mode 100644 index 0000000..c5c89ba --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/authentication/command/service/AuthenticationCommandServiceImpl.java @@ -0,0 +1,228 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.authentication.command.service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Base64; +import java.util.HexFormat; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.consumer.authentication.command.data.AuthenticationConstants; +import org.apache.fineract.consumer.authentication.command.data.EstablishedSessionCommandData; +import org.apache.fineract.consumer.authentication.command.data.LoginChallengeCommandData; +import org.apache.fineract.consumer.authentication.command.data.LoginCommand; +import org.apache.fineract.consumer.authentication.command.data.LogoutCommand; +import org.apache.fineract.consumer.authentication.command.data.RefreshSessionCommand; +import org.apache.fineract.consumer.authentication.command.data.VerifyTwoFactorCommand; +import org.apache.fineract.consumer.authentication.command.domain.RefreshToken; +import org.apache.fineract.consumer.authentication.command.exception.InvalidCredentialsException; +import org.apache.fineract.consumer.authentication.command.exception.RefreshTokenInvalidException; +import org.apache.fineract.consumer.authentication.command.exception.TwoFactorInvalidException; +import org.apache.fineract.consumer.authentication.command.repository.RefreshTokenCommandRepository; +import org.apache.fineract.consumer.infrastructure.command.Command; +import org.apache.fineract.consumer.infrastructure.configs.AuthenticationProperties; +import org.apache.fineract.consumer.infrastructure.fineractclient.configs.FineractClientProperties; +import org.apache.fineract.consumer.infrastructure.jwt.IssuedJwt; +import org.apache.fineract.consumer.infrastructure.jwt.JwtClaims; +import org.apache.fineract.consumer.infrastructure.jwt.JwtIssuer; +import org.apache.fineract.consumer.otp.command.data.OtpConstants; +import org.apache.fineract.consumer.otp.command.data.OtpDestination; +import org.apache.fineract.consumer.otp.command.exception.OtpTokenInvalidException; +import org.apache.fineract.consumer.otp.command.service.OtpCommandService; +import org.apache.fineract.consumer.user.command.domain.UserStatus; +import org.apache.fineract.consumer.user.query.data.UserCredentialsQueryData; +import org.apache.fineract.consumer.user.query.data.UserQueryData; +import org.apache.fineract.consumer.user.query.service.UserQueryService; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthenticationCommandServiceImpl implements AuthenticationCommandService { + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final int REFRESH_TOKEN_BYTE_LENGTH = 32; + private static final String REFRESH_TOKEN_HASH_ALGORITHM = "SHA-256"; + + private final UserQueryService userQueryService; + private final OtpCommandService otpCommandService; + private final PasswordEncoder passwordEncoder; + private final JwtIssuer jwtIssuer; + private final JwtDecoder jwtDecoder; + private final RefreshTokenCommandRepository refreshTokenCommandRepository; + private final AuthenticationProperties authenticationProperties; + private final FineractClientProperties fineractClientProperties; + + @Override + @Command + public LoginChallengeCommandData login(LoginCommand command) { + UserCredentialsQueryData user = userQueryService.findCredentialsByEmail(command.getEmail()) + .filter(candidate -> candidate.getStatus() == UserStatus.BOUND) + .filter(candidate -> passwordEncoder.matches(command.getPassword(), candidate.getPasswordHash())) + .orElseThrow(InvalidCredentialsException::new); + + OtpDestination destination = OtpDestination.builder() + .deliveryMethod(OtpConstants.EMAIL_DELIVERY_METHOD_NAME) + .target(command.getEmail()) + .build(); + otpCommandService.createOtp(user.getExternalId(), destination); + + IssuedJwt challenge = jwtIssuer.issue( + user.getExternalId().toString(), + Map.of( + JwtClaims.PURPOSE, AuthenticationConstants.CHALLENGE_PURPOSE_VALUE, + JwtClaims.DEVICE_FINGERPRINT, command.getDeviceFingerprint()), + authenticationProperties.getChallengeTokenTtl()); + + return LoginChallengeCommandData.builder() + .challengeToken(challenge.getTokenValue()) + .expiresAt(challenge.getExpiresAt()) + .sentTo(maskEmail(command.getEmail())) + .build(); + } + + @Override + @Command + public EstablishedSessionCommandData verifyTwoFactor(VerifyTwoFactorCommand command) { + Jwt challenge = decodeChallengeToken(command.getChallengeToken()); + boolean purposeValid = AuthenticationConstants.CHALLENGE_PURPOSE_VALUE + .equals(challenge.getClaimAsString(JwtClaims.PURPOSE)); + boolean deviceValid = command.getDeviceFingerprint() + .equals(challenge.getClaimAsString(JwtClaims.DEVICE_FINGERPRINT)); + if (!purposeValid || !deviceValid) { + throw new TwoFactorInvalidException(); + } + + UUID externalId = UUID.fromString(challenge.getSubject()); + try { + otpCommandService.validateOtp(externalId, command.getToken()); + } catch (OtpTokenInvalidException e) { + throw new TwoFactorInvalidException(e); + } + + UserQueryData user = userQueryService.findByExternalId(externalId); + return establishSession(user.getId(), externalId, command.getDeviceFingerprint(), null); + } + + @Override + @Command + public EstablishedSessionCommandData refresh(RefreshSessionCommand command) { + RefreshToken current = refreshTokenCommandRepository.findByTokenHash(sha256Hex(command.getRefreshToken())) + .orElseThrow(RefreshTokenInvalidException::new); + + if (current.getRotatedTo() != null) { + revokeSuccessorChain(current); + throw new RefreshTokenInvalidException(); + } + boolean revoked = current.getRevokedAt() != null; + boolean expired = current.getExpiresAt().isBefore(Instant.now()); + boolean deviceMismatch = !current.getDeviceFingerprint().equals(command.getDeviceFingerprint()); + if (revoked || expired || deviceMismatch) { + throw new RefreshTokenInvalidException(); + } + + UserQueryData user = userQueryService.findById(current.getUserId()); + return establishSession(user.getId(), user.getExternalId(), command.getDeviceFingerprint(), current); + } + + @Override + @Command + public void logout(LogoutCommand command) { + refreshTokenCommandRepository.findByTokenHash(sha256Hex(command.getRefreshToken())) + .ifPresent(token -> { + token.revoke(); + refreshTokenCommandRepository.save(token); + }); + } + + private EstablishedSessionCommandData establishSession(Long userId, UUID externalId, String deviceFingerprint, + RefreshToken predecessor) { + IssuedJwt accessToken = jwtIssuer.issue( + externalId.toString(), + Map.of(JwtClaims.TENANT, fineractClientProperties.getTenantId()), + authenticationProperties.getAccessTokenTtl()); + + String refreshTokenValue = generateRefreshTokenValue(); + Instant refreshExpiresAt = Instant.now().plus(authenticationProperties.getRefreshTokenTtl()); + RefreshToken issued = refreshTokenCommandRepository.save( + RefreshToken.issue(userId, sha256Hex(refreshTokenValue), deviceFingerprint, refreshExpiresAt)); + if (predecessor != null) { + predecessor.rotateTo(issued.getId()); + refreshTokenCommandRepository.save(predecessor); + } + + return EstablishedSessionCommandData.builder() + .accessToken(accessToken.getTokenValue()) + .accessTokenExpiresAt(accessToken.getExpiresAt()) + .refreshToken(refreshTokenValue) + .refreshTokenExpiresAt(refreshExpiresAt) + .build(); + } + + private Jwt decodeChallengeToken(String challengeToken) { + try { + return jwtDecoder.decode(challengeToken); + } catch (JwtException e) { + throw new TwoFactorInvalidException(e); + } + } + + private void revokeSuccessorChain(RefreshToken start) { + RefreshToken current = start; + while (current != null) { + current.revoke(); + refreshTokenCommandRepository.save(current); + current = current.getRotatedTo() == null + ? null + : refreshTokenCommandRepository.findById(current.getRotatedTo()).orElse(null); + } + } + + private static String generateRefreshTokenValue() { + byte[] bytes = new byte[REFRESH_TOKEN_BYTE_LENGTH]; + SECURE_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static String sha256Hex(String value) { + try { + MessageDigest digest = MessageDigest.getInstance(REFRESH_TOKEN_HASH_ALGORITHM); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(REFRESH_TOKEN_HASH_ALGORITHM + " unavailable", e); + } + } + + private static String maskEmail(String email) { + int at = email.indexOf('@'); + if (at < 1) { + return "***"; + } + return email.charAt(0) + "***" + email.substring(at); + } +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/command/Command.java b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/command/Command.java index ea9e3be..60429ef 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/command/Command.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/command/Command.java @@ -23,9 +23,8 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import org.springframework.transaction.annotation.Transactional; + @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) -@Transactional public @interface Command {} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/AuthenticationConfig.java b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/AuthenticationConfig.java new file mode 100644 index 0000000..8eb0621 --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/AuthenticationConfig.java @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.infrastructure.configs; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(AuthenticationProperties.class) +public class AuthenticationConfig { +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/AuthenticationProperties.java b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/AuthenticationProperties.java new file mode 100644 index 0000000..129e47b --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/AuthenticationProperties.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.infrastructure.configs; + +import java.time.Duration; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties("authentication") +public class AuthenticationProperties { + + private final Duration accessTokenTtl; + private final Duration challengeTokenTtl; + private final Duration refreshTokenTtl; + private final boolean refreshCookieSecure; +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/JwtConfig.java b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/JwtConfig.java new file mode 100644 index 0000000..e89c8bb --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/JwtConfig.java @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.infrastructure.configs; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.fineract.consumer.infrastructure.jwt.JwtClaims; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; + +@Configuration +@EnableConfigurationProperties(JwtProperties.class) +public class JwtConfig { + + private static final String EC_ALGORITHM = "EC"; + private static final String PRIVATE_KEY_PEM_TYPE = "PRIVATE KEY"; + private static final String PUBLIC_KEY_PEM_TYPE = "PUBLIC KEY"; + + @Bean + public ECKey jwtSigningKey(JwtProperties jwtProperties) { + try { + String pem = new String(jwtProperties.getKeyLocation().getInputStream().readAllBytes(), + StandardCharsets.UTF_8); + KeyFactory keyFactory = KeyFactory.getInstance(EC_ALGORITHM); + ECPrivateKey privateKey = (ECPrivateKey) keyFactory + .generatePrivate(new PKCS8EncodedKeySpec(extractPemBlock(pem, PRIVATE_KEY_PEM_TYPE))); + ECPublicKey publicKey = (ECPublicKey) keyFactory + .generatePublic(new X509EncodedKeySpec(extractPemBlock(pem, PUBLIC_KEY_PEM_TYPE))); + return new ECKey.Builder(Curve.P_256, publicKey) + .privateKey(privateKey) + .keyIDFromThumbprint() + .build(); + } catch (IOException | NoSuchAlgorithmException | InvalidKeySpecException | JOSEException e) { + throw new IllegalStateException( + "Failed to load the JWT signing keypair from " + jwtProperties.getKeyLocation() + + " — generate it with scripts/generate-dev-jwt-key.sh or set JWT_KEY_LOCATION", + e); + } + } + + @Bean + public JwtEncoder jwtEncoder(ECKey jwtSigningKey) { + return new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(jwtSigningKey))); + } + + @Bean + @Primary + public JwtDecoder jwtDecoder(ECKey jwtSigningKey, JwtProperties jwtProperties) { + NimbusJwtDecoder decoder = buildDecoder(jwtSigningKey); + decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(jwtProperties.getIssuer())); + return decoder; + } + + @Bean + public JwtDecoder accessTokenJwtDecoder(ECKey jwtSigningKey, JwtProperties jwtProperties) { + NimbusJwtDecoder decoder = buildDecoder(jwtSigningKey); + decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( + JwtValidators.createDefaultWithIssuer(jwtProperties.getIssuer()), + JwtConfig::rejectSinglePurposeTokens)); + return decoder; + } + + private static NimbusJwtDecoder buildDecoder(ECKey jwtSigningKey) { + return NimbusJwtDecoder + .withJwkSource(new ImmutableJWKSet<>(new JWKSet(jwtSigningKey.toPublicJWK()))) + .jwsAlgorithm(SignatureAlgorithm.ES256) + .build(); + } + + private static OAuth2TokenValidatorResult rejectSinglePurposeTokens(Jwt jwt) { + if (jwt.hasClaim(JwtClaims.PURPOSE)) { + return OAuth2TokenValidatorResult.failure(new OAuth2Error( + OAuth2ErrorCodes.INVALID_TOKEN, "single-purpose token is not an access token", null)); + } + return OAuth2TokenValidatorResult.success(); + } + + private static byte[] extractPemBlock(String pem, String pemType) { + Pattern blockPattern = Pattern + .compile("-----BEGIN " + pemType + "-----(.*?)-----END " + pemType + "-----", Pattern.DOTALL); + Matcher matcher = blockPattern.matcher(pem); + if (!matcher.find()) { + throw new IllegalStateException("JWT key PEM is missing a '" + pemType + "' block"); + } + return Base64.getMimeDecoder().decode(matcher.group(1).trim()); + } +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/JwtProperties.java b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/JwtProperties.java new file mode 100644 index 0000000..3d86118 --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/JwtProperties.java @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.infrastructure.configs; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.core.io.Resource; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties("jwt") +public class JwtProperties { + + private final Resource keyLocation; + private final String issuer; +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/PasswordEncoderConfig.java b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/PasswordEncoderConfig.java new file mode 100644 index 0000000..0116d68 --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/PasswordEncoderConfig.java @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.infrastructure.configs; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/SecurityConfig.java b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/SecurityConfig.java index 27c7c90..0d1be13 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/SecurityConfig.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/configs/SecurityConfig.java @@ -19,10 +19,14 @@ package org.apache.fineract.consumer.infrastructure.configs; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.web.SecurityFilterChain; @Configuration @@ -30,7 +34,8 @@ public class SecurityConfig { @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http, + @Qualifier("accessTokenJwtDecoder") JwtDecoder accessTokenJwtDecoder) throws Exception { return http .authorizeHttpRequests(authz -> authz .requestMatchers( @@ -41,8 +46,14 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/actuator/health", "/api/v1/registration/**" ).permitAll() + .requestMatchers(HttpMethod.POST, + "/api/v1/authentication/login", + "/api/v1/authentication/2fa", + "/api/v1/authentication/refresh" + ).permitAll() .anyRequest().authenticated()) - // TODO: Setup CSRF + .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.decoder(accessTokenJwtDecoder))) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .csrf(csrf -> csrf.disable()) .build(); } diff --git a/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/jwt/IssuedJwt.java b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/jwt/IssuedJwt.java new file mode 100644 index 0000000..c6f2b34 --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/jwt/IssuedJwt.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.infrastructure.jwt; + +import java.time.Instant; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +@Getter +@RequiredArgsConstructor +@Builder +@EqualsAndHashCode +@ToString(onlyExplicitlyIncluded = true) +public final class IssuedJwt { + + private final String tokenValue; + @ToString.Include + private final Instant expiresAt; +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/jwt/JwtClaims.java b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/jwt/JwtClaims.java new file mode 100644 index 0000000..fd99dd6 --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/jwt/JwtClaims.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.infrastructure.jwt; + +public final class JwtClaims { + + private JwtClaims() { + } + + public static final String PURPOSE = "purpose"; + public static final String DEVICE_FINGERPRINT = "device_fingerprint"; + public static final String TENANT = "tenant"; +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/jwt/JwtIssuer.java b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/jwt/JwtIssuer.java new file mode 100644 index 0000000..9933562 --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/jwt/JwtIssuer.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.infrastructure.jwt; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.consumer.infrastructure.configs.JwtProperties; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class JwtIssuer { + + private final JwtEncoder jwtEncoder; + private final JwtProperties jwtProperties; + + public IssuedJwt issue(String subject, Map claims, Duration timeToLive) { + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plus(timeToLive); + JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder() + .issuer(jwtProperties.getIssuer()) + .subject(subject) + .issuedAt(issuedAt) + .expiresAt(expiresAt); + claims.forEach(claimsBuilder::claim); + JwsHeader header = JwsHeader.with(SignatureAlgorithm.ES256).build(); + String tokenValue = jwtEncoder.encode(JwtEncoderParameters.from(header, claimsBuilder.build())) + .getTokenValue(); + return IssuedJwt.builder() + .tokenValue(tokenValue) + .expiresAt(expiresAt) + .build(); + } +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/query/Query.java b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/query/Query.java index 6b43b56..057530d 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/query/Query.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/query/Query.java @@ -23,9 +23,8 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import org.springframework.transaction.annotation.Transactional; + @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) -@Transactional(readOnly = true) public @interface Query {} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/web/ConsumerHeaders.java b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/web/ConsumerHeaders.java new file mode 100644 index 0000000..001aef0 --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/infrastructure/web/ConsumerHeaders.java @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.infrastructure.web; + +public final class ConsumerHeaders { + + private ConsumerHeaders() { + } + + public static final String DEVICE_FINGERPRINT = "X-Device-Fingerprint"; +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/otp/command/data/OtpDeliveryMethod.java b/consumer/src/main/java/org/apache/fineract/consumer/otp/command/data/OtpDestination.java similarity index 93% rename from consumer/src/main/java/org/apache/fineract/consumer/otp/command/data/OtpDeliveryMethod.java rename to consumer/src/main/java/org/apache/fineract/consumer/otp/command/data/OtpDestination.java index ff19fb2..ac98708 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/otp/command/data/OtpDeliveryMethod.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/otp/command/data/OtpDestination.java @@ -30,7 +30,7 @@ @Builder @EqualsAndHashCode @ToString -public final class OtpDeliveryMethod { - private final String name; +public final class OtpDestination { + private final String deliveryMethod; private final String target; } diff --git a/consumer/src/main/java/org/apache/fineract/consumer/otp/command/data/OtpMetadata.java b/consumer/src/main/java/org/apache/fineract/consumer/otp/command/data/OtpMetadata.java index 4026139..1bba8fe 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/otp/command/data/OtpMetadata.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/otp/command/data/OtpMetadata.java @@ -34,5 +34,5 @@ public final class OtpMetadata { private final ZonedDateTime requestTime; private final int tokenLiveTimeInSec; - private final OtpDeliveryMethod deliveryMethod; + private final OtpDestination destination; } diff --git a/consumer/src/main/java/org/apache/fineract/consumer/otp/command/data/PendingOtp.java b/consumer/src/main/java/org/apache/fineract/consumer/otp/command/data/PendingOtp.java index e3fe09d..ac02cef 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/otp/command/data/PendingOtp.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/otp/command/data/PendingOtp.java @@ -34,11 +34,11 @@ public final class PendingOtp { private final String token; private final OtpMetadata metadata; - public static PendingOtp create(String token, int tokenLiveTimeInSec, OtpDeliveryMethod deliveryMethod) { + public static PendingOtp create(String token, int tokenLiveTimeInSec, OtpDestination destination) { OtpMetadata metadata = OtpMetadata.builder() .requestTime(ZonedDateTime.now()) .tokenLiveTimeInSec(tokenLiveTimeInSec) - .deliveryMethod(deliveryMethod) + .destination(destination) .build(); return new PendingOtp(token, metadata); } diff --git a/consumer/src/main/java/org/apache/fineract/consumer/otp/command/exception/OtpDeliveryMethodInvalidException.java b/consumer/src/main/java/org/apache/fineract/consumer/otp/command/exception/OtpDestinationInvalidException.java similarity index 89% rename from consumer/src/main/java/org/apache/fineract/consumer/otp/command/exception/OtpDeliveryMethodInvalidException.java rename to consumer/src/main/java/org/apache/fineract/consumer/otp/command/exception/OtpDestinationInvalidException.java index cb590dc..faaebd8 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/otp/command/exception/OtpDeliveryMethodInvalidException.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/otp/command/exception/OtpDestinationInvalidException.java @@ -22,11 +22,11 @@ import org.apache.fineract.consumer.infrastructure.exception.AbstractConsumerException; import org.springframework.http.HttpStatus; -public class OtpDeliveryMethodInvalidException extends AbstractConsumerException { +public class OtpDestinationInvalidException extends AbstractConsumerException { public static final String CODE = "error.msg.consumer.otp.delivery.method.invalid"; - public OtpDeliveryMethodInvalidException() { + public OtpDestinationInvalidException() { super(HttpStatus.BAD_REQUEST, CODE, "unsupported delivery method"); } } diff --git a/consumer/src/main/java/org/apache/fineract/consumer/otp/command/service/OtpCommandService.java b/consumer/src/main/java/org/apache/fineract/consumer/otp/command/service/OtpCommandService.java index 4ba95c8..0950b1f 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/otp/command/service/OtpCommandService.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/otp/command/service/OtpCommandService.java @@ -20,12 +20,12 @@ package org.apache.fineract.consumer.otp.command.service; import java.util.UUID; -import org.apache.fineract.consumer.otp.command.data.OtpDeliveryMethod; +import org.apache.fineract.consumer.otp.command.data.OtpDestination; import org.apache.fineract.consumer.otp.command.data.PendingOtp; public interface OtpCommandService { - PendingOtp createOtp(UUID externalId, OtpDeliveryMethod method); + PendingOtp createOtp(UUID externalId, OtpDestination destination); void validateOtp(UUID externalId, String token); } diff --git a/consumer/src/main/java/org/apache/fineract/consumer/otp/command/service/OtpCommandServiceImpl.java b/consumer/src/main/java/org/apache/fineract/consumer/otp/command/service/OtpCommandServiceImpl.java index 13a0bf9..a89ad4f 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/otp/command/service/OtpCommandServiceImpl.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/otp/command/service/OtpCommandServiceImpl.java @@ -24,9 +24,9 @@ import lombok.RequiredArgsConstructor; import org.apache.fineract.consumer.infrastructure.command.Command; import org.apache.fineract.consumer.otp.command.data.OtpConstants; -import org.apache.fineract.consumer.otp.command.data.OtpDeliveryMethod; +import org.apache.fineract.consumer.otp.command.data.OtpDestination; import org.apache.fineract.consumer.otp.command.data.PendingOtp; -import org.apache.fineract.consumer.otp.command.exception.OtpDeliveryMethodInvalidException; +import org.apache.fineract.consumer.otp.command.exception.OtpDestinationInvalidException; import org.apache.fineract.consumer.otp.command.exception.OtpTokenInvalidException; import org.apache.fineract.consumer.otp.command.repository.OtpCommandRepository; import org.springframework.stereotype.Service; @@ -45,14 +45,14 @@ public class OtpCommandServiceImpl implements OtpCommandService { @Override @Command - public PendingOtp createOtp(UUID externalId, OtpDeliveryMethod method) { - if (OtpConstants.EMAIL_DELIVERY_METHOD_NAME.equalsIgnoreCase(method.getName())) { - PendingOtp request = generateNewToken(method); - otpEmailDeliveryService.deliver(method.getTarget(), request.getToken()); + public PendingOtp createOtp(UUID externalId, OtpDestination destination) { + if (OtpConstants.EMAIL_DELIVERY_METHOD_NAME.equalsIgnoreCase(destination.getDeliveryMethod())) { + PendingOtp request = generateNewToken(destination); + otpEmailDeliveryService.deliver(destination.getTarget(), request.getToken()); otpCommandRepository.addPendingOtp(externalId, request); return request; } - throw new OtpDeliveryMethodInvalidException(); + throw new OtpDestinationInvalidException(); } @Override @@ -65,9 +65,9 @@ public void validateOtp(UUID externalId, String token) { otpCommandRepository.deletePendingOtpForUser(externalId); } - private PendingOtp generateNewToken(OtpDeliveryMethod deliveryMethod) { + private PendingOtp generateNewToken(OtpDestination destination) { String token = generateToken(OTP_LENGTH); - return PendingOtp.create(token, OTP_TTL_SECONDS, deliveryMethod); + return PendingOtp.create(token, OTP_TTL_SECONDS, destination); } private static String generateToken(int length) { diff --git a/consumer/src/main/java/org/apache/fineract/consumer/registration/command/api/RegistrationCommandController.java b/consumer/src/main/java/org/apache/fineract/consumer/registration/command/api/RegistrationCommandController.java index dd7d4df..4cf918d 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/registration/command/api/RegistrationCommandController.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/registration/command/api/RegistrationCommandController.java @@ -30,6 +30,7 @@ import org.apache.fineract.consumer.registration.command.data.VerifyOtpCommand; import org.apache.fineract.consumer.registration.command.data.VerifyOtpCommandData; import org.apache.fineract.consumer.registration.command.data.VerifyOtpCommandRequest; +import org.apache.fineract.consumer.infrastructure.web.ConsumerHeaders; import org.apache.fineract.consumer.registration.command.service.RegistrationCommandService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -49,10 +50,11 @@ public class RegistrationCommandController { @PostMapping("/submit") public ResponseEntity submit( @Valid @RequestBody SubmitRegistrationCommandRequest request, - @RequestHeader("X-Device-Fingerprint") String deviceFingerprint) { + @RequestHeader(ConsumerHeaders.DEVICE_FINGERPRINT) String deviceFingerprint) { SubmitRegistrationCommand command = SubmitRegistrationCommand.builder() .fineractClientId(request.getFineractClientId()) .email(request.getEmail()) + .password(request.getPassword()) .documentTypeName(request.getDocumentTypeName()) .documentKey(request.getDocumentKey()) .deviceFingerprint(deviceFingerprint) diff --git a/consumer/src/main/java/org/apache/fineract/consumer/registration/command/data/SubmitRegistrationCommand.java b/consumer/src/main/java/org/apache/fineract/consumer/registration/command/data/SubmitRegistrationCommand.java index d2391a8..67dfddc 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/registration/command/data/SubmitRegistrationCommand.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/registration/command/data/SubmitRegistrationCommand.java @@ -32,6 +32,7 @@ public final class SubmitRegistrationCommand { private final Long fineractClientId; private final String email; + private final String password; private final String documentTypeName; private final String documentKey; private final String deviceFingerprint; diff --git a/consumer/src/main/java/org/apache/fineract/consumer/registration/command/data/SubmitRegistrationCommandRequest.java b/consumer/src/main/java/org/apache/fineract/consumer/registration/command/data/SubmitRegistrationCommandRequest.java index 7f4cce5..6391e1f 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/registration/command/data/SubmitRegistrationCommandRequest.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/registration/command/data/SubmitRegistrationCommandRequest.java @@ -22,7 +22,9 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; import lombok.Builder; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -42,6 +44,11 @@ public final class SubmitRegistrationCommandRequest { @Email private final String email; + @NotBlank + @Size(min = 15, max = 64) + @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z0-9]).+$") + private final String password; + @NotBlank private final String documentTypeName; diff --git a/consumer/src/main/java/org/apache/fineract/consumer/registration/command/service/RegistrationCommandServiceImpl.java b/consumer/src/main/java/org/apache/fineract/consumer/registration/command/service/RegistrationCommandServiceImpl.java index 324faa8..f2342e9 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/registration/command/service/RegistrationCommandServiceImpl.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/registration/command/service/RegistrationCommandServiceImpl.java @@ -22,7 +22,7 @@ import java.time.ZonedDateTime; import lombok.RequiredArgsConstructor; import org.apache.fineract.consumer.infrastructure.command.Command; -import org.apache.fineract.consumer.otp.command.data.OtpDeliveryMethod; +import org.apache.fineract.consumer.otp.command.data.OtpDestination; import org.apache.fineract.consumer.otp.command.data.PendingOtp; import org.apache.fineract.consumer.otp.command.service.OtpCommandService; import org.apache.fineract.consumer.registration.command.data.SendOtpCommand; @@ -41,6 +41,7 @@ import org.apache.fineract.consumer.user.command.service.UserCommandService; import org.apache.fineract.consumer.user.query.data.UserQueryData; import org.apache.fineract.consumer.user.query.service.UserQueryService; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @Service @@ -51,6 +52,7 @@ public class RegistrationCommandServiceImpl implements RegistrationCommandServic private final UserCommandService userCommandService; private final UserQueryService userQueryService; private final OtpCommandService otpCommandService; + private final PasswordEncoder passwordEncoder; @Override @Command @@ -68,6 +70,7 @@ public SubmitRegistrationCommandData submit(SubmitRegistrationCommand command) { CreateUserCommand createUser = CreateUserCommand.builder() .email(command.getEmail()) + .passwordHash(passwordEncoder.encode(command.getPassword())) .fineractClientId(command.getFineractClientId()) .deviceFingerprint(command.getDeviceFingerprint()) .build(); @@ -84,11 +87,11 @@ public SubmitRegistrationCommandData submit(SubmitRegistrationCommand command) { @Command public SendOtpCommandData sendOtp(SendOtpCommand command) { UserQueryData user = userQueryService.findByExternalId(command.getRegistrationId()); - OtpDeliveryMethod method = OtpDeliveryMethod.builder() - .name(command.getDeliveryMethod()) + OtpDestination destination = OtpDestination.builder() + .deliveryMethod(command.getDeliveryMethod()) .target(user.getEmail()) .build(); - PendingOtp request = otpCommandService.createOtp(user.getExternalId(), method); + PendingOtp request = otpCommandService.createOtp(user.getExternalId(), destination); ZonedDateTime expiresAt = request.getMetadata().getRequestTime() .plusSeconds(request.getMetadata().getTokenLiveTimeInSec()); return SendOtpCommandData.builder() @@ -105,7 +108,7 @@ public VerifyOtpCommandData verifyOtp(VerifyOtpCommand command) { otpCommandService.validateOtp(user.getExternalId(), command.getToken()); userCommandService.markOtpVerified(user.getId()); return VerifyOtpCommandData.builder() - .status(UserStatus.PENDING_2FA) + .status(UserStatus.BOUND) .build(); } diff --git a/consumer/src/main/java/org/apache/fineract/consumer/user/command/data/CreateUserCommand.java b/consumer/src/main/java/org/apache/fineract/consumer/user/command/data/CreateUserCommand.java index 4f19c0e..e2d7fb6 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/user/command/data/CreateUserCommand.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/user/command/data/CreateUserCommand.java @@ -29,9 +29,10 @@ @RequiredArgsConstructor @Builder @EqualsAndHashCode -@ToString +@ToString(onlyExplicitlyIncluded = true) public final class CreateUserCommand { private final String email; + private final String passwordHash; private final Long fineractClientId; private final String deviceFingerprint; } diff --git a/consumer/src/main/java/org/apache/fineract/consumer/user/command/domain/User.java b/consumer/src/main/java/org/apache/fineract/consumer/user/command/domain/User.java index cc9766e..ae97fe8 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/user/command/domain/User.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/user/command/domain/User.java @@ -52,6 +52,9 @@ public class User { @Column(name = "email", nullable = false, unique = true) private String email; + @Column(name = "password_hash", nullable = false, length = 100) + private String passwordHash; + @Column(name = "client_id", nullable = false, unique = true) private Long fineractClientId; @@ -72,11 +75,13 @@ public class User { @Column(name = "updated_at", nullable = false) private Instant updatedAt; - public static User createPendingOtp(UUID externalId, String email, Long fineractClientId, String deviceFingerprint) { + public static User createPendingOtp(UUID externalId, String email, String passwordHash, Long fineractClientId, + String deviceFingerprint) { Instant now = Instant.now(); User user = new User(); user.externalId = externalId; user.email = email; + user.passwordHash = passwordHash; user.fineractClientId = fineractClientId; user.status = UserStatus.PENDING_OTP; user.deviceFingerprint = deviceFingerprint; @@ -89,14 +94,6 @@ public void markOtpVerified() { if (status != UserStatus.PENDING_OTP) { throw new InvalidBindingStateException(); } - status = UserStatus.PENDING_2FA; - updatedAt = Instant.now(); - } - - public void completeBinding() { - if (status != UserStatus.PENDING_2FA) { - throw new InvalidBindingStateException(); - } Instant now = Instant.now(); status = UserStatus.BOUND; boundAt = now; diff --git a/consumer/src/main/java/org/apache/fineract/consumer/user/command/domain/UserStatus.java b/consumer/src/main/java/org/apache/fineract/consumer/user/command/domain/UserStatus.java index 062f740..113c843 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/user/command/domain/UserStatus.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/user/command/domain/UserStatus.java @@ -21,6 +21,5 @@ public enum UserStatus { PENDING_OTP, - PENDING_2FA, BOUND } diff --git a/consumer/src/main/java/org/apache/fineract/consumer/user/command/service/UserCommandService.java b/consumer/src/main/java/org/apache/fineract/consumer/user/command/service/UserCommandService.java index 8ec34d8..df6b695 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/user/command/service/UserCommandService.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/user/command/service/UserCommandService.java @@ -27,6 +27,4 @@ public interface UserCommandService { UserCreatedCommandData create(CreateUserCommand command); void markOtpVerified(Long userId); - - void completeBinding(Long userId); } diff --git a/consumer/src/main/java/org/apache/fineract/consumer/user/command/service/UserCommandServiceImpl.java b/consumer/src/main/java/org/apache/fineract/consumer/user/command/service/UserCommandServiceImpl.java index 8c3bbaf..266e75d 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/user/command/service/UserCommandServiceImpl.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/user/command/service/UserCommandServiceImpl.java @@ -49,6 +49,7 @@ public UserCreatedCommandData create(CreateUserCommand command) { User user = User.createPendingOtp( UUID.randomUUID(), command.getEmail(), + command.getPasswordHash(), command.getFineractClientId(), command.getDeviceFingerprint()); User saved; @@ -70,12 +71,4 @@ public void markOtpVerified(Long userId) { user.markOtpVerified(); repository.save(user); } - - @Override - @Command - public void completeBinding(Long userId) { - User user = repository.findById(userId).orElseThrow(UserNotFoundException::new); - user.completeBinding(); - repository.save(user); - } } diff --git a/consumer/src/main/java/org/apache/fineract/consumer/user/query/data/UserCredentialsQueryData.java b/consumer/src/main/java/org/apache/fineract/consumer/user/query/data/UserCredentialsQueryData.java new file mode 100644 index 0000000..467ba7b --- /dev/null +++ b/consumer/src/main/java/org/apache/fineract/consumer/user/query/data/UserCredentialsQueryData.java @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.user.query.data; + +import java.util.UUID; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; +import org.apache.fineract.consumer.user.command.domain.UserStatus; + +@Getter +@RequiredArgsConstructor +@Builder +@EqualsAndHashCode +@ToString(onlyExplicitlyIncluded = true) +public final class UserCredentialsQueryData { + private final Long id; + private final UUID externalId; + private final UserStatus status; + private final String passwordHash; +} diff --git a/consumer/src/main/java/org/apache/fineract/consumer/user/query/repository/UserQueryRepository.java b/consumer/src/main/java/org/apache/fineract/consumer/user/query/repository/UserQueryRepository.java index 3e8291d..b7610e3 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/user/query/repository/UserQueryRepository.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/user/query/repository/UserQueryRepository.java @@ -22,6 +22,7 @@ import java.util.Optional; import java.util.UUID; import org.apache.fineract.consumer.user.command.domain.User; +import org.apache.fineract.consumer.user.query.data.UserCredentialsQueryData; import org.apache.fineract.consumer.user.query.data.UserQueryData; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; @@ -34,4 +35,18 @@ public interface UserQueryRepository extends Repository { WHERE u.externalId = :externalId """) Optional findByExternalId(UUID externalId); + + @Query(""" + SELECT new org.apache.fineract.consumer.user.query.data.UserQueryData(u.id, u.externalId, u.email, u.status) + FROM User u + WHERE u.id = :id + """) + Optional findById(Long id); + + @Query(""" + SELECT new org.apache.fineract.consumer.user.query.data.UserCredentialsQueryData(u.id, u.externalId, u.status, u.passwordHash) + FROM User u + WHERE u.email = :email + """) + Optional findCredentialsByEmail(String email); } diff --git a/consumer/src/main/java/org/apache/fineract/consumer/user/query/service/UserQueryService.java b/consumer/src/main/java/org/apache/fineract/consumer/user/query/service/UserQueryService.java index 09e7e3b..f99688d 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/user/query/service/UserQueryService.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/user/query/service/UserQueryService.java @@ -19,10 +19,16 @@ package org.apache.fineract.consumer.user.query.service; +import java.util.Optional; import java.util.UUID; +import org.apache.fineract.consumer.user.query.data.UserCredentialsQueryData; import org.apache.fineract.consumer.user.query.data.UserQueryData; public interface UserQueryService { UserQueryData findByExternalId(UUID externalId); + + UserQueryData findById(Long id); + + Optional findCredentialsByEmail(String email); } diff --git a/consumer/src/main/java/org/apache/fineract/consumer/user/query/service/UserQueryServiceImpl.java b/consumer/src/main/java/org/apache/fineract/consumer/user/query/service/UserQueryServiceImpl.java index e5f5e22..8f67461 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/user/query/service/UserQueryServiceImpl.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/user/query/service/UserQueryServiceImpl.java @@ -19,10 +19,12 @@ package org.apache.fineract.consumer.user.query.service; +import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; import org.apache.fineract.consumer.infrastructure.query.Query; import org.apache.fineract.consumer.user.command.exception.UserNotFoundException; +import org.apache.fineract.consumer.user.query.data.UserCredentialsQueryData; import org.apache.fineract.consumer.user.query.data.UserQueryData; import org.apache.fineract.consumer.user.query.repository.UserQueryRepository; import org.springframework.stereotype.Service; @@ -39,4 +41,17 @@ public UserQueryData findByExternalId(UUID externalId) { return repository.findByExternalId(externalId) .orElseThrow(UserNotFoundException::new); } + + @Override + @Query + public UserQueryData findById(Long id) { + return repository.findById(id) + .orElseThrow(UserNotFoundException::new); + } + + @Override + @Query + public Optional findCredentialsByEmail(String email) { + return repository.findCredentialsByEmail(email); + } } diff --git a/consumer/src/main/resources/application.properties b/consumer/src/main/resources/application.properties index 541a18e..05a3566 100644 --- a/consumer/src/main/resources/application.properties +++ b/consumer/src/main/resources/application.properties @@ -32,6 +32,18 @@ fineract.client.username=${FINERACT_SERVICE_USERNAME:mifos} fineract.client.password=${FINERACT_SERVICE_PASSWORD:password} fineract.client.tenant-id=${FINERACT_SERVICE_TENANT:default} +# JWT signing — ES256 keypair PEM (PKCS#8 private key + public key in one file), read once at startup. +# Dev: generated by scripts/generate-dev-jwt-key.sh (gitignored). Prod: mounted from a secret manager. +jwt.key-location=${JWT_KEY_LOCATION:file:dev-jwt-key.pem} +jwt.issuer=${JWT_ISSUER:fineract-consumer-bff} + +# Authentication session tuning — Durations accept Spring's shorthand (15m, 1d). +# refresh-cookie-secure stays false in dev (plain-HTTP compose stack); set true wherever TLS terminates in front. +authentication.access-token-ttl=${AUTH_ACCESS_TOKEN_TTL:15m} +authentication.challenge-token-ttl=${AUTH_CHALLENGE_TOKEN_TTL:5m} +authentication.refresh-token-ttl=${AUTH_REFRESH_TOKEN_TTL:1d} +authentication.refresh-cookie-secure=${AUTH_REFRESH_COOKIE_SECURE:false} + # OTP email delivery — Mailpit in docker-compose; institution SMTP in prod spring.mail.host=${SPRING_MAIL_HOST:localhost} spring.mail.port=${SPRING_MAIL_PORT:1025} diff --git a/consumer/src/main/resources/db/changelog/changes/001-create-users-table.yaml b/consumer/src/main/resources/db/changelog/changes/001-create-users-table.yaml index 3f01e23..4e50cfb 100644 --- a/consumer/src/main/resources/db/changelog/changes/001-create-users-table.yaml +++ b/consumer/src/main/resources/db/changelog/changes/001-create-users-table.yaml @@ -21,7 +21,7 @@ databaseChangeLog: author: fineract-consumer-facing changes: - sql: - sql: "CREATE TYPE user_status AS ENUM ('PENDING_OTP', 'PENDING_2FA', 'BOUND')" + sql: "CREATE TYPE user_status AS ENUM ('PENDING_OTP', 'BOUND')" - createTable: tableName: users columns: @@ -44,6 +44,11 @@ databaseChangeLog: constraints: nullable: false unique: true + - column: + name: password_hash + type: VARCHAR(100) + constraints: + nullable: false - column: name: client_id type: BIGINT diff --git a/consumer/src/main/resources/db/changelog/changes/002-create-refresh-tokens.yaml b/consumer/src/main/resources/db/changelog/changes/002-create-refresh-tokens.yaml new file mode 100644 index 0000000..329a7e8 --- /dev/null +++ b/consumer/src/main/resources/db/changelog/changes/002-create-refresh-tokens.yaml @@ -0,0 +1,78 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +databaseChangeLog: + - changeSet: + id: 002-create-refresh-tokens + author: fineract-consumer-facing + changes: + - createTable: + tableName: refresh_tokens + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: user_id + type: BIGINT + constraints: + nullable: false + foreignKeyName: fk_refresh_tokens_user_id + references: users(id) + - column: + name: token_hash + type: VARCHAR(64) + constraints: + nullable: false + unique: true + - column: + name: device_fingerprint + type: VARCHAR(255) + constraints: + nullable: false + - column: + name: issued_at + type: TIMESTAMP WITH TIME ZONE + constraints: + nullable: false + - column: + name: expires_at + type: TIMESTAMP WITH TIME ZONE + constraints: + nullable: false + - column: + name: revoked_at + type: TIMESTAMP WITH TIME ZONE + - column: + name: rotated_to + type: BIGINT + constraints: + foreignKeyName: fk_refresh_tokens_rotated_to + references: refresh_tokens(id) + - createIndex: + tableName: refresh_tokens + indexName: idx_refresh_tokens_user_id + columns: + - column: + name: user_id + rollback: + - dropTable: + tableName: refresh_tokens diff --git a/consumer/src/main/resources/db/changelog/db.changelog-master.yaml b/consumer/src/main/resources/db/changelog/db.changelog-master.yaml index c2c4de6..5a867e1 100644 --- a/consumer/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/consumer/src/main/resources/db/changelog/db.changelog-master.yaml @@ -18,3 +18,5 @@ databaseChangeLog: - include: file: db/changelog/changes/001-create-users-table.yaml + - include: + file: db/changelog/changes/002-create-refresh-tokens.yaml diff --git a/consumer/src/test/java/org/apache/fineract/consumer/cucumber/helpers/MailpitProbe.java b/consumer/src/test/java/org/apache/fineract/consumer/cucumber/clients/MailpitClient.java similarity index 81% rename from consumer/src/test/java/org/apache/fineract/consumer/cucumber/helpers/MailpitProbe.java rename to consumer/src/test/java/org/apache/fineract/consumer/cucumber/clients/MailpitClient.java index 21366dc..4ecea80 100644 --- a/consumer/src/test/java/org/apache/fineract/consumer/cucumber/helpers/MailpitProbe.java +++ b/consumer/src/test/java/org/apache/fineract/consumer/cucumber/clients/MailpitClient.java @@ -17,7 +17,7 @@ * under the License. */ -package org.apache.fineract.consumer.cucumber.helpers; +package org.apache.fineract.consumer.cucumber.clients; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -32,7 +32,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -public class MailpitProbe { +public class MailpitClient { private static final String BASE_URL = System.getenv().getOrDefault("MAILPIT_URL", "http://localhost:8025"); private static final Duration POLL_TIMEOUT = Duration.ofSeconds(5); @@ -44,6 +44,10 @@ public class MailpitProbe { .connectTimeout(Duration.ofSeconds(5)) .build(); + public void deleteMessages(String recipient) { + delete("/api/v1/search?query=" + urlEncode("to:" + recipient)); + } + public String waitForOtp(String recipient) { long deadline = System.nanoTime() + POLL_TIMEOUT.toNanos(); while (System.nanoTime() < deadline) { @@ -94,6 +98,25 @@ private JsonNode get(String path) { } } + private void delete(String path) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + path)) + .timeout(Duration.ofSeconds(5)) + .DELETE() + .build(); + HttpResponse response = HTTP.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() >= 400) { + throw new RuntimeException("Mailpit DELETE " + path + " failed with " + response.statusCode() + + ": " + response.body()); + } + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + private static String urlEncode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); } diff --git a/consumer/src/test/java/org/apache/fineract/consumer/cucumber/steps/RegistrationSteps.java b/consumer/src/test/java/org/apache/fineract/consumer/cucumber/steps/RegistrationSteps.java index 5fefaf6..cf789a8 100644 --- a/consumer/src/test/java/org/apache/fineract/consumer/cucumber/steps/RegistrationSteps.java +++ b/consumer/src/test/java/org/apache/fineract/consumer/cucumber/steps/RegistrationSteps.java @@ -33,11 +33,12 @@ import org.apache.fineract.consumer.client.model.SubmitRegistrationCommandRequest; import org.apache.fineract.consumer.client.model.VerifyOtpCommandData; import org.apache.fineract.consumer.client.model.VerifyOtpCommandRequest; +import org.apache.fineract.consumer.cucumber.clients.MailpitClient; import org.apache.fineract.consumer.cucumber.helpers.FineractSeeder; -import org.apache.fineract.consumer.cucumber.helpers.MailpitProbe; import org.apache.fineract.consumer.otp.command.data.OtpConstants; import org.apache.fineract.consumer.otp.command.exception.OtpTokenInvalidException; import org.apache.fineract.consumer.registration.command.exception.IdentityNotVerifiedException; +import org.apache.fineract.consumer.user.command.exception.UserAlreadyExistsException; import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.json.JsonMapper; @@ -46,10 +47,11 @@ public class RegistrationSteps { private static final String BFF_BASE_URL = System.getenv().getOrDefault("BASE_URL", "http://localhost:8080"); private static final String DEVICE_FINGERPRINT = "cucumber-test-device"; private static final String WRONG_OTP = "WRONG1"; + private static final String PASSWORD = "Cucumber-password1"; private static final ObjectMapper JSON = JsonMapper.builder().build(); private final FineractSeeder fineractSeed = new FineractSeeder(); - private final MailpitProbe mailpit = new MailpitProbe(); + private final MailpitClient mailpit = new MailpitClient(); private final RegistrationCommandControllerApi bff = buildBffClient(); private FineractSeeder.SeededClient seededClient; @@ -76,6 +78,18 @@ public void submitMismatch() { submit("WRONG-VALUE-" + UUID.randomUUID().toString().substring(0, 4).toUpperCase()); } + @When("a second Fineract client submits registration with the same email") + public void secondClientSubmitsSameEmail() { + FineractSeeder.SeededClient secondClient = fineractSeed.seedClientWithPassport(); + submit(secondClient, email, secondClient.documentKey()); + } + + @When("I submit registration again with a different email") + public void submitAgainWithDifferentEmail() { + String differentEmail = "user-" + UUID.randomUUID().toString().substring(0, 8) + "@test.com"; + submit(seededClient, differentEmail, seededClient.documentKey()); + } + @Then("registration is accepted in PENDING_OTP state") public void acceptedPendingOtp() { assertThat(lastError).as("expected success, got error").isNull(); @@ -149,14 +163,21 @@ public void completeOtpVerification() { otpDelivered(); retrieveOtpFromMailpit(); verifyCorrectOtp(); - advancedToPending2fa(); + advancedToBound(); } - @Then("my registration advances to PENDING_2FA") - public void advancedToPending2fa() { + @Then("my registration advances to BOUND") + public void advancedToBound() { assertThat(lastError).as("expected verify success, got error").isNull(); assertThat(lastVerify).isNotNull(); - assertThat(lastVerify.getStatus()).isEqualTo(VerifyOtpCommandData.StatusEnum.PENDING_2_FA); + assertThat(lastVerify.getStatus()).isEqualTo(VerifyOtpCommandData.StatusEnum.BOUND); + } + + @Then("registration is rejected as an existing user") + public void registrationRejectedAsExistingUser() { + assertThat(lastError).as("expected conflict, got success").isNotNull(); + assertThat(lastError.status()).isEqualTo(409); + assertThat(readCode(lastError.contentUTF8())).isEqualTo(UserAlreadyExistsException.CODE); } @Then("the OTP is rejected as invalid") @@ -167,10 +188,15 @@ public void otpRejected() { } private void submit(String documentKey) { + submit(seededClient, email, documentKey); + } + + private void submit(FineractSeeder.SeededClient client, String email, String documentKey) { SubmitRegistrationCommandRequest request = new SubmitRegistrationCommandRequest() - .fineractClientId(seededClient.fineractClientId()) + .fineractClientId(client.fineractClientId()) .email(email) - .documentTypeName(seededClient.documentTypeName()) + .password(PASSWORD) + .documentTypeName(client.documentTypeName()) .documentKey(documentKey); try { lastSubmit = bff.submit(DEVICE_FINGERPRINT, request); diff --git a/consumer/src/test/resources/features/registration.feature b/consumer/src/test/resources/features/registration.feature index d17910d..8f40d7f 100644 --- a/consumer/src/test/resources/features/registration.feature +++ b/consumer/src/test/resources/features/registration.feature @@ -27,7 +27,7 @@ Feature: Consumer registration Then an OTP is delivered to my email When I retrieve the OTP from Mailpit And I verify the OTP - Then my registration advances to PENDING_2FA + Then my registration advances to BOUND Scenario: Submit with mismatched identifier is rejected When I submit registration with a non-matching Passport value @@ -45,3 +45,15 @@ Feature: Consumer registration And I request an email OTP And I verify a wrong OTP Then the OTP is rejected as invalid + + Scenario: Registering the same email twice is rejected + When I submit registration with the matching Passport + Then registration is accepted in PENDING_OTP state + When a second Fineract client submits registration with the same email + Then registration is rejected as an existing user + + Scenario: A Fineract client that already has a registration cannot register again + When I submit registration with the matching Passport + Then registration is accepted in PENDING_OTP state + When I submit registration again with a different email + Then registration is rejected as an existing user From db252563ab7ef4d3ed846e742fc5eea26a192f2c Mon Sep 17 00:00:00 2001 From: edk12564 Date: Sat, 13 Jun 2026 19:25:41 -0500 Subject: [PATCH 2/3] - added tests for auth/login feature - added more tests for infrastructure, user features --- .../command/domain/RefreshTokenTest.java | 78 ++++ .../AuthenticationCommandServiceImplTest.java | 409 ++++++++++++++++++ .../clients/AuthenticationClient.java | 98 +++++ .../cucumber/helpers/RegistrationHelper.java | 77 ++++ .../cucumber/hooks/ConsumerDatabaseReset.java | 46 ++ .../consumer/cucumber/steps/LoginSteps.java | 297 +++++++++++++ .../infrastructure/jwt/JwtIssuerTest.java | 137 ++++++ .../user/command/domain/UserTest.java | 75 ++++ .../service/UserCommandServiceImplTest.java | 159 +++++++ .../service/UserQueryServiceImplTest.java | 113 +++++ .../src/test/resources/features/login.feature | 65 +++ 11 files changed, 1554 insertions(+) create mode 100644 consumer/src/test/java/org/apache/fineract/consumer/authentication/command/domain/RefreshTokenTest.java create mode 100644 consumer/src/test/java/org/apache/fineract/consumer/authentication/command/service/AuthenticationCommandServiceImplTest.java create mode 100644 consumer/src/test/java/org/apache/fineract/consumer/cucumber/clients/AuthenticationClient.java create mode 100644 consumer/src/test/java/org/apache/fineract/consumer/cucumber/helpers/RegistrationHelper.java create mode 100644 consumer/src/test/java/org/apache/fineract/consumer/cucumber/hooks/ConsumerDatabaseReset.java create mode 100644 consumer/src/test/java/org/apache/fineract/consumer/cucumber/steps/LoginSteps.java create mode 100644 consumer/src/test/java/org/apache/fineract/consumer/infrastructure/jwt/JwtIssuerTest.java create mode 100644 consumer/src/test/java/org/apache/fineract/consumer/user/command/domain/UserTest.java create mode 100644 consumer/src/test/java/org/apache/fineract/consumer/user/command/service/UserCommandServiceImplTest.java create mode 100644 consumer/src/test/java/org/apache/fineract/consumer/user/query/service/UserQueryServiceImplTest.java create mode 100644 consumer/src/test/resources/features/login.feature diff --git a/consumer/src/test/java/org/apache/fineract/consumer/authentication/command/domain/RefreshTokenTest.java b/consumer/src/test/java/org/apache/fineract/consumer/authentication/command/domain/RefreshTokenTest.java new file mode 100644 index 0000000..fc58a5b --- /dev/null +++ b/consumer/src/test/java/org/apache/fineract/consumer/authentication/command/domain/RefreshTokenTest.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.authentication.command.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class RefreshTokenTest { + + private static final Long USER_ID = 7L; + private static final String TOKEN_HASH = "a".repeat(64); + private static final String DEVICE_FINGERPRINT = "test-device"; + + @Test + void issueSetsAllFieldsAndIsNeitherRevokedNorRotated() { + Instant expiresAt = Instant.now().plusSeconds(3600); + + RefreshToken token = RefreshToken.issue(USER_ID, TOKEN_HASH, DEVICE_FINGERPRINT, expiresAt); + + assertThat(token.getUserId()).isEqualTo(USER_ID); + assertThat(token.getTokenHash()).isEqualTo(TOKEN_HASH); + assertThat(token.getDeviceFingerprint()).isEqualTo(DEVICE_FINGERPRINT); + assertThat(token.getIssuedAt()).isNotNull(); + assertThat(token.getExpiresAt()).isEqualTo(expiresAt); + assertThat(token.getRevokedAt()).isNull(); + assertThat(token.getRotatedTo()).isNull(); + } + + @Test + void revokeSetsRevokedAt() { + RefreshToken token = RefreshToken.issue(USER_ID, TOKEN_HASH, DEVICE_FINGERPRINT, Instant.now().plusSeconds(60)); + + token.revoke(); + + assertThat(token.getRevokedAt()).isNotNull(); + } + + @Test + void revokeIsIdempotent() throws InterruptedException { + RefreshToken token = RefreshToken.issue(USER_ID, TOKEN_HASH, DEVICE_FINGERPRINT, Instant.now().plusSeconds(60)); + token.revoke(); + Instant firstRevocation = token.getRevokedAt(); + + Thread.sleep(5); + token.revoke(); + + assertThat(token.getRevokedAt()).isEqualTo(firstRevocation); + } + + @Test + void rotateToRecordsSuccessorAndRevokes() { + RefreshToken token = RefreshToken.issue(USER_ID, TOKEN_HASH, DEVICE_FINGERPRINT, Instant.now().plusSeconds(60)); + + token.rotateTo(42L); + + assertThat(token.getRotatedTo()).isEqualTo(42L); + assertThat(token.getRevokedAt()).isNotNull(); + } +} diff --git a/consumer/src/test/java/org/apache/fineract/consumer/authentication/command/service/AuthenticationCommandServiceImplTest.java b/consumer/src/test/java/org/apache/fineract/consumer/authentication/command/service/AuthenticationCommandServiceImplTest.java new file mode 100644 index 0000000..5cd6198 --- /dev/null +++ b/consumer/src/test/java/org/apache/fineract/consumer/authentication/command/service/AuthenticationCommandServiceImplTest.java @@ -0,0 +1,409 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.authentication.command.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.apache.fineract.consumer.authentication.command.data.AuthenticationConstants; +import org.apache.fineract.consumer.authentication.command.data.EstablishedSessionCommandData; +import org.apache.fineract.consumer.authentication.command.data.LoginChallengeCommandData; +import org.apache.fineract.consumer.authentication.command.data.LoginCommand; +import org.apache.fineract.consumer.authentication.command.data.LogoutCommand; +import org.apache.fineract.consumer.authentication.command.data.RefreshSessionCommand; +import org.apache.fineract.consumer.authentication.command.data.VerifyTwoFactorCommand; +import org.apache.fineract.consumer.authentication.command.domain.RefreshToken; +import org.apache.fineract.consumer.authentication.command.exception.InvalidCredentialsException; +import org.apache.fineract.consumer.authentication.command.exception.RefreshTokenInvalidException; +import org.apache.fineract.consumer.authentication.command.exception.TwoFactorInvalidException; +import org.apache.fineract.consumer.authentication.command.repository.RefreshTokenCommandRepository; +import org.apache.fineract.consumer.infrastructure.configs.AuthenticationProperties; +import org.apache.fineract.consumer.infrastructure.fineractclient.configs.FineractClientProperties; +import org.apache.fineract.consumer.infrastructure.jwt.IssuedJwt; +import org.apache.fineract.consumer.infrastructure.jwt.JwtClaims; +import org.apache.fineract.consumer.infrastructure.jwt.JwtIssuer; +import org.apache.fineract.consumer.otp.command.data.OtpConstants; +import org.apache.fineract.consumer.otp.command.data.OtpDestination; +import org.apache.fineract.consumer.otp.command.exception.OtpTokenInvalidException; +import org.apache.fineract.consumer.otp.command.service.OtpCommandService; +import org.apache.fineract.consumer.user.command.domain.UserStatus; +import org.apache.fineract.consumer.user.query.data.UserCredentialsQueryData; +import org.apache.fineract.consumer.user.query.data.UserQueryData; +import org.apache.fineract.consumer.user.query.service.UserQueryService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class AuthenticationCommandServiceImplTest { + + private static final Long USER_ID = 7L; + private static final UUID EXTERNAL_ID = UUID.fromString("3f2c8a1e-0000-4000-8000-000000000001"); + private static final String EMAIL = "user@test.com"; + private static final String RAW_PASSWORD = "Correct-password1"; + private static final String PASSWORD_HASH = "{bcrypt}$2a$10$hash"; + private static final String DEVICE_FINGERPRINT = "test-device"; + private static final String OTHER_DEVICE_FINGERPRINT = "other-device"; + private static final String TENANT_ID = "default"; + private static final String OTP_TOKEN = "ABC123"; + private static final String CHALLENGE_TOKEN = "challenge-token"; + private static final String PRESENTED_REFRESH_TOKEN = "presented-refresh-token"; + private static final Long NEW_TOKEN_ID = 42L; + private static final Long SUCCESSOR_ID = 43L; + + private static final AuthenticationProperties PROPERTIES = new AuthenticationProperties( + Duration.ofMinutes(15), Duration.ofMinutes(5), Duration.ofDays(1), false); + private static final FineractClientProperties FINERACT_PROPERTIES = new FineractClientProperties( + null, null, null, TENANT_ID); + + @Mock + private UserQueryService userQueryService; + @Mock + private OtpCommandService otpCommandService; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private JwtIssuer jwtIssuer; + @Mock + private JwtDecoder jwtDecoder; + @Mock + private RefreshTokenCommandRepository refreshTokenCommandRepository; + + private AuthenticationCommandServiceImpl service; + + @BeforeEach + void setUp() { + service = new AuthenticationCommandServiceImpl(userQueryService, otpCommandService, passwordEncoder, + jwtIssuer, jwtDecoder, refreshTokenCommandRepository, PROPERTIES, FINERACT_PROPERTIES); + } + + private static UserCredentialsQueryData credentials(UserStatus status) { + return UserCredentialsQueryData.builder() + .id(USER_ID) + .externalId(EXTERNAL_ID) + .status(status) + .passwordHash(PASSWORD_HASH) + .build(); + } + + private static LoginCommand loginCommand() { + return LoginCommand.builder() + .email(EMAIL) + .password(RAW_PASSWORD) + .deviceFingerprint(DEVICE_FINGERPRINT) + .build(); + } + + private static IssuedJwt issuedJwt(String tokenValue, Duration ttl) { + return IssuedJwt.builder() + .tokenValue(tokenValue) + .expiresAt(Instant.now().plus(ttl)) + .build(); + } + + @Nested + class Login { + + @Test + void successSendsOtpAndIssuesDeviceBoundChallenge() { + when(userQueryService.findCredentialsByEmail(EMAIL)).thenReturn(Optional.of(credentials(UserStatus.BOUND))); + when(passwordEncoder.matches(RAW_PASSWORD, PASSWORD_HASH)).thenReturn(true); + when(jwtIssuer.issue(eq(EXTERNAL_ID.toString()), anyMap(), eq(PROPERTIES.getChallengeTokenTtl()))) + .thenReturn(issuedJwt(CHALLENGE_TOKEN, PROPERTIES.getChallengeTokenTtl())); + + LoginChallengeCommandData challenge = service.login(loginCommand()); + + verify(otpCommandService).createOtp(EXTERNAL_ID, OtpDestination.builder() + .deliveryMethod(OtpConstants.EMAIL_DELIVERY_METHOD_NAME) + .target(EMAIL) + .build()); + @SuppressWarnings("unchecked") + ArgumentCaptor> claims = ArgumentCaptor.forClass(Map.class); + verify(jwtIssuer).issue(eq(EXTERNAL_ID.toString()), claims.capture(), + eq(PROPERTIES.getChallengeTokenTtl())); + assertThat(claims.getValue()) + .containsEntry(JwtClaims.PURPOSE, AuthenticationConstants.CHALLENGE_PURPOSE_VALUE) + .containsEntry(JwtClaims.DEVICE_FINGERPRINT, DEVICE_FINGERPRINT); + assertThat(challenge.getChallengeToken()).isEqualTo(CHALLENGE_TOKEN); + assertThat(challenge.getSentTo()).isEqualTo("u***@test.com"); + } + + @Test + void unknownEmailIsRejectedWithoutSendingOtp() { + when(userQueryService.findCredentialsByEmail(EMAIL)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.login(loginCommand())) + .isInstanceOf(InvalidCredentialsException.class); + verify(otpCommandService, never()).createOtp(any(), any()); + } + + @Test + void wrongPasswordIsRejectedWithoutSendingOtp() { + when(userQueryService.findCredentialsByEmail(EMAIL)).thenReturn(Optional.of(credentials(UserStatus.BOUND))); + when(passwordEncoder.matches(RAW_PASSWORD, PASSWORD_HASH)).thenReturn(false); + + assertThatThrownBy(() -> service.login(loginCommand())) + .isInstanceOf(InvalidCredentialsException.class); + verify(otpCommandService, never()).createOtp(any(), any()); + } + + @Test + void notBoundUserIsRejectedBeforePasswordCheck() { + when(userQueryService.findCredentialsByEmail(EMAIL)) + .thenReturn(Optional.of(credentials(UserStatus.PENDING_OTP))); + + assertThatThrownBy(() -> service.login(loginCommand())) + .isInstanceOf(InvalidCredentialsException.class); + verify(passwordEncoder, never()).matches(anyString(), anyString()); + } + } + + @Nested + class VerifyTwoFactor { + + private VerifyTwoFactorCommand command() { + return VerifyTwoFactorCommand.builder() + .challengeToken(CHALLENGE_TOKEN) + .token(OTP_TOKEN) + .deviceFingerprint(DEVICE_FINGERPRINT) + .build(); + } + + private Jwt challengeJwt(String purpose, String deviceFingerprint) { + return Jwt.withTokenValue(CHALLENGE_TOKEN) + .header("alg", "ES256") + .subject(EXTERNAL_ID.toString()) + .claim(JwtClaims.PURPOSE, purpose) + .claim(JwtClaims.DEVICE_FINGERPRINT, deviceFingerprint) + .build(); + } + + @Test + void successEstablishesSessionWithHashedPersistedRefreshToken() { + when(jwtDecoder.decode(CHALLENGE_TOKEN)) + .thenReturn(challengeJwt(AuthenticationConstants.CHALLENGE_PURPOSE_VALUE, DEVICE_FINGERPRINT)); + when(userQueryService.findByExternalId(EXTERNAL_ID)).thenReturn(UserQueryData.builder() + .id(USER_ID) + .externalId(EXTERNAL_ID) + .email(EMAIL) + .status(UserStatus.BOUND) + .build()); + when(jwtIssuer.issue(eq(EXTERNAL_ID.toString()), anyMap(), eq(PROPERTIES.getAccessTokenTtl()))) + .thenReturn(issuedJwt("access-token", PROPERTIES.getAccessTokenTtl())); + when(refreshTokenCommandRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + EstablishedSessionCommandData session = service.verifyTwoFactor(command()); + + verify(otpCommandService).validateOtp(EXTERNAL_ID, OTP_TOKEN); + assertThat(session.getAccessToken()).isEqualTo("access-token"); + assertThat(session.getRefreshToken()).isNotBlank(); + + ArgumentCaptor saved = ArgumentCaptor.forClass(RefreshToken.class); + verify(refreshTokenCommandRepository).save(saved.capture()); + assertThat(saved.getValue().getUserId()).isEqualTo(USER_ID); + assertThat(saved.getValue().getDeviceFingerprint()).isEqualTo(DEVICE_FINGERPRINT); + assertThat(saved.getValue().getTokenHash()) + .isNotEqualTo(session.getRefreshToken()) + .hasSize(64) + .matches("[0-9a-f]+"); + } + + @Test + void missingChallengePurposeIsRejected() { + when(jwtDecoder.decode(CHALLENGE_TOKEN)).thenReturn(challengeJwt("other-purpose", DEVICE_FINGERPRINT)); + + assertThatThrownBy(() -> service.verifyTwoFactor(command())) + .isInstanceOf(TwoFactorInvalidException.class); + verify(otpCommandService, never()).validateOtp(any(), any()); + } + + @Test + void deviceFingerprintMismatchIsRejected() { + when(jwtDecoder.decode(CHALLENGE_TOKEN)).thenReturn( + challengeJwt(AuthenticationConstants.CHALLENGE_PURPOSE_VALUE, OTHER_DEVICE_FINGERPRINT)); + + assertThatThrownBy(() -> service.verifyTwoFactor(command())) + .isInstanceOf(TwoFactorInvalidException.class); + verify(otpCommandService, never()).validateOtp(any(), any()); + } + + @Test + void invalidChallengeTokenIsRejected() { + when(jwtDecoder.decode(CHALLENGE_TOKEN)).thenThrow(new JwtException("bad token")); + + assertThatThrownBy(() -> service.verifyTwoFactor(command())) + .isInstanceOf(TwoFactorInvalidException.class); + } + + @Test + void wrongOtpIsWrappedAsTwoFactorFailure() { + when(jwtDecoder.decode(CHALLENGE_TOKEN)) + .thenReturn(challengeJwt(AuthenticationConstants.CHALLENGE_PURPOSE_VALUE, DEVICE_FINGERPRINT)); + org.mockito.Mockito.doThrow(new OtpTokenInvalidException()) + .when(otpCommandService).validateOtp(EXTERNAL_ID, OTP_TOKEN); + + assertThatThrownBy(() -> service.verifyTwoFactor(command())) + .isInstanceOf(TwoFactorInvalidException.class); + verify(refreshTokenCommandRepository, never()).save(any()); + } + } + + @Nested + class Refresh { + + private RefreshSessionCommand command(String deviceFingerprint) { + return RefreshSessionCommand.builder() + .refreshToken(PRESENTED_REFRESH_TOKEN) + .deviceFingerprint(deviceFingerprint) + .build(); + } + + private RefreshToken activeToken() { + return RefreshToken.issue(USER_ID, "hash-of-presented-token", DEVICE_FINGERPRINT, + Instant.now().plusSeconds(3600)); + } + + @Test + void successRotatesAndRevokesPredecessor() { + RefreshToken predecessor = activeToken(); + when(refreshTokenCommandRepository.findByTokenHash(anyString())).thenReturn(Optional.of(predecessor)); + when(userQueryService.findById(USER_ID)).thenReturn(UserQueryData.builder() + .id(USER_ID) + .externalId(EXTERNAL_ID) + .email(EMAIL) + .status(UserStatus.BOUND) + .build()); + when(jwtIssuer.issue(eq(EXTERNAL_ID.toString()), anyMap(), eq(PROPERTIES.getAccessTokenTtl()))) + .thenReturn(issuedJwt("new-access-token", PROPERTIES.getAccessTokenTtl())); + when(refreshTokenCommandRepository.save(any())).thenAnswer(invocation -> { + RefreshToken saved = invocation.getArgument(0); + if (saved.getId() == null) { + ReflectionTestUtils.setField(saved, "id", NEW_TOKEN_ID); + } + return saved; + }); + + EstablishedSessionCommandData session = service.refresh(command(DEVICE_FINGERPRINT)); + + assertThat(session.getAccessToken()).isEqualTo("new-access-token"); + assertThat(session.getRefreshToken()).isNotEqualTo(PRESENTED_REFRESH_TOKEN); + assertThat(predecessor.getRotatedTo()).isEqualTo(NEW_TOKEN_ID); + assertThat(predecessor.getRevokedAt()).isNotNull(); + } + + @Test + void replayedTokenRevokesSuccessorChain() { + RefreshToken replayed = activeToken(); + replayed.rotateTo(SUCCESSOR_ID); + RefreshToken successor = activeToken(); + when(refreshTokenCommandRepository.findByTokenHash(anyString())).thenReturn(Optional.of(replayed)); + when(refreshTokenCommandRepository.findById(SUCCESSOR_ID)).thenReturn(Optional.of(successor)); + when(refreshTokenCommandRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + assertThatThrownBy(() -> service.refresh(command(DEVICE_FINGERPRINT))) + .isInstanceOf(RefreshTokenInvalidException.class); + assertThat(successor.getRevokedAt()).isNotNull(); + verify(jwtIssuer, never()).issue(anyString(), anyMap(), any()); + } + + @Test + void unknownTokenIsRejected() { + when(refreshTokenCommandRepository.findByTokenHash(anyString())).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.refresh(command(DEVICE_FINGERPRINT))) + .isInstanceOf(RefreshTokenInvalidException.class); + } + + @Test + void revokedTokenIsRejected() { + RefreshToken revoked = activeToken(); + revoked.revoke(); + when(refreshTokenCommandRepository.findByTokenHash(anyString())).thenReturn(Optional.of(revoked)); + + assertThatThrownBy(() -> service.refresh(command(DEVICE_FINGERPRINT))) + .isInstanceOf(RefreshTokenInvalidException.class); + } + + @Test + void expiredTokenIsRejected() { + RefreshToken expired = RefreshToken.issue(USER_ID, "hash-of-presented-token", DEVICE_FINGERPRINT, + Instant.now().minusSeconds(1)); + when(refreshTokenCommandRepository.findByTokenHash(anyString())).thenReturn(Optional.of(expired)); + + assertThatThrownBy(() -> service.refresh(command(DEVICE_FINGERPRINT))) + .isInstanceOf(RefreshTokenInvalidException.class); + } + + @Test + void deviceFingerprintMismatchIsRejected() { + when(refreshTokenCommandRepository.findByTokenHash(anyString())).thenReturn(Optional.of(activeToken())); + + assertThatThrownBy(() -> service.refresh(command(OTHER_DEVICE_FINGERPRINT))) + .isInstanceOf(RefreshTokenInvalidException.class); + } + } + + @Nested + class Logout { + + @Test + void knownTokenIsRevoked() { + RefreshToken token = RefreshToken.issue(USER_ID, "hash-of-presented-token", DEVICE_FINGERPRINT, + Instant.now().plusSeconds(3600)); + when(refreshTokenCommandRepository.findByTokenHash(anyString())).thenReturn(Optional.of(token)); + when(refreshTokenCommandRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + service.logout(LogoutCommand.builder().refreshToken(PRESENTED_REFRESH_TOKEN).build()); + + assertThat(token.getRevokedAt()).isNotNull(); + verify(refreshTokenCommandRepository).save(token); + } + + @Test + void unknownTokenIsIgnored() { + when(refreshTokenCommandRepository.findByTokenHash(anyString())).thenReturn(Optional.empty()); + + service.logout(LogoutCommand.builder().refreshToken(PRESENTED_REFRESH_TOKEN).build()); + + verify(refreshTokenCommandRepository, never()).save(any()); + } + } +} diff --git a/consumer/src/test/java/org/apache/fineract/consumer/cucumber/clients/AuthenticationClient.java b/consumer/src/test/java/org/apache/fineract/consumer/cucumber/clients/AuthenticationClient.java new file mode 100644 index 0000000..32fb1fa --- /dev/null +++ b/consumer/src/test/java/org/apache/fineract/consumer/cucumber/clients/AuthenticationClient.java @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.cucumber.clients; + +import feign.Feign; +import feign.FeignException; +import feign.Headers; +import feign.Param; +import feign.Request; +import feign.RequestLine; +import feign.Response; +import feign.jackson.JacksonEncoder; +import feign.okhttp.OkHttpClient; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.apache.fineract.consumer.authentication.command.data.AuthenticationConstants; +import org.apache.fineract.consumer.infrastructure.web.ConsumerHeaders; + +public final class AuthenticationClient { + + private static final String BFF_BASE_URL = System.getenv().getOrDefault("BASE_URL", "http://localhost:8080"); + private static final String REFRESH_COOKIE_PREFIX = AuthenticationConstants.REFRESH_TOKEN_COOKIE_NAME + "="; + private static final long CONNECT_TIMEOUT_SECONDS = 5; + private static final long READ_TIMEOUT_SECONDS = 10; + + private static final Api API = Feign.builder() + .client(new OkHttpClient()) + .encoder(new JacksonEncoder()) + .options(new Request.Options( + CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS, + READ_TIMEOUT_SECONDS, TimeUnit.SECONDS, + true)) + .target(Api.class, BFF_BASE_URL); + + public Response login(String email, String password, String deviceFingerprint) { + return throwIfError(API.login(deviceFingerprint, Map.of("email", email, "password", password))); + } + + public Response verifyTwoFactor(String challengeToken, String otpToken, String deviceFingerprint) { + return throwIfError(API.verifyTwoFactor(deviceFingerprint, + Map.of("challengeToken", challengeToken, "token", otpToken))); + } + + public Response refresh(String refreshCookieValue, String deviceFingerprint) { + String cookie = refreshCookieValue == null ? null : REFRESH_COOKIE_PREFIX + refreshCookieValue; + return throwIfError(API.refresh(cookie, deviceFingerprint)); + } + + public Response logout(String bearerToken, String refreshCookieValue) { + String authorization = bearerToken == null ? + null : AuthenticationConstants.BEARER_TOKEN_TYPE + " " + bearerToken; + String cookie = refreshCookieValue == null ? null : REFRESH_COOKIE_PREFIX + refreshCookieValue; + return throwIfError(API.logout(authorization, cookie)); + } + + private static Response throwIfError(Response response) { + if (response.status() >= 400) { + throw FeignException.errorStatus("authentication", response); + } + return response; + } + + private interface Api { + + @RequestLine("POST /api/v1/authentication/login") + @Headers({ "Content-Type: application/json", ConsumerHeaders.DEVICE_FINGERPRINT + ": {fingerprint}" }) + Response login(@Param("fingerprint") String fingerprint, Map body); + + @RequestLine("POST /api/v1/authentication/2fa") + @Headers({ "Content-Type: application/json", ConsumerHeaders.DEVICE_FINGERPRINT + ": {fingerprint}" }) + Response verifyTwoFactor(@Param("fingerprint") String fingerprint, Map body); + + @RequestLine("POST /api/v1/authentication/refresh") + @Headers({ "Cookie: {refreshCookie}", ConsumerHeaders.DEVICE_FINGERPRINT + ": {fingerprint}" }) + Response refresh(@Param("refreshCookie") String refreshCookie, @Param("fingerprint") String fingerprint); + + @RequestLine("POST /api/v1/authentication/logout") + @Headers({ "Authorization: {authorization}", "Cookie: {refreshCookie}" }) + Response logout(@Param("authorization") String authorization, @Param("refreshCookie") String refreshCookie); + } +} diff --git a/consumer/src/test/java/org/apache/fineract/consumer/cucumber/helpers/RegistrationHelper.java b/consumer/src/test/java/org/apache/fineract/consumer/cucumber/helpers/RegistrationHelper.java new file mode 100644 index 0000000..581388b --- /dev/null +++ b/consumer/src/test/java/org/apache/fineract/consumer/cucumber/helpers/RegistrationHelper.java @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.cucumber.helpers; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.UUID; +import org.apache.fineract.consumer.client.ApiClient; +import org.apache.fineract.consumer.client.api.RegistrationCommandControllerApi; +import org.apache.fineract.consumer.client.model.SendOtpCommandRequest; +import org.apache.fineract.consumer.client.model.SubmitRegistrationCommandData; +import org.apache.fineract.consumer.client.model.SubmitRegistrationCommandRequest; +import org.apache.fineract.consumer.client.model.VerifyOtpCommandData; +import org.apache.fineract.consumer.client.model.VerifyOtpCommandRequest; +import org.apache.fineract.consumer.cucumber.clients.MailpitClient; +import org.apache.fineract.consumer.otp.command.data.OtpConstants; + +public class RegistrationHelper { + + private static final String BFF_BASE_URL = System.getenv().getOrDefault("BASE_URL", "http://localhost:8080"); + private static final String PASSWORD = "Cucumber-password1"; + + private final FineractSeeder fineractSeeder = new FineractSeeder(); + private final MailpitClient mailpit = new MailpitClient(); + private final RegistrationCommandControllerApi bff = buildBffClient(); + + public record BoundUser(String email, String password) {} + + public BoundUser registerBoundUser(String deviceFingerprint) { + FineractSeeder.SeededClient seededClient = fineractSeeder.seedClientWithPassport(); + String email = "user-" + UUID.randomUUID().toString().substring(0, 8) + "@test.com"; + + SubmitRegistrationCommandData submitted = bff.submit(deviceFingerprint, + new SubmitRegistrationCommandRequest() + .fineractClientId(seededClient.fineractClientId()) + .email(email) + .password(PASSWORD) + .documentTypeName(seededClient.documentTypeName()) + .documentKey(seededClient.documentKey())); + assertThat(submitted.getStatus()).isEqualTo(SubmitRegistrationCommandData.StatusEnum.PENDING_OTP); + + bff.sendOtp(new SendOtpCommandRequest() + .registrationId(submitted.getRegistrationId()) + .deliveryMethod(OtpConstants.EMAIL_DELIVERY_METHOD_NAME)); + + String otp = mailpit.waitForOtp(email); + VerifyOtpCommandData verified = bff.verifyOtp(new VerifyOtpCommandRequest() + .registrationId(submitted.getRegistrationId()) + .token(otp)); + assertThat(verified.getStatus()).isEqualTo(VerifyOtpCommandData.StatusEnum.BOUND); + + return new BoundUser(email, PASSWORD); + } + + private static RegistrationCommandControllerApi buildBffClient() { + ApiClient apiClient = new ApiClient(); + apiClient.setBasePath(BFF_BASE_URL); + return apiClient.buildClient(RegistrationCommandControllerApi.class); + } +} diff --git a/consumer/src/test/java/org/apache/fineract/consumer/cucumber/hooks/ConsumerDatabaseReset.java b/consumer/src/test/java/org/apache/fineract/consumer/cucumber/hooks/ConsumerDatabaseReset.java new file mode 100644 index 0000000..78ad705 --- /dev/null +++ b/consumer/src/test/java/org/apache/fineract/consumer/cucumber/hooks/ConsumerDatabaseReset.java @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.cucumber.hooks; + +import io.cucumber.java.Before; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; + +public final class ConsumerDatabaseReset { + + private static final String JDBC_URL = System.getenv() + .getOrDefault("BFF_DB_URL", "jdbc:postgresql://localhost:5432/consumerapp"); + private static final String JDBC_USER = System.getenv().getOrDefault("BFF_DB_USER", "consumerapp"); + private static final String JDBC_PASSWORD = System.getenv().getOrDefault("BFF_DB_PASSWORD", "password"); + private static final String TRUNCATE_SQL = "TRUNCATE TABLE users, refresh_tokens RESTART IDENTITY CASCADE"; + + @Before + public void truncateBffTables() { + try (Connection connection = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD); + Statement statement = connection.createStatement()) { + statement.execute(TRUNCATE_SQL); + } catch (SQLException e) { + throw new IllegalStateException( + "Failed to truncate BFF tables at " + JDBC_URL + " — is the Docker Compose stack up?", e); + } + } +} diff --git a/consumer/src/test/java/org/apache/fineract/consumer/cucumber/steps/LoginSteps.java b/consumer/src/test/java/org/apache/fineract/consumer/cucumber/steps/LoginSteps.java new file mode 100644 index 0000000..ea6c692 --- /dev/null +++ b/consumer/src/test/java/org/apache/fineract/consumer/cucumber/steps/LoginSteps.java @@ -0,0 +1,297 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.cucumber.steps; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +import feign.FeignException; +import feign.Response; +import feign.Util; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Map; +import java.util.UUID; +import org.apache.fineract.consumer.authentication.command.data.AuthenticationConstants; +import org.apache.fineract.consumer.authentication.command.exception.InvalidCredentialsException; +import org.apache.fineract.consumer.authentication.command.exception.RefreshTokenInvalidException; +import org.apache.fineract.consumer.authentication.command.exception.TwoFactorInvalidException; +import org.apache.fineract.consumer.cucumber.clients.AuthenticationClient; +import org.apache.fineract.consumer.cucumber.clients.MailpitClient; +import org.apache.fineract.consumer.cucumber.helpers.RegistrationHelper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +public class LoginSteps { + + private static final String DEVICE_FINGERPRINT = "cucumber-login-device"; + private static final String OTHER_DEVICE_FINGERPRINT = "cucumber-other-device"; + private static final String WRONG_PASSWORD = "Wrong-password-123"; + private static final String WRONG_OTP = "WRONG1"; + private static final String SET_COOKIE_HEADER = "set-cookie"; + private static final String REFRESH_COOKIE_PREFIX = AuthenticationConstants.REFRESH_TOKEN_COOKIE_NAME + "="; + private static final ObjectMapper JSON = JsonMapper.builder().build(); + + public record AuthResponse(int status, JsonNode body, String refreshCookie) {} + + private final RegistrationHelper registrationHelper = new RegistrationHelper(); + private final MailpitClient mailpit = new MailpitClient(); + private final AuthenticationClient authClient = new AuthenticationClient(); + + private RegistrationHelper.BoundUser user; + private AuthResponse lastResponse; + private String challengeToken; + private String otpToken; + private String accessToken; + private String currentRefreshCookie; + private String previousRefreshCookie; + + @Given("a registered and bound user exists") + public void registeredAndBoundUser() { + user = registrationHelper.registerBoundUser(DEVICE_FINGERPRINT); + } + + @When("I log in with my correct password") + public void loginWithCorrectPassword() { + login(user.email(), user.password()); + } + + @When("I log in with a wrong password") + public void loginWithWrongPassword() { + clearLoginInbox(); + try { + authClient.login(user.email(), WRONG_PASSWORD, DEVICE_FINGERPRINT); + fail("expected login to be rejected"); + } catch (FeignException e) { + lastResponse = toAuthResponse(e); + } + } + + @When("I log in with an unknown email") + public void loginWithUnknownEmail() { + clearLoginInbox(); + String unknownEmail = "unknown-" + UUID.randomUUID().toString().substring(0, 8) + "@test.com"; + try { + authClient.login(unknownEmail, user.password(), DEVICE_FINGERPRINT); + fail("expected login to be rejected"); + } catch (FeignException e) { + lastResponse = toAuthResponse(e); + } + } + + @Then("I receive a login challenge sent to my masked email") + public void receivedLoginChallenge() { + assertThat(challengeToken).isNotBlank(); + String sentTo = lastResponse.body().path("sentTo").asString(); + assertThat(sentTo) + .isNotEqualTo(user.email()) + .startsWith(user.email().substring(0, 1)) + .contains("***") + .endsWith(user.email().substring(user.email().indexOf('@'))); + } + + @When("I retrieve the login OTP from Mailpit") + public void retrieveLoginOtp() { + otpToken = mailpit.waitForOtp(user.email()); + assertThat(otpToken).isNotBlank(); + } + + @When("I verify the login OTP") + public void verifyLoginOtp() { + verifyTwoFactor(otpToken, DEVICE_FINGERPRINT); + } + + @When("I verify a wrong login OTP") + public void verifyWrongLoginOtp() { + try { + authClient.verifyTwoFactor(challengeToken, WRONG_OTP, DEVICE_FINGERPRINT); + fail("expected two-factor verification to be rejected"); + } catch (FeignException e) { + lastResponse = toAuthResponse(e); + } + } + + @When("I verify the same login OTP again") + public void verifySameLoginOtpAgain() { + try { + authClient.verifyTwoFactor(challengeToken, otpToken, DEVICE_FINGERPRINT); + fail("expected two-factor verification to be rejected"); + } catch (FeignException e) { + lastResponse = toAuthResponse(e); + } + } + + @When("I complete a login successfully") + public void completeLoginSuccessfully() { + loginWithCorrectPassword(); + receivedLoginChallenge(); + retrieveLoginOtp(); + verifyLoginOtp(); + receivedSession(); + } + + @Then("I receive a session with an access token and refresh cookie") + public void receivedSession() { + assertThat(accessToken).isNotBlank(); + assertThat(currentRefreshCookie).isNotBlank(); + assertThat(lastResponse.body().path("tokenType").asString()) + .isEqualTo(AuthenticationConstants.BEARER_TOKEN_TYPE); + } + + @Then("a protected endpoint accepts the access token") + public void protectedEndpointAcceptsAccessToken() { + authClient.logout(accessToken, currentRefreshCookie); + } + + @Then("a protected endpoint rejects the challenge token as a bearer token") + public void protectedEndpointRejectsChallengeToken() { + try { + authClient.logout(challengeToken, null); + fail("expected logout to be rejected"); + } catch (FeignException e) { + assertThat(e.status()).isEqualTo(401); + } + } + + @Then("the login is rejected with a generic credentials error") + public void loginRejectedGenerically() { + assertThat(lastResponse.status()).isEqualTo(401); + assertThat(lastResponse.body().path("code").asString()).isEqualTo(InvalidCredentialsException.CODE); + assertThat(lastResponse.body().toString()).doesNotContain(user.email()); + } + + @Then("the two-factor verification is rejected") + public void twoFactorRejected() { + assertThat(lastResponse.status()).isEqualTo(401); + assertThat(lastResponse.body().path("code").asString()).isEqualTo(TwoFactorInvalidException.CODE); + } + + @When("I refresh my session") + public void refreshSession() { + refresh(currentRefreshCookie, DEVICE_FINGERPRINT); + } + + @When("I refresh using the previous refresh cookie") + public void refreshWithPreviousCookie() { + try { + authClient.refresh(previousRefreshCookie, DEVICE_FINGERPRINT); + fail("expected refresh to be rejected"); + } catch (FeignException e) { + lastResponse = toAuthResponse(e); + } + } + + @When("I refresh using the latest refresh cookie") + public void refreshWithLatestCookie() { + try { + authClient.refresh(currentRefreshCookie, DEVICE_FINGERPRINT); + fail("expected refresh to be rejected"); + } catch (FeignException e) { + lastResponse = toAuthResponse(e); + } + } + + @When("I refresh my session from a different device") + public void refreshFromDifferentDevice() { + try { + authClient.refresh(currentRefreshCookie, OTHER_DEVICE_FINGERPRINT); + fail("expected refresh to be rejected"); + } catch (FeignException e) { + lastResponse = toAuthResponse(e); + } + } + + @Then("the refresh is rejected") + public void refreshRejected() { + assertThat(lastResponse.status()).isEqualTo(401); + assertThat(lastResponse.body().path("code").asString()).isEqualTo(RefreshTokenInvalidException.CODE); + } + + private void login(String email, String password) { + clearLoginInbox(); + lastResponse = toAuthResponse(authClient.login(email, password, DEVICE_FINGERPRINT)); + challengeToken = lastResponse.body().path("challengeToken").asString(); + } + + private void verifyTwoFactor(String otp, String deviceFingerprint) { + lastResponse = toAuthResponse(authClient.verifyTwoFactor(challengeToken, otp, deviceFingerprint)); + accessToken = lastResponse.body().path("accessToken").asString(); + previousRefreshCookie = currentRefreshCookie; + currentRefreshCookie = lastResponse.refreshCookie(); + } + + private void refresh(String refreshCookie, String deviceFingerprint) { + lastResponse = toAuthResponse(authClient.refresh(refreshCookie, deviceFingerprint)); + accessToken = lastResponse.body().path("accessToken").asString(); + previousRefreshCookie = currentRefreshCookie; + currentRefreshCookie = lastResponse.refreshCookie(); + } + + private void clearLoginInbox() { + mailpit.deleteMessages(user.email()); + } + + private static AuthResponse toAuthResponse(Response response) { + try (response) { + String body = response.body() == null + ? null + : Util.toString(response.body().asReader(StandardCharsets.UTF_8)); + return new AuthResponse(response.status(), parseJson(body), extractRefreshCookie(response.headers())); + } catch (IOException e) { + throw new UncheckedIOException("Failed to read authentication response body", e); + } + } + + private static AuthResponse toAuthResponse(FeignException e) { + return new AuthResponse(e.status(), parseJson(e.contentUTF8()), extractRefreshCookie(e.responseHeaders())); + } + + private static JsonNode parseJson(String body) { + if (body == null || body.isBlank()) { + return JSON.missingNode(); + } + try { + return JSON.readTree(body); + } catch (Exception ex) { + return JSON.missingNode(); + } + } + + private static String extractRefreshCookie(Map> headers) { + return headers.entrySet().stream() + .filter(entry -> SET_COOKIE_HEADER.equalsIgnoreCase(entry.getKey())) + .flatMap(entry -> entry.getValue().stream()) + .filter(value -> value.startsWith(REFRESH_COOKIE_PREFIX)) + .map(value -> value.substring(REFRESH_COOKIE_PREFIX.length(), cookieValueEnd(value))) + .findFirst() + .orElse(null); + } + + private static int cookieValueEnd(String setCookieValue) { + int semicolon = setCookieValue.indexOf(';'); + return semicolon >= 0 ? semicolon : setCookieValue.length(); + } +} diff --git a/consumer/src/test/java/org/apache/fineract/consumer/infrastructure/jwt/JwtIssuerTest.java b/consumer/src/test/java/org/apache/fineract/consumer/infrastructure/jwt/JwtIssuerTest.java new file mode 100644 index 0000000..7da40dd --- /dev/null +++ b/consumer/src/test/java/org/apache/fineract/consumer/infrastructure/jwt/JwtIssuerTest.java @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.infrastructure.jwt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.spec.ECGenParameterSpec; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import org.apache.fineract.consumer.infrastructure.configs.JwtConfig; +import org.apache.fineract.consumer.infrastructure.configs.JwtProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; + +class JwtIssuerTest { + + private static final String ISSUER = "test-issuer"; + private static final String SUBJECT = "0b8e7b0e-9c2d-4f6a-8d3e-1a2b3c4d5e6f"; + + private JwtIssuer jwtIssuer; + private JwtDecoder jwtDecoder; + private JwtProperties jwtProperties; + + @BeforeEach + void setUp() throws Exception { + jwtProperties = propertiesFor(generateKeyPairPem(), ISSUER); + JwtConfig jwtConfig = new JwtConfig(); + var signingKey = jwtConfig.jwtSigningKey(jwtProperties); + jwtIssuer = new JwtIssuer(jwtConfig.jwtEncoder(signingKey), jwtProperties); + jwtDecoder = jwtConfig.jwtDecoder(signingKey, jwtProperties); + } + + @Test + void issuedTokenDecodesWithSubjectIssuerAndCustomClaims() { + IssuedJwt issued = jwtIssuer.issue(SUBJECT, + Map.of("tenant", "default", "roles", List.of("CONSUMER")), + Duration.ofMinutes(15)); + + Jwt decoded = jwtDecoder.decode(issued.getTokenValue()); + + assertThat(decoded.getSubject()).isEqualTo(SUBJECT); + assertThat(decoded.getClaimAsString(JwtClaimNames.ISS)).isEqualTo(ISSUER); + assertThat(decoded.getClaimAsString("tenant")).isEqualTo("default"); + assertThat(decoded.getClaimAsStringList("roles")).containsExactly("CONSUMER"); + assertThat(decoded.getExpiresAt()).isEqualTo(issued.getExpiresAt().truncatedTo(ChronoUnit.SECONDS)); + assertThat(issued.getExpiresAt()) + .isCloseTo(Instant.now().plus(Duration.ofMinutes(15)), within(Duration.ofSeconds(5))); + } + + @Test + void tamperedTokenIsRejected() { + IssuedJwt issued = jwtIssuer.issue(SUBJECT, Map.of(), Duration.ofMinutes(5)); + String[] parts = issued.getTokenValue().split("\\."); + String tamperedPayload = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{\"sub\":\"someone-else\"}".getBytes()); + String tampered = parts[0] + "." + tamperedPayload + "." + parts[2]; + + assertThatThrownBy(() -> jwtDecoder.decode(tampered)).isInstanceOf(JwtException.class); + } + + @Test + void tokenSignedByDifferentKeyIsRejected() throws Exception { + JwtProperties otherProperties = propertiesFor(generateKeyPairPem(), ISSUER); + JwtConfig otherConfig = new JwtConfig(); + var otherKey = otherConfig.jwtSigningKey(otherProperties); + JwtIssuer otherIssuer = new JwtIssuer(otherConfig.jwtEncoder(otherKey), otherProperties); + IssuedJwt foreignToken = otherIssuer.issue(SUBJECT, Map.of(), Duration.ofMinutes(5)); + + assertThatThrownBy(() -> jwtDecoder.decode(foreignToken.getTokenValue())) + .isInstanceOf(JwtException.class); + } + + @Test + void pemWithoutPrivateKeyBlockFailsFast() throws Exception { + String publicOnlyPem = generateKeyPairPem().replaceAll( + "(?s)-----BEGIN PRIVATE KEY-----.*?-----END PRIVATE KEY-----", ""); + + assertThatThrownBy(() -> new JwtConfig().jwtSigningKey(propertiesFor(publicOnlyPem, ISSUER))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("PRIVATE KEY"); + } + + @Test + void issuedJwtToStringNeverContainsTheTokenValue() { + IssuedJwt issued = jwtIssuer.issue(SUBJECT, Map.of(), Duration.ofMinutes(5)); + + assertThat(issued.toString()).doesNotContain(issued.getTokenValue()); + } + + private static JwtProperties propertiesFor(String pem, String issuer) { + return new JwtProperties(new ByteArrayResource(pem.getBytes()), issuer); + } + + private static String generateKeyPairPem() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + KeyPair keyPair = generator.generateKeyPair(); + return pemBlock("PRIVATE KEY", keyPair.getPrivate().getEncoded()) + + pemBlock("PUBLIC KEY", keyPair.getPublic().getEncoded()); + } + + private static String pemBlock(String pemType, byte[] derBytes) { + return "-----BEGIN " + pemType + "-----\n" + + Base64.getMimeEncoder().encodeToString(derBytes) + + "\n-----END " + pemType + "-----\n"; + } +} diff --git a/consumer/src/test/java/org/apache/fineract/consumer/user/command/domain/UserTest.java b/consumer/src/test/java/org/apache/fineract/consumer/user/command/domain/UserTest.java new file mode 100644 index 0000000..c18457c --- /dev/null +++ b/consumer/src/test/java/org/apache/fineract/consumer/user/command/domain/UserTest.java @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.user.command.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.UUID; +import org.apache.fineract.consumer.user.command.exception.InvalidBindingStateException; +import org.junit.jupiter.api.Test; + +class UserTest { + + private static final UUID EXTERNAL_ID = UUID.fromString("3f2c8a1e-0000-4000-8000-000000000001"); + private static final String EMAIL = "user@test.com"; + private static final String PASSWORD_HASH = "{bcrypt}$2a$10$hash"; + private static final Long FINERACT_CLIENT_ID = 42L; + private static final String DEVICE_FINGERPRINT = "test-device"; + + private static User pendingOtpUser() { + return User.createPendingOtp(EXTERNAL_ID, EMAIL, PASSWORD_HASH, FINERACT_CLIENT_ID, DEVICE_FINGERPRINT); + } + + @Test + void createPendingOtpSetsAllFieldsAndStartsUnbound() { + User user = pendingOtpUser(); + + assertThat(user.getExternalId()).isEqualTo(EXTERNAL_ID); + assertThat(user.getEmail()).isEqualTo(EMAIL); + assertThat(user.getPasswordHash()).isEqualTo(PASSWORD_HASH); + assertThat(user.getFineractClientId()).isEqualTo(FINERACT_CLIENT_ID); + assertThat(user.getDeviceFingerprint()).isEqualTo(DEVICE_FINGERPRINT); + assertThat(user.getStatus()).isEqualTo(UserStatus.PENDING_OTP); + assertThat(user.getBoundAt()).isNull(); + assertThat(user.getCreatedAt()).isNotNull(); + assertThat(user.getUpdatedAt()).isEqualTo(user.getCreatedAt()); + } + + @Test + void markOtpVerifiedTransitionsToBound() { + User user = pendingOtpUser(); + + user.markOtpVerified(); + + assertThat(user.getStatus()).isEqualTo(UserStatus.BOUND); + assertThat(user.getBoundAt()).isNotNull(); + assertThat(user.getUpdatedAt()).isEqualTo(user.getBoundAt()); + } + + @Test + void markOtpVerifiedWhenAlreadyBoundIsRejected() { + User user = pendingOtpUser(); + user.markOtpVerified(); + + assertThatThrownBy(user::markOtpVerified) + .isInstanceOf(InvalidBindingStateException.class); + } +} diff --git a/consumer/src/test/java/org/apache/fineract/consumer/user/command/service/UserCommandServiceImplTest.java b/consumer/src/test/java/org/apache/fineract/consumer/user/command/service/UserCommandServiceImplTest.java new file mode 100644 index 0000000..1dee89c --- /dev/null +++ b/consumer/src/test/java/org/apache/fineract/consumer/user/command/service/UserCommandServiceImplTest.java @@ -0,0 +1,159 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.user.command.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.util.UUID; +import org.apache.fineract.consumer.user.command.data.CreateUserCommand; +import org.apache.fineract.consumer.user.command.data.UserCreatedCommandData; +import org.apache.fineract.consumer.user.command.domain.User; +import org.apache.fineract.consumer.user.command.domain.UserStatus; +import org.apache.fineract.consumer.user.command.exception.UserAlreadyExistsException; +import org.apache.fineract.consumer.user.command.exception.UserNotFoundException; +import org.apache.fineract.consumer.user.command.repository.UserCommandRepository; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class UserCommandServiceImplTest { + + private static final Long USER_ID = 7L; + private static final UUID EXTERNAL_ID = UUID.fromString("3f2c8a1e-0000-4000-8000-000000000001"); + private static final String EMAIL = "user@test.com"; + private static final String PASSWORD_HASH = "{bcrypt}$2a$10$hash"; + private static final Long FINERACT_CLIENT_ID = 42L; + private static final String DEVICE_FINGERPRINT = "test-device"; + + @Mock + private UserCommandRepository repository; + + @InjectMocks + private UserCommandServiceImpl service; + + private static CreateUserCommand createCommand() { + return CreateUserCommand.builder() + .email(EMAIL) + .passwordHash(PASSWORD_HASH) + .fineractClientId(FINERACT_CLIENT_ID) + .deviceFingerprint(DEVICE_FINGERPRINT) + .build(); + } + + private static User existingUser() { + return User.createPendingOtp(EXTERNAL_ID, EMAIL, PASSWORD_HASH, FINERACT_CLIENT_ID, DEVICE_FINGERPRINT); + } + + @Nested + class Create { + + @Test + void successPersistsPendingOtpUserAndReturnsIdentifiers() { + when(repository.findByEmail(EMAIL)).thenReturn(Optional.empty()); + when(repository.findByFineractClientId(FINERACT_CLIENT_ID)).thenReturn(Optional.empty()); + when(repository.save(any())).thenAnswer(invocation -> { + User saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", USER_ID); + return saved; + }); + + UserCreatedCommandData created = service.create(createCommand()); + + ArgumentCaptor saved = ArgumentCaptor.forClass(User.class); + verify(repository).save(saved.capture()); + assertThat(saved.getValue().getEmail()).isEqualTo(EMAIL); + assertThat(saved.getValue().getPasswordHash()).isEqualTo(PASSWORD_HASH); + assertThat(saved.getValue().getFineractClientId()).isEqualTo(FINERACT_CLIENT_ID); + assertThat(saved.getValue().getDeviceFingerprint()).isEqualTo(DEVICE_FINGERPRINT); + assertThat(saved.getValue().getStatus()).isEqualTo(UserStatus.PENDING_OTP); + assertThat(saved.getValue().getExternalId()).isNotNull(); + + assertThat(created.getUserId()).isEqualTo(USER_ID); + assertThat(created.getExternalId()).isEqualTo(saved.getValue().getExternalId()); + } + + @Test + void existingEmailIsRejectedWithoutSaving() { + when(repository.findByEmail(EMAIL)).thenReturn(Optional.of(existingUser())); + + assertThatThrownBy(() -> service.create(createCommand())) + .isInstanceOf(UserAlreadyExistsException.class); + verify(repository, never()).save(any()); + } + + @Test + void existingFineractClientIsRejectedWithoutSaving() { + when(repository.findByEmail(EMAIL)).thenReturn(Optional.empty()); + when(repository.findByFineractClientId(FINERACT_CLIENT_ID)).thenReturn(Optional.of(existingUser())); + + assertThatThrownBy(() -> service.create(createCommand())) + .isInstanceOf(UserAlreadyExistsException.class); + verify(repository, never()).save(any()); + } + + @Test + void uniqueConstraintRaceIsTranslatedToAlreadyExists() { + when(repository.findByEmail(EMAIL)).thenReturn(Optional.empty()); + when(repository.findByFineractClientId(FINERACT_CLIENT_ID)).thenReturn(Optional.empty()); + when(repository.save(any())).thenThrow(new DataIntegrityViolationException("duplicate key")); + + assertThatThrownBy(() -> service.create(createCommand())) + .isInstanceOf(UserAlreadyExistsException.class); + } + } + + @Nested + class MarkOtpVerified { + + @Test + void successTransitionsUserToBoundAndSaves() { + User user = existingUser(); + when(repository.findById(USER_ID)).thenReturn(Optional.of(user)); + + service.markOtpVerified(USER_ID); + + verify(repository).save(user); + assertThat(user.getStatus()).isEqualTo(UserStatus.BOUND); + assertThat(user.getBoundAt()).isNotNull(); + } + + @Test + void unknownUserIsRejected() { + when(repository.findById(USER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.markOtpVerified(USER_ID)) + .isInstanceOf(UserNotFoundException.class); + verify(repository, never()).save(any()); + } + } +} diff --git a/consumer/src/test/java/org/apache/fineract/consumer/user/query/service/UserQueryServiceImplTest.java b/consumer/src/test/java/org/apache/fineract/consumer/user/query/service/UserQueryServiceImplTest.java new file mode 100644 index 0000000..cd8a638 --- /dev/null +++ b/consumer/src/test/java/org/apache/fineract/consumer/user/query/service/UserQueryServiceImplTest.java @@ -0,0 +1,113 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.consumer.user.query.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.util.UUID; +import org.apache.fineract.consumer.user.command.domain.UserStatus; +import org.apache.fineract.consumer.user.command.exception.UserNotFoundException; +import org.apache.fineract.consumer.user.query.data.UserCredentialsQueryData; +import org.apache.fineract.consumer.user.query.data.UserQueryData; +import org.apache.fineract.consumer.user.query.repository.UserQueryRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class UserQueryServiceImplTest { + + private static final Long USER_ID = 7L; + private static final UUID EXTERNAL_ID = UUID.fromString("3f2c8a1e-0000-4000-8000-000000000001"); + private static final String EMAIL = "user@test.com"; + private static final String PASSWORD_HASH = "{bcrypt}$2a$10$hash"; + + @Mock + private UserQueryRepository repository; + + @InjectMocks + private UserQueryServiceImpl service; + + private static UserQueryData userQueryData() { + return UserQueryData.builder() + .id(USER_ID) + .externalId(EXTERNAL_ID) + .email(EMAIL) + .status(UserStatus.BOUND) + .build(); + } + + @Test + void findByExternalIdReturnsUser() { + UserQueryData user = userQueryData(); + when(repository.findByExternalId(EXTERNAL_ID)).thenReturn(Optional.of(user)); + + assertThat(service.findByExternalId(EXTERNAL_ID)).isEqualTo(user); + } + + @Test + void findByExternalIdUnknownIsRejected() { + when(repository.findByExternalId(EXTERNAL_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.findByExternalId(EXTERNAL_ID)) + .isInstanceOf(UserNotFoundException.class); + } + + @Test + void findByIdReturnsUser() { + UserQueryData user = userQueryData(); + when(repository.findById(USER_ID)).thenReturn(Optional.of(user)); + + assertThat(service.findById(USER_ID)).isEqualTo(user); + } + + @Test + void findByIdUnknownIsRejected() { + when(repository.findById(USER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.findById(USER_ID)) + .isInstanceOf(UserNotFoundException.class); + } + + @Test + void findCredentialsByEmailReturnsCredentialsWhenPresent() { + UserCredentialsQueryData credentials = UserCredentialsQueryData.builder() + .id(USER_ID) + .externalId(EXTERNAL_ID) + .status(UserStatus.BOUND) + .passwordHash(PASSWORD_HASH) + .build(); + when(repository.findCredentialsByEmail(EMAIL)).thenReturn(Optional.of(credentials)); + + assertThat(service.findCredentialsByEmail(EMAIL)).contains(credentials); + } + + @Test + void findCredentialsByEmailReturnsEmptyWhenUnknown() { + when(repository.findCredentialsByEmail(EMAIL)).thenReturn(Optional.empty()); + + assertThat(service.findCredentialsByEmail(EMAIL)).isEmpty(); + } +} diff --git a/consumer/src/test/resources/features/login.feature b/consumer/src/test/resources/features/login.feature new file mode 100644 index 0000000..63529d9 --- /dev/null +++ b/consumer/src/test/resources/features/login.feature @@ -0,0 +1,65 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +Feature: Consumer login + + Background: + Given a registered and bound user exists + + Scenario: Successful login through OTP verification + When I log in with my correct password + Then I receive a login challenge sent to my masked email + When I retrieve the login OTP from Mailpit + And I verify the login OTP + Then I receive a session with an access token and refresh cookie + And a protected endpoint accepts the access token + + Scenario: Login with a wrong password is rejected generically + When I log in with a wrong password + Then the login is rejected with a generic credentials error + + Scenario: Login with an unknown email is rejected generically + When I log in with an unknown email + Then the login is rejected with a generic credentials error + + Scenario: Verifying a wrong login OTP is rejected + When I log in with my correct password + And I verify a wrong login OTP + Then the two-factor verification is rejected + + Scenario: Replaying a verified login OTP is rejected + When I complete a login successfully + And I verify the same login OTP again + Then the two-factor verification is rejected + + Scenario: A challenge token cannot be used as an access token + When I log in with my correct password + Then a protected endpoint rejects the challenge token as a bearer token + + Scenario: Refreshing rotates the refresh token and replay revokes the chain + When I complete a login successfully + And I refresh my session + Then I receive a session with an access token and refresh cookie + When I refresh using the previous refresh cookie + Then the refresh is rejected + When I refresh using the latest refresh cookie + Then the refresh is rejected + + Scenario: Refreshing from a different device is rejected + When I complete a login successfully + And I refresh my session from a different device + Then the refresh is rejected From acb5be382820fadd633d2e20216ad7b836507e97 Mon Sep 17 00:00:00 2001 From: edk12564 Date: Sun, 14 Jun 2026 01:47:11 -0500 Subject: [PATCH 3/3] - removed duplicated devicefingerprint column from users table - adjusted registration and users features and cucumber tests to match --- .../command/api/RegistrationCommandController.java | 6 +----- .../command/data/SubmitRegistrationCommand.java | 1 - .../command/service/RegistrationCommandServiceImpl.java | 1 - .../consumer/user/command/data/CreateUserCommand.java | 1 - .../apache/fineract/consumer/user/command/domain/User.java | 7 +------ .../user/command/service/UserCommandServiceImpl.java | 3 +-- .../db/changelog/changes/001-create-users-table.yaml | 5 ----- .../consumer/cucumber/helpers/RegistrationHelper.java | 4 ++-- .../fineract/consumer/cucumber/steps/LoginSteps.java | 2 +- .../consumer/cucumber/steps/RegistrationSteps.java | 3 +-- .../fineract/consumer/user/command/domain/UserTest.java | 4 +--- .../user/command/service/UserCommandServiceImplTest.java | 5 +---- 12 files changed, 9 insertions(+), 33 deletions(-) diff --git a/consumer/src/main/java/org/apache/fineract/consumer/registration/command/api/RegistrationCommandController.java b/consumer/src/main/java/org/apache/fineract/consumer/registration/command/api/RegistrationCommandController.java index 4cf918d..92bc845 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/registration/command/api/RegistrationCommandController.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/registration/command/api/RegistrationCommandController.java @@ -30,13 +30,11 @@ import org.apache.fineract.consumer.registration.command.data.VerifyOtpCommand; import org.apache.fineract.consumer.registration.command.data.VerifyOtpCommandData; import org.apache.fineract.consumer.registration.command.data.VerifyOtpCommandRequest; -import org.apache.fineract.consumer.infrastructure.web.ConsumerHeaders; import org.apache.fineract.consumer.registration.command.service.RegistrationCommandService; 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; @@ -49,15 +47,13 @@ public class RegistrationCommandController { @PostMapping("/submit") public ResponseEntity submit( - @Valid @RequestBody SubmitRegistrationCommandRequest request, - @RequestHeader(ConsumerHeaders.DEVICE_FINGERPRINT) String deviceFingerprint) { + @Valid @RequestBody SubmitRegistrationCommandRequest request) { SubmitRegistrationCommand command = SubmitRegistrationCommand.builder() .fineractClientId(request.getFineractClientId()) .email(request.getEmail()) .password(request.getPassword()) .documentTypeName(request.getDocumentTypeName()) .documentKey(request.getDocumentKey()) - .deviceFingerprint(deviceFingerprint) .build(); SubmitRegistrationCommandData data = registrationCommandService.submit(command); return ResponseEntity.status(HttpStatus.CREATED).body(data); diff --git a/consumer/src/main/java/org/apache/fineract/consumer/registration/command/data/SubmitRegistrationCommand.java b/consumer/src/main/java/org/apache/fineract/consumer/registration/command/data/SubmitRegistrationCommand.java index 67dfddc..b43c10b 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/registration/command/data/SubmitRegistrationCommand.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/registration/command/data/SubmitRegistrationCommand.java @@ -35,5 +35,4 @@ public final class SubmitRegistrationCommand { private final String password; private final String documentTypeName; private final String documentKey; - private final String deviceFingerprint; } diff --git a/consumer/src/main/java/org/apache/fineract/consumer/registration/command/service/RegistrationCommandServiceImpl.java b/consumer/src/main/java/org/apache/fineract/consumer/registration/command/service/RegistrationCommandServiceImpl.java index f2342e9..c202622 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/registration/command/service/RegistrationCommandServiceImpl.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/registration/command/service/RegistrationCommandServiceImpl.java @@ -72,7 +72,6 @@ public SubmitRegistrationCommandData submit(SubmitRegistrationCommand command) { .email(command.getEmail()) .passwordHash(passwordEncoder.encode(command.getPassword())) .fineractClientId(command.getFineractClientId()) - .deviceFingerprint(command.getDeviceFingerprint()) .build(); UserCreatedCommandData createdUser = userCommandService.create(createUser); diff --git a/consumer/src/main/java/org/apache/fineract/consumer/user/command/data/CreateUserCommand.java b/consumer/src/main/java/org/apache/fineract/consumer/user/command/data/CreateUserCommand.java index e2d7fb6..5f5d0d4 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/user/command/data/CreateUserCommand.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/user/command/data/CreateUserCommand.java @@ -34,5 +34,4 @@ public final class CreateUserCommand { private final String email; private final String passwordHash; private final Long fineractClientId; - private final String deviceFingerprint; } diff --git a/consumer/src/main/java/org/apache/fineract/consumer/user/command/domain/User.java b/consumer/src/main/java/org/apache/fineract/consumer/user/command/domain/User.java index ae97fe8..04b3a43 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/user/command/domain/User.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/user/command/domain/User.java @@ -66,17 +66,13 @@ public class User { @Column(name = "bound_at") private Instant boundAt; - @Column(name = "device_fingerprint", nullable = false) - private String deviceFingerprint; - @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; @Column(name = "updated_at", nullable = false) private Instant updatedAt; - public static User createPendingOtp(UUID externalId, String email, String passwordHash, Long fineractClientId, - String deviceFingerprint) { + public static User createPendingOtp(UUID externalId, String email, String passwordHash, Long fineractClientId) { Instant now = Instant.now(); User user = new User(); user.externalId = externalId; @@ -84,7 +80,6 @@ public static User createPendingOtp(UUID externalId, String email, String passwo user.passwordHash = passwordHash; user.fineractClientId = fineractClientId; user.status = UserStatus.PENDING_OTP; - user.deviceFingerprint = deviceFingerprint; user.createdAt = now; user.updatedAt = now; return user; diff --git a/consumer/src/main/java/org/apache/fineract/consumer/user/command/service/UserCommandServiceImpl.java b/consumer/src/main/java/org/apache/fineract/consumer/user/command/service/UserCommandServiceImpl.java index 266e75d..6aeffdc 100644 --- a/consumer/src/main/java/org/apache/fineract/consumer/user/command/service/UserCommandServiceImpl.java +++ b/consumer/src/main/java/org/apache/fineract/consumer/user/command/service/UserCommandServiceImpl.java @@ -50,8 +50,7 @@ public UserCreatedCommandData create(CreateUserCommand command) { UUID.randomUUID(), command.getEmail(), command.getPasswordHash(), - command.getFineractClientId(), - command.getDeviceFingerprint()); + command.getFineractClientId()); User saved; try { saved = repository.save(user); diff --git a/consumer/src/main/resources/db/changelog/changes/001-create-users-table.yaml b/consumer/src/main/resources/db/changelog/changes/001-create-users-table.yaml index 4e50cfb..21583a5 100644 --- a/consumer/src/main/resources/db/changelog/changes/001-create-users-table.yaml +++ b/consumer/src/main/resources/db/changelog/changes/001-create-users-table.yaml @@ -63,11 +63,6 @@ databaseChangeLog: - column: name: bound_at type: TIMESTAMP WITH TIME ZONE - - column: - name: device_fingerprint - type: VARCHAR(255) - constraints: - nullable: false - column: name: created_at type: TIMESTAMP WITH TIME ZONE diff --git a/consumer/src/test/java/org/apache/fineract/consumer/cucumber/helpers/RegistrationHelper.java b/consumer/src/test/java/org/apache/fineract/consumer/cucumber/helpers/RegistrationHelper.java index 581388b..24eff42 100644 --- a/consumer/src/test/java/org/apache/fineract/consumer/cucumber/helpers/RegistrationHelper.java +++ b/consumer/src/test/java/org/apache/fineract/consumer/cucumber/helpers/RegistrationHelper.java @@ -43,11 +43,11 @@ public class RegistrationHelper { public record BoundUser(String email, String password) {} - public BoundUser registerBoundUser(String deviceFingerprint) { + public BoundUser registerBoundUser() { FineractSeeder.SeededClient seededClient = fineractSeeder.seedClientWithPassport(); String email = "user-" + UUID.randomUUID().toString().substring(0, 8) + "@test.com"; - SubmitRegistrationCommandData submitted = bff.submit(deviceFingerprint, + SubmitRegistrationCommandData submitted = bff.submit( new SubmitRegistrationCommandRequest() .fineractClientId(seededClient.fineractClientId()) .email(email) diff --git a/consumer/src/test/java/org/apache/fineract/consumer/cucumber/steps/LoginSteps.java b/consumer/src/test/java/org/apache/fineract/consumer/cucumber/steps/LoginSteps.java index ea6c692..0ac3db4 100644 --- a/consumer/src/test/java/org/apache/fineract/consumer/cucumber/steps/LoginSteps.java +++ b/consumer/src/test/java/org/apache/fineract/consumer/cucumber/steps/LoginSteps.java @@ -71,7 +71,7 @@ public record AuthResponse(int status, JsonNode body, String refreshCookie) {} @Given("a registered and bound user exists") public void registeredAndBoundUser() { - user = registrationHelper.registerBoundUser(DEVICE_FINGERPRINT); + user = registrationHelper.registerBoundUser(); } @When("I log in with my correct password") diff --git a/consumer/src/test/java/org/apache/fineract/consumer/cucumber/steps/RegistrationSteps.java b/consumer/src/test/java/org/apache/fineract/consumer/cucumber/steps/RegistrationSteps.java index cf789a8..3ef3f2b 100644 --- a/consumer/src/test/java/org/apache/fineract/consumer/cucumber/steps/RegistrationSteps.java +++ b/consumer/src/test/java/org/apache/fineract/consumer/cucumber/steps/RegistrationSteps.java @@ -45,7 +45,6 @@ public class RegistrationSteps { private static final String BFF_BASE_URL = System.getenv().getOrDefault("BASE_URL", "http://localhost:8080"); - private static final String DEVICE_FINGERPRINT = "cucumber-test-device"; private static final String WRONG_OTP = "WRONG1"; private static final String PASSWORD = "Cucumber-password1"; private static final ObjectMapper JSON = JsonMapper.builder().build(); @@ -199,7 +198,7 @@ private void submit(FineractSeeder.SeededClient client, String email, String doc .documentTypeName(client.documentTypeName()) .documentKey(documentKey); try { - lastSubmit = bff.submit(DEVICE_FINGERPRINT, request); + lastSubmit = bff.submit(request); lastError = null; } catch (FeignException e) { lastError = e; diff --git a/consumer/src/test/java/org/apache/fineract/consumer/user/command/domain/UserTest.java b/consumer/src/test/java/org/apache/fineract/consumer/user/command/domain/UserTest.java index c18457c..0077f9c 100644 --- a/consumer/src/test/java/org/apache/fineract/consumer/user/command/domain/UserTest.java +++ b/consumer/src/test/java/org/apache/fineract/consumer/user/command/domain/UserTest.java @@ -32,10 +32,9 @@ class UserTest { private static final String EMAIL = "user@test.com"; private static final String PASSWORD_HASH = "{bcrypt}$2a$10$hash"; private static final Long FINERACT_CLIENT_ID = 42L; - private static final String DEVICE_FINGERPRINT = "test-device"; private static User pendingOtpUser() { - return User.createPendingOtp(EXTERNAL_ID, EMAIL, PASSWORD_HASH, FINERACT_CLIENT_ID, DEVICE_FINGERPRINT); + return User.createPendingOtp(EXTERNAL_ID, EMAIL, PASSWORD_HASH, FINERACT_CLIENT_ID); } @Test @@ -46,7 +45,6 @@ void createPendingOtpSetsAllFieldsAndStartsUnbound() { assertThat(user.getEmail()).isEqualTo(EMAIL); assertThat(user.getPasswordHash()).isEqualTo(PASSWORD_HASH); assertThat(user.getFineractClientId()).isEqualTo(FINERACT_CLIENT_ID); - assertThat(user.getDeviceFingerprint()).isEqualTo(DEVICE_FINGERPRINT); assertThat(user.getStatus()).isEqualTo(UserStatus.PENDING_OTP); assertThat(user.getBoundAt()).isNull(); assertThat(user.getCreatedAt()).isNotNull(); diff --git a/consumer/src/test/java/org/apache/fineract/consumer/user/command/service/UserCommandServiceImplTest.java b/consumer/src/test/java/org/apache/fineract/consumer/user/command/service/UserCommandServiceImplTest.java index 1dee89c..976caff 100644 --- a/consumer/src/test/java/org/apache/fineract/consumer/user/command/service/UserCommandServiceImplTest.java +++ b/consumer/src/test/java/org/apache/fineract/consumer/user/command/service/UserCommandServiceImplTest.java @@ -53,7 +53,6 @@ class UserCommandServiceImplTest { private static final String EMAIL = "user@test.com"; private static final String PASSWORD_HASH = "{bcrypt}$2a$10$hash"; private static final Long FINERACT_CLIENT_ID = 42L; - private static final String DEVICE_FINGERPRINT = "test-device"; @Mock private UserCommandRepository repository; @@ -66,12 +65,11 @@ private static CreateUserCommand createCommand() { .email(EMAIL) .passwordHash(PASSWORD_HASH) .fineractClientId(FINERACT_CLIENT_ID) - .deviceFingerprint(DEVICE_FINGERPRINT) .build(); } private static User existingUser() { - return User.createPendingOtp(EXTERNAL_ID, EMAIL, PASSWORD_HASH, FINERACT_CLIENT_ID, DEVICE_FINGERPRINT); + return User.createPendingOtp(EXTERNAL_ID, EMAIL, PASSWORD_HASH, FINERACT_CLIENT_ID); } @Nested @@ -94,7 +92,6 @@ void successPersistsPendingOtpUserAndReturnsIdentifiers() { assertThat(saved.getValue().getEmail()).isEqualTo(EMAIL); assertThat(saved.getValue().getPasswordHash()).isEqualTo(PASSWORD_HASH); assertThat(saved.getValue().getFineractClientId()).isEqualTo(FINERACT_CLIENT_ID); - assertThat(saved.getValue().getDeviceFingerprint()).isEqualTo(DEVICE_FINGERPRINT); assertThat(saved.getValue().getStatus()).isEqualTo(UserStatus.PENDING_OTP); assertThat(saved.getValue().getExternalId()).isNotNull();