├── .github ├── sample-workflows │ └── azure-webapp-deployment.yml └── workflows │ ├── digital-ocean-app-platform-deployment.yml │ └── test-on-push.yml ├── .gitignore ├── .mvn └── wrapper │ ├── maven-wrapper.jar │ └── maven-wrapper.properties ├── LICENSE.txt ├── README.md ├── mvnw ├── mvnw.cmd ├── pom.xml └── src ├── main ├── java │ └── com │ │ └── naturalprogrammer │ │ └── springmvc │ │ ├── NpSpringMvcDemoApplication.java │ │ ├── common │ │ ├── CommonUtils.java │ │ ├── MessageGetter.java │ │ ├── Path.java │ │ ├── error │ │ │ ├── BeanValidator.java │ │ │ ├── Error.java │ │ │ ├── ErrorCode.java │ │ │ ├── MyControllerAdvice.java │ │ │ ├── Problem.java │ │ │ ├── ProblemBuilder.java │ │ │ └── ProblemType.java │ │ ├── features │ │ │ └── get_context │ │ │ │ ├── ContextGetter.java │ │ │ │ ├── ContextResource.java │ │ │ │ └── GetContextController.java │ │ ├── jwt │ │ │ ├── AbstractJwtService.java │ │ │ ├── JweService.java │ │ │ ├── JwsService.java │ │ │ └── JwtPurpose.java │ │ └── mail │ │ │ ├── LoggingMailSender.java │ │ │ ├── MailData.java │ │ │ ├── MailSender.java │ │ │ └── SmtpMailSender.java │ │ ├── config │ │ ├── CommonConfig.java │ │ ├── MyProperties.java │ │ ├── OpenApiConfig.java │ │ ├── security │ │ │ ├── JwtAuthenticationConverter.java │ │ │ └── SecurityConfig.java │ │ └── sociallogin │ │ │ ├── FacebookLoginProvider.java │ │ │ ├── GoogleLoginProvider.java │ │ │ ├── HttpCookieOAuth2AuthorizationRequestRepository.java │ │ │ ├── MyOAuth2UserService.java │ │ │ ├── MyOidcUserService.java │ │ │ ├── OAuth2AuthenticationFailureHandler.java │ │ │ ├── OAuth2AuthenticationSuccessHandler.java │ │ │ ├── SocialLoginProvider.java │ │ │ └── SocialUser.java │ │ └── user │ │ ├── domain │ │ ├── AbstractEntity.java │ │ ├── Role.java │ │ └── User.java │ │ ├── features │ │ ├── change_mail │ │ │ ├── ChangeEmailController.java │ │ │ ├── EmailChangeRequestProcessor.java │ │ │ ├── EmailChanger.java │ │ │ ├── UserEmailChangeRequest.java │ │ │ └── UserEmailChangeVerificationRequest.java │ │ ├── change_password │ │ │ ├── ChangePasswordController.java │ │ │ ├── ChangePasswordRequest.java │ │ │ └── PasswordChanger.java │ │ ├── display_name_edit │ │ │ ├── DisplayNameEditController.java │ │ │ ├── DisplayNameEditor.java │ │ │ └── UserDisplayNameEditRequest.java │ │ ├── forgot_password │ │ │ ├── ForgotPasswordController.java │ │ │ ├── ForgotPasswordInitiator.java │ │ │ └── ForgotPasswordRequest.java │ │ ├── get │ │ │ ├── GetUserController.java │ │ │ └── UserGetter.java │ │ ├── get_by_email │ │ │ ├── GetUserByEmailController.java │ │ │ └── UsersGetter.java │ │ ├── login │ │ │ ├── AccessTokenCreator.java │ │ │ ├── AccessTokenResource.java │ │ │ ├── AuthScope.java │ │ │ ├── AuthTokenController.java │ │ │ ├── AuthTokenCreator.java │ │ │ ├── AuthTokensResource.java │ │ │ ├── LoginRequest.java │ │ │ ├── LoginService.java │ │ │ ├── ResourceTokenExchangeRequest.java │ │ │ ├── ResourceTokenExchanger.java │ │ │ └── SocialLoginController.java │ │ ├── resend_verification │ │ │ ├── ResendVerificationController.java │ │ │ └── VerificationMailReSender.java │ │ ├── reset_password │ │ │ ├── PasswordResetter.java │ │ │ ├── ResetPasswordController.java │ │ │ └── ResetPasswordRequest.java │ │ ├── signup │ │ │ ├── SignupController.java │ │ │ ├── SignupRequest.java │ │ │ └── SignupService.java │ │ └── verification │ │ │ ├── UserVerificationController.java │ │ │ ├── UserVerificationRequest.java │ │ │ ├── UserVerifier.java │ │ │ └── VerificationMailSender.java │ │ ├── repositories │ │ └── UserRepository.java │ │ ├── services │ │ ├── UserResource.java │ │ └── UserService.java │ │ └── validators │ │ ├── PasswordValidator.java │ │ └── ValidPassword.java └── resources │ ├── ValidationMessages.properties │ ├── ValidationMessages_or.properties │ ├── config │ ├── application-azure-live.yml │ ├── application-azure-staging.yml │ ├── application-default.yml │ ├── application-digitalocean-staging.yml │ ├── application.yml │ ├── rsa-2048-private-key.txt │ └── rsa-2048-public-key.txt │ ├── db │ └── migration │ │ └── V2023.01.16.13.37__user-table.sql │ ├── messages.properties │ └── static │ └── index.html └── test ├── java └── com │ └── naturalprogrammer │ └── springmvc │ ├── common │ ├── features │ │ └── get_context │ │ │ └── GetContextIntegrationTest.java │ └── jwt │ │ └── JwtServiceTest.java │ ├── helpers │ ├── AbstractIntegrationTest.java │ ├── MyResultMatchers.java │ ├── MyTestConfig.java │ └── MyTestUtils.java │ └── user │ ├── UserTestUtils.java │ ├── features │ ├── change_mail │ │ ├── ChangeEmailIntegrationTest.java │ │ ├── EmailChangeRequestProcessorTest.java │ │ ├── NewEmailVerificationMailSenderTest.java │ │ ├── RequestChangingEmailIntegrationTest.java │ │ └── ValidatedEmailChangerTest.java │ ├── change_password │ │ ├── ChangePasswordIntegrationTest.java │ │ └── PasswordChangerTest.java │ ├── display_name_edit │ │ ├── DisplayNameEditIntegrationTest.java │ │ └── ValidatedDisplayNameEditorTest.java │ ├── forgot_password │ │ ├── ForgotPasswordInitiatorTest.java │ │ ├── ForgotPasswordIntegrationTest.java │ │ └── ForgotPasswordMailSenderTest.java │ ├── get │ │ └── GetUserIntegrationTest.java │ ├── get_by_email │ │ └── GetUserByEmailIntegrationTest.java │ ├── login │ │ ├── AccessTokenCreationIntegrationTest.java │ │ ├── AccessTokenCreatorTest.java │ │ ├── AuthTokenCreationIntegrationTest.java │ │ ├── ExchangeResourceTokenIntegrationTest.java │ │ └── LoginIntegrationTest.java │ ├── resend_verification │ │ ├── ResendVerificationIntegrationTest.java │ │ └── VerificationMailReSenderTest.java │ ├── reset_password │ │ ├── PasswordResetterTest.java │ │ └── ResetPasswordIntegrationTest.java │ ├── signup │ │ ├── BeanValidatorTest.java │ │ ├── MyControllerAdviceIntegrationTest.java │ │ └── SignupIntegrationTest.java │ └── verification │ │ ├── UserVerificationIntegrationTest.java │ │ ├── ValidatedUserVerifierTest.java │ │ └── VerificationMailSenderTest.java │ └── validators │ └── PasswordValidatorTest.java └── resources ├── config └── application-test.yml ├── mockito-extensions └── org.mockito.plugins.MockMaker └── test-data └── sql └── before-each-test.sql /.github/sample-workflows/azure-webapp-deployment.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # GitHub recommends pinning actions to a commit SHA. 7 | # To get a newer version, you will need to update the SHA. 8 | # You can also reference a tag or branch, but the action may change without warning. 9 | 10 | name: Build and deploy JAR app to Azure Web App 11 | 12 | env: 13 | JAVA_VERSION: '21' # set this to the Java version to use 14 | 15 | on: 16 | push: 17 | branches: 18 | - main 19 | 20 | jobs: 21 | build: 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - uses: actions/checkout@v3 26 | 27 | - name: Set up Java 28 | uses: actions/setup-java@v3 29 | with: 30 | java-version: ${{ env.JAVA_VERSION }} 31 | distribution: 'temurin' 32 | cache: 'maven' 33 | 34 | - name: Build with Maven 35 | run: mvn clean install 36 | 37 | - name: Upload artifact for deployment job 38 | uses: actions/upload-artifact@v3 39 | with: 40 | name: java-app 41 | path: '${{ github.workspace }}/target/*.jar' 42 | 43 | deploy-staging: 44 | runs-on: ubuntu-latest 45 | needs: build 46 | environment: 47 | name: 'staging' 48 | url: ${{ steps.deploy-to-webapp.outputs.webapp-url }} 49 | 50 | steps: 51 | - name: Download artifact from build job 52 | uses: actions/download-artifact@v3 53 | with: 54 | name: java-app 55 | 56 | - name: Deploy to Azure Web App 57 | id: deploy-to-webapp 58 | uses: azure/webapps-deploy@85270a1854658d167ab239bce43949edb336fa7c 59 | with: 60 | app-name: ${{ vars.AZURE_WEBAPP_NAME }} 61 | publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }} 62 | package: '*.jar' 63 | 64 | deploy-live: 65 | runs-on: ubuntu-latest 66 | needs: deploy-staging 67 | environment: 68 | name: 'live' 69 | url: ${{ steps.deploy-to-webapp.outputs.webapp-url }} 70 | 71 | steps: 72 | - name: Download artifact from build job 73 | uses: actions/download-artifact@v3 74 | with: 75 | name: java-app 76 | 77 | - name: Deploy to Azure Web App 78 | id: deploy-to-webapp 79 | uses: azure/webapps-deploy@85270a1854658d167ab239bce43949edb336fa7c 80 | with: 81 | app-name: ${{ vars.AZURE_WEBAPP_NAME }} 82 | publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }} 83 | package: '*.jar' -------------------------------------------------------------------------------- /.github/workflows/digital-ocean-app-platform-deployment.yml: -------------------------------------------------------------------------------- 1 | name: DigitalOcean App Platform Deployment 2 | run-name: ${{ github.actor }} is pushing docker image to DigitalOcean 3 | 4 | # 1 5 | on: 6 | push: 7 | branches: 8 | - main 9 | 10 | #2 11 | env: 12 | DOCKER_REGISTRY_USERNAME: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} 13 | DOCKER_REGISTRY_PASSWORD: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} 14 | 15 | #3 16 | jobs: 17 | build_and_push_image: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - name: Set up Java 23 | uses: actions/setup-java@v3 24 | with: 25 | java-version: 21 26 | distribution: 'temurin' 27 | cache: 'maven' 28 | 29 | # - name: Set App Version 30 | # run: mvn versions:set -DnewVersion=${{ github.sha }} 31 | 32 | - name: Build with Maven 33 | run: mvn --batch-mode --update-snapshots clean test 34 | # Uncomment this to deploy push image run: mvn --batch-mode --update-snapshots clean spring-boot:build-image -Dspring-boot.build-image.imageName=registry.digitalocean.com/naturalprogrammer/np-spring-mvc-demo:app 35 | -------------------------------------------------------------------------------- /.github/workflows/test-on-push.yml: -------------------------------------------------------------------------------- 1 | name: test-on-push 2 | run-name: ${{ github.actor }} is testing on push 3 | on: 4 | push: 5 | branches-ignore: 6 | - main 7 | 8 | jobs: 9 | test-on-push: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Set up JDK 14 | uses: actions/setup-java@v3 15 | with: 16 | java-version: '21' 17 | distribution: 'temurin' 18 | - name: Build with Maven 19 | run: mvn --batch-mode --update-snapshots clean test 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naturalprogrammer/np-spring-mvc-demo/9c3792eb6738bc16714ee2b231d29d49923ea00b/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip 2 | wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Demo Project for developing real-world stateless REST APIs using Spring Boot 3.x (successor 2 | of [Spring Lemon](https://github.com/naturalprogrammer/spring-lemon)). Depicts the following: 3 | 4 | 1. Using a stateless security model, using JWT authentication 5 | 2. Using JWE tokens for email verification, forgot password etc. 6 | 3. Configuring Spring Security to suit stateless API development 7 | 4. Supporting multiple social sign up/in, using OpenID Connect or OAuth2 providers such as Google and Facebook, *in a 8 | stateless manner* 9 | 5. Coding a robust user module with features including sign up/in, verify email, social sign up/in, update profile, 10 | forgot password, change password, change email, resource/access token creation etc. 11 | 6. Testing best practices 12 | 7. Elegant functional 13 | programming [using Optional and Either](https://dzone.com/articles/the-beauty-of-java-optional-and-either) 14 | 8. Using [specific media types](https://medium.com/@naturalprogrammer/why-and-how-to-use-specific-media-types-instead-of-application-json-36e91efe167b) 15 | instead of application/json 16 | 9. Complying to https://www.rfc-editor.org/rfc/rfc7807 for HTTP error responses 17 | 10. *How to **not** use exception handling for validation and business rules*: We all know that 18 | using [exceptions for foreseen cases](https://reflectoring.io/business-exceptions/) is bad. Still, most of us 19 | use `@Valid` for validations, as well 20 | as throw BusinessExceptions, and then handle the exceptions in a controller advice. In this project, you'd see an 21 | elegant way to avoid exceptions -- by using `Optional`, `Either` and functional programming. 22 | 11. OpenApi documentation auto generation 23 | 12. Java packaging strategy for modulith applications 24 | 13. GitHub Actions CI/CD pipelines for Azure WebApp and DigitalOcean App Platform deployments 25 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/NpSpringMvcDemoApplication.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; 6 | import org.springframework.boot.context.properties.ConfigurationPropertiesScan; 7 | import org.springframework.scheduling.annotation.EnableAsync; 8 | 9 | @SpringBootApplication(exclude = {UserDetailsServiceAutoConfiguration.class}) 10 | @ConfigurationPropertiesScan 11 | @EnableAsync 12 | public class NpSpringMvcDemoApplication { 13 | 14 | public static void main(String[] args) { 15 | SpringApplication.run(NpSpringMvcDemoApplication.class, args); 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/CommonUtils.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.Problem; 4 | import io.jbock.util.Either; 5 | import jakarta.servlet.http.Cookie; 6 | import jakarta.servlet.http.HttpServletRequest; 7 | import jakarta.servlet.http.HttpServletResponse; 8 | import org.apache.commons.lang3.ArrayUtils; 9 | import org.apache.commons.lang3.SerializationUtils; 10 | import org.springframework.http.ResponseEntity; 11 | import org.springframework.security.core.Authentication; 12 | import org.springframework.security.core.context.SecurityContextHolder; 13 | import org.springframework.stereotype.Component; 14 | 15 | import java.io.Serializable; 16 | import java.util.Base64; 17 | import java.util.Optional; 18 | import java.util.UUID; 19 | import java.util.function.Function; 20 | 21 | @Component 22 | public class CommonUtils { 23 | 24 | public static final String X_FORWARDED_FOR = "X-Forwarded-For"; 25 | public static final String CONTENT_TYPE_PREFIX = "application/vnd.com.naturalprogrammer."; 26 | 27 | public Optional getAuthentication() { 28 | return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()); 29 | } 30 | 31 | public Optional getUserId() { 32 | return getAuthentication() 33 | .map(Authentication::getName) 34 | .map(UUID::fromString); 35 | } 36 | 37 | public static ResponseEntity toResponse(Either either, Function> success) { 38 | return either.fold(Problem::toResponse, success); 39 | } 40 | 41 | public static Optional fetchCookie(HttpServletRequest request, String name) { 42 | 43 | Cookie[] cookies = request.getCookies(); 44 | 45 | if (cookies != null) 46 | for (Cookie cookie : cookies) 47 | if (cookie.getName().equals(name)) 48 | return Optional.of(cookie); 49 | 50 | return Optional.empty(); 51 | } 52 | 53 | public static void deleteCookies(HttpServletRequest request, HttpServletResponse response, String... cookiesToDelete) { 54 | 55 | Cookie[] cookies = request.getCookies(); 56 | 57 | if (cookies != null) 58 | for (Cookie cookie : cookies) 59 | if (ArrayUtils.contains(cookiesToDelete, cookie.getName())) { 60 | cookie.setValue(""); 61 | cookie.setPath("/"); 62 | cookie.setMaxAge(0); 63 | response.addCookie(cookie); 64 | } 65 | } 66 | 67 | /** 68 | * Serializes an object 69 | */ 70 | public static String serialize(Serializable obj) { 71 | 72 | return Base64.getUrlEncoder().encodeToString( 73 | SerializationUtils.serialize(obj)); 74 | } 75 | 76 | /** 77 | * Deserializes an object 78 | */ 79 | public static T deserialize(String serializedObj) { 80 | 81 | return SerializationUtils.deserialize( 82 | Base64.getUrlDecoder().decode(serializedObj)); 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/MessageGetter.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import org.springframework.context.MessageSource; 5 | import org.springframework.context.i18n.LocaleContextHolder; 6 | import org.springframework.stereotype.Component; 7 | 8 | @Component 9 | @RequiredArgsConstructor 10 | public class MessageGetter { 11 | 12 | private final MessageSource messageSource; 13 | 14 | public String getMessage(String messageKey, Object... args) { 15 | return messageSource.getMessage(messageKey, args, LocaleContextHolder.getLocale()); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/Path.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common; 2 | 3 | import lombok.AccessLevel; 4 | import lombok.NoArgsConstructor; 5 | 6 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 7 | public final class Path { 8 | 9 | public static final String USERS = "/users"; 10 | public static final String USER = "/user"; // current user 11 | public static final String LOGIN = "/login"; 12 | public static final String FORGOT_PASSWORD = "/forgot-password"; 13 | public static final String RESET_PASSWORD = "/reset-password"; 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/error/BeanValidator.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common.error; 2 | 3 | import io.jbock.util.Either; 4 | import jakarta.validation.ConstraintViolation; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.beans.factory.ObjectFactory; 7 | import org.springframework.stereotype.Component; 8 | import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; 9 | 10 | import java.util.List; 11 | import java.util.Optional; 12 | import java.util.Set; 13 | import java.util.function.Supplier; 14 | 15 | import static com.naturalprogrammer.springmvc.common.error.ProblemType.INVALID_DATA; 16 | 17 | @Component 18 | @RequiredArgsConstructor 19 | public class BeanValidator { 20 | 21 | private final LocalValidatorFactoryBean validator; 22 | private final ObjectFactory problemBuilder; 23 | 24 | public Optional validate(R request) { 25 | Set> violations = validator.validate(request); 26 | return violations.isEmpty() ? Optional.empty() : Optional.of( 27 | problemBuilder.getObject() 28 | .type(INVALID_DATA) 29 | .detail(request.toString()) 30 | .errors(toErrors(violations)) 31 | .build()); 32 | } 33 | 34 | public Either validateAndGet(T request, Supplier> supplier) { 35 | Set> violations = validator.validate(request); 36 | return violations.isEmpty() ? supplier.get() : Either.left( 37 | problemBuilder.getObject() 38 | .type(INVALID_DATA) 39 | .detail(request.toString()) 40 | .errors(toErrors(violations)) 41 | .build()); 42 | } 43 | 44 | private List toErrors(Set> violations) { 45 | return violations.stream().map(this::toError).toList(); 46 | } 47 | 48 | private Error toError(ConstraintViolation violation) { 49 | return new Error( 50 | toCode(violation.getMessageTemplate()), 51 | violation.getMessage(), 52 | violation.getPropertyPath().toString()); 53 | } 54 | 55 | private String toCode(String messageTemplate) { 56 | 57 | // Extracts "NotBlank" from "{jakarta.validation.constraints.NotBlank.message}" 58 | 59 | int lastPeriodIndex = messageTemplate.lastIndexOf("."); 60 | int secondLastPeriodIndex = messageTemplate.lastIndexOf(".", lastPeriodIndex - 1); 61 | return messageTemplate.substring(secondLastPeriodIndex + 1, lastPeriodIndex); 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/error/Error.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common.error; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | 5 | public record Error( 6 | 7 | @Schema(example = "NotBlank") 8 | String code, 9 | 10 | @Schema(example = "must not be blank") 11 | String message, 12 | 13 | @Schema(example = "email") 14 | String field 15 | ) { 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/error/ErrorCode.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common.error; 2 | 3 | import lombok.AccessLevel; 4 | import lombok.Getter; 5 | import lombok.RequiredArgsConstructor; 6 | 7 | @Getter 8 | @RequiredArgsConstructor(access = AccessLevel.PRIVATE) 9 | public enum ErrorCode { 10 | 11 | USED_EMAIL("UsedEmail", ProblemType.USED_EMAIL.getTitle()), 12 | TOKEN_VERIFICATION_FAILED("TokenVerificationFailed", ProblemType.TOKEN_VERIFICATION_FAILED.getTitle()), 13 | PASSWORD_MISMATCH("PasswordMismatch", ProblemType.PASSWORD_MISMATCH.getTitle()), 14 | EMAIL_MISMATCH("EmailMismatch", ProblemType.EMAIL_MISMATCH.getTitle()); 15 | 16 | private final String code; 17 | private final String message; 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/error/MyControllerAdvice.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common.error; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.springframework.beans.factory.ObjectFactory; 6 | import org.springframework.http.ResponseEntity; 7 | import org.springframework.http.converter.HttpMessageNotReadableException; 8 | import org.springframework.web.HttpMediaTypeNotSupportedException; 9 | import org.springframework.web.bind.annotation.ExceptionHandler; 10 | import org.springframework.web.bind.annotation.RestControllerAdvice; 11 | 12 | import java.nio.file.AccessDeniedException; 13 | 14 | import static com.naturalprogrammer.springmvc.common.error.Problem.toResponse; 15 | 16 | @Slf4j 17 | @RestControllerAdvice 18 | @RequiredArgsConstructor 19 | public class MyControllerAdvice { 20 | 21 | private final ObjectFactory problemBuilder; 22 | 23 | @ExceptionHandler(value = HttpMediaTypeNotSupportedException.class) 24 | public ResponseEntity handleException(HttpMediaTypeNotSupportedException ex) { 25 | 26 | var problem = problemBuilder.getObject().build(ProblemType.HTTP_MEDIA_TYPE_NOT_SUPPORTED, ex.getMessage()); 27 | log.info("HttpMediaTypeNotSupportedException (%s): %s".formatted(ex.getMessage(), problem), ex); 28 | return toResponse(problem); 29 | } 30 | 31 | @ExceptionHandler(value = HttpMessageNotReadableException.class) 32 | public ResponseEntity handleException(HttpMessageNotReadableException ex) { 33 | 34 | var problem = problemBuilder.getObject().build(ProblemType.HTTP_MESSAGE_NOT_READABLE, ex.getMessage()); 35 | log.info("HttpMessageNotReadableException (%s): %s".formatted(ex.getMessage(), problem), ex); 36 | return toResponse(problem); 37 | } 38 | 39 | @ExceptionHandler(value = Exception.class) 40 | public ResponseEntity handleException(Exception ex) throws Exception { 41 | 42 | if (isBetterHandledBySpring(ex)) { 43 | log.warn("Spring exception", ex); 44 | throw ex; 45 | } 46 | 47 | var problem = problemBuilder.getObject().build(ProblemType.GENERIC_ERROR, null); 48 | log.error("Unknown error %s (%s): %s".formatted(ex.getClass().getCanonicalName(), ex.getMessage(), problem), ex); 49 | return toResponse(problem); 50 | } 51 | 52 | private boolean isBetterHandledBySpring(Exception ex) { 53 | return ex instanceof AccessDeniedException; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/error/Problem.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common.error; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import org.springframework.http.HttpHeaders; 5 | import org.springframework.http.MediaType; 6 | import org.springframework.http.ResponseEntity; 7 | 8 | import java.util.List; 9 | 10 | // https://www.rfc-editor.org/rfc/rfc7807 11 | public record Problem( 12 | 13 | @Schema(title = "a unique identifier (UUID)", example = "aa6e97d9-9069-4051-8f08-16d066096c0f") 14 | String id, 15 | 16 | @Schema(title = "URL to the problem", example = "/problems/invalid-signup") 17 | String type, 18 | 19 | @Schema(title = "General description of the problem", example = "Invalid fields received while doing signup") 20 | String title, 21 | 22 | @Schema(example = "422") 23 | int status, 24 | 25 | @Schema( 26 | title = "Specific description of the problem", 27 | description = "Instance specific description of the problem, e.g. input DTO's toString()", 28 | example = "SignupRequest{email='null', displayName='null'}" 29 | ) 30 | String detail, 31 | 32 | @Schema( 33 | title = "Relative URI for more info of the instance", 34 | example = "null" 35 | ) 36 | String instance, 37 | 38 | List errors 39 | ) { 40 | public static ResponseEntity toResponse(Problem problem) { 41 | return ResponseEntity 42 | .status(problem.status()) 43 | .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_PROBLEM_JSON_VALUE) 44 | .body(problem); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/error/ProblemBuilder.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common.error; 2 | 3 | import com.naturalprogrammer.springmvc.common.MessageGetter; 4 | import lombok.RequiredArgsConstructor; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.context.annotation.Scope; 7 | import org.springframework.stereotype.Component; 8 | 9 | import java.util.ArrayList; 10 | import java.util.Collection; 11 | import java.util.List; 12 | import java.util.UUID; 13 | 14 | import static org.springframework.beans.factory.config.BeanDefinition.SCOPE_PROTOTYPE; 15 | 16 | @Slf4j 17 | @Component 18 | @RequiredArgsConstructor 19 | @Scope(value = SCOPE_PROTOTYPE) 20 | public class ProblemBuilder { 21 | 22 | private final MessageGetter messageGetter; 23 | 24 | private String type; 25 | private String title; 26 | private int status; 27 | private String detail; 28 | private final List errors = new ArrayList<>(); 29 | 30 | public ProblemBuilder type(ProblemType problemType) { 31 | type = problemType.getType(); 32 | title = messageGetter.getMessage(problemType.getTitle()); 33 | status = problemType.getStatus().value(); 34 | return this; 35 | } 36 | 37 | public ProblemBuilder detail(String problemDetail) { 38 | detail = problemDetail; 39 | return this; 40 | } 41 | 42 | public ProblemBuilder detailMessage(String messageKey, Object... args) { 43 | detail = messageGetter.getMessage(messageKey, args); 44 | return this; 45 | } 46 | 47 | public ProblemBuilder error(String field, ErrorCode errorCode, Object... args) { 48 | var errorMessage = messageGetter.getMessage(errorCode.getMessage(), args); 49 | errors.add(new Error(errorCode.getCode(), errorMessage, field)); 50 | return this; 51 | } 52 | 53 | public ProblemBuilder errors(Collection errors) { 54 | this.errors.addAll(errors); 55 | return this; 56 | } 57 | 58 | public Problem build() { 59 | var problem = new Problem( 60 | UUID.randomUUID().toString(), 61 | type, 62 | title, 63 | status, 64 | detail, 65 | null, 66 | errors 67 | ); 68 | log.info("Faced {}", problem); 69 | return problem; 70 | } 71 | 72 | public Problem build(ProblemType type, String detail) { 73 | return type(type).detail(detail).build(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/error/ProblemType.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common.error; 2 | 3 | import lombok.AccessLevel; 4 | import lombok.Getter; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.http.HttpStatus; 7 | 8 | import static org.springframework.http.HttpStatus.*; 9 | 10 | @Getter 11 | @RequiredArgsConstructor(access = AccessLevel.PRIVATE) 12 | public enum ProblemType { 13 | 14 | GENERIC_ERROR("/problems/generic-error", "generic-error", INTERNAL_SERVER_ERROR), 15 | HTTP_MESSAGE_NOT_READABLE("/problems/http-message-not-readable", "http-message-not-readable", BAD_REQUEST), 16 | HTTP_MEDIA_TYPE_NOT_SUPPORTED("/problems/http-media-type-not-supported", "http-media-type-not-supported", BAD_REQUEST), 17 | INVALID_DATA("/problems/invalid-data", "invalid-data", UNPROCESSABLE_ENTITY), 18 | USED_EMAIL("/problems/used-email", "used-email", CONFLICT), 19 | TOKEN_VERIFICATION_FAILED("/problems/token-verification-failed", "token-verification-failed", FORBIDDEN), 20 | WRONG_JWT_AUDIENCE("/problems/wrong-jwt-audience", "wrong-jwt-audience", FORBIDDEN), 21 | EXPIRED_JWT("/problems/expired-jwt", "expired-jwt", FORBIDDEN), 22 | WRONG_CREDENTIALS("/problems/wrong-credentials", "wrong-credentials", UNAUTHORIZED), 23 | NOT_FOUND("/problems/not-found", "not-found", HttpStatus.NOT_FOUND), 24 | USER_ALREADY_VERIFIED("/problems/user-already-verified", "user-already-verified", CONFLICT), 25 | PASSWORD_MISMATCH("/problems/password-mismatch", "password-mismatch", FORBIDDEN), 26 | EMAIL_MISMATCH("/problems/email-mismatch", "email-mismatch", FORBIDDEN); 27 | 28 | private final String type; 29 | private final String title; 30 | private final HttpStatus status; 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/features/get_context/ContextGetter.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common.features.get_context; 2 | 3 | import com.naturalprogrammer.springmvc.config.MyProperties; 4 | import lombok.RequiredArgsConstructor; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.stereotype.Service; 7 | 8 | import java.util.List; 9 | 10 | @Slf4j 11 | @Service 12 | @RequiredArgsConstructor 13 | public class ContextGetter { 14 | 15 | private final MyProperties properties; 16 | 17 | public ContextResource get() { 18 | var context = new ContextResource(List.of(new ContextResource.KeyResource( 19 | properties.jws().id(), 20 | properties.jws().publicKeyString() 21 | ))); 22 | log.info("Got {}", context); 23 | return context; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/features/get_context/ContextResource.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common.features.get_context; 2 | 3 | import java.util.List; 4 | 5 | import static com.naturalprogrammer.springmvc.common.CommonUtils.CONTENT_TYPE_PREFIX; 6 | 7 | public record ContextResource(List keys) { 8 | 9 | public record KeyResource(String id, String publicKey) { 10 | 11 | } 12 | 13 | public static final String CONTENT_TYPE = CONTENT_TYPE_PREFIX + "context.v1+json"; 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/features/get_context/GetContextController.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common.features.get_context; 2 | 3 | import io.swagger.v3.oas.annotations.Operation; 4 | import io.swagger.v3.oas.annotations.media.Content; 5 | import io.swagger.v3.oas.annotations.media.Schema; 6 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 7 | import io.swagger.v3.oas.annotations.responses.ApiResponses; 8 | import io.swagger.v3.oas.annotations.tags.Tag; 9 | import lombok.RequiredArgsConstructor; 10 | import org.springframework.web.bind.annotation.GetMapping; 11 | import org.springframework.web.bind.annotation.RestController; 12 | 13 | @RestController 14 | @RequiredArgsConstructor 15 | @Tag(name = "Core", description = "Core API") 16 | public class GetContextController { 17 | 18 | private final ContextGetter contextGetter; 19 | 20 | @Operation(summary = "Get context") 21 | @ApiResponses(value = { 22 | @ApiResponse(responseCode = "200", description = "Context", 23 | content = @Content( 24 | mediaType = ContextResource.CONTENT_TYPE, 25 | schema = @Schema(implementation = ContextResource.class)) 26 | ) 27 | }) 28 | @GetMapping(value = "/context", produces = ContextResource.CONTENT_TYPE) 29 | ContextResource getContext() { 30 | return contextGetter.get(); 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/jwt/AbstractJwtService.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common.jwt; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.ProblemType; 4 | import com.naturalprogrammer.springmvc.config.MyProperties; 5 | import com.nimbusds.jose.Payload; 6 | import com.nimbusds.jwt.JWTClaimsSet; 7 | import io.jbock.util.Either; 8 | import lombok.RequiredArgsConstructor; 9 | 10 | import java.time.Clock; 11 | import java.util.Collections; 12 | import java.util.Date; 13 | import java.util.Map; 14 | 15 | @RequiredArgsConstructor 16 | public abstract class AbstractJwtService { 17 | 18 | private final Clock clock; 19 | private final MyProperties properties; 20 | 21 | protected Payload createPayload(String subject, Date validUntil, Map claims) { 22 | 23 | var now = clock.instant(); 24 | 25 | var builder = new JWTClaimsSet.Builder() 26 | .issuer(properties.homepage()) 27 | .subject(subject) 28 | .issueTime(Date.from(now)) 29 | .audience(properties.homepage()) 30 | .expirationTime(validUntil); 31 | claims.forEach(builder::claim); 32 | 33 | return new Payload(builder.build().toJSONObject()); 34 | } 35 | 36 | public String createToken(String subject, Date validUntil) { 37 | return createToken(subject, validUntil, Collections.emptyMap()); 38 | } 39 | 40 | public abstract String createToken(String subject, Date validUntil, Map claims); 41 | 42 | protected abstract Either getClaims(String token); 43 | 44 | public Either parseToken(String token) { 45 | return getClaims(token) 46 | .flatMap(this::verifyExpiration) 47 | .flatMap(this::verifyAudience); 48 | } 49 | 50 | private Either verifyAudience(JWTClaimsSet claims) { 51 | return claims.getAudience().contains(properties.homepage()) 52 | ? Either.right(claims) 53 | : Either.left(ProblemType.WRONG_JWT_AUDIENCE); 54 | } 55 | 56 | private Either verifyExpiration(JWTClaimsSet claims) { 57 | return claims.getExpirationTime().after(Date.from(clock.instant())) 58 | ? Either.right(claims) 59 | : Either.left(ProblemType.EXPIRED_JWT); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/jwt/JweService.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common.jwt; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.ProblemType; 4 | import com.naturalprogrammer.springmvc.config.MyProperties; 5 | import com.nimbusds.jose.EncryptionMethod; 6 | import com.nimbusds.jose.JWEAlgorithm; 7 | import com.nimbusds.jose.JWEHeader; 8 | import com.nimbusds.jose.JWEObject; 9 | import com.nimbusds.jose.crypto.DirectDecrypter; 10 | import com.nimbusds.jose.crypto.DirectEncrypter; 11 | import com.nimbusds.jwt.JWTClaimsSet; 12 | import io.jbock.util.Either; 13 | import lombok.SneakyThrows; 14 | import lombok.extern.slf4j.Slf4j; 15 | import org.springframework.stereotype.Component; 16 | 17 | import java.time.Clock; 18 | import java.util.Date; 19 | import java.util.Map; 20 | 21 | @Slf4j 22 | @Component 23 | public class JweService extends AbstractJwtService { 24 | 25 | private final JWEHeader header; 26 | private final DirectEncrypter encrypter; 27 | private final DirectDecrypter decrypter; 28 | 29 | @SneakyThrows 30 | public JweService(Clock clock, MyProperties properties) { 31 | super(clock, properties); 32 | 33 | header = new JWEHeader.Builder(JWEAlgorithm.DIR, EncryptionMethod.A128CBC_HS256) 34 | .keyID(properties.jwe().id()) 35 | .build(); 36 | 37 | var keyBytes = properties.jwe().key().getBytes(); 38 | encrypter = new DirectEncrypter(keyBytes); 39 | decrypter = new DirectDecrypter(keyBytes); 40 | } 41 | 42 | @Override 43 | @SneakyThrows 44 | public String createToken(String subject, Date validUntil, Map claims) { 45 | var jwe = new JWEObject(header, createPayload(subject, validUntil, claims)); 46 | jwe.encrypt(encrypter); 47 | return jwe.serialize(); 48 | } 49 | 50 | @Override 51 | protected Either getClaims(String token) { 52 | try { 53 | var jwe = JWEObject.parse(token); 54 | jwe.decrypt(decrypter); 55 | return Either.right(JWTClaimsSet.parse(jwe.getPayload().toJSONObject())); 56 | } catch (Exception ex) { 57 | log.warn("JWE decryption failed", ex); 58 | return Either.left(ProblemType.TOKEN_VERIFICATION_FAILED); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/jwt/JwsService.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common.jwt; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.ProblemType; 4 | import com.naturalprogrammer.springmvc.config.MyProperties; 5 | import com.nimbusds.jose.*; 6 | import com.nimbusds.jose.crypto.RSASSASigner; 7 | import com.nimbusds.jose.crypto.RSASSAVerifier; 8 | import com.nimbusds.jose.jwk.RSAKey; 9 | import com.nimbusds.jwt.JWTClaimsSet; 10 | import io.jbock.util.Either; 11 | import lombok.SneakyThrows; 12 | import lombok.extern.slf4j.Slf4j; 13 | import org.springframework.stereotype.Component; 14 | 15 | import java.time.Clock; 16 | import java.util.Date; 17 | import java.util.Map; 18 | 19 | @Slf4j 20 | @Component 21 | public class JwsService extends AbstractJwtService { 22 | 23 | private final JWSHeader header; 24 | private final JWSSigner signer; 25 | private final JWSVerifier verifier; 26 | 27 | @SneakyThrows 28 | public JwsService(Clock clock, MyProperties properties) { 29 | 30 | super(clock, properties); 31 | 32 | header = new JWSHeader.Builder(JWSAlgorithm.RS256) 33 | .keyID(properties.jws().id()) 34 | .build(); 35 | 36 | var key = new RSAKey 37 | .Builder(properties.jws().publicKey()) 38 | .privateKey(properties.jws().privateKey()) 39 | .keyID(properties.jws().id()) 40 | .build(); 41 | 42 | signer = new RSASSASigner(key); 43 | verifier = new RSASSAVerifier(key); 44 | } 45 | 46 | @Override 47 | @SneakyThrows 48 | public String createToken(String subject, Date validUntil, Map claims) { 49 | var jws = new JWSObject(header, createPayload(subject, validUntil, claims)); 50 | jws.sign(signer); 51 | return jws.serialize(); 52 | } 53 | 54 | @Override 55 | @SneakyThrows 56 | protected Either getClaims(String token) { 57 | var jws = JWSObject.parse(token); 58 | return jws.verify(verifier) 59 | ? Either.right(JWTClaimsSet.parse(jws.getPayload().toJSONObject())) 60 | : Either.left(ProblemType.TOKEN_VERIFICATION_FAILED); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/jwt/JwtPurpose.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common.jwt; 2 | 3 | public enum JwtPurpose { 4 | 5 | AUTH, 6 | EMAIL_VERIFICATION, 7 | EMAIL_CHANGE, 8 | FORGOT_PASSWORD; 9 | 10 | public static final String PURPOSE = "purpose"; 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/mail/LoggingMailSender.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common.mail; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.springframework.context.annotation.Profile; 5 | import org.springframework.stereotype.Component; 6 | 7 | import java.util.ArrayList; 8 | import java.util.List; 9 | 10 | @Slf4j 11 | @Component 12 | @Profile({"default", "test", "azure-staging", "azure-live", "digitalocean-staging", "digitalocean-live"}) 13 | public class LoggingMailSender implements MailSender { 14 | 15 | private static final List SENT_MAILS = new ArrayList<>(); 16 | 17 | @Override 18 | public void send(MailData mail) { 19 | SENT_MAILS.add(mail); 20 | log.info("Sending {}", mail); 21 | } 22 | 23 | public static List sentMails() { 24 | return SENT_MAILS; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/mail/MailData.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common.mail; 2 | 3 | import org.springframework.core.io.InputStreamSource; 4 | 5 | public record MailData( 6 | String to, 7 | String subject, 8 | String bodyHtml, 9 | Attachment attachment 10 | ) { 11 | public record Attachment( 12 | String name, 13 | InputStreamSource inputStreamSource, 14 | String contentType 15 | ) { 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/mail/MailSender.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common.mail; 2 | 3 | public interface MailSender { 4 | void send(MailData mail); 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/common/mail/SmtpMailSender.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common.mail; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import lombok.SneakyThrows; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.context.annotation.Profile; 7 | import org.springframework.mail.javamail.JavaMailSender; 8 | import org.springframework.mail.javamail.MimeMessageHelper; 9 | import org.springframework.scheduling.annotation.Async; 10 | import org.springframework.stereotype.Component; 11 | 12 | @Slf4j 13 | @Component 14 | @RequiredArgsConstructor 15 | @Profile({"staging", "live"}) 16 | public class SmtpMailSender implements MailSender { 17 | 18 | private final JavaMailSender javaMailSender; 19 | 20 | @Async 21 | @SneakyThrows 22 | @Override 23 | public void send(MailData mail) { 24 | 25 | log.info("Sending {}", mail); 26 | var message = javaMailSender.createMimeMessage(); 27 | 28 | // true = multipart message 29 | var helper = new MimeMessageHelper(message, true); 30 | helper.setTo(mail.to()); 31 | helper.setSubject(mail.subject()); 32 | helper.setText(mail.bodyHtml(), true); 33 | 34 | var attachment = mail.attachment(); 35 | if (attachment != null) { 36 | helper.addAttachment(attachment.name(), attachment.inputStreamSource(), attachment.contentType()); 37 | } 38 | 39 | javaMailSender.send(message); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/config/CommonConfig.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.config; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.security.crypto.factory.PasswordEncoderFactories; 6 | import org.springframework.security.crypto.password.PasswordEncoder; 7 | 8 | import java.time.Clock; 9 | 10 | @Configuration 11 | public class CommonConfig { 12 | 13 | @Bean 14 | public Clock clock() { 15 | return Clock.systemUTC(); 16 | } 17 | 18 | @Bean 19 | public PasswordEncoder passwordEncoder() { 20 | return PasswordEncoderFactories.createDelegatingPasswordEncoder(); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/config/MyProperties.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.config; 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties; 4 | 5 | import java.security.interfaces.RSAPrivateKey; 6 | import java.security.interfaces.RSAPublicKey; 7 | import java.util.Base64; 8 | 9 | @ConfigurationProperties(prefix = "my") 10 | public record MyProperties( 11 | String homepage, 12 | String oauth2AuthenticationSuccessUrl, 13 | Jws jws, 14 | Jwe jwe 15 | ) { 16 | 17 | public record Jws( 18 | String id, 19 | RSAPublicKey publicKey, 20 | RSAPrivateKey privateKey 21 | ) { 22 | 23 | public String publicKeyString() { 24 | return Base64.getEncoder().encodeToString(publicKey().getEncoded()); 25 | } 26 | } 27 | 28 | public record Jwe( 29 | String id, 30 | String key 31 | ) { 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/config/OpenApiConfig.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.config; 2 | 3 | import io.swagger.v3.oas.annotations.OpenAPIDefinition; 4 | import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; 5 | import io.swagger.v3.oas.annotations.info.Contact; 6 | import io.swagger.v3.oas.annotations.info.Info; 7 | import io.swagger.v3.oas.annotations.info.License; 8 | import io.swagger.v3.oas.annotations.security.SecurityRequirement; 9 | import io.swagger.v3.oas.annotations.security.SecurityScheme; 10 | 11 | @SecurityScheme( 12 | name = "JWT", 13 | type = SecuritySchemeType.HTTP, 14 | bearerFormat = "JWT", 15 | scheme = "bearer" 16 | ) 17 | @OpenAPIDefinition( 18 | info = @Info( 19 | title = "A sample Spring Boot non-reactive application", 20 | description = """ 21 | A model real world Application with user module using Spring Boot non-reactive stack 22 | """, 23 | contact = @Contact( 24 | name = "Sanjay Patel", 25 | url = "https://www.naturalprogrammer.com", 26 | email = "skpatel20@gmail.com" 27 | ), 28 | license = @License( 29 | name = "Apache 2.0 License", 30 | url = "https://github.com/naturalprogrammer/np-spring-mvc-demo/LICENSE.txt")), 31 | security = {@SecurityRequirement(name = "JWT")} 32 | ) 33 | public class OpenApiConfig { 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/config/security/JwtAuthenticationConverter.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.config.security; 2 | 3 | 4 | import com.naturalprogrammer.springmvc.user.domain.User; 5 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 6 | import lombok.RequiredArgsConstructor; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.springframework.core.convert.converter.Converter; 9 | import org.springframework.security.authentication.AbstractAuthenticationToken; 10 | import org.springframework.security.authentication.AccountExpiredException; 11 | import org.springframework.security.authentication.BadCredentialsException; 12 | import org.springframework.security.authentication.CredentialsExpiredException; 13 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 14 | import org.springframework.security.oauth2.jwt.Jwt; 15 | import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; 16 | 17 | import java.util.ArrayList; 18 | import java.util.Arrays; 19 | import java.util.Collection; 20 | import java.util.UUID; 21 | import java.util.stream.Collectors; 22 | 23 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.AUTH; 24 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.PURPOSE; 25 | import static java.util.Objects.requireNonNull; 26 | import static org.apache.commons.lang3.ObjectUtils.notEqual; 27 | import static org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames.SCOPE; 28 | 29 | @Slf4j 30 | @RequiredArgsConstructor 31 | public class JwtAuthenticationConverter implements Converter { 32 | 33 | private final UserRepository userRepository; 34 | 35 | @Override 36 | public AbstractAuthenticationToken convert(Jwt jwt) { 37 | 38 | var purpose = jwt.getClaim(PURPOSE); 39 | if (notEqual(purpose, AUTH.name())) { 40 | throw new BadCredentialsException( 41 | "Authentication failed because the purpose of Jwt is not %s but %s: %s".formatted( 42 | AUTH, purpose, jwt 43 | )); 44 | } 45 | 46 | var userIdStr = jwt.getSubject(); 47 | var userId = UUID.fromString(userIdStr); 48 | 49 | var user = userRepository.findById(userId).orElseThrow(() -> { 50 | log.warn("User {} not found while logging in with JWT {}", userIdStr, jwt); 51 | return new AccountExpiredException("User %s not found".formatted(userIdStr)); 52 | }); 53 | 54 | var obsoleteToken = user.getTokensValidFrom().isAfter(requireNonNull(jwt.getIssuedAt())); 55 | if (obsoleteToken) { 56 | log.warn("Obsolete token {} used for user {}", jwt, user); 57 | throw new CredentialsExpiredException("Obsolete token used for user %s".formatted(userIdStr)); 58 | } 59 | 60 | var authorities = getAuthorities(user); 61 | 62 | var scope = jwt.getClaimAsString(SCOPE); // e.g. "openid email profile" 63 | if (scope != null) 64 | authorities.addAll(getScopes(scope)); 65 | 66 | return new JwtAuthenticationToken(jwt, authorities, userIdStr); 67 | } 68 | 69 | public Collection getAuthorities(User user) { 70 | return user.getRoles().stream() 71 | .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name())) 72 | .collect(Collectors.toCollection(ArrayList::new)); 73 | } 74 | 75 | private Collection getScopes(String scopes) { 76 | return Arrays.stream(scopes.split("\\s+")) 77 | .map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope)) 78 | .toList(); 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/config/sociallogin/FacebookLoginProvider.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.config.sociallogin; 2 | 3 | import org.springframework.stereotype.Component; 4 | 5 | import java.util.Map; 6 | 7 | @Component 8 | public class FacebookLoginProvider implements SocialLoginProvider { 9 | 10 | @Override 11 | public String getRegistrationId() { 12 | return "facebook"; 13 | } 14 | 15 | @Override 16 | public boolean isEmailVerified(Map oauth2Attributes) { 17 | // Facebook no more returns verified 18 | // https://developers.facebook.com/docs/graph-api/reference/user 19 | return false; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/config/sociallogin/GoogleLoginProvider.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.config.sociallogin; 2 | 3 | import org.springframework.security.oauth2.core.oidc.StandardClaimNames; 4 | import org.springframework.stereotype.Component; 5 | 6 | import java.util.Map; 7 | 8 | @Component 9 | public class GoogleLoginProvider implements SocialLoginProvider { 10 | 11 | @Override 12 | public String getRegistrationId() { 13 | return "google"; 14 | } 15 | 16 | @Override 17 | public boolean isEmailVerified(Map oauth2Attributes) { 18 | 19 | Object verified = oauth2Attributes.get(StandardClaimNames.EMAIL_VERIFIED); 20 | if (verified == null) 21 | verified = oauth2Attributes.get("verified"); 22 | 23 | return (boolean) verified; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/config/sociallogin/MyOAuth2UserService.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.config.sociallogin; 2 | 3 | import com.naturalprogrammer.springmvc.user.domain.Role; 4 | import com.naturalprogrammer.springmvc.user.domain.User; 5 | import com.naturalprogrammer.springmvc.user.features.signup.SignupRequest; 6 | import com.naturalprogrammer.springmvc.user.services.UserService; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; 9 | import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; 10 | import org.springframework.security.oauth2.core.oidc.StandardClaimNames; 11 | import org.springframework.security.oauth2.core.user.OAuth2User; 12 | import org.springframework.stereotype.Component; 13 | 14 | import java.util.List; 15 | import java.util.Locale; 16 | import java.util.Map; 17 | import java.util.UUID; 18 | import java.util.function.Function; 19 | import java.util.stream.Collectors; 20 | 21 | @Slf4j 22 | @Component 23 | public class MyOAuth2UserService extends DefaultOAuth2UserService { 24 | 25 | private final Map loginProviders; 26 | private final UserService userService; 27 | 28 | public MyOAuth2UserService( 29 | List loginProviders, 30 | UserService userService 31 | ) { 32 | this.loginProviders = loginProviders.stream().collect(Collectors.toMap( 33 | SocialLoginProvider::getRegistrationId, 34 | Function.identity() 35 | )); 36 | this.userService = userService; 37 | } 38 | 39 | @Override 40 | public OAuth2User loadUser(OAuth2UserRequest userRequest) { 41 | 42 | OAuth2User oauth2User = super.loadUser(userRequest); 43 | var user = getUser(oauth2User, userRequest.getClientRegistration().getRegistrationId()); 44 | return new SocialUser(user.getId(), oauth2User); 45 | } 46 | 47 | /** 48 | * Builds the security principal from the given userReqest. 49 | * Registers the user if not already registered 50 | */ 51 | public User getUser(OAuth2User oauth2User, String registrationId) { 52 | 53 | var attributes = oauth2User.getAttributes(); 54 | var email = (String) attributes.get(StandardClaimNames.EMAIL); 55 | 56 | return userService 57 | .findByEmail(email) 58 | .map(user -> { 59 | log.info("Got {} for {}", user, oauth2User); 60 | return user; 61 | }) 62 | .orElseGet(() -> { 63 | var signupRequest = new SignupRequest( 64 | email, 65 | UUID.randomUUID().toString(), 66 | email.substring(0, email.indexOf('@')), 67 | null 68 | ); 69 | var emailVerified = isEmailVerified(registrationId, attributes); 70 | var role = emailVerified ? Role.VERIFIED : Role.UNVERIFIED; 71 | 72 | var user = userService.createUser(signupRequest, Locale.ENGLISH, role); 73 | log.info("Created {} for {}", user, oauth2User); 74 | return user; 75 | }); 76 | } 77 | 78 | private boolean isEmailVerified(String registrationId, Map attributes) { 79 | var provider = loginProviders.get(registrationId); 80 | return provider != null && provider.isEmailVerified(attributes); 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/config/sociallogin/MyOidcUserService.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.config.sociallogin; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; 6 | import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; 7 | import org.springframework.security.oauth2.core.oidc.user.OidcUser; 8 | import org.springframework.stereotype.Component; 9 | 10 | @Slf4j 11 | @Component 12 | @RequiredArgsConstructor 13 | public class MyOidcUserService extends OidcUserService { 14 | 15 | private final MyOAuth2UserService oauth2UserService; 16 | 17 | @Override 18 | public OidcUser loadUser(OidcUserRequest userRequest) { 19 | 20 | OidcUser oidcUser = super.loadUser(userRequest); 21 | var user = oauth2UserService.getUser(oidcUser, 22 | userRequest.getClientRegistration().getRegistrationId()); 23 | return new SocialUser(user.getId(), oidcUser); 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/config/sociallogin/OAuth2AuthenticationFailureHandler.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.config.sociallogin; 2 | 3 | import jakarta.servlet.ServletException; 4 | import jakarta.servlet.http.HttpServletRequest; 5 | import jakarta.servlet.http.HttpServletResponse; 6 | import org.springframework.security.core.AuthenticationException; 7 | import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; 8 | 9 | import java.io.IOException; 10 | 11 | import static com.naturalprogrammer.springmvc.common.CommonUtils.deleteCookies; 12 | import static com.naturalprogrammer.springmvc.config.sociallogin.HttpCookieOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST_COOKIE_NAME; 13 | import static com.naturalprogrammer.springmvc.config.sociallogin.HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_COOKIE_PARAM_NAME; 14 | 15 | public class OAuth2AuthenticationFailureHandler 16 | extends SimpleUrlAuthenticationFailureHandler { 17 | 18 | @Override 19 | public void onAuthenticationFailure(HttpServletRequest request, 20 | HttpServletResponse response, 21 | AuthenticationException exception 22 | ) throws ServletException, IOException { 23 | 24 | deleteCookies(request, response, 25 | AUTHORIZATION_REQUEST_COOKIE_NAME, 26 | REDIRECT_URI_COOKIE_PARAM_NAME 27 | ); 28 | 29 | super.onAuthenticationFailure(request, response, exception); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/config/sociallogin/OAuth2AuthenticationSuccessHandler.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020-2021 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this artifact or file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.naturalprogrammer.springmvc.config.sociallogin; 18 | 19 | import com.naturalprogrammer.springmvc.common.CommonUtils; 20 | import com.naturalprogrammer.springmvc.config.MyProperties; 21 | import com.naturalprogrammer.springmvc.user.features.login.AuthTokenCreator; 22 | import jakarta.servlet.http.Cookie; 23 | import jakarta.servlet.http.HttpServletRequest; 24 | import jakarta.servlet.http.HttpServletResponse; 25 | import lombok.RequiredArgsConstructor; 26 | import org.springframework.security.core.context.SecurityContextHolder; 27 | import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; 28 | import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; 29 | import org.springframework.stereotype.Component; 30 | 31 | import static com.naturalprogrammer.springmvc.common.CommonUtils.deleteCookies; 32 | import static com.naturalprogrammer.springmvc.config.sociallogin.HttpCookieOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST_COOKIE_NAME; 33 | import static com.naturalprogrammer.springmvc.config.sociallogin.HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_COOKIE_PARAM_NAME; 34 | 35 | 36 | /** 37 | * Authentication success handler for redirecting the 38 | * OAuth2 signed in user to a URL with a short lived auth token 39 | * 40 | * @author Sanjay Patel 41 | */ 42 | @Component 43 | @RequiredArgsConstructor 44 | public class OAuth2AuthenticationSuccessHandler 45 | extends SimpleUrlAuthenticationSuccessHandler { 46 | 47 | private final AuthTokenCreator authTokenCreator; 48 | private final MyProperties properties; 49 | 50 | @Override 51 | protected String determineTargetUrl(HttpServletRequest request, 52 | HttpServletResponse response) { 53 | 54 | var userId = getCurrentUserId(); 55 | String refreshingResourceToken = authTokenCreator.createClientSpecificResourceToken(userId); 56 | 57 | String targetUrl = CommonUtils 58 | .fetchCookie(request, REDIRECT_URI_COOKIE_PARAM_NAME) 59 | .map(Cookie::getValue) 60 | .orElse(properties.oauth2AuthenticationSuccessUrl()); 61 | 62 | deleteCookies(request, response, 63 | AUTHORIZATION_REQUEST_COOKIE_NAME, 64 | REDIRECT_URI_COOKIE_PARAM_NAME); 65 | 66 | return targetUrl.formatted(userId, refreshingResourceToken); 67 | } 68 | 69 | private String getCurrentUserId() { 70 | var auth = (OAuth2AuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); 71 | var socialUser = (SocialUser) auth.getPrincipal(); 72 | return socialUser.getUserId().toString(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/config/sociallogin/SocialLoginProvider.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.config.sociallogin; 2 | 3 | import java.util.Map; 4 | 5 | public interface SocialLoginProvider { 6 | 7 | String getRegistrationId(); 8 | 9 | boolean isEmailVerified(Map oauth2Attributes); 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/config/sociallogin/SocialUser.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.config.sociallogin; 2 | 3 | import lombok.Getter; 4 | import org.springframework.security.core.GrantedAuthority; 5 | import org.springframework.security.oauth2.core.oidc.OidcIdToken; 6 | import org.springframework.security.oauth2.core.oidc.OidcUserInfo; 7 | import org.springframework.security.oauth2.core.oidc.user.OidcUser; 8 | import org.springframework.security.oauth2.core.user.OAuth2User; 9 | 10 | import java.util.Collection; 11 | import java.util.Map; 12 | import java.util.UUID; 13 | 14 | @Getter 15 | public class SocialUser implements OidcUser { 16 | 17 | private final UUID userId; 18 | private final Map attributes; 19 | private final Collection authorities; 20 | private final String name; 21 | private final Map claims; 22 | private final OidcUserInfo userInfo; 23 | private final OidcIdToken idToken; 24 | 25 | public SocialUser(UUID userId, OAuth2User oauth2User) { 26 | this.userId = userId; 27 | attributes = oauth2User.getAttributes(); 28 | authorities = oauth2User.getAuthorities(); 29 | name = oauth2User.getName(); 30 | claims = null; 31 | userInfo = null; 32 | idToken = null; 33 | } 34 | 35 | public SocialUser(UUID userId, OidcUser oidcUser) { 36 | this.userId = userId; 37 | attributes = oidcUser.getAttributes(); 38 | authorities = oidcUser.getAuthorities(); 39 | name = oidcUser.getName(); 40 | claims = oidcUser.getClaims(); 41 | userInfo = oidcUser.getUserInfo(); 42 | idToken = oidcUser.getIdToken(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/domain/AbstractEntity.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.domain; 2 | 3 | import jakarta.persistence.Id; 4 | import jakarta.persistence.MappedSuperclass; 5 | import jakarta.persistence.Version; 6 | import lombok.Getter; 7 | import lombok.Setter; 8 | import lombok.ToString; 9 | import org.springframework.data.annotation.CreatedBy; 10 | import org.springframework.data.annotation.CreatedDate; 11 | import org.springframework.data.annotation.LastModifiedBy; 12 | import org.springframework.data.annotation.LastModifiedDate; 13 | 14 | import java.time.Instant; 15 | import java.util.UUID; 16 | 17 | @Getter 18 | @Setter 19 | @ToString 20 | @MappedSuperclass 21 | public abstract class AbstractEntity { 22 | 23 | @Id 24 | private UUID id; 25 | 26 | @CreatedBy 27 | private String createdBy; 28 | 29 | @CreatedDate 30 | private Instant createdAt; 31 | 32 | @LastModifiedBy 33 | private String modifiedBy; 34 | 35 | @LastModifiedDate 36 | private Instant modifiedAt; 37 | 38 | @Version 39 | private Integer version; 40 | 41 | public String getIdStr() { 42 | return id.toString(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/domain/Role.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.domain; 2 | 3 | public enum Role { 4 | CUSTOMER, // Business user 5 | ADMIN, // Admin 6 | UNVERIFIED, // Email unverified 7 | VERIFIED; // Verified 8 | 9 | public String authority() { 10 | return "ROLE_" + name(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/domain/User.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.domain; 2 | 3 | import jakarta.persistence.*; 4 | import lombok.Getter; 5 | import lombok.Setter; 6 | import lombok.ToString; 7 | 8 | import java.time.Clock; 9 | import java.time.Instant; 10 | import java.time.temporal.ChronoUnit; 11 | import java.util.HashSet; 12 | import java.util.List; 13 | import java.util.Locale; 14 | import java.util.Set; 15 | 16 | @Entity 17 | @Table(name = "usr") 18 | @Getter 19 | @Setter 20 | @ToString(callSuper = true) 21 | public class User extends AbstractEntity { 22 | 23 | public static final int EMAIL_MAX = 1024; 24 | public static final int NAME_MIN = 1; 25 | public static final int NAME_MAX = 50; 26 | public static final int PASSWORD_MIN = 8; 27 | public static final int PASSWORD_MAX = 50; 28 | 29 | @Column(nullable = false, unique = true, length = EMAIL_MAX) 30 | private String email; 31 | 32 | @Column(nullable = false) // no length because it will be encrypted 33 | @ToString.Exclude 34 | private String password; 35 | 36 | @Column(nullable = false, length = NAME_MAX) 37 | private String displayName; 38 | 39 | @Column 40 | private Locale locale; 41 | 42 | @Enumerated(EnumType.STRING) 43 | private Set roles = new HashSet<>(); 44 | 45 | @Column(length = EMAIL_MAX) 46 | private String newEmail; 47 | 48 | // A JWT issued before this won't be valid 49 | @Column(nullable = false) 50 | private Instant tokensValidFrom; 51 | 52 | public boolean hasRoles(Role... roles) { 53 | return this.roles.containsAll(List.of(roles)); 54 | } 55 | 56 | public boolean isAdmin() { 57 | return hasRoles(Role.ADMIN, Role.VERIFIED); 58 | } 59 | 60 | public void resetTokensValidFrom(Clock clock) { 61 | tokensValidFrom = clock.instant().truncatedTo(ChronoUnit.SECONDS); 62 | } 63 | } -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/change_mail/ChangeEmailController.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.change_mail; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.Problem; 4 | import io.swagger.v3.oas.annotations.Operation; 5 | import io.swagger.v3.oas.annotations.media.Content; 6 | import io.swagger.v3.oas.annotations.media.Schema; 7 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 8 | import io.swagger.v3.oas.annotations.responses.ApiResponses; 9 | import io.swagger.v3.oas.annotations.tags.Tag; 10 | import lombok.RequiredArgsConstructor; 11 | import org.springframework.http.MediaType; 12 | import org.springframework.http.ResponseEntity; 13 | import org.springframework.web.bind.annotation.*; 14 | 15 | import static com.naturalprogrammer.springmvc.common.Path.USER; 16 | 17 | 18 | @RestController 19 | @RequiredArgsConstructor 20 | @RequestMapping(USER) 21 | @Tag(name = "User", description = "User API") 22 | class ChangeEmailController { 23 | 24 | private final EmailChangeRequestProcessor emailChangeRequestProcessor; 25 | private final EmailChanger emailChanger; 26 | 27 | @Operation(summary = "Request changing email") 28 | @ApiResponses(value = { 29 | @ApiResponse(responseCode = "204", description = "Email change requested"), 30 | @ApiResponse(responseCode = "422", description = "Invalid input", 31 | content = @Content( 32 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 33 | schema = @Schema(implementation = Problem.class)) 34 | ), 35 | @ApiResponse(responseCode = "403", description = "Old email or password mismatch", 36 | content = @Content( 37 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 38 | schema = @Schema(implementation = Problem.class)) 39 | ) 40 | }) 41 | @PostMapping(value = "/email-change-request", consumes = UserEmailChangeRequest.CONTENT_TYPE) 42 | ResponseEntity requestChangingEmail(@RequestBody UserEmailChangeRequest request) { 43 | return emailChangeRequestProcessor 44 | .process(request) 45 | .map(Problem::toResponse) 46 | .orElseGet(() -> ResponseEntity.noContent().build()); 47 | } 48 | 49 | @Operation(summary = "Verify new email and update") 50 | @ApiResponses(value = { 51 | @ApiResponse(responseCode = "204", description = "Email changed. Old auth tokens invalidated"), 52 | @ApiResponse(responseCode = "422", description = "Invalid input", 53 | content = @Content( 54 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 55 | schema = @Schema(implementation = Problem.class)) 56 | ), 57 | @ApiResponse(responseCode = "403", description = "Token verification failed", 58 | content = @Content( 59 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 60 | schema = @Schema(implementation = Problem.class)) 61 | ), 62 | @ApiResponse(responseCode = "409", description = "New email already used", 63 | content = @Content( 64 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 65 | schema = @Schema(implementation = Problem.class)) 66 | ) 67 | }) 68 | @PatchMapping(value = "/email-change-request", consumes = UserEmailChangeVerificationRequest.CONTENT_TYPE) 69 | ResponseEntity changeEmail(@RequestBody UserEmailChangeVerificationRequest request) { 70 | return emailChanger.changeEmail(request) 71 | .map(Problem::toResponse) 72 | .orElseGet(() -> ResponseEntity.noContent().build()); 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/change_mail/EmailChanger.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.change_mail; 2 | 3 | import com.naturalprogrammer.springmvc.common.CommonUtils; 4 | import com.naturalprogrammer.springmvc.common.error.*; 5 | import com.naturalprogrammer.springmvc.common.jwt.JweService; 6 | import com.naturalprogrammer.springmvc.user.domain.User; 7 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 8 | import com.nimbusds.jwt.JWTClaimsSet; 9 | import lombok.RequiredArgsConstructor; 10 | import lombok.extern.slf4j.Slf4j; 11 | import org.springframework.beans.factory.ObjectFactory; 12 | import org.springframework.stereotype.Service; 13 | 14 | import java.time.Clock; 15 | import java.util.Optional; 16 | import java.util.UUID; 17 | 18 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.EMAIL_CHANGE; 19 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.PURPOSE; 20 | import static java.util.Collections.emptyList; 21 | import static org.apache.commons.lang3.ObjectUtils.notEqual; 22 | import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.EMAIL; 23 | 24 | @Slf4j 25 | @Service 26 | @RequiredArgsConstructor 27 | class EmailChanger { 28 | 29 | private final CommonUtils commonUtils; 30 | private final BeanValidator validator; 31 | private final ValidatedEmailChanger validatedEmailChanger; 32 | 33 | public Optional changeEmail(UserEmailChangeVerificationRequest request) { 34 | 35 | var userId = commonUtils.getUserId().orElseThrow(); 36 | log.info("Changing email for user {} with {}", userId, request); 37 | return validator 38 | .validate(request) 39 | .or(() -> validatedEmailChanger.changeEmail(userId, request)); 40 | } 41 | } 42 | 43 | @Slf4j 44 | @Service 45 | @RequiredArgsConstructor 46 | class ValidatedEmailChanger { 47 | 48 | private final UserRepository userRepository; 49 | private final JweService jweService; 50 | private final ObjectFactory problemBuilder; 51 | private final Clock clock; 52 | 53 | public Optional changeEmail(UUID userId, UserEmailChangeVerificationRequest request) { 54 | 55 | var user = userRepository.findById(userId).orElseThrow(); 56 | 57 | return jweService 58 | .parseToken(request.emailVerificationToken()) 59 | .fold( 60 | problemType -> Optional.of(problemBuilder.getObject() 61 | .type(problemType) 62 | .detail(request.emailVerificationToken()) 63 | .error("emailVerificationToken", ErrorCode.TOKEN_VERIFICATION_FAILED) 64 | .build()), 65 | claims -> changeEmail(user, claims) 66 | ); 67 | } 68 | 69 | private Optional changeEmail(User user, JWTClaimsSet claims) { 70 | 71 | if (notEqual(claims.getSubject(), user.getIdStr()) || 72 | notEqual(claims.getClaim(PURPOSE), EMAIL_CHANGE.name()) || 73 | notEqual(claims.getClaim(EMAIL), user.getNewEmail())) 74 | return Optional.of(problemBuilder.getObject().build(ProblemType.TOKEN_VERIFICATION_FAILED, user.toString())); 75 | 76 | if (userRepository.existsByEmail(user.getNewEmail())) 77 | return Optional.of(problemBuilder.getObject() 78 | .type(ProblemType.USED_EMAIL) 79 | .detailMessage("used-given-email", user.getNewEmail()) 80 | .errors(emptyList()) 81 | .build()); 82 | 83 | user.setEmail(user.getNewEmail()); 84 | user.setNewEmail(null); 85 | user.resetTokensValidFrom(clock); 86 | userRepository.save(user); 87 | return Optional.empty(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/change_mail/UserEmailChangeRequest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.change_mail; 2 | 3 | import com.naturalprogrammer.springmvc.user.domain.User; 4 | import com.naturalprogrammer.springmvc.user.validators.ValidPassword; 5 | import io.swagger.v3.oas.annotations.media.Schema; 6 | import jakarta.validation.constraints.Email; 7 | import jakarta.validation.constraints.NotBlank; 8 | import jakarta.validation.constraints.Size; 9 | import lombok.extern.slf4j.Slf4j; 10 | 11 | import static com.naturalprogrammer.springmvc.common.CommonUtils.CONTENT_TYPE_PREFIX; 12 | import static com.naturalprogrammer.springmvc.user.validators.PasswordValidator.PASSWORD_DESCRIPTION; 13 | import static org.apache.commons.lang3.StringUtils.trim; 14 | 15 | @Slf4j 16 | record UserEmailChangeRequest( 17 | 18 | @Email 19 | @NotBlank 20 | @Size(max = User.EMAIL_MAX) 21 | @Schema(example = "sanjay@example.com") 22 | String oldEmail, 23 | 24 | @ValidPassword 25 | @Schema(example = "Secret99!", description = PASSWORD_DESCRIPTION) 26 | String password, 27 | 28 | @Email 29 | @NotBlank 30 | @Size(max = User.EMAIL_MAX) 31 | @Schema(example = "sanjay@example.com") 32 | String newEmail 33 | ) { 34 | 35 | public UserEmailChangeRequest trimmed() { 36 | var trimmed = new UserEmailChangeRequest(trim(oldEmail), trim(password), trim(newEmail)); 37 | log.info("Trimmed {} to {}", this, trimmed); 38 | return trimmed; 39 | } 40 | 41 | @Override 42 | public String toString() { 43 | return "UserEmailChangeRequest{" + 44 | "oldEmail='" + oldEmail + '\'' + 45 | ", newEmail='" + newEmail + '\'' + 46 | '}'; 47 | } 48 | 49 | static final String CONTENT_TYPE = CONTENT_TYPE_PREFIX + "user-email-change-request.v1+json"; 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/change_mail/UserEmailChangeVerificationRequest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.change_mail; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import jakarta.validation.constraints.NotBlank; 5 | import jakarta.validation.constraints.Size; 6 | import lombok.extern.slf4j.Slf4j; 7 | 8 | import static com.naturalprogrammer.springmvc.common.CommonUtils.CONTENT_TYPE_PREFIX; 9 | 10 | @Slf4j 11 | record UserEmailChangeVerificationRequest( 12 | 13 | @NotBlank 14 | @Size(max = 5000) 15 | @Schema(example = "{token received via email}") 16 | String emailVerificationToken 17 | ) { 18 | public static final String CONTENT_TYPE = CONTENT_TYPE_PREFIX + "user-email-change-verification-request.v1+json"; 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/change_password/ChangePasswordController.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.change_password; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.Problem; 4 | import io.swagger.v3.oas.annotations.Operation; 5 | import io.swagger.v3.oas.annotations.media.Content; 6 | import io.swagger.v3.oas.annotations.media.Schema; 7 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 8 | import io.swagger.v3.oas.annotations.responses.ApiResponses; 9 | import io.swagger.v3.oas.annotations.tags.Tag; 10 | import lombok.RequiredArgsConstructor; 11 | import org.springframework.http.MediaType; 12 | import org.springframework.http.ResponseEntity; 13 | import org.springframework.web.bind.annotation.PatchMapping; 14 | import org.springframework.web.bind.annotation.RequestBody; 15 | import org.springframework.web.bind.annotation.RequestMapping; 16 | import org.springframework.web.bind.annotation.RestController; 17 | 18 | import static com.naturalprogrammer.springmvc.common.Path.USER; 19 | 20 | @RestController 21 | @RequiredArgsConstructor 22 | @RequestMapping(USER) 23 | @Tag(name = "User", description = "User API") 24 | public class ChangePasswordController { 25 | 26 | private final PasswordChanger passwordChanger; 27 | 28 | @Operation(summary = "Change password") 29 | @ApiResponses(value = { 30 | @ApiResponse(responseCode = "204", description = "Password changed"), 31 | @ApiResponse(responseCode = "422", description = "Invalid input", 32 | content = @Content( 33 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 34 | schema = @Schema(implementation = Problem.class)) 35 | ), 36 | @ApiResponse(responseCode = "403", description = "Old password mismatch", 37 | content = @Content( 38 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 39 | schema = @Schema(implementation = Problem.class)) 40 | ) 41 | }) 42 | @PatchMapping(value = "/password", consumes = ChangePasswordRequest.CONTENT_TYPE) 43 | public ResponseEntity changePassword(@RequestBody ChangePasswordRequest request) { 44 | return passwordChanger 45 | .changePassword(request) 46 | .map(Problem::toResponse) 47 | .orElseGet(() -> ResponseEntity.noContent().build()); 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/change_password/ChangePasswordRequest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.change_password; 2 | 3 | import com.naturalprogrammer.springmvc.user.validators.ValidPassword; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | 6 | import static com.naturalprogrammer.springmvc.common.CommonUtils.CONTENT_TYPE_PREFIX; 7 | import static com.naturalprogrammer.springmvc.user.validators.PasswordValidator.PASSWORD_DESCRIPTION; 8 | import static org.apache.commons.lang3.StringUtils.trim; 9 | 10 | public record ChangePasswordRequest( 11 | 12 | @ValidPassword 13 | @Schema(example = "YourOldPassword9!", description = PASSWORD_DESCRIPTION) 14 | String oldPassword, 15 | 16 | @ValidPassword 17 | @Schema(example = "YourNewPassword9!", description = PASSWORD_DESCRIPTION) 18 | String newPassword 19 | 20 | ) { 21 | 22 | public ChangePasswordRequest trimmed() { 23 | return new ChangePasswordRequest(trim(oldPassword), trim(newPassword)); 24 | } 25 | 26 | @Override 27 | public String toString() { 28 | return "ChangePasswordRequest{}"; 29 | } 30 | 31 | public static final String CONTENT_TYPE = CONTENT_TYPE_PREFIX + "change-password-request.v1+json"; 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/change_password/PasswordChanger.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.change_password; 2 | 3 | import com.naturalprogrammer.springmvc.common.CommonUtils; 4 | import com.naturalprogrammer.springmvc.common.error.*; 5 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 6 | import lombok.RequiredArgsConstructor; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.springframework.beans.factory.ObjectFactory; 9 | import org.springframework.security.crypto.password.PasswordEncoder; 10 | import org.springframework.stereotype.Service; 11 | 12 | import java.time.Clock; 13 | import java.util.Optional; 14 | import java.util.UUID; 15 | 16 | @Slf4j 17 | @Service 18 | @RequiredArgsConstructor 19 | public class PasswordChanger { 20 | 21 | private final BeanValidator validator; 22 | private final CommonUtils commonUtils; 23 | private final UserRepository userRepository; 24 | private final PasswordEncoder passwordEncoder; 25 | private final ObjectFactory problemBuilder; 26 | private final Clock clock; 27 | 28 | public Optional changePassword(ChangePasswordRequest request) { 29 | 30 | var userId = commonUtils.getUserId().orElseThrow(); 31 | 32 | log.info("Changing password for user {}", userId); 33 | var trimmedRequest = request.trimmed(); 34 | return validator 35 | .validate(trimmedRequest) 36 | .or(() -> changeValidatedPassword(userId, trimmedRequest)); 37 | } 38 | 39 | private Optional changeValidatedPassword(UUID userId, ChangePasswordRequest request) { 40 | 41 | var user = userRepository.findById(userId).orElseThrow(); 42 | if (!passwordEncoder.matches(request.oldPassword(), user.getPassword())) { 43 | var problem = problemBuilder.getObject() 44 | .type(ProblemType.PASSWORD_MISMATCH) 45 | .detailMessage("password-mismatch-for-user", userId) 46 | .error("oldPassword", ErrorCode.PASSWORD_MISMATCH) 47 | .build(); 48 | return Optional.of(problem); 49 | } 50 | user.setPassword(passwordEncoder.encode(request.newPassword())); 51 | user.resetTokensValidFrom(clock); 52 | userRepository.save(user); 53 | return Optional.empty(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/display_name_edit/DisplayNameEditController.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.display_name_edit; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.Problem; 4 | import com.naturalprogrammer.springmvc.user.services.UserResource; 5 | import io.swagger.v3.oas.annotations.Operation; 6 | import io.swagger.v3.oas.annotations.media.Content; 7 | import io.swagger.v3.oas.annotations.media.Schema; 8 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 9 | import io.swagger.v3.oas.annotations.responses.ApiResponses; 10 | import io.swagger.v3.oas.annotations.tags.Tag; 11 | import lombok.RequiredArgsConstructor; 12 | import org.springframework.http.MediaType; 13 | import org.springframework.http.ResponseEntity; 14 | import org.springframework.web.bind.annotation.*; 15 | 16 | import java.util.UUID; 17 | 18 | import static com.naturalprogrammer.springmvc.common.CommonUtils.toResponse; 19 | import static com.naturalprogrammer.springmvc.common.Path.USERS; 20 | 21 | 22 | @RestController 23 | @RequiredArgsConstructor 24 | @RequestMapping(USERS) 25 | @Tag(name = "User", description = "User API") 26 | class DisplayNameEditController { 27 | 28 | private final DisplayNameEditor displayNameEditor; 29 | 30 | @Operation(summary = "Edit display name") 31 | @ApiResponses(value = { 32 | @ApiResponse(responseCode = "200", description = "Display name updated", 33 | content = @Content( 34 | mediaType = UserResource.CONTENT_TYPE, 35 | schema = @Schema(implementation = UserResource.class)) 36 | ), 37 | @ApiResponse(responseCode = "422", description = "Invalid input", 38 | content = @Content( 39 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 40 | schema = @Schema(implementation = Problem.class)) 41 | ), 42 | @ApiResponse(responseCode = "404", description = "User not found or insufficient rights (must be self or admin)", 43 | content = @Content( 44 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 45 | schema = @Schema(implementation = Problem.class)) 46 | ) 47 | }) 48 | @PatchMapping(value = "/{id}/display-name", 49 | consumes = UserDisplayNameEditRequest.CONTENT_TYPE, 50 | produces = UserResource.CONTENT_TYPE) 51 | ResponseEntity editDisplayName( 52 | @PathVariable UUID id, 53 | @RequestBody UserDisplayNameEditRequest request 54 | ) { 55 | return toResponse(displayNameEditor.edit(id, request), ResponseEntity::ok); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/display_name_edit/DisplayNameEditor.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.display_name_edit; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.BeanValidator; 4 | import com.naturalprogrammer.springmvc.common.error.Problem; 5 | import com.naturalprogrammer.springmvc.user.domain.User; 6 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 7 | import com.naturalprogrammer.springmvc.user.services.UserResource; 8 | import com.naturalprogrammer.springmvc.user.services.UserService; 9 | import io.jbock.util.Either; 10 | import lombok.RequiredArgsConstructor; 11 | import lombok.extern.slf4j.Slf4j; 12 | import org.springframework.stereotype.Service; 13 | 14 | import java.util.UUID; 15 | 16 | @Slf4j 17 | @Service 18 | @RequiredArgsConstructor 19 | class DisplayNameEditor { 20 | 21 | private final BeanValidator validator; 22 | private final ValidatedDisplayNameEditor validatedDisplayNameEditor; 23 | 24 | public Either edit(UUID userId, UserDisplayNameEditRequest request) { 25 | 26 | log.info("Editing display name for user {}: {}", userId, request); 27 | var trimmedRequest = request.trimmed(); 28 | return validator.validateAndGet(trimmedRequest, () -> validatedDisplayNameEditor.edit(userId, trimmedRequest)); 29 | } 30 | 31 | } 32 | 33 | @Slf4j 34 | @Service 35 | @RequiredArgsConstructor 36 | class ValidatedDisplayNameEditor { 37 | 38 | private final UserRepository userRepository; 39 | private final UserService userService; 40 | 41 | public Either edit(UUID userId, UserDisplayNameEditRequest request) { 42 | 43 | if (!userService.isSelfOrAdmin(userId)) { 44 | log.warn("User {} is not self or admin when trying edit its display name to {}", userId, request); 45 | return Either.left(userService.userNotFound(userId)); 46 | } 47 | 48 | return userRepository.findById(userId) 49 | .map(user -> edit(user, request)) 50 | .orElseGet(() -> { 51 | log.warn("User {} not found when trying to edit displayName to {}", userId, request); 52 | return Either.left(userService.userNotFound(userId)); 53 | }); 54 | } 55 | 56 | private Either edit(User user, UserDisplayNameEditRequest request) { 57 | 58 | user.setDisplayName(request.displayName()); 59 | userRepository.save(user); 60 | var resource = userService.toResource(user); 61 | 62 | log.info("Edited name for {}. Returning {}", user, resource); 63 | return Either.right(resource); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/display_name_edit/UserDisplayNameEditRequest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.display_name_edit; 2 | 3 | import com.naturalprogrammer.springmvc.user.domain.User; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import jakarta.validation.constraints.NotBlank; 6 | import jakarta.validation.constraints.Size; 7 | import lombok.extern.slf4j.Slf4j; 8 | 9 | import static com.naturalprogrammer.springmvc.common.CommonUtils.CONTENT_TYPE_PREFIX; 10 | import static org.apache.commons.lang3.StringUtils.trim; 11 | 12 | @Slf4j 13 | record UserDisplayNameEditRequest( 14 | @NotBlank 15 | @Size(min = User.NAME_MIN, max = User.NAME_MAX) 16 | @Schema(example = "Sanjay Patel") 17 | String displayName 18 | ) { 19 | 20 | public UserDisplayNameEditRequest trimmed() { 21 | var trimmed = new UserDisplayNameEditRequest(trim(displayName)); 22 | log.info("Trimmed {} to {}", this, trimmed); 23 | return trimmed; 24 | } 25 | 26 | static final String CONTENT_TYPE = CONTENT_TYPE_PREFIX + "user-display-name-edit-request.v1+json"; 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/forgot_password/ForgotPasswordController.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.forgot_password; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.Problem; 4 | import io.swagger.v3.oas.annotations.Operation; 5 | import io.swagger.v3.oas.annotations.media.Content; 6 | import io.swagger.v3.oas.annotations.media.Schema; 7 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 8 | import io.swagger.v3.oas.annotations.responses.ApiResponses; 9 | import io.swagger.v3.oas.annotations.tags.Tag; 10 | import lombok.RequiredArgsConstructor; 11 | import org.springframework.http.MediaType; 12 | import org.springframework.http.ResponseEntity; 13 | import org.springframework.web.bind.annotation.PostMapping; 14 | import org.springframework.web.bind.annotation.RequestBody; 15 | import org.springframework.web.bind.annotation.RestController; 16 | 17 | import static com.naturalprogrammer.springmvc.common.Path.FORGOT_PASSWORD; 18 | 19 | @RestController 20 | @RequiredArgsConstructor 21 | @Tag(name = "User", description = "User API") 22 | class ForgotPasswordController { 23 | 24 | private final ForgotPasswordInitiator forgotPasswordInitiator; 25 | 26 | @Operation(summary = "Forgot password") 27 | @ApiResponses(value = { 28 | @ApiResponse(responseCode = "204", description = "Forgot password link mailed"), 29 | @ApiResponse(responseCode = "422", description = "Invalid email", 30 | content = @Content( 31 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 32 | schema = @Schema(implementation = Problem.class)) 33 | ) 34 | }) 35 | @PostMapping(value = FORGOT_PASSWORD, consumes = ForgotPasswordRequest.CONTENT_TYPE) 36 | ResponseEntity forgotPassword(@RequestBody ForgotPasswordRequest request) { 37 | 38 | return forgotPasswordInitiator 39 | .initiate(request) 40 | .map(Problem::toResponse) 41 | .orElseGet(() -> ResponseEntity.noContent().build()); 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/forgot_password/ForgotPasswordInitiator.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.forgot_password; 2 | 3 | import com.naturalprogrammer.springmvc.common.MessageGetter; 4 | import com.naturalprogrammer.springmvc.common.error.BeanValidator; 5 | import com.naturalprogrammer.springmvc.common.error.Problem; 6 | import com.naturalprogrammer.springmvc.common.jwt.JweService; 7 | import com.naturalprogrammer.springmvc.common.mail.MailData; 8 | import com.naturalprogrammer.springmvc.common.mail.MailSender; 9 | import com.naturalprogrammer.springmvc.config.MyProperties; 10 | import com.naturalprogrammer.springmvc.user.domain.User; 11 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 12 | import lombok.RequiredArgsConstructor; 13 | import lombok.extern.slf4j.Slf4j; 14 | import org.springframework.stereotype.Service; 15 | 16 | import java.time.Clock; 17 | import java.time.temporal.ChronoUnit; 18 | import java.util.Date; 19 | import java.util.Map; 20 | import java.util.Optional; 21 | 22 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.FORGOT_PASSWORD; 23 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.PURPOSE; 24 | import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.EMAIL; 25 | 26 | @Slf4j 27 | @Service 28 | @RequiredArgsConstructor 29 | class ForgotPasswordInitiator { 30 | 31 | private final BeanValidator validator; 32 | private final UserRepository userRepository; 33 | private final ForgotPasswordMailSender forgotPasswordMailSender; 34 | 35 | public Optional initiate(ForgotPasswordRequest request) { 36 | 37 | log.info("Initiating forgot password for {}", request); 38 | var trimmedRequest = request.trimmed(); 39 | return validator 40 | .validate(trimmedRequest) 41 | .or(() -> { 42 | initiateValidated(trimmedRequest); 43 | return Optional.empty(); 44 | }); 45 | } 46 | 47 | private void initiateValidated(ForgotPasswordRequest request) { 48 | userRepository 49 | .findByEmail(request.email()) 50 | .ifPresentOrElse(forgotPasswordMailSender::send, () -> 51 | log.warn("User {} not found while sending forgot password link", request) 52 | ); 53 | } 54 | } 55 | 56 | @Slf4j 57 | @Service 58 | @RequiredArgsConstructor 59 | class ForgotPasswordMailSender { 60 | 61 | public static final long FORGOT_PASSWORD_TOKEN_VALID_DAYS = 1; 62 | 63 | private final MailSender mailSender; 64 | private final MessageGetter messageGetter; 65 | private final JweService jweService; 66 | private final Clock clock; 67 | private final MyProperties properties; 68 | 69 | public void send(User user) { 70 | var token = createForgotPasswordToken(user); 71 | var mail = new MailData( 72 | user.getEmail(), 73 | messageGetter.getMessage("forgot-password-mail-subject"), 74 | messageGetter.getMessage("forgot-password-mail-body", 75 | user.getDisplayName(), properties.homepage(), token), 76 | null 77 | ); 78 | mailSender.send(mail); 79 | } 80 | 81 | private String createForgotPasswordToken(User user) { 82 | return jweService.createToken( 83 | user.getIdStr(), 84 | Date.from(clock.instant().plus(FORGOT_PASSWORD_TOKEN_VALID_DAYS, ChronoUnit.DAYS)), 85 | Map.of( 86 | PURPOSE, FORGOT_PASSWORD, 87 | EMAIL, user.getEmail() 88 | ) 89 | ); 90 | } 91 | } -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/forgot_password/ForgotPasswordRequest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.forgot_password; 2 | 3 | import com.naturalprogrammer.springmvc.user.domain.User; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import jakarta.validation.constraints.Email; 6 | import jakarta.validation.constraints.NotBlank; 7 | import jakarta.validation.constraints.Size; 8 | import lombok.extern.slf4j.Slf4j; 9 | 10 | import static com.naturalprogrammer.springmvc.common.CommonUtils.CONTENT_TYPE_PREFIX; 11 | import static org.apache.commons.lang3.StringUtils.trim; 12 | 13 | @Slf4j 14 | record ForgotPasswordRequest( 15 | 16 | @Email 17 | @NotBlank 18 | @Size(max = User.EMAIL_MAX) 19 | @Schema(example = "sanjay@example.com") 20 | String email 21 | ) { 22 | 23 | public ForgotPasswordRequest trimmed() { 24 | var trimmed = new ForgotPasswordRequest(trim(email)); 25 | log.info("Trimmed {} to {}", this, trimmed); 26 | return trimmed; 27 | } 28 | 29 | public static final String CONTENT_TYPE = CONTENT_TYPE_PREFIX + "forgot-password-request.v1+json"; 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/get/GetUserController.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.get; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.Problem; 4 | import com.naturalprogrammer.springmvc.user.services.UserResource; 5 | import io.swagger.v3.oas.annotations.Operation; 6 | import io.swagger.v3.oas.annotations.media.Content; 7 | import io.swagger.v3.oas.annotations.media.Schema; 8 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 9 | import io.swagger.v3.oas.annotations.responses.ApiResponses; 10 | import io.swagger.v3.oas.annotations.tags.Tag; 11 | import lombok.RequiredArgsConstructor; 12 | import org.springframework.http.MediaType; 13 | import org.springframework.http.ResponseEntity; 14 | import org.springframework.web.bind.annotation.GetMapping; 15 | import org.springframework.web.bind.annotation.PathVariable; 16 | import org.springframework.web.bind.annotation.RequestMapping; 17 | import org.springframework.web.bind.annotation.RestController; 18 | 19 | import java.util.UUID; 20 | 21 | import static com.naturalprogrammer.springmvc.common.CommonUtils.toResponse; 22 | import static com.naturalprogrammer.springmvc.common.Path.USERS; 23 | 24 | 25 | @RestController 26 | @RequiredArgsConstructor 27 | @RequestMapping(USERS) 28 | @Tag(name = "User", description = "User API") 29 | class GetUserController { 30 | 31 | private final UserGetter userGetter; 32 | 33 | @Operation(summary = "Get User") 34 | @ApiResponses(value = { 35 | @ApiResponse(responseCode = "200", description = "User", 36 | content = @Content( 37 | mediaType = UserResource.CONTENT_TYPE, 38 | schema = @Schema(implementation = UserResource.class)) 39 | ), 40 | @ApiResponse(responseCode = "404", description = "User not found or insufficient rights (must be self or admin)", 41 | content = @Content( 42 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 43 | schema = @Schema(implementation = Problem.class)) 44 | ) 45 | }) 46 | @GetMapping(value = "/{id}", produces = UserResource.CONTENT_TYPE) 47 | ResponseEntity getUser(@PathVariable UUID id) { 48 | return toResponse(userGetter.get(id), ResponseEntity::ok); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/get/UserGetter.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.get; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.Problem; 4 | import com.naturalprogrammer.springmvc.user.domain.User; 5 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 6 | import com.naturalprogrammer.springmvc.user.services.UserResource; 7 | import com.naturalprogrammer.springmvc.user.services.UserService; 8 | import io.jbock.util.Either; 9 | import lombok.RequiredArgsConstructor; 10 | import lombok.extern.slf4j.Slf4j; 11 | import org.springframework.stereotype.Service; 12 | 13 | import java.util.UUID; 14 | 15 | @Slf4j 16 | @Service 17 | @RequiredArgsConstructor 18 | class UserGetter { 19 | 20 | private final UserRepository userRepository; 21 | private final UserService userService; 22 | 23 | public Either get(UUID userId) { 24 | 25 | if (!userService.isSelfOrAdmin(userId)) { 26 | log.warn("User {} is not self or admin when trying get user", userId); 27 | return Either.left(userService.userNotFound(userId)); 28 | } 29 | 30 | return userRepository.findById(userId) 31 | .map(this::getUserResponse) 32 | .orElseGet(() -> { 33 | log.warn("User {} not found when trying get user", userId); 34 | return Either.left(userService.userNotFound(userId)); 35 | }); 36 | } 37 | 38 | private Either getUserResponse(User user) { 39 | var response = userService.toResource(user); 40 | log.info("Got {}", response); 41 | return Either.right(response); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/get_by_email/GetUserByEmailController.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.get_by_email; 2 | 3 | import com.naturalprogrammer.springmvc.user.services.UserResource; 4 | import io.swagger.v3.oas.annotations.Operation; 5 | import io.swagger.v3.oas.annotations.media.ArraySchema; 6 | import io.swagger.v3.oas.annotations.media.Content; 7 | import io.swagger.v3.oas.annotations.media.Schema; 8 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 9 | import io.swagger.v3.oas.annotations.responses.ApiResponses; 10 | import io.swagger.v3.oas.annotations.tags.Tag; 11 | import lombok.RequiredArgsConstructor; 12 | import org.springframework.web.bind.annotation.GetMapping; 13 | import org.springframework.web.bind.annotation.RequestMapping; 14 | import org.springframework.web.bind.annotation.RequestParam; 15 | import org.springframework.web.bind.annotation.RestController; 16 | 17 | import java.util.List; 18 | 19 | import static com.naturalprogrammer.springmvc.common.Path.USERS; 20 | 21 | 22 | @RestController 23 | @RequiredArgsConstructor 24 | @RequestMapping(USERS) 25 | @Tag(name = "User", description = "User API") 26 | class GetUserByEmailController { 27 | 28 | private final UsersGetter usersGetter; 29 | 30 | @Operation(summary = "Get users by email") 31 | @ApiResponses(value = { 32 | @ApiResponse(responseCode = "200", description = "Users", 33 | content = @Content( 34 | mediaType = UserResource.LIST_TYPE, 35 | array = @ArraySchema( 36 | schema = @Schema(implementation = UserResource.class) 37 | ) 38 | ) 39 | ) 40 | }) 41 | @GetMapping(produces = UserResource.LIST_TYPE) 42 | List getUsers(@RequestParam String email) { 43 | return usersGetter.getBy(email); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/get_by_email/UsersGetter.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.get_by_email; 2 | 3 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 4 | import com.naturalprogrammer.springmvc.user.services.UserResource; 5 | import com.naturalprogrammer.springmvc.user.services.UserService; 6 | import lombok.RequiredArgsConstructor; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.springframework.stereotype.Service; 9 | 10 | import java.util.List; 11 | 12 | @Slf4j 13 | @Service 14 | @RequiredArgsConstructor 15 | class UsersGetter { 16 | 17 | private final UserRepository userRepository; 18 | private final UserService userService; 19 | 20 | public List getBy(String email) { 21 | 22 | var users = userRepository 23 | .findByEmail(email) 24 | .stream() 25 | .map(userService::toResource) 26 | .toList(); 27 | 28 | log.info("Got users by email {}: {}", email, users); 29 | return users; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/login/AccessTokenCreator.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.login; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.Problem; 4 | import com.naturalprogrammer.springmvc.common.error.ProblemBuilder; 5 | import com.naturalprogrammer.springmvc.common.error.ProblemType; 6 | import com.naturalprogrammer.springmvc.user.services.UserService; 7 | import io.jbock.util.Either; 8 | import lombok.RequiredArgsConstructor; 9 | import org.springframework.beans.factory.ObjectFactory; 10 | import org.springframework.stereotype.Service; 11 | 12 | import java.time.Clock; 13 | import java.util.UUID; 14 | 15 | import static com.naturalprogrammer.springmvc.user.features.login.AuthTokenCreator.ACCESS_TOKEN_VALID_MILLIS; 16 | import static java.time.temporal.ChronoUnit.SECONDS; 17 | 18 | @Service 19 | @RequiredArgsConstructor 20 | class AccessTokenCreator { 21 | 22 | private final UserService userService; 23 | private final ObjectFactory problemBuilder; 24 | private final AuthTokenCreator authTokenCreator; 25 | private final Clock clock; 26 | 27 | public Either create(UUID userId) { 28 | if (userService.isSelfOrAdmin(userId)) { 29 | var accessTokenValidUntil = clock.instant().plusMillis(ACCESS_TOKEN_VALID_MILLIS + 1).truncatedTo(SECONDS); 30 | var accessToken = authTokenCreator.createAccessToken(userId.toString(), accessTokenValidUntil); 31 | return Either.right(new AccessTokenResource(accessToken, accessTokenValidUntil)); 32 | } 33 | return Either.left(problemBuilder.getObject().build(ProblemType.NOT_FOUND, "User %s not found".formatted(userId))); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/login/AccessTokenResource.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.login; 2 | 3 | import java.time.Instant; 4 | 5 | import static com.naturalprogrammer.springmvc.common.CommonUtils.CONTENT_TYPE_PREFIX; 6 | 7 | public record AccessTokenResource( 8 | String accessToken, 9 | Instant accessTokenValidUntil 10 | ) { 11 | 12 | @Override 13 | public String toString() { 14 | return "AuthTokensResource{" + 15 | ", accessTokenValidUntil='" + accessTokenValidUntil + '\'' + 16 | '}'; 17 | } 18 | 19 | public static final String CONTENT_TYPE = CONTENT_TYPE_PREFIX + "access-token.v1+json"; 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/login/AuthScope.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.login; 2 | 3 | import lombok.AccessLevel; 4 | import lombok.Getter; 5 | import lombok.RequiredArgsConstructor; 6 | 7 | @Getter 8 | @RequiredArgsConstructor(access = AccessLevel.PRIVATE) 9 | public enum AuthScope { 10 | 11 | NORMAL("normal"), 12 | AUTH_TOKENS("auth_tokens"), 13 | EXCHANGE_RESOURCE_TOKEN("exchange_resource_token"); 14 | 15 | private final String value; 16 | 17 | public final String scope() { 18 | return "SCOPE_" + value; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/login/AuthTokensResource.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.login; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | 5 | import java.time.Instant; 6 | 7 | import static com.naturalprogrammer.springmvc.common.CommonUtils.CONTENT_TYPE_PREFIX; 8 | 9 | public record AuthTokensResource( 10 | 11 | @Schema( 12 | title = "Resource-token for generating tokens next time (using GET /user-id/resource-token)", 13 | example = "eyJraWQiOiJlMDQ5OGRhZC00ZjVmLTQwY2YtODZlMy0yNzI2ZWM3ODQ2M2QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI4NmI0ODY0MS00NjU2LTQ1YmEtYWY4NS1lZjE0OGM3Mjg0ZjUiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjgwODAiLCJzY29wZSI6InJlc291cmNlX3Rva2VuIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwIiwiZXhwIjoxNjgwNDM1NjAwLCJpYXQiOjE2ODA0MDIxNzV9.agJFF6L3PjjY7r1lPfcPiBI8i-SrcfCvrJLqHu3_DnGklD-ayb2nzVUFM-9qXQ2n9X2u5qD_YBiMPQaYn2aemK-EuUr85YMdcmiipYQJxfjcJWXweBcVMyna6fGvZHP32Gz4ANYOJxu0vFXvYDpTIUuhTcW4pKhXJdjTF_JvHFEmIOzzmFTEW08JqoWi2Q702XzqKmz8Uk6F_zolxL-kRd4QsWfiB-a8C7y6k48ItqOvGJD20FQyd6bsRi7XqyXkvluvUPtzD87txKmJgaooVCdJschnnKcX-T-jsf6rFpv3NfYu5Uak0IIhyel7-LCUm9m81_kbSz2mUzID1em8wg" 14 | ) 15 | String resourceToken, 16 | 17 | @Schema( 18 | title = "Access-token for accessing the API", 19 | example = "eyJraWQiOiJlMDQ5OGRhZC00ZjVmLTQwY2YtODZlMy0yNzI2ZWM3ODQ2M2QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI4NmI0ODY0MS00NjU2LTQ1YmEtYWY4NS1lZjE0OGM3Mjg0ZjUiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjgwODAiLCJzY29wZSI6Im5vcm1hbCIsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImV4cCI6MTY4MDQwMzk3NSwiaWF0IjoxNjgwNDAyMTc1fQ.O35Wbzg_FYa7gr_H5YZL1c2oUfzOzsD9TiTLWhxnhiZPSovfVudyKUpRIbRtVKgx7fokIhd6vwNeetPTIpGYfaVCT9ytGuieeLvC38axdL8007cPLamrhWS1q-0ZuKRuqNEwimxINqoAp7VAL7UJm_s-8fhcYy34T9BQXbW-bvATwcQMm6YMGUGN8yuBnYkzKHloKt4Q6w_7__m1hyFg1240R2bYcmMl5D4pHjnRDd0R3KHvSgI0kc7YaQcImZEz3YOjKpTIdAw1dqKX5OEpYfzUwl66I6N2gZtq3PY8hmJJBjvx-tBnHXcsjYQ9-9T7gFw7iev3wi37vvlpNmshBA", description = "To generate new tokens, use GET /{user-id}/resource-token (must be same user or an Admin)" 20 | ) 21 | String accessToken, 22 | 23 | @Schema( 24 | title = "Till when the resource token is valid", 25 | example = "2029-12-29T12:13:56Z" 26 | ) 27 | Instant resourceTokenValidUntil, 28 | 29 | @Schema( 30 | title = "Till when the access token is valid", 31 | example = "2028-11-28T11:44:51Z" 32 | ) 33 | Instant accessTokenValidUntil 34 | ) { 35 | 36 | @Override 37 | public String toString() { 38 | return "AuthTokensResource{" + 39 | "resourceTokenValidUntil='" + resourceTokenValidUntil + '\'' + 40 | ", accessTokenValidUntil='" + accessTokenValidUntil + '\'' + 41 | '}'; 42 | } 43 | 44 | public static final String CONTENT_TYPE = CONTENT_TYPE_PREFIX + "auth-token.v1+json"; 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/login/LoginRequest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.login; 2 | 3 | import com.naturalprogrammer.springmvc.user.validators.ValidPassword; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import jakarta.validation.constraints.Email; 6 | import jakarta.validation.constraints.NotBlank; 7 | import lombok.extern.slf4j.Slf4j; 8 | 9 | import static com.naturalprogrammer.springmvc.common.CommonUtils.CONTENT_TYPE_PREFIX; 10 | import static com.naturalprogrammer.springmvc.user.features.login.AuthTokenCreator.RESOURCE_TOKEN_VALID_MILLIS_DESCR; 11 | import static com.naturalprogrammer.springmvc.user.validators.PasswordValidator.PASSWORD_DESCRIPTION; 12 | import static org.apache.commons.lang3.StringUtils.trim; 13 | 14 | @Slf4j 15 | record LoginRequest( 16 | 17 | @NotBlank 18 | @Email 19 | @Schema(example = "sanjay@example.com") 20 | String email, 21 | 22 | @ValidPassword 23 | @Schema(example = "Secret99!", description = PASSWORD_DESCRIPTION) 24 | String password, 25 | 26 | @Schema(example = "1209600000", description = RESOURCE_TOKEN_VALID_MILLIS_DESCR) 27 | Long resourceTokenValidForMillis 28 | 29 | ) { 30 | 31 | public static final String CONTENT_TYPE = CONTENT_TYPE_PREFIX + "login-request.v1+json"; 32 | 33 | LoginRequest trimmed() { 34 | var trimmed = new LoginRequest( 35 | trim(email), trim(password), resourceTokenValidForMillis 36 | ); 37 | log.info("Trimmed {} to {}", this, trimmed); 38 | return trimmed; 39 | } 40 | 41 | @Override 42 | public String toString() { 43 | return "LoginRequest{" + 44 | "email='" + email + '\'' + 45 | ", resourceTokenValidForMillis=" + resourceTokenValidForMillis + 46 | '}'; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/login/LoginService.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.login; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.BeanValidator; 4 | import com.naturalprogrammer.springmvc.common.error.Problem; 5 | import com.naturalprogrammer.springmvc.common.error.ProblemBuilder; 6 | import com.naturalprogrammer.springmvc.common.error.ProblemType; 7 | import com.naturalprogrammer.springmvc.user.domain.User; 8 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 9 | import io.jbock.util.Either; 10 | import lombok.RequiredArgsConstructor; 11 | import lombok.extern.slf4j.Slf4j; 12 | import org.springframework.beans.factory.ObjectFactory; 13 | import org.springframework.security.crypto.password.PasswordEncoder; 14 | import org.springframework.stereotype.Service; 15 | 16 | @Slf4j 17 | @Service 18 | @RequiredArgsConstructor 19 | public class LoginService { 20 | 21 | private final BeanValidator validator; 22 | private final UserRepository userRepository; 23 | private final PasswordEncoder passwordEncoder; 24 | private final ObjectFactory problemBuilder; 25 | private final AuthTokenCreator authTokenCreator; 26 | 27 | public Either login(LoginRequest request) { 28 | 29 | log.info("Creating AuthToken for {}", request); 30 | var trimmedRequest = request.trimmed(); 31 | return validator.validateAndGet(trimmedRequest, () -> loginValidated(trimmedRequest)); 32 | } 33 | 34 | private Either loginValidated(LoginRequest loginRequest) { 35 | 36 | return userRepository 37 | .findByEmail(loginRequest.email()) 38 | .map(user -> createResourceToken(user, loginRequest)) 39 | .orElseGet(() -> Either.left(problemBuilder.getObject().build(ProblemType.WRONG_CREDENTIALS, loginRequest.toString()))); 40 | } 41 | 42 | private Either createResourceToken(User user, LoginRequest loginRequest) { 43 | return passwordEncoder.matches(loginRequest.password(), user.getPassword()) 44 | ? Either.right(authTokenCreator.create(user.getIdStr(), loginRequest.resourceTokenValidForMillis())) 45 | : Either.left(problemBuilder.getObject().build(ProblemType.WRONG_CREDENTIALS, loginRequest.toString())); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/login/ResourceTokenExchangeRequest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.login; 2 | 3 | import jakarta.validation.constraints.NotBlank; 4 | 5 | import static com.naturalprogrammer.springmvc.common.CommonUtils.CONTENT_TYPE_PREFIX; 6 | 7 | record ResourceTokenExchangeRequest( 8 | 9 | @NotBlank 10 | String myClientId, 11 | 12 | Long resourceTokenValidForMillis 13 | ) { 14 | public static final String CONTENT_TYPE = CONTENT_TYPE_PREFIX + "resource-token-exchange-request.v1+json"; 15 | 16 | @Override 17 | public String toString() { 18 | return "ResourceTokenExchangeRequest{" + 19 | "resourceTokenValidForMillis=" + resourceTokenValidForMillis + 20 | '}'; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/login/SocialLoginController.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.login; 2 | 3 | import io.swagger.v3.oas.annotations.Operation; 4 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 5 | import io.swagger.v3.oas.annotations.responses.ApiResponses; 6 | import io.swagger.v3.oas.annotations.security.SecurityRequirements; 7 | import io.swagger.v3.oas.annotations.tags.Tag; 8 | import org.springframework.web.bind.annotation.GetMapping; 9 | import org.springframework.web.bind.annotation.RestController; 10 | 11 | /** 12 | * This is a dummy controller, just for OpenAPI documentation 13 | * The actual work happens in a filter provided by Spring 14 | */ 15 | @RestController 16 | @Tag(name = "User", description = "User API") 17 | public class SocialLoginController { 18 | 19 | @Operation(summary = "Google Login") 20 | @SecurityRequirements 21 | @ApiResponses(value = { 22 | @ApiResponse(responseCode = "302", description = "Redirect to Google login") 23 | }) 24 | @GetMapping("/oauth2/authorization/google") 25 | void googleLogin() { 26 | // This is a dummy controller, just for OpenAPI documentation 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/resend_verification/ResendVerificationController.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.resend_verification; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.Problem; 4 | import io.swagger.v3.oas.annotations.Operation; 5 | import io.swagger.v3.oas.annotations.media.Content; 6 | import io.swagger.v3.oas.annotations.media.Schema; 7 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 8 | import io.swagger.v3.oas.annotations.responses.ApiResponses; 9 | import io.swagger.v3.oas.annotations.tags.Tag; 10 | import lombok.RequiredArgsConstructor; 11 | import org.springframework.http.MediaType; 12 | import org.springframework.http.ResponseEntity; 13 | import org.springframework.web.bind.annotation.PathVariable; 14 | import org.springframework.web.bind.annotation.PostMapping; 15 | import org.springframework.web.bind.annotation.RequestMapping; 16 | import org.springframework.web.bind.annotation.RestController; 17 | 18 | import java.util.UUID; 19 | 20 | import static com.naturalprogrammer.springmvc.common.Path.USERS; 21 | 22 | 23 | @RestController 24 | @RequiredArgsConstructor 25 | @RequestMapping(USERS) 26 | @Tag(name = "User", description = "User API") 27 | class ResendVerificationController { 28 | 29 | private final VerificationMailReSender verificationMailReSender; 30 | 31 | @Operation(summary = "Resend verification mail") 32 | @ApiResponses(value = { 33 | @ApiResponse(responseCode = "204", description = "Verification mail resent"), 34 | @ApiResponse(responseCode = "404", description = "User not found or insufficient rights (must be self or admin)", 35 | content = @Content( 36 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 37 | schema = @Schema(implementation = Problem.class)) 38 | ), 39 | @ApiResponse(responseCode = "409", description = "User already verified", 40 | content = @Content( 41 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 42 | schema = @Schema(implementation = Problem.class)) 43 | ) 44 | }) 45 | @PostMapping(value = "/{id}/verifications") 46 | ResponseEntity resendVerification(@PathVariable UUID id) { 47 | 48 | return verificationMailReSender 49 | .resend(id) 50 | .map(Problem::toResponse) 51 | .orElseGet(() -> ResponseEntity.noContent().build()); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/resend_verification/VerificationMailReSender.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.resend_verification; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.Problem; 4 | import com.naturalprogrammer.springmvc.common.error.ProblemBuilder; 5 | import com.naturalprogrammer.springmvc.common.error.ProblemType; 6 | import com.naturalprogrammer.springmvc.user.domain.Role; 7 | import com.naturalprogrammer.springmvc.user.domain.User; 8 | import com.naturalprogrammer.springmvc.user.features.verification.VerificationMailSender; 9 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 10 | import com.naturalprogrammer.springmvc.user.services.UserService; 11 | import lombok.RequiredArgsConstructor; 12 | import lombok.extern.slf4j.Slf4j; 13 | import org.springframework.beans.factory.ObjectFactory; 14 | import org.springframework.stereotype.Service; 15 | 16 | import java.util.Optional; 17 | import java.util.UUID; 18 | 19 | @Slf4j 20 | @Service 21 | @RequiredArgsConstructor 22 | class VerificationMailReSender { 23 | 24 | private final UserService userService; 25 | private final UserRepository userRepository; 26 | private final VerificationMailSender verificationMailSender; 27 | private final ObjectFactory problemBuilder; 28 | 29 | public Optional resend(UUID userId) { 30 | 31 | if (!userService.isSelfOrAdmin(userId)) { 32 | log.warn("User {} is not self or admin when trying to create verification token", userId); 33 | return Optional.of(userService.userNotFound(userId)); 34 | } 35 | return userRepository 36 | .findById(userId) 37 | .map(this::resend) 38 | .orElseGet(() -> { 39 | log.warn("User {} not found when trying to create verification token", userId); 40 | return Optional.of(userService.userNotFound(userId)); 41 | }); 42 | } 43 | 44 | public Optional resend(User user) { 45 | 46 | if (user.hasRoles(Role.VERIFIED)) { 47 | log.warn("User {} already verified trying to create verification token", user); 48 | var problem = problemBuilder.getObject() 49 | .type(ProblemType.USER_ALREADY_VERIFIED) 50 | .detailMessage("given-user-already-verified", user.getId()) 51 | .build(); 52 | return Optional.of(problem); 53 | } 54 | verificationMailSender.send(user); 55 | return Optional.empty(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/reset_password/PasswordResetter.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.reset_password; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.BeanValidator; 4 | import com.naturalprogrammer.springmvc.common.error.Problem; 5 | import com.naturalprogrammer.springmvc.common.error.ProblemBuilder; 6 | import com.naturalprogrammer.springmvc.common.error.ProblemType; 7 | import com.naturalprogrammer.springmvc.common.jwt.JweService; 8 | import com.naturalprogrammer.springmvc.user.domain.User; 9 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 10 | import com.naturalprogrammer.springmvc.user.services.UserService; 11 | import com.nimbusds.jwt.JWTClaimsSet; 12 | import lombok.RequiredArgsConstructor; 13 | import lombok.SneakyThrows; 14 | import lombok.extern.slf4j.Slf4j; 15 | import org.springframework.beans.factory.ObjectFactory; 16 | import org.springframework.security.crypto.password.PasswordEncoder; 17 | import org.springframework.stereotype.Service; 18 | 19 | import java.time.Clock; 20 | import java.util.Optional; 21 | import java.util.UUID; 22 | 23 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.FORGOT_PASSWORD; 24 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.PURPOSE; 25 | import static org.apache.commons.lang3.ObjectUtils.notEqual; 26 | import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.EMAIL; 27 | 28 | @Slf4j 29 | @Service 30 | @RequiredArgsConstructor 31 | class PasswordResetter { 32 | 33 | private final BeanValidator validator; 34 | private final JweService jweService; 35 | private final ObjectFactory problemBuilder; 36 | private final UserRepository userRepository; 37 | private final UserService userService; 38 | private final PasswordEncoder passwordEncoder; 39 | private final Clock clock; 40 | 41 | public Optional reset(ResetPasswordRequest request) { 42 | 43 | log.info("Resetting password for {}", request); 44 | var trimmedRequest = request.trimmed(); 45 | return validator 46 | .validate(trimmedRequest) 47 | .or(() -> resetValidatedPassword(trimmedRequest)); 48 | } 49 | 50 | private Optional resetValidatedPassword(ResetPasswordRequest request) { 51 | 52 | return jweService 53 | .parseToken(request.token()) 54 | .fold(problemType -> { 55 | var problem = problemBuilder.getObject().build( 56 | problemType, 57 | request.toString()); 58 | return Optional.of(problem); 59 | }, claims -> resetPassword(request, claims)); 60 | } 61 | 62 | @SneakyThrows 63 | private Optional resetPassword(ResetPasswordRequest request, JWTClaimsSet claims) { 64 | 65 | if (notEqual(claims.getClaim(PURPOSE), FORGOT_PASSWORD.name())) { 66 | log.warn("Received token with invalid purpose while resetting password {}", claims); 67 | return Optional.of(problemBuilder.getObject().build(ProblemType.TOKEN_VERIFICATION_FAILED, request.toString())); 68 | } 69 | 70 | var userId = UUID.fromString(claims.getSubject()); 71 | var email = claims.getStringClaim(EMAIL); 72 | 73 | var possibleUser = userRepository.findById(userId); 74 | if (possibleUser.isEmpty()) 75 | return Optional.of(userService.userNotFound(userId)); 76 | return possibleUser.flatMap(user -> resetPassword(user, email, request.newPassword())); 77 | } 78 | 79 | private Optional resetPassword(User user, String expectedEmail, String newPassword) { 80 | 81 | if (notEqual(user.getEmail(), expectedEmail)) { 82 | log.warn("While resetting password, email already changed from {} to {}", expectedEmail, user); 83 | return Optional.of(userService.userNotFound(expectedEmail)); 84 | } 85 | user.setPassword(passwordEncoder.encode(newPassword)); 86 | user.resetTokensValidFrom(clock); 87 | userRepository.save(user); 88 | return Optional.empty(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/reset_password/ResetPasswordController.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.reset_password; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.Problem; 4 | import io.swagger.v3.oas.annotations.Operation; 5 | import io.swagger.v3.oas.annotations.media.Content; 6 | import io.swagger.v3.oas.annotations.media.Schema; 7 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 8 | import io.swagger.v3.oas.annotations.responses.ApiResponses; 9 | import io.swagger.v3.oas.annotations.tags.Tag; 10 | import lombok.RequiredArgsConstructor; 11 | import org.springframework.http.MediaType; 12 | import org.springframework.http.ResponseEntity; 13 | import org.springframework.web.bind.annotation.PostMapping; 14 | import org.springframework.web.bind.annotation.RequestBody; 15 | import org.springframework.web.bind.annotation.RestController; 16 | 17 | import static com.naturalprogrammer.springmvc.common.Path.RESET_PASSWORD; 18 | 19 | @RestController 20 | @RequiredArgsConstructor 21 | @Tag(name = "User", description = "User API") 22 | class ResetPasswordController { 23 | 24 | private final PasswordResetter passwordResetter; 25 | 26 | @Operation(summary = "Reset password") 27 | @ApiResponses(value = { 28 | @ApiResponse(responseCode = "204", description = "Forgot password link mailed"), 29 | @ApiResponse(responseCode = "422", description = "Token absent or too long", 30 | content = @Content( 31 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 32 | schema = @Schema(implementation = Problem.class)) 33 | ), 34 | @ApiResponse(responseCode = "403", description = "Invalid token", 35 | content = @Content( 36 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 37 | schema = @Schema(implementation = Problem.class)) 38 | ), 39 | @ApiResponse(responseCode = "404", description = "User not found. Got deleted?", 40 | content = @Content( 41 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 42 | schema = @Schema(implementation = Problem.class)) 43 | )}) 44 | @PostMapping(value = RESET_PASSWORD, consumes = ResetPasswordRequest.CONTENT_TYPE) 45 | ResponseEntity forgotPassword(@RequestBody ResetPasswordRequest request) { 46 | 47 | return passwordResetter 48 | .reset(request) 49 | .map(Problem::toResponse) 50 | .orElseGet(() -> ResponseEntity.noContent().build()); 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/reset_password/ResetPasswordRequest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.reset_password; 2 | 3 | import com.naturalprogrammer.springmvc.user.validators.ValidPassword; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import jakarta.validation.constraints.NotBlank; 6 | import jakarta.validation.constraints.Size; 7 | import lombok.extern.slf4j.Slf4j; 8 | 9 | import static com.naturalprogrammer.springmvc.common.CommonUtils.CONTENT_TYPE_PREFIX; 10 | import static com.naturalprogrammer.springmvc.user.validators.PasswordValidator.PASSWORD_DESCRIPTION; 11 | import static org.apache.commons.lang3.StringUtils.trim; 12 | 13 | @Slf4j 14 | record ResetPasswordRequest( 15 | 16 | @NotBlank 17 | @Size(max = 4096) 18 | @Schema(example = "JWE token") 19 | String token, 20 | 21 | @ValidPassword 22 | @Schema(example = "Secret99!", description = PASSWORD_DESCRIPTION) 23 | String newPassword 24 | 25 | ) { 26 | 27 | public ResetPasswordRequest trimmed() { 28 | var trimmed = new ResetPasswordRequest( 29 | trim(token), trim(newPassword) 30 | ); 31 | log.info("Trimmed {} to {}", this, trimmed); 32 | return trimmed; 33 | } 34 | 35 | @Override 36 | public String toString() { 37 | return "ResetPasswordRequest{" + 38 | "token='" + token + '\'' + 39 | '}'; 40 | } 41 | 42 | public static final String CONTENT_TYPE = CONTENT_TYPE_PREFIX + "reset-password-request.v1+json"; 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/signup/SignupController.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.signup; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.Problem; 4 | import com.naturalprogrammer.springmvc.user.services.UserResource; 5 | import io.swagger.v3.oas.annotations.Operation; 6 | import io.swagger.v3.oas.annotations.headers.Header; 7 | import io.swagger.v3.oas.annotations.media.Content; 8 | import io.swagger.v3.oas.annotations.media.Schema; 9 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 10 | import io.swagger.v3.oas.annotations.responses.ApiResponses; 11 | import io.swagger.v3.oas.annotations.security.SecurityRequirements; 12 | import io.swagger.v3.oas.annotations.tags.Tag; 13 | import lombok.RequiredArgsConstructor; 14 | import org.springframework.http.HttpStatus; 15 | import org.springframework.http.MediaType; 16 | import org.springframework.http.ResponseEntity; 17 | import org.springframework.web.bind.annotation.*; 18 | 19 | import java.util.Locale; 20 | 21 | import static com.naturalprogrammer.springmvc.common.CommonUtils.toResponse; 22 | import static com.naturalprogrammer.springmvc.common.Path.USERS; 23 | import static org.springframework.http.HttpHeaders.ACCEPT_LANGUAGE; 24 | import static org.springframework.http.HttpHeaders.LOCATION; 25 | 26 | 27 | @RestController 28 | @RequiredArgsConstructor 29 | @RequestMapping(USERS) 30 | @Tag(name = "User", description = "User API") 31 | class SignupController { 32 | 33 | private final SignupService signupService; 34 | 35 | @Operation(summary = "Signup") 36 | @SecurityRequirements 37 | @ApiResponses(value = { 38 | @ApiResponse(responseCode = "201", description = "Successfully signed up", 39 | headers = { 40 | @Header( 41 | name = LOCATION, 42 | description = "GET the user at this location", 43 | schema = @Schema(example = "/users/8fd1502e-759d-419f-aaac-e61478fc6406")) 44 | }, 45 | content = @Content( 46 | mediaType = UserResource.CONTENT_TYPE, 47 | schema = @Schema(implementation = UserResource.class)) 48 | ), 49 | @ApiResponse(responseCode = "422", description = "Invalid input", 50 | content = @Content( 51 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 52 | schema = @Schema(implementation = Problem.class)) 53 | ), 54 | @ApiResponse(responseCode = "409", description = "Email already used", 55 | content = @Content( 56 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 57 | schema = @Schema(implementation = Problem.class)) 58 | ) 59 | }) 60 | @PostMapping(consumes = SignupRequest.CONTENT_TYPE, produces = UserResource.CONTENT_TYPE) 61 | public ResponseEntity signup( 62 | @RequestBody SignupRequest request, 63 | @Schema(example = "en-IN") 64 | @RequestHeader(name = ACCEPT_LANGUAGE, required = false) String language) { 65 | 66 | Locale locale = language == null ? Locale.forLanguageTag("en-IN") : Locale.forLanguageTag(language); 67 | return toResponse( 68 | signupService.signup(request, locale), 69 | this::toUserResponse 70 | ); 71 | } 72 | 73 | private ResponseEntity toUserResponse(UserResource userResource) { 74 | return ResponseEntity 75 | .status(HttpStatus.CREATED) 76 | .header(LOCATION, USERS + "/" + userResource.id()) 77 | .body(userResource); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/signup/SignupRequest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.signup; 2 | 3 | import com.naturalprogrammer.springmvc.user.domain.User; 4 | import com.naturalprogrammer.springmvc.user.validators.ValidPassword; 5 | import io.swagger.v3.oas.annotations.media.Schema; 6 | import jakarta.validation.constraints.Email; 7 | import jakarta.validation.constraints.NotBlank; 8 | import jakarta.validation.constraints.Size; 9 | import lombok.extern.slf4j.Slf4j; 10 | 11 | import static com.naturalprogrammer.springmvc.common.CommonUtils.CONTENT_TYPE_PREFIX; 12 | import static com.naturalprogrammer.springmvc.user.features.login.AuthTokenCreator.RESOURCE_TOKEN_VALID_MILLIS_DESCR; 13 | import static com.naturalprogrammer.springmvc.user.validators.PasswordValidator.PASSWORD_DESCRIPTION; 14 | import static org.apache.commons.lang3.StringUtils.trim; 15 | 16 | @Slf4j 17 | public record SignupRequest( 18 | 19 | @Email 20 | @NotBlank 21 | @Size(max = User.EMAIL_MAX) 22 | @Schema(example = "sanjay@example.com") 23 | String email, 24 | 25 | @ValidPassword 26 | @Schema(example = "Secret99!", description = PASSWORD_DESCRIPTION) 27 | String password, 28 | 29 | @NotBlank 30 | @Size(min = User.NAME_MIN, max = User.NAME_MAX) 31 | @Schema(example = "Sanjay Patel") 32 | String displayName, 33 | 34 | @Schema(example = "1209600000", description = RESOURCE_TOKEN_VALID_MILLIS_DESCR) 35 | Long resourceTokenValidForMillis 36 | ) { 37 | 38 | public SignupRequest trimmed() { 39 | var trimmed = new SignupRequest( 40 | trim(email), trim(password), trim(displayName), resourceTokenValidForMillis 41 | ); 42 | log.info("Trimmed {} to {}", this, trimmed); 43 | return trimmed; 44 | } 45 | 46 | public static final String CONTENT_TYPE = CONTENT_TYPE_PREFIX + "signup-request.v1+json"; 47 | 48 | @Override 49 | public String toString() { 50 | return "SignupRequest{" + 51 | "email='" + email + '\'' + 52 | ", displayName='" + displayName + '\'' + 53 | ", resourceTokenValidForMillis=" + resourceTokenValidForMillis + 54 | '}'; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/signup/SignupService.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.signup; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.*; 4 | import com.naturalprogrammer.springmvc.user.domain.Role; 5 | import com.naturalprogrammer.springmvc.user.features.login.AuthTokenCreator; 6 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 7 | import com.naturalprogrammer.springmvc.user.services.UserResource; 8 | import com.naturalprogrammer.springmvc.user.services.UserService; 9 | import io.jbock.util.Either; 10 | import lombok.RequiredArgsConstructor; 11 | import lombok.extern.slf4j.Slf4j; 12 | import org.springframework.beans.factory.ObjectFactory; 13 | import org.springframework.stereotype.Service; 14 | 15 | import java.util.Locale; 16 | 17 | @Slf4j 18 | @Service 19 | @RequiredArgsConstructor 20 | class SignupService { 21 | 22 | private final BeanValidator validator; 23 | private final ObjectFactory problemBuilder; 24 | private final UserRepository userRepository; 25 | private final UserService userService; 26 | private final AuthTokenCreator authTokenCreator; 27 | 28 | public Either signup(SignupRequest request, Locale locale) { 29 | log.info("Signing up {} with locale {}", request, locale); 30 | var trimmedRequest = request.trimmed(); 31 | return validator.validateAndGet(trimmedRequest, () -> signupValidated(trimmedRequest, locale)); 32 | } 33 | 34 | private Either signupValidated(SignupRequest request, Locale locale) { 35 | 36 | if (userRepository.existsByEmail(request.email())) { 37 | var problem = problemBuilder.getObject() 38 | .type(ProblemType.USED_EMAIL) 39 | .detail(request.toString()) 40 | .error("email", ErrorCode.USED_EMAIL) 41 | .build(); 42 | 43 | return Either.left(problem); 44 | } 45 | 46 | var user = userService.createUser(request, locale, Role.UNVERIFIED); 47 | var token = authTokenCreator.create( 48 | user.getIdStr(), 49 | request.resourceTokenValidForMillis() 50 | ); 51 | UserResource resource = userService.toResource(user, token); 52 | log.info("Signed up {}. Returning {}", user, resource); 53 | return Either.right(resource); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/verification/UserVerificationController.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.verification; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.Problem; 4 | import com.naturalprogrammer.springmvc.user.services.UserResource; 5 | import io.swagger.v3.oas.annotations.Operation; 6 | import io.swagger.v3.oas.annotations.media.Content; 7 | import io.swagger.v3.oas.annotations.media.Schema; 8 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 9 | import io.swagger.v3.oas.annotations.responses.ApiResponses; 10 | import io.swagger.v3.oas.annotations.tags.Tag; 11 | import lombok.RequiredArgsConstructor; 12 | import org.springframework.http.MediaType; 13 | import org.springframework.http.ResponseEntity; 14 | import org.springframework.web.bind.annotation.*; 15 | 16 | import java.util.UUID; 17 | 18 | import static com.naturalprogrammer.springmvc.common.CommonUtils.toResponse; 19 | import static com.naturalprogrammer.springmvc.common.Path.USERS; 20 | 21 | 22 | @RestController 23 | @RequiredArgsConstructor 24 | @RequestMapping(USERS) 25 | @Tag(name = "User", description = "User API") 26 | class UserVerificationController { 27 | 28 | private final UserVerifier userVerifier; 29 | 30 | @Operation(summary = "Verify email") 31 | @ApiResponses(value = { 32 | @ApiResponse(responseCode = "200", description = "Email verified", 33 | content = @Content( 34 | mediaType = UserResource.CONTENT_TYPE, 35 | schema = @Schema(implementation = UserResource.class)) 36 | ), 37 | @ApiResponse(responseCode = "422", description = "Invalid input", 38 | content = @Content( 39 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 40 | schema = @Schema(implementation = Problem.class)) 41 | ), 42 | @ApiResponse(responseCode = "404", description = "User not found or insufficient rights (must be self or admin)", 43 | content = @Content( 44 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 45 | schema = @Schema(implementation = Problem.class)) 46 | ), 47 | @ApiResponse(responseCode = "403", description = "Token verification failed", 48 | content = @Content( 49 | mediaType = MediaType.APPLICATION_PROBLEM_JSON_VALUE, 50 | schema = @Schema(implementation = Problem.class)) 51 | ), 52 | }) 53 | @PutMapping(value = "/{id}/verifications", 54 | consumes = UserVerificationRequest.CONTENT_TYPE, 55 | produces = UserResource.CONTENT_TYPE) 56 | ResponseEntity verifyEmail( 57 | @PathVariable UUID id, 58 | @RequestBody UserVerificationRequest request 59 | ) { 60 | return toResponse(userVerifier.verify(id, request), ResponseEntity::ok); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/verification/UserVerificationRequest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.verification; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import jakarta.validation.constraints.NotBlank; 5 | import jakarta.validation.constraints.Size; 6 | import lombok.extern.slf4j.Slf4j; 7 | 8 | import static com.naturalprogrammer.springmvc.common.CommonUtils.CONTENT_TYPE_PREFIX; 9 | 10 | @Slf4j 11 | record UserVerificationRequest( 12 | @NotBlank 13 | @Size(max = 5000) 14 | @Schema(example = "{token received via email}") 15 | String emailVerificationToken 16 | ) { 17 | 18 | public static final String CONTENT_TYPE = CONTENT_TYPE_PREFIX + "user-verification-request.v1+json"; 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/verification/UserVerifier.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.verification; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.*; 4 | import com.naturalprogrammer.springmvc.common.jwt.JweService; 5 | import com.naturalprogrammer.springmvc.user.domain.Role; 6 | import com.naturalprogrammer.springmvc.user.domain.User; 7 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 8 | import com.naturalprogrammer.springmvc.user.services.UserResource; 9 | import com.naturalprogrammer.springmvc.user.services.UserService; 10 | import com.nimbusds.jwt.JWTClaimsSet; 11 | import io.jbock.util.Either; 12 | import lombok.RequiredArgsConstructor; 13 | import lombok.extern.slf4j.Slf4j; 14 | import org.springframework.beans.factory.ObjectFactory; 15 | import org.springframework.stereotype.Service; 16 | 17 | import java.util.UUID; 18 | 19 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.EMAIL_VERIFICATION; 20 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.PURPOSE; 21 | import static org.apache.commons.lang3.ObjectUtils.notEqual; 22 | import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.EMAIL; 23 | 24 | @Slf4j 25 | @Service 26 | @RequiredArgsConstructor 27 | class UserVerifier { 28 | 29 | private final BeanValidator validator; 30 | private final ValidatedUserVerifier validatedUserVerifier; 31 | 32 | public Either verify(UUID userId, UserVerificationRequest request) { 33 | log.info("Verifying user {}: {}", userId, request); 34 | return validator.validateAndGet(request, () -> validatedUserVerifier.verify(userId, request)); 35 | } 36 | } 37 | 38 | @Slf4j 39 | @Service 40 | @RequiredArgsConstructor 41 | class ValidatedUserVerifier { 42 | 43 | private final ObjectFactory problemBuilder; 44 | private final UserRepository userRepository; 45 | private final UserService userService; 46 | private final JweService jweService; 47 | 48 | public Either verify(UUID userId, UserVerificationRequest request) { 49 | 50 | if (!userService.isSelfOrAdmin(userId)) { 51 | log.warn("User {} is not self or admin when trying to verify email with {}", userId, request); 52 | return Either.left(userService.userNotFound(userId)); 53 | } 54 | 55 | return userRepository.findById(userId) 56 | .map(user -> verify(user, request)) 57 | .orElseGet(() -> { 58 | log.warn("User {} not found when trying to verify email with {}", userId, request); 59 | return Either.left(userService.userNotFound(userId)); 60 | }); 61 | } 62 | 63 | private Either verify(User user, UserVerificationRequest request) { 64 | 65 | return jweService 66 | .parseToken(request.emailVerificationToken()) 67 | .mapLeft(problemType -> problemBuilder.getObject() 68 | .type(problemType) 69 | .detail(request.emailVerificationToken()) 70 | .error("emailVerificationToken", ErrorCode.TOKEN_VERIFICATION_FAILED) 71 | .build()) 72 | .flatMap(claims -> verify(user, claims)); 73 | } 74 | 75 | private Either verify(User user, JWTClaimsSet claims) { 76 | 77 | if (notEqual(claims.getSubject(), user.getIdStr()) || 78 | notEqual(claims.getClaim(PURPOSE), EMAIL_VERIFICATION.name()) || 79 | notEqual(claims.getClaim(EMAIL), user.getEmail())) 80 | return Either.left(problemBuilder.getObject().build(ProblemType.TOKEN_VERIFICATION_FAILED, user.toString())); 81 | 82 | user.getRoles().remove(Role.UNVERIFIED); 83 | user.getRoles().add(Role.VERIFIED); 84 | userRepository.save(user); 85 | return Either.right(userService.toResource(user)); 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/features/verification/VerificationMailSender.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.verification; 2 | 3 | import com.naturalprogrammer.springmvc.common.MessageGetter; 4 | import com.naturalprogrammer.springmvc.common.jwt.JweService; 5 | import com.naturalprogrammer.springmvc.common.mail.MailData; 6 | import com.naturalprogrammer.springmvc.common.mail.MailSender; 7 | import com.naturalprogrammer.springmvc.config.MyProperties; 8 | import com.naturalprogrammer.springmvc.user.domain.User; 9 | import lombok.RequiredArgsConstructor; 10 | import org.springframework.stereotype.Component; 11 | 12 | import java.time.Clock; 13 | import java.time.temporal.ChronoUnit; 14 | import java.util.Date; 15 | import java.util.Map; 16 | 17 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.EMAIL_VERIFICATION; 18 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.PURPOSE; 19 | import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.EMAIL; 20 | 21 | @Component 22 | @RequiredArgsConstructor 23 | public class VerificationMailSender { 24 | 25 | public static final long VERIFICATION_TOKEN_VALID_DAYS = 1; 26 | 27 | private final JweService jweService; 28 | private final Clock clock; 29 | private final MessageGetter messageGetter; 30 | private final MailSender mailSender; 31 | private final MyProperties properties; 32 | 33 | public void send(User user) { 34 | var verificationToken = createVerificationToken(user); 35 | var mail = new MailData( 36 | user.getEmail(), 37 | messageGetter.getMessage("verification-mail-subject"), 38 | messageGetter.getMessage("verification-mail-body", 39 | user.getDisplayName(), properties.homepage(), verificationToken), 40 | null 41 | ); 42 | mailSender.send(mail); 43 | } 44 | 45 | private String createVerificationToken(User user) { 46 | return jweService.createToken( 47 | user.getIdStr(), 48 | Date.from(clock.instant().plus(VERIFICATION_TOKEN_VALID_DAYS, ChronoUnit.DAYS)), 49 | Map.of( 50 | PURPOSE, EMAIL_VERIFICATION, 51 | EMAIL, user.getEmail() 52 | ) 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/repositories/UserRepository.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.repositories; 2 | 3 | import com.naturalprogrammer.springmvc.user.domain.User; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | import java.util.Optional; 7 | import java.util.UUID; 8 | 9 | public interface UserRepository extends JpaRepository { 10 | 11 | boolean existsByEmail(String email); 12 | 13 | Optional findByEmail(String email); 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/services/UserResource.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.services; 2 | 3 | import com.naturalprogrammer.springmvc.user.domain.Role; 4 | import com.naturalprogrammer.springmvc.user.features.login.AuthTokensResource; 5 | import io.swagger.v3.oas.annotations.media.Schema; 6 | 7 | import java.util.Set; 8 | import java.util.UUID; 9 | 10 | import static com.naturalprogrammer.springmvc.common.CommonUtils.CONTENT_TYPE_PREFIX; 11 | 12 | public record UserResource( 13 | 14 | @Schema(example = "8fd1502e-759d-419f-aaac-e61478fc6406") 15 | UUID id, 16 | 17 | @Schema(example = "sanjay@example.com") 18 | String email, 19 | 20 | @Schema(example = "Sanjay Patel") 21 | String displayName, 22 | 23 | @Schema(example = "en-IN") 24 | String locale, 25 | 26 | @Schema(example = "['USER', 'UNVERIFIED]") 27 | Set roles, 28 | 29 | @Schema(title = "Access and resource tokens for accessing the API. Optional") 30 | AuthTokensResource authTokens 31 | ) { 32 | public static final String CONTENT_TYPE = CONTENT_TYPE_PREFIX + "user.v1+json"; 33 | public static final String LIST_TYPE = CONTENT_TYPE_PREFIX + "users.v1+json"; 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/validators/PasswordValidator.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.validators; 2 | 3 | import com.naturalprogrammer.springmvc.user.domain.User; 4 | import jakarta.validation.ConstraintValidator; 5 | import jakarta.validation.ConstraintValidatorContext; 6 | 7 | import java.util.regex.Pattern; 8 | 9 | public class PasswordValidator implements ConstraintValidator { 10 | 11 | // At least 1 upper, lower, special characters and digit, min 8 chars, max 50 chars 12 | public static final String PASSWORD_REGEX = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&-+=()!])(?=\\S+$).{" 13 | + User.PASSWORD_MIN + "," + User.PASSWORD_MAX + "}$"; 14 | public static final String PASSWORD_DESCRIPTION = "Password must have least 1 upper, lower, special characters and digit, min 8 chars, max 50 chars"; 15 | 16 | private static final Pattern PASSWORD_PATTERN = Pattern.compile(PASSWORD_REGEX); 17 | 18 | @Override 19 | public boolean isValid(String password, ConstraintValidatorContext constraintValidatorContext) { 20 | return password != null && PASSWORD_PATTERN.matcher(password).matches(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/naturalprogrammer/springmvc/user/validators/ValidPassword.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.validators; 2 | 3 | import jakarta.validation.Constraint; 4 | 5 | import java.lang.annotation.Retention; 6 | import java.lang.annotation.RetentionPolicy; 7 | 8 | @Retention(RetentionPolicy.RUNTIME) 9 | @Constraint(validatedBy = PasswordValidator.class) 10 | public @interface ValidPassword { 11 | 12 | String message() default "{com.naturalprogrammer.spring.invalid.password}"; 13 | 14 | Class[] groups() default {}; 15 | 16 | Class[] payload() default {}; 17 | } 18 | -------------------------------------------------------------------------------- /src/main/resources/ValidationMessages.properties: -------------------------------------------------------------------------------- 1 | com.naturalprogrammer.spring.invalid.password=Password must have least 1 upper, lower, special characters and digit, min 8 chars, max 50 chars -------------------------------------------------------------------------------- /src/main/resources/ValidationMessages_or.properties: -------------------------------------------------------------------------------- 1 | jakarta.validation.constraints.NotBlank.message=ଖାଲି ହେବା ଉଚିତ୍ ନୁହେଁ -------------------------------------------------------------------------------- /src/main/resources/config/application-azure-live.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | url: jdbc:postgresql://svr-postgres-common.postgres.database.azure.com:5432/np-spring-mvc-demo?serverTimezone=UTC 4 | username: admin1 5 | kafka: 6 | bootstrap-servers: localhost:9092 7 | security: 8 | protocol: PLAINTEXT 9 | 10 | security: 11 | oauth2: 12 | client: 13 | registration: 14 | google: 15 | client-id: 1011974249454-6gq0hr01gqh3cndoqnss5r69tkk2nd84.apps.googleusercontent.com 16 | client-secret: saDA6Cj60wipncFM-hzBD-C6 17 | 18 | logging.level: 19 | org.apache.kafka: OFF 20 | 21 | my: 22 | homepage: https://np-spring-mvc-demo.azurewebsites.net 23 | oauth2-authentication-success-url: https://np-spring-mvc-demo.azurewebsites.net?userId=%s&resourceToken=%s 24 | jws: 25 | id: e0498dad-4f5f-40cf-86e3-2726ec78463d 26 | public-key: classpath:/config/rsa-2048-public-key.txt 27 | private-key: classpath:/config/rsa-2048-private-key.txt 28 | jwe: 29 | id: c00f9459-82cb-48bc-882d-66b3b65258b4 30 | key: 841D8A6C80CBA4FCAD32D5367C18C53B 31 | -------------------------------------------------------------------------------- /src/main/resources/config/application-azure-staging.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | url: jdbc:postgresql://svr-postgres-common.postgres.database.azure.com:5432/np-spring-mvc-demo-staging?serverTimezone=UTC 4 | username: admin1 5 | kafka: 6 | bootstrap-servers: localhost:9092 7 | security: 8 | protocol: PLAINTEXT 9 | 10 | security: 11 | oauth2: 12 | client: 13 | registration: 14 | google: 15 | client-id: 1011974249454-6gq0hr01gqh3cndoqnss5r69tkk2nd84.apps.googleusercontent.com 16 | client-secret: saDA6Cj60wipncFM-hzBD-C6 17 | 18 | logging.level: 19 | org.apache.kafka: OFF 20 | 21 | my: 22 | homepage: https://staging-np-spring-mvc-demo.azurewebsites.net 23 | oauth2-authentication-success-url: https://staging-np-spring-mvc-demo.azurewebsites.net?userId=%s&resourceToken=%s 24 | jws: 25 | id: e0498dad-4f5f-40cf-86e3-2726ec78463d 26 | public-key: classpath:/config/rsa-2048-public-key.txt 27 | private-key: classpath:/config/rsa-2048-private-key.txt 28 | jwe: 29 | id: c00f9459-82cb-48bc-882d-66b3b65258b4 30 | key: 841D8A6C80CBA4FCAD32D5367C18C53B 31 | -------------------------------------------------------------------------------- /src/main/resources/config/application-default.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | url: jdbc:postgresql://localhost:5432/np-spring-mvc-demo 4 | password: password 5 | kafka: 6 | bootstrap-servers: localhost:9092 7 | security: 8 | protocol: PLAINTEXT 9 | 10 | security: 11 | oauth2: 12 | client: 13 | registration: 14 | google: 15 | client-id: 1011974249454-6gq0hr01gqh3cndoqnss5r69tkk2nd84.apps.googleusercontent.com 16 | client-secret: saDA6Cj60wipncFM-hzBD-C6 17 | 18 | logging.level: 19 | org.apache.kafka: OFF 20 | 21 | my: 22 | homepage: http://localhost:8080 23 | oauth2-authentication-success-url: http://localhost:8080?userId=%s&resourceToken=%s 24 | jws: 25 | id: e0498dad-4f5f-40cf-86e3-2726ec78463d 26 | public-key: classpath:/config/rsa-2048-public-key.txt 27 | private-key: classpath:/config/rsa-2048-private-key.txt 28 | jwe: 29 | id: c00f9459-82cb-48bc-882d-66b3b65258b4 30 | key: 841D8A6C80CBA4FCAD32D5367C18C53B 31 | -------------------------------------------------------------------------------- /src/main/resources/config/application-digitalocean-staging.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | url: jdbc:postgresql://db-postgresql-nyc3-93635-do-user-1523363-0.c.db.ondigitalocean.com:25060/np-spring-mvc-demo-staging?serverTimezone=UTC&sslmode=require 4 | username: np-spring-mvc-demo-staging 5 | kafka: 6 | bootstrap-servers: localhost:9092 7 | security: 8 | protocol: PLAINTEXT 9 | 10 | security: 11 | oauth2: 12 | client: 13 | registration: 14 | google: 15 | client-id: 1011974249454-6gq0hr01gqh3cndoqnss5r69tkk2nd84.apps.googleusercontent.com 16 | 17 | logging.level: 18 | org.apache.kafka: OFF 19 | 20 | my: 21 | homepage: https://staging-spring-mvc-demo.naturalprogrammer.com 22 | oauth2-authentication-success-url: https://staging-spring-mvc-demo.naturalprogrammer.com?userId=%s&resourceToken=%s 23 | jws: 24 | id: e0498dad-4f5f-40cf-86e3-2726ec78463d 25 | jwe: 26 | id: c00f9459-82cb-48bc-882d-66b3b65258b4 27 | -------------------------------------------------------------------------------- /src/main/resources/config/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | url: ~ 4 | username: np-spring-mvc-demo 5 | password: ~ 6 | application: 7 | name: np-spring-mvc-demo 8 | jackson: 9 | default-property-inclusion: NON_ABSENT 10 | deserialization: 11 | accept-single-value-as-array: true 12 | 13 | jpa: 14 | database-platform: org.hibernate.dialect.PostgreSQLDialect 15 | open-in-view: false 16 | hibernate: 17 | use-new-id-generator-mappings: false 18 | ddl-auto: validate 19 | 20 | security: 21 | strategy: MODE_INHERITABLETHREADLOCAL 22 | oauth2: 23 | client: 24 | registration: 25 | google: 26 | client-id: ~ 27 | client-secret: ~ 28 | 29 | mail: 30 | port: ~ 31 | username: ~ 32 | password: ~ 33 | properties: 34 | mail: 35 | smtp: 36 | auth: true 37 | starttls: 38 | enable: true 39 | 40 | kafka: 41 | bootstrap-servers: ~ 42 | security: 43 | protocol: ~ 44 | producer: 45 | key-serializer: org.apache.kafka.common.serialization.StringSerializer 46 | value-serializer: org.apache.kafka.common.serialization.StringSerializer 47 | # https://stackoverflow.com/questions/60570238/is-a-write-to-a-kafka-topic-only-successful-if-write-on-each-partitions-replica 48 | acks: all 49 | retries: 2147483647 50 | properties: 51 | max.block.ms: 9223372036854775807 52 | max.in.flight.requests.per.connection: 1 53 | consumer: 54 | group-id: ${spring.application.name} 55 | key-deserializer: org.apache.kafka.common.serialization.StringDeserializer 56 | value-deserializer: org.apache.kafka.common.serialization.StringDeserializer 57 | enable-auto-commit: false 58 | listener: 59 | ack-mode: manual_immediate 60 | 61 | management: 62 | metrics: 63 | tags: 64 | application: ${spring.application.name} 65 | endpoints: 66 | jmx.exposure.exclude: "*" 67 | web.exposure.include: health, info, beans, metrics, threaddump, prometheus 68 | 69 | my: 70 | homepage: ~ 71 | oauth2-authentication-success-url: ~ 72 | jws: 73 | # a UUID generated independently 74 | id: ~ 75 | # Generated using https://www.javainuse.com/rsagenerator (Key size 2048) 76 | public-key: ~ 77 | private-key: ~ 78 | jwe: 79 | # a UUID generated independently 80 | id: ~ 81 | # An aes-128-cbc key generated at https://asecuritysite.com/encryption/keygen (take the "key" field) 82 | key: ~ 83 | -------------------------------------------------------------------------------- /src/main/resources/config/rsa-2048-private-key.txt: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCW1DbX40SzsG8hJbuHXRMSt2EE0VHkOB199/S1gOMKdY05pgb+JOl1CSb2hXtmtedtAUuyxz1x+IOMOt/5VNUM4ATW26dJprWsz1seoh/UldOie3CG3ZwAEZRCCSnf6RY1j7U5K7oRJIKRQgutpxPzlxwV4K0DbTAaseExjZs4MjiYmLgd7syvgDmxHV4lBXgYlkkEsgf0f/ocMOXtrM0HGizPuFDk3LbBKSieyx1OGHIrzgwSgSmjl1jb8gcmM3r5fgH4IjszJYtZakYhrRHfvNXksO1t4+YFx8Vl8WO/YcMUTHZVvkSQFqNDzxe9FdxYqETzAjz0eOyTTSGZnOo1AgMBAAECggEAKFZS4IPYYNIDtnK352i97Bh86uPsKcPUJ1dD67KvhaGQhmVfo2JNyU4MTIvAR+TIIr/g9cwRI8TZsYwhUDYe0FWtFaUi5TCfj7rY3KVxK9JyChdHLdpgmSgaZVq8BzT4CpUHW2XVWjZQcPaf1u5DCLdV/Ifc3Xi7D7iYyD7dzzY8pr7GXtpp5ZPngWWrFtO7c9iugzuZmx9q/77ig+eeUkq0/GBtwGKQhns91dxUx32xIuB2I/hhgBPsGbP2ooO4/qHUHUX7tdj2qRpQ2YfE2tDX7FehNGH2t+Mtz+9ylSsxzlULXlx/EluD6RGn1xuPg21Yb0BiuCalISHxvQWY4QKBgQDncUV8tonnpnYcG2cLgm7wzK4Sg7whSI5buYolzq4tO1LAqpERvr0JUw5FH71lFvOsk6eKtsYhhYwlpYPK3iECtEvEO4Sx8sLnOD+4R4sJ71xWEDKbtWWH3Crhyl1xtoSZjM1+roGbFhGFQVKqDRcnjmX7h200kQqyRtouT6fkXQKBgQCm1TfnB74J+V7Bnu0S313Hq+VfxF2ir0JQtUPKb5402hettd0l/EXYJu7nvsM/qOc2fZ37gJavXUgehXaDHSPI/iA4lFDeieM9Z4JdxXEWfh5LQp1oUOe4PXqCyFsxhJ/F/v15gnX/vfVtKngXgC78FRK3ioF5yCYC6nECdw4/uQKBgDy/nI+ZkiT9qm7COo/o0pnd/6rYbR8HXmZxEvPNhiZVNelgW+eeexvhcxNtu1a3aYpYz1c3llXiKeEPysIK//snu+NsA+55W/M47nC+Rp5692+XnNEGEfpRLehKJ7DbSX7MWHvx7g75AexKMxpziSpW1CnraByuHXKh6k7Rla35AoGAJaMajntXqyZf8yxobYaIwKAOCjHhse759f0+wu+1cFT3XJAXyRcFpsL5yLxnjfgL2WYzeubCl+Nifsg7OU8coJ58JGeJavsOke7FIWSGo71mFjJ8EmmWLSFpfxG2SRCTfvaMtpnxBvYS5ULcfujDMMmMRG3x0ciOkr/TKOw1AbkCgYEAl/sdlaY1n+pdt0/3NNAdRA592ZX0oAMC56vvrlEfC98rQoWeupwMKxPeKzb4eTJPvvGFWhsLz3LuQkzahPnk7d3xqgWw9RlxFweG9qHNEvlArE3JaDtRyU4eATDB/DIT8IrNw+8vEFjMx6vdwH42yk2wDYcEEVImJYDBIOIWVZY= 3 | -----END PRIVATE KEY----- -------------------------------------------------------------------------------- /src/main/resources/config/rsa-2048-public-key.txt: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAltQ21+NEs7BvISW7h10TErdhBNFR5Dgdfff0tYDjCnWNOaYG/iTpdQkm9oV7ZrXnbQFLssc9cfiDjDrf+VTVDOAE1tunSaa1rM9bHqIf1JXTontwht2cABGUQgkp3+kWNY+1OSu6ESSCkUILracT85ccFeCtA20wGrHhMY2bODI4mJi4He7Mr4A5sR1eJQV4GJZJBLIH9H/6HDDl7azNBxosz7hQ5Ny2wSkonssdThhyK84MEoEpo5dY2/IHJjN6+X4B+CI7MyWLWWpGIa0R37zV5LDtbePmBcfFZfFjv2HDFEx2Vb5EkBajQ88XvRXcWKhE8wI89Hjsk00hmZzqNQIDAQAB 3 | -----END PUBLIC KEY----- -------------------------------------------------------------------------------- /src/main/resources/db/migration/V2023.01.16.13.37__user-table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS usr 2 | ( 3 | id UUID PRIMARY KEY, 4 | 5 | created_by TEXT, 6 | created_at TIMESTAMP WITH TIME ZONE, 7 | modified_by TEXT, 8 | modified_at TIMESTAMP WITH TIME ZONE, 9 | 10 | email TEXT UNIQUE NOT NULL, 11 | password TEXT NOT NULL, 12 | display_name TEXT NOT NULL, 13 | locale TEXT NOT NULL, 14 | roles TEXT[] NOT NULL, 15 | 16 | new_email TEXT UNIQUE, 17 | tokens_valid_from TIMESTAMP WITH TIME ZONE NOT NULL, 18 | version INTEGER, 19 | 20 | CONSTRAINT email_len CHECK ( char_length(email) <= 1024 ), 21 | CONSTRAINT display_name_len CHECK ( char_length(display_name) <= 1024 ), 22 | CONSTRAINT locale_len CHECK ( char_length(locale) <= 255 ), 23 | CONSTRAINT new_email_len CHECK ( char_length(new_email) <= 1024 ) 24 | ); 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/main/resources/messages.properties: -------------------------------------------------------------------------------- 1 | generic-error=Generic error 2 | http-message-not-readable=Http message not readable. Maybe malformed JSON? 3 | http-media-type-not-supported=Http media type not supported. Maybe you aren't using the VND type? 4 | invalid-data=Invalid data given. See "errors" for details 5 | used-email=Email already used 6 | used-given-email=Email {0} already used 7 | token-verification-failed=JWT Verification failed 8 | wrong-jwt-audience=Wrong JWT Audience 9 | expired-jwt=Expired JWT 10 | wrong-credentials=Either the email or password is wrong 11 | not-found=Entity not found 12 | user-not-found=User {0} not found 13 | user-already-verified=User already verified 14 | given-user-already-verified=User {0} already verified 15 | verification-mail-subject=Please verify your email 16 | verification-mail-body=Hello {0}

,\ 17 | Please Click here to verify your email registered at {1} 18 | forgot-password-mail-subject=Forgot password link 19 | forgot-password-mail-body=Hello {0}

,\ 20 | Please Click here to reset your password at {1} 21 | password-mismatch=Password mismatch 22 | password-mismatch-for-user=Password mismatch for user {0} 23 | email-mismatch=Email mismatch 24 | email-mismatch-for-user=Email mismatch for user {0} -------------------------------------------------------------------------------- /src/main/resources/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Demo 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | Login With Google (v3): click 18 | here 19 |
20 | 23 | 62 | 63 | -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/common/features/get_context/GetContextIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.common.features.get_context; 2 | 3 | import com.naturalprogrammer.springmvc.helpers.AbstractIntegrationTest; 4 | import org.junit.jupiter.api.Test; 5 | 6 | import static org.hamcrest.Matchers.hasSize; 7 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 8 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; 9 | 10 | class GetContextIntegrationTest extends AbstractIntegrationTest { 11 | 12 | @Test 13 | void should_GetContext() throws Exception { 14 | 15 | // when, then 16 | mvc.perform(get("/context")) 17 | .andExpect(status().isOk()) 18 | .andExpect(content().contentType(ContextResource.CONTENT_TYPE)) 19 | .andExpect(jsonPath("keys", hasSize(1))) 20 | .andExpect(jsonPath("keys[0].id").value("e0498dad-4f5f-40cf-86e3-2726ec78463d")) 21 | .andExpect(jsonPath("keys[0].publicKey").value("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAltQ21+NEs7BvISW7h10TErdhBNFR5Dgdfff0tYDjCnWNOaYG/iTpdQkm9oV7ZrXnbQFLssc9cfiDjDrf+VTVDOAE1tunSaa1rM9bHqIf1JXTontwht2cABGUQgkp3+kWNY+1OSu6ESSCkUILracT85ccFeCtA20wGrHhMY2bODI4mJi4He7Mr4A5sR1eJQV4GJZJBLIH9H/6HDDl7azNBxosz7hQ5Ny2wSkonssdThhyK84MEoEpo5dY2/IHJjN6+X4B+CI7MyWLWWpGIa0R37zV5LDtbePmBcfFZfFjv2HDFEx2Vb5EkBajQ88XvRXcWKhE8wI89Hjsk00hmZzqNQIDAQAB")); 22 | } 23 | } -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/helpers/AbstractIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.helpers; 2 | 3 | import org.springframework.beans.factory.annotation.Autowired; 4 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | import org.springframework.test.context.ActiveProfiles; 7 | import org.springframework.test.context.jdbc.Sql; 8 | import org.springframework.test.web.servlet.MockMvc; 9 | import org.testcontainers.junit.jupiter.Testcontainers; 10 | 11 | @SpringBootTest 12 | @Testcontainers 13 | @AutoConfigureMockMvc 14 | @ActiveProfiles("test") 15 | @Sql("classpath:/test-data/sql/before-each-test.sql") 16 | public abstract class AbstractIntegrationTest { 17 | 18 | @Autowired 19 | protected MockMvc mvc; 20 | } 21 | -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/helpers/MyResultMatchers.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.helpers; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import com.naturalprogrammer.springmvc.common.error.Problem; 5 | import jakarta.annotation.PostConstruct; 6 | import lombok.RequiredArgsConstructor; 7 | import org.assertj.core.api.SoftAssertions; 8 | import org.springframework.http.MediaType; 9 | import org.springframework.stereotype.Component; 10 | import org.springframework.test.web.servlet.ResultMatcher; 11 | 12 | import java.util.Collections; 13 | import java.util.Map; 14 | 15 | import static com.naturalprogrammer.springmvc.common.error.ProblemType.NOT_FOUND; 16 | 17 | @Component 18 | @RequiredArgsConstructor 19 | public class MyResultMatchers { 20 | 21 | private static MyResultMatchers INSTANCE; 22 | private final ObjectMapper objectMapper; 23 | 24 | @PostConstruct 25 | private void setInstance() { 26 | INSTANCE = this; 27 | } 28 | 29 | public ResultMatcher isProblem(int expectedStatus, String type, Map expectedErrors) { 30 | return mvcResult -> { 31 | 32 | var softly = new SoftAssertions(); 33 | 34 | var response = mvcResult.getResponse(); 35 | softly.assertThat(response.getStatus()).as("Status").isEqualTo(expectedStatus); 36 | softly.assertThat(response.getContentType()).as("Content Type").isEqualTo(MediaType.APPLICATION_PROBLEM_JSON_VALUE); 37 | 38 | var bodyStr = response.getContentAsString(); 39 | var problem = objectMapper.readValue(bodyStr, Problem.class); 40 | softly.assertThat(problem.id()).as("Problem Id").hasSize(36); 41 | softly.assertThat(problem.type()).as("Problem type").isEqualTo(type); 42 | softly.assertThat(problem.status()).as("Status").isEqualTo(expectedStatus); 43 | softly.assertThat(problem.errors()).as("Error count").hasSize(expectedErrors.size()); 44 | problem.errors().forEach(actualError -> { 45 | softly.assertThat(expectedErrors).as("Error code %s exists", actualError.code()).containsKey(actualError.code()); 46 | var expectedField = expectedErrors.get(actualError.code()); 47 | softly.assertThat(expectedField).as("Error field %d", expectedField).isEqualTo(actualError.field()); 48 | }); 49 | softly.assertAll(); 50 | }; 51 | } 52 | 53 | public ResultMatcher isProblem(int expectedStatus, String type, String errorCode, String errorField) { 54 | return isProblem(expectedStatus, type, Map.of(errorCode, errorField)); 55 | } 56 | 57 | public ResultMatcher isNotFoundProblem() { 58 | return isProblem(404, NOT_FOUND.getType(), Collections.emptyMap()); 59 | } 60 | 61 | public static MyResultMatchers result() { 62 | return INSTANCE; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/helpers/MyTestConfig.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.helpers; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | import org.springframework.context.annotation.Profile; 5 | 6 | 7 | @Profile("test") 8 | @Configuration 9 | public class MyTestConfig { 10 | 11 | } 12 | -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/helpers/MyTestUtils.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.helpers; 2 | 3 | import com.naturalprogrammer.springmvc.common.MessageGetter; 4 | import com.naturalprogrammer.springmvc.common.error.Problem; 5 | import com.naturalprogrammer.springmvc.common.error.ProblemBuilder; 6 | import jakarta.validation.Validation; 7 | import jakarta.validation.Validator; 8 | import org.apache.commons.lang3.time.DateUtils; 9 | import org.springframework.beans.factory.ObjectFactory; 10 | 11 | import java.util.*; 12 | import java.util.stream.Collectors; 13 | 14 | import static org.mockito.ArgumentMatchers.any; 15 | import static org.mockito.BDDMockito.given; 16 | import static org.mockito.Mockito.mock; 17 | 18 | public class MyTestUtils { 19 | 20 | public static final Validator TEST_VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator(); 21 | 22 | public static void mockValidator(Validator validator) { 23 | given(validator.validate(any())).willAnswer(invocation -> 24 | TEST_VALIDATOR.validate(invocation.getArgument(0)) 25 | ); 26 | } 27 | 28 | public static void mockProblemBuilder(ObjectFactory problemBuilder) { 29 | var messageGetter = mock(MessageGetter.class); 30 | mockMessageGetter(messageGetter); 31 | given(problemBuilder.getObject()).willReturn(new ProblemBuilder(messageGetter)); 32 | } 33 | 34 | public static void mockMessageGetter(MessageGetter messageGetter) { 35 | given(messageGetter.getMessage(any(), any(Object[].class))).willAnswer(invocation -> { 36 | Object[] args = invocation.getArguments(); 37 | return Arrays.stream(args).map(Object::toString).collect(Collectors.joining()); 38 | }); 39 | } 40 | 41 | public static Problem randomProblem() { 42 | return new Problem( 43 | UUID.randomUUID().toString(), 44 | "/problems/invalid-signup", 45 | "Invalid fields received while doing signup", 46 | 422, 47 | "SignupRequest{email='null', displayName='null'}", 48 | null, 49 | Collections.emptyList() 50 | ); 51 | } 52 | 53 | public static Date futureTime() { 54 | return DateUtils.truncate(DateUtils.addHours(new Date(), 1), Calendar.SECOND); 55 | } 56 | 57 | public static Date pastTime() { 58 | return DateUtils.truncate(DateUtils.addHours(new Date(), -1), Calendar.SECOND); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/user/UserTestUtils.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user; 2 | 3 | import com.naturalprogrammer.springmvc.user.domain.User; 4 | import lombok.AccessLevel; 5 | import lombok.NoArgsConstructor; 6 | import net.datafaker.Faker; 7 | 8 | import java.time.Instant; 9 | import java.time.temporal.ChronoUnit; 10 | import java.util.Locale; 11 | import java.util.UUID; 12 | 13 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 14 | public class UserTestUtils { 15 | 16 | public static final Faker FAKER = new Faker(); 17 | public static final String RANDOM_USER_PASSWORD = "Password9!"; 18 | 19 | public static User randomUser() { 20 | var user = new User(); 21 | user.setId(UUID.randomUUID()); 22 | user.setEmail(FAKER.internet().emailAddress()); 23 | user.setPassword("{noop}" + RANDOM_USER_PASSWORD); 24 | user.setDisplayName(FAKER.name().fullName()); 25 | user.setLocale(Locale.forLanguageTag("en-IN")); 26 | user.setTokensValidFrom(Instant.now().truncatedTo(ChronoUnit.SECONDS)); 27 | return user; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/user/features/change_mail/ChangeEmailIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.change_mail; 2 | 3 | import com.naturalprogrammer.springmvc.common.jwt.JweService; 4 | import com.naturalprogrammer.springmvc.helpers.AbstractIntegrationTest; 5 | import com.naturalprogrammer.springmvc.user.features.login.AuthTokenCreator; 6 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 7 | import org.junit.jupiter.api.Test; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | 10 | import java.time.Instant; 11 | import java.time.temporal.ChronoUnit; 12 | import java.util.Date; 13 | import java.util.Map; 14 | 15 | import static com.naturalprogrammer.springmvc.common.Path.USER; 16 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.EMAIL_CHANGE; 17 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.PURPOSE; 18 | import static com.naturalprogrammer.springmvc.helpers.MyTestUtils.futureTime; 19 | import static com.naturalprogrammer.springmvc.user.UserTestUtils.FAKER; 20 | import static com.naturalprogrammer.springmvc.user.UserTestUtils.randomUser; 21 | import static com.naturalprogrammer.springmvc.user.features.verification.VerificationMailSender.VERIFICATION_TOKEN_VALID_DAYS; 22 | import static org.assertj.core.api.Assertions.assertThat; 23 | import static org.springframework.http.HttpHeaders.AUTHORIZATION; 24 | import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.EMAIL; 25 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; 26 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 27 | 28 | class ChangeEmailIntegrationTest extends AbstractIntegrationTest { 29 | 30 | @Autowired 31 | private UserRepository userRepository; 32 | 33 | @Autowired 34 | private AuthTokenCreator authTokenCreator; 35 | 36 | @Autowired 37 | private JweService jweService; 38 | 39 | private final Date future = futureTime(); 40 | 41 | @Test 42 | void Should_ChangeEmail() throws Exception { 43 | 44 | // given 45 | var user = randomUser(); 46 | var newEmail = FAKER.internet().emailAddress(); 47 | user.setNewEmail(newEmail); 48 | user = userRepository.save(user); 49 | var accessToken = authTokenCreator.createAccessToken(user.getIdStr(), future.toInstant()); 50 | 51 | var now = Instant.now().truncatedTo(ChronoUnit.SECONDS); 52 | var verificationToken = jweService.createToken( 53 | user.getIdStr(), 54 | Date.from(now.plus(VERIFICATION_TOKEN_VALID_DAYS, ChronoUnit.DAYS)), 55 | Map.of( 56 | PURPOSE, EMAIL_CHANGE, 57 | EMAIL, newEmail 58 | ) 59 | ); 60 | 61 | // when, then 62 | mvc.perform(patch(USER + "/email-change-request") 63 | .contentType(UserEmailChangeVerificationRequest.CONTENT_TYPE) 64 | .header(AUTHORIZATION, "Bearer " + accessToken) 65 | .content(""" 66 | { 67 | "emailVerificationToken" : "%s" 68 | } 69 | """.formatted(verificationToken))) 70 | .andExpect(status().isNoContent()); 71 | 72 | user = userRepository.findById(user.getId()).orElseThrow(); 73 | assertThat(user.getEmail()).isEqualTo(newEmail); 74 | assertThat(user.getNewEmail()).isNull(); 75 | assertThat(user.getTokensValidFrom()).isAfterOrEqualTo(now); 76 | } 77 | } -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/user/features/change_mail/EmailChangeRequestProcessorTest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.change_mail; 2 | 3 | import com.naturalprogrammer.springmvc.common.CommonUtils; 4 | import com.naturalprogrammer.springmvc.common.error.BeanValidator; 5 | import com.naturalprogrammer.springmvc.common.error.Problem; 6 | import com.naturalprogrammer.springmvc.user.domain.User; 7 | import org.junit.jupiter.api.Test; 8 | import org.junit.jupiter.api.extension.ExtendWith; 9 | import org.mockito.InjectMocks; 10 | import org.mockito.Mock; 11 | import org.mockito.junit.jupiter.MockitoExtension; 12 | 13 | import java.util.Optional; 14 | 15 | import static com.naturalprogrammer.springmvc.helpers.MyTestUtils.randomProblem; 16 | import static com.naturalprogrammer.springmvc.user.UserTestUtils.randomUser; 17 | import static org.assertj.core.api.AssertionsForClassTypes.assertThat; 18 | import static org.mockito.BDDMockito.given; 19 | 20 | @ExtendWith(MockitoExtension.class) 21 | class EmailChangeRequestProcessorTest { 22 | 23 | @Mock 24 | private BeanValidator validator; 25 | 26 | @Mock 27 | private CommonUtils commonUtils; 28 | 29 | @InjectMocks 30 | private EmailChangeRequestProcessor subject; 31 | 32 | private final User user = randomUser(); 33 | private final UserEmailChangeRequest request = new UserEmailChangeRequest("foo", "bar", "yoy"); 34 | private final Problem problem = randomProblem(); 35 | 36 | @Test 37 | void shouldNot_processEmailChangeRequest_when_validationFails() { 38 | 39 | // given 40 | given(commonUtils.getUserId()).willReturn(Optional.of(user.getId())); 41 | given(validator.validate(request)).willReturn(Optional.of(problem)); 42 | 43 | // when 44 | var possibleProblem = subject.process(request); 45 | 46 | // then 47 | assertThat(possibleProblem).hasValue(problem); 48 | } 49 | } -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/user/features/change_mail/NewEmailVerificationMailSenderTest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.change_mail; 2 | 3 | import com.naturalprogrammer.springmvc.common.MessageGetter; 4 | import com.naturalprogrammer.springmvc.common.jwt.JweService; 5 | import com.naturalprogrammer.springmvc.common.mail.MailData; 6 | import com.naturalprogrammer.springmvc.common.mail.MailSender; 7 | import com.naturalprogrammer.springmvc.config.MyProperties; 8 | import org.assertj.core.api.SoftAssertions; 9 | import org.junit.jupiter.api.Test; 10 | import org.junit.jupiter.api.extension.ExtendWith; 11 | import org.mockito.ArgumentCaptor; 12 | import org.mockito.Captor; 13 | import org.mockito.InjectMocks; 14 | import org.mockito.Mock; 15 | import org.mockito.junit.jupiter.MockitoExtension; 16 | 17 | import java.time.Clock; 18 | import java.time.Instant; 19 | import java.time.temporal.ChronoUnit; 20 | import java.util.Date; 21 | import java.util.Map; 22 | import java.util.UUID; 23 | 24 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.EMAIL_CHANGE; 25 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.PURPOSE; 26 | import static com.naturalprogrammer.springmvc.helpers.MyTestUtils.mockMessageGetter; 27 | import static com.naturalprogrammer.springmvc.user.UserTestUtils.FAKER; 28 | import static com.naturalprogrammer.springmvc.user.UserTestUtils.randomUser; 29 | import static com.naturalprogrammer.springmvc.user.features.verification.VerificationMailSender.VERIFICATION_TOKEN_VALID_DAYS; 30 | import static org.mockito.BDDMockito.given; 31 | import static org.mockito.Mockito.verify; 32 | import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.EMAIL; 33 | 34 | @ExtendWith(MockitoExtension.class) 35 | class NewEmailVerificationMailSenderTest { 36 | 37 | @Mock 38 | private JweService jweService; 39 | 40 | @Mock 41 | private Clock clock; 42 | 43 | @Mock 44 | private MessageGetter messageGetter; 45 | 46 | @Mock 47 | private MailSender mailSender; 48 | 49 | @Mock 50 | private MyProperties properties; 51 | 52 | @InjectMocks 53 | private NewEmailVerificationMailSender subject; 54 | 55 | @Captor 56 | private ArgumentCaptor mailCaptor; 57 | 58 | @Test 59 | void should_sendNewEmailVerificationMail() { 60 | 61 | // given 62 | var user = randomUser(); 63 | user.setNewEmail(FAKER.internet().emailAddress()); 64 | var now = Instant.now(); 65 | var verificationToken = UUID.randomUUID().toString(); 66 | var homepage = "https://test8567.example.com"; 67 | 68 | given(clock.instant()).willReturn(now); 69 | given(jweService.createToken( 70 | user.getIdStr(), 71 | Date.from(now.plus(VERIFICATION_TOKEN_VALID_DAYS, ChronoUnit.DAYS)), 72 | Map.of( 73 | PURPOSE, EMAIL_CHANGE, 74 | EMAIL, user.getNewEmail() 75 | ))).willReturn(verificationToken); 76 | mockMessageGetter(messageGetter); 77 | given(properties.homepage()).willReturn(homepage); 78 | 79 | // when 80 | subject.send(user); 81 | 82 | // then 83 | verify(mailSender).send(mailCaptor.capture()); 84 | var mailData = mailCaptor.getValue(); 85 | var softly = new SoftAssertions(); 86 | softly.assertThat(mailData.to()).isEqualTo(user.getNewEmail()); 87 | softly.assertThat(mailData.subject()).isEqualTo("verification-mail-subject"); 88 | softly.assertThat(mailData.bodyHtml()).isEqualTo("verification-mail-body" 89 | + user.getDisplayName() + homepage + verificationToken); 90 | softly.assertAll(); 91 | } 92 | 93 | } -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/user/features/change_password/ChangePasswordIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.change_password; 2 | 3 | import com.naturalprogrammer.springmvc.helpers.AbstractIntegrationTest; 4 | import com.naturalprogrammer.springmvc.user.features.login.AuthTokenCreator; 5 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 6 | import org.junit.jupiter.api.Test; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.security.crypto.password.PasswordEncoder; 9 | 10 | import java.util.Date; 11 | 12 | import static com.naturalprogrammer.springmvc.common.Path.USER; 13 | import static com.naturalprogrammer.springmvc.helpers.MyTestUtils.futureTime; 14 | import static com.naturalprogrammer.springmvc.user.UserTestUtils.randomUser; 15 | import static org.assertj.core.api.Assertions.assertThat; 16 | import static org.springframework.http.HttpHeaders.AUTHORIZATION; 17 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; 18 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 19 | 20 | class ChangePasswordIntegrationTest extends AbstractIntegrationTest { 21 | 22 | @Autowired 23 | private UserRepository userRepository; 24 | 25 | @Autowired 26 | private AuthTokenCreator authTokenCreator; 27 | 28 | @Autowired 29 | private PasswordEncoder passwordEncoder; 30 | 31 | private final Date future = futureTime(); 32 | 33 | @Test 34 | void should_changePassword() throws Exception { 35 | 36 | // given 37 | var user = randomUser(); 38 | var oldPassword = "OldPassword9!"; 39 | user.setPassword(passwordEncoder.encode(oldPassword)); 40 | user = userRepository.save(user); 41 | var accessToken = authTokenCreator.createAccessToken(user.getIdStr(), future.toInstant()); 42 | var newPassword = "newPassword9!"; 43 | 44 | // when, then 45 | mvc.perform(patch(USER + "/password") 46 | .contentType(ChangePasswordRequest.CONTENT_TYPE) 47 | .header(AUTHORIZATION, "Bearer " + accessToken) 48 | .content(""" 49 | { 50 | "oldPassword" : "%s", 51 | "newPassword" : "%s" 52 | } 53 | """.formatted(oldPassword, newPassword))) 54 | .andExpect(status().isNoContent()); 55 | 56 | user = userRepository.findById(user.getId()).orElseThrow(); 57 | assertThat(passwordEncoder.matches(newPassword, user.getPassword())).isTrue(); 58 | } 59 | } -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/user/features/change_password/PasswordChangerTest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.change_password; 2 | 3 | import com.naturalprogrammer.springmvc.common.CommonUtils; 4 | import com.naturalprogrammer.springmvc.common.error.*; 5 | import com.naturalprogrammer.springmvc.user.domain.User; 6 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 7 | import org.junit.jupiter.api.Test; 8 | import org.junit.jupiter.api.extension.ExtendWith; 9 | import org.mockito.InjectMocks; 10 | import org.mockito.Mock; 11 | import org.mockito.junit.jupiter.MockitoExtension; 12 | import org.springframework.beans.factory.ObjectFactory; 13 | import org.springframework.security.crypto.password.PasswordEncoder; 14 | 15 | import java.util.Optional; 16 | 17 | import static com.naturalprogrammer.springmvc.helpers.MyTestUtils.mockProblemBuilder; 18 | import static com.naturalprogrammer.springmvc.helpers.MyTestUtils.randomProblem; 19 | import static com.naturalprogrammer.springmvc.user.UserTestUtils.randomUser; 20 | import static org.assertj.core.api.Assertions.assertThat; 21 | import static org.mockito.BDDMockito.given; 22 | 23 | @ExtendWith(MockitoExtension.class) 24 | class PasswordChangerTest { 25 | 26 | @Mock 27 | private BeanValidator validator; 28 | 29 | @Mock 30 | private CommonUtils commonUtils; 31 | 32 | @Mock 33 | private UserRepository userRepository; 34 | 35 | @Mock 36 | private PasswordEncoder passwordEncoder; 37 | 38 | @Mock 39 | private ObjectFactory problemBuilder; 40 | 41 | @InjectMocks 42 | private PasswordChanger subject; 43 | 44 | private final User user = randomUser(); 45 | private final ChangePasswordRequest request = new ChangePasswordRequest("foo", "bar"); 46 | private final Problem problem = randomProblem(); 47 | 48 | private void mockGetAuthentication() { 49 | given(commonUtils.getUserId()).willReturn(Optional.of(user.getId())); 50 | } 51 | 52 | @Test 53 | void shouldNot_changePassword_when_validationFails() { 54 | 55 | // given 56 | mockGetAuthentication(); 57 | given(validator.validate(request)).willReturn(Optional.of(problem)); 58 | 59 | // when 60 | var possibleProblem = subject.changePassword(request); 61 | 62 | // then 63 | assertThat(possibleProblem).hasValue(problem); 64 | } 65 | 66 | @Test 67 | void shouldNot_changePassword_when_oldPasswordDoesNotMatch() { 68 | 69 | // given 70 | mockGetAuthentication(); 71 | given(validator.validate(request)).willReturn(Optional.empty()); 72 | given(userRepository.findById(user.getId())).willReturn(Optional.of(user)); 73 | given(passwordEncoder.matches(request.oldPassword(), user.getPassword())).willReturn(false); 74 | mockProblemBuilder(problemBuilder); 75 | 76 | // when 77 | var possibleProblem = subject.changePassword(request); 78 | 79 | // then 80 | assertThat(possibleProblem).isNotEmpty(); 81 | var problem = possibleProblem.orElseThrow(); 82 | assertThat(problem.id()).isNotBlank(); 83 | assertThat(problem.type()).isEqualTo(ProblemType.PASSWORD_MISMATCH.getType()); 84 | assertThat(problem.title()).isEqualTo(ProblemType.PASSWORD_MISMATCH.getTitle()); 85 | assertThat(problem.status()).isEqualTo(ProblemType.PASSWORD_MISMATCH.getStatus().value()); 86 | assertThat(problem.detail()).isEqualTo("password-mismatch-for-user" + user.getId()); 87 | assertThat(problem.instance()).isNull(); 88 | assertThat(problem.errors()).hasSize(1); 89 | var error = problem.errors().get(0); 90 | assertThat(error.code()).isEqualTo(ErrorCode.PASSWORD_MISMATCH.getCode()); 91 | assertThat(error.field()).isEqualTo("oldPassword"); 92 | assertThat(error.message()).isEqualTo(ErrorCode.PASSWORD_MISMATCH.getMessage()); 93 | } 94 | 95 | } -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/user/features/display_name_edit/ValidatedDisplayNameEditorTest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.display_name_edit; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.Problem; 4 | import com.naturalprogrammer.springmvc.user.domain.User; 5 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 6 | import com.naturalprogrammer.springmvc.user.services.UserService; 7 | import org.junit.jupiter.api.BeforeEach; 8 | import org.junit.jupiter.api.Test; 9 | import org.junit.jupiter.api.extension.ExtendWith; 10 | import org.mockito.InjectMocks; 11 | import org.mockito.Mock; 12 | import org.mockito.junit.jupiter.MockitoExtension; 13 | 14 | import java.util.Optional; 15 | 16 | import static com.naturalprogrammer.springmvc.helpers.MyTestUtils.randomProblem; 17 | import static com.naturalprogrammer.springmvc.user.UserTestUtils.randomUser; 18 | import static org.assertj.core.api.Assertions.assertThat; 19 | import static org.mockito.BDDMockito.given; 20 | 21 | @ExtendWith(MockitoExtension.class) 22 | class ValidatedDisplayNameEditorTest { 23 | 24 | @Mock 25 | private UserRepository userRepository; 26 | 27 | @Mock 28 | private UserService userService; 29 | 30 | @InjectMocks 31 | private ValidatedDisplayNameEditor subject; 32 | 33 | private final User user = randomUser(); 34 | private final Problem problem = randomProblem(); 35 | private final UserDisplayNameEditRequest request = new UserDisplayNameEditRequest("Some new Name"); 36 | 37 | @BeforeEach 38 | void setUp() { 39 | given(userService.userNotFound(user.getId())).willReturn(problem); 40 | } 41 | 42 | @Test 43 | void should_preventEditingDisplayName_when_userNotFound() { 44 | 45 | // given 46 | given(userService.isSelfOrAdmin(user.getId())).willReturn(true); 47 | given(userRepository.findById(user.getId())).willReturn(Optional.empty()); 48 | 49 | // when 50 | var either = subject.edit(user.getId(), request); 51 | 52 | // then 53 | assertThat(either.getLeft()).hasValue(problem); 54 | } 55 | 56 | @Test 57 | void should_preventVerification_when_userIsNotSelfOrAdmin() { 58 | 59 | // given 60 | given(userService.isSelfOrAdmin(user.getId())).willReturn(false); 61 | 62 | // when 63 | var either = subject.edit(user.getId(), request); 64 | 65 | // then 66 | assertThat(either.getLeft()).hasValue(problem); 67 | } 68 | 69 | } -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/user/features/forgot_password/ForgotPasswordInitiatorTest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.forgot_password; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.BeanValidator; 4 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 5 | import org.junit.jupiter.api.Test; 6 | import org.junit.jupiter.api.extension.ExtendWith; 7 | import org.mockito.InjectMocks; 8 | import org.mockito.Mock; 9 | import org.mockito.junit.jupiter.MockitoExtension; 10 | 11 | import java.util.Optional; 12 | 13 | import static com.naturalprogrammer.springmvc.helpers.MyTestUtils.randomProblem; 14 | import static com.naturalprogrammer.springmvc.user.UserTestUtils.randomUser; 15 | import static org.assertj.core.api.Assertions.assertThat; 16 | import static org.mockito.ArgumentMatchers.any; 17 | import static org.mockito.BDDMockito.given; 18 | import static org.mockito.Mockito.never; 19 | import static org.mockito.Mockito.verify; 20 | 21 | @ExtendWith(MockitoExtension.class) 22 | class ForgotPasswordInitiatorTest { 23 | 24 | @Mock 25 | private BeanValidator validator; 26 | 27 | @Mock 28 | private UserRepository userRepository; 29 | 30 | @Mock 31 | private ForgotPasswordMailSender forgotPasswordMailSender; 32 | 33 | @InjectMocks 34 | private ForgotPasswordInitiator subject; 35 | 36 | @Test 37 | void should_initiateForgotPassword() { 38 | 39 | // given 40 | var user = randomUser(); 41 | var request = new ForgotPasswordRequest(user.getEmail()); 42 | given(validator.validate(request)).willReturn(Optional.empty()); 43 | given(userRepository.findByEmail(request.email())).willReturn(Optional.of(user)); 44 | 45 | // when 46 | subject.initiate(request); 47 | 48 | // then 49 | verify(forgotPasswordMailSender).send(user); 50 | } 51 | 52 | @Test 53 | void shouldNot_initiateForgotPassword_when_validationFails() { 54 | 55 | // given 56 | var request = new ForgotPasswordRequest(""); 57 | var problem = randomProblem(); 58 | given(validator.validate(request)).willReturn(Optional.of(problem)); 59 | 60 | // when 61 | var possibleProblem = subject.initiate(request); 62 | 63 | // then 64 | assertThat(possibleProblem).hasValue(problem); 65 | verify(forgotPasswordMailSender, never()).send(any()); 66 | } 67 | 68 | @Test 69 | void should_silentlyNotInitiateForgotPasswordBut_when_userNotFound() { 70 | 71 | // given 72 | var user = randomUser(); 73 | var request = new ForgotPasswordRequest(user.getEmail()); 74 | given(validator.validate(request)).willReturn(Optional.empty()); 75 | given(userRepository.findByEmail(request.email())).willReturn(Optional.empty()); 76 | 77 | // when 78 | var possibleProblem = subject.initiate(request); 79 | 80 | // then 81 | assertThat(possibleProblem).isEmpty(); 82 | verify(forgotPasswordMailSender, never()).send(any()); 83 | } 84 | 85 | } -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/user/features/forgot_password/ForgotPasswordIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.forgot_password; 2 | 3 | import com.naturalprogrammer.springmvc.helpers.AbstractIntegrationTest; 4 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 5 | import org.junit.jupiter.api.Test; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | 8 | import static com.naturalprogrammer.springmvc.common.Path.FORGOT_PASSWORD; 9 | import static com.naturalprogrammer.springmvc.common.mail.LoggingMailSender.sentMails; 10 | import static com.naturalprogrammer.springmvc.user.UserTestUtils.randomUser; 11 | import static org.assertj.core.api.Assertions.assertThat; 12 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 13 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 14 | 15 | class ForgotPasswordIntegrationTest extends AbstractIntegrationTest { 16 | 17 | @Autowired 18 | private UserRepository userRepository; 19 | 20 | @Test 21 | void should_initiateForgotPassword() throws Exception { 22 | 23 | // given 24 | var user = userRepository.save(randomUser()); 25 | sentMails().clear(); 26 | 27 | // when, then 28 | mvc.perform(post(FORGOT_PASSWORD) 29 | .contentType(ForgotPasswordRequest.CONTENT_TYPE) 30 | .content(""" 31 | { 32 | "email" : "%s" 33 | } 34 | """.formatted(user.getEmail()))) 35 | .andExpect(status().isNoContent()); 36 | 37 | assertThat(sentMails()).hasSize(1); 38 | var mailData = sentMails().get(0); 39 | assertThat(mailData.to()).isEqualTo(user.getEmail()); 40 | assertThat(mailData.bodyHtml()).contains(user.getDisplayName()); 41 | assertThat(mailData.subject()).isEqualTo("Forgot password link"); 42 | } 43 | 44 | } -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/user/features/forgot_password/ForgotPasswordMailSenderTest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.forgot_password; 2 | 3 | import com.naturalprogrammer.springmvc.common.MessageGetter; 4 | import com.naturalprogrammer.springmvc.common.jwt.JweService; 5 | import com.naturalprogrammer.springmvc.common.mail.MailData; 6 | import com.naturalprogrammer.springmvc.common.mail.MailSender; 7 | import com.naturalprogrammer.springmvc.config.MyProperties; 8 | import org.junit.jupiter.api.Test; 9 | import org.junit.jupiter.api.extension.ExtendWith; 10 | import org.mockito.ArgumentCaptor; 11 | import org.mockito.Captor; 12 | import org.mockito.InjectMocks; 13 | import org.mockito.Mock; 14 | import org.mockito.junit.jupiter.MockitoExtension; 15 | 16 | import java.time.Clock; 17 | import java.time.Instant; 18 | import java.time.temporal.ChronoUnit; 19 | import java.util.Date; 20 | import java.util.Map; 21 | import java.util.UUID; 22 | 23 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.FORGOT_PASSWORD; 24 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.PURPOSE; 25 | import static com.naturalprogrammer.springmvc.user.UserTestUtils.FAKER; 26 | import static com.naturalprogrammer.springmvc.user.UserTestUtils.randomUser; 27 | import static com.naturalprogrammer.springmvc.user.features.forgot_password.ForgotPasswordMailSender.FORGOT_PASSWORD_TOKEN_VALID_DAYS; 28 | import static org.assertj.core.api.Assertions.assertThat; 29 | import static org.mockito.ArgumentMatchers.any; 30 | import static org.mockito.BDDMockito.given; 31 | import static org.mockito.BDDMockito.willDoNothing; 32 | import static org.mockito.Mockito.verify; 33 | import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.EMAIL; 34 | 35 | @ExtendWith(MockitoExtension.class) 36 | class ForgotPasswordMailSenderTest { 37 | 38 | @Mock 39 | private MailSender mailSender; 40 | 41 | @Mock 42 | private MessageGetter messageGetter; 43 | 44 | @Mock 45 | private JweService jweService; 46 | 47 | @Mock 48 | private Clock clock; 49 | 50 | @Mock 51 | private MyProperties properties; 52 | 53 | @Captor 54 | private ArgumentCaptor mailDataCaptor; 55 | 56 | @InjectMocks 57 | private ForgotPasswordMailSender subject; 58 | 59 | @Test 60 | void should_sendForgotPasswordMail() { 61 | 62 | // given 63 | var user = randomUser(); 64 | var now = Instant.now(); 65 | var token = UUID.randomUUID().toString(); 66 | var forgotPasswordSubject = "Forgot Password Subject"; 67 | var forgotPasswordBody = "Forgot Password Body"; 68 | var homepage = FAKER.internet().url(); 69 | 70 | given(clock.instant()).willReturn(now); 71 | given(jweService.createToken( 72 | user.getIdStr(), 73 | Date.from(now.plus(FORGOT_PASSWORD_TOKEN_VALID_DAYS, ChronoUnit.DAYS)), 74 | Map.of( 75 | PURPOSE, FORGOT_PASSWORD, 76 | EMAIL, user.getEmail() 77 | ) 78 | )).willReturn(token); 79 | given(properties.homepage()).willReturn(homepage); 80 | given(messageGetter.getMessage("forgot-password-mail-subject")).willReturn(forgotPasswordSubject); 81 | given(messageGetter.getMessage("forgot-password-mail-body", 82 | user.getDisplayName(), homepage, token)).willReturn(forgotPasswordBody); 83 | willDoNothing().given(mailSender).send(any()); 84 | 85 | // when 86 | subject.send(user); 87 | 88 | // then 89 | verify(mailSender).send(mailDataCaptor.capture()); 90 | var mailData = mailDataCaptor.getValue(); 91 | assertThat(mailData.to()).isEqualTo(user.getEmail()); 92 | assertThat(mailData.subject()).isEqualTo(forgotPasswordSubject); 93 | assertThat(mailData.bodyHtml()).isEqualTo(forgotPasswordBody); 94 | assertThat(mailData.attachment()).isNull(); 95 | } 96 | } -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/user/features/login/AccessTokenCreatorTest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.login; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.ProblemBuilder; 4 | import com.naturalprogrammer.springmvc.common.error.ProblemType; 5 | import com.naturalprogrammer.springmvc.user.services.UserService; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.extension.ExtendWith; 8 | import org.mockito.InjectMocks; 9 | import org.mockito.Mock; 10 | import org.mockito.junit.jupiter.MockitoExtension; 11 | import org.springframework.beans.factory.ObjectFactory; 12 | 13 | import java.util.UUID; 14 | 15 | import static com.naturalprogrammer.springmvc.helpers.MyTestUtils.mockProblemBuilder; 16 | import static org.assertj.core.api.Assertions.assertThat; 17 | import static org.mockito.BDDMockito.given; 18 | 19 | @ExtendWith(MockitoExtension.class) 20 | class AccessTokenCreatorTest { 21 | 22 | @Mock 23 | private UserService userService; 24 | 25 | @Mock 26 | private ObjectFactory problemBuilder; 27 | 28 | @InjectMocks 29 | private AccessTokenCreator subject; 30 | 31 | @Test 32 | void shouldNot_createAccessToken_when_notSelfOrAdmin() { 33 | 34 | // given 35 | var userId = UUID.randomUUID(); 36 | given(userService.isSelfOrAdmin(userId)).willReturn(false); 37 | mockProblemBuilder(problemBuilder); 38 | 39 | // when 40 | var either = subject.create(userId); 41 | 42 | // then 43 | assertThat(either.isLeft()).isTrue(); 44 | var problem = either.getLeft().orElseThrow(); 45 | assertThat(problem.type()).isEqualTo(ProblemType.NOT_FOUND.getType()); 46 | } 47 | } -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/user/features/resend_verification/ResendVerificationIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.resend_verification; 2 | 3 | import com.naturalprogrammer.springmvc.helpers.AbstractIntegrationTest; 4 | import com.naturalprogrammer.springmvc.user.domain.Role; 5 | import com.naturalprogrammer.springmvc.user.features.login.AuthTokenCreator; 6 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 7 | import org.junit.jupiter.api.Test; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | 10 | import java.util.Set; 11 | 12 | import static com.naturalprogrammer.springmvc.common.Path.USERS; 13 | import static com.naturalprogrammer.springmvc.common.mail.LoggingMailSender.sentMails; 14 | import static com.naturalprogrammer.springmvc.helpers.MyTestUtils.futureTime; 15 | import static com.naturalprogrammer.springmvc.user.UserTestUtils.randomUser; 16 | import static org.assertj.core.api.Assertions.assertThat; 17 | import static org.springframework.http.HttpHeaders.AUTHORIZATION; 18 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 19 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 20 | 21 | class ResendVerificationIntegrationTest extends AbstractIntegrationTest { 22 | 23 | @Autowired 24 | private UserRepository userRepository; 25 | 26 | @Autowired 27 | private AuthTokenCreator authTokenCreator; 28 | 29 | @Test 30 | void should_resendVerificationMail() throws Exception { 31 | 32 | // given 33 | var user = randomUser(); 34 | user.setRoles(Set.of(Role.UNVERIFIED)); 35 | user = userRepository.save(user); 36 | var accessToken = authTokenCreator.createAccessToken(user.getIdStr(), futureTime().toInstant()); 37 | sentMails().clear(); 38 | 39 | // when 40 | mvc.perform(post(USERS + "/{id}/verifications", user.getId()) 41 | .header(AUTHORIZATION, "Bearer " + accessToken)) 42 | .andExpect(status().isNoContent()); 43 | 44 | // then 45 | assertThat(sentMails()).hasSize(1); 46 | var mailData = sentMails().get(0); 47 | assertThat(mailData.to()).isEqualTo(user.getEmail()); 48 | assertThat(mailData.bodyHtml()).contains(user.getDisplayName()); 49 | assertThat(mailData.subject()).isEqualTo("Please verify your email"); 50 | } 51 | 52 | } -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/user/features/resend_verification/VerificationMailReSenderTest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.resend_verification; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.Problem; 4 | import com.naturalprogrammer.springmvc.common.error.ProblemBuilder; 5 | import com.naturalprogrammer.springmvc.common.error.ProblemType; 6 | import com.naturalprogrammer.springmvc.user.domain.Role; 7 | import com.naturalprogrammer.springmvc.user.domain.User; 8 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 9 | import com.naturalprogrammer.springmvc.user.services.UserService; 10 | import org.junit.jupiter.api.Test; 11 | import org.junit.jupiter.api.extension.ExtendWith; 12 | import org.mockito.InjectMocks; 13 | import org.mockito.Mock; 14 | import org.mockito.junit.jupiter.MockitoExtension; 15 | import org.springframework.beans.factory.ObjectFactory; 16 | 17 | import java.util.Optional; 18 | import java.util.Set; 19 | 20 | import static com.naturalprogrammer.springmvc.helpers.MyTestUtils.mockProblemBuilder; 21 | import static com.naturalprogrammer.springmvc.helpers.MyTestUtils.randomProblem; 22 | import static com.naturalprogrammer.springmvc.user.UserTestUtils.randomUser; 23 | import static org.assertj.core.api.Assertions.assertThat; 24 | import static org.mockito.BDDMockito.given; 25 | 26 | @ExtendWith(MockitoExtension.class) 27 | class VerificationMailReSenderTest { 28 | 29 | @Mock 30 | private UserService userService; 31 | 32 | @Mock 33 | private UserRepository userRepository; 34 | 35 | @Mock 36 | private ObjectFactory problemBuilder; 37 | 38 | @InjectMocks 39 | private VerificationMailReSender subject; 40 | 41 | private final User user = randomUser(); 42 | private final Problem problem = randomProblem(); 43 | 44 | @Test 45 | void shouldNot_sendVerificationMail_when_notSelfOrAdmin() { 46 | 47 | // given 48 | given(userService.isSelfOrAdmin(user.getId())).willReturn(false); 49 | given(userService.userNotFound(user.getId())).willReturn(problem); 50 | 51 | // when 52 | var possibleProblem = subject.resend(user.getId()); 53 | 54 | // then 55 | assertThat(possibleProblem).hasValue(problem); 56 | } 57 | 58 | @Test 59 | void shouldNot_sendVerificationMail_when_userNotFound() { 60 | 61 | // given 62 | given(userService.isSelfOrAdmin(user.getId())).willReturn(true); 63 | given(userRepository.findById(user.getId())).willReturn(Optional.empty()); 64 | given(userService.userNotFound(user.getId())).willReturn(problem); 65 | 66 | // when 67 | var possibleProblem = subject.resend(user.getId()); 68 | 69 | // then 70 | assertThat(possibleProblem).hasValue(problem); 71 | } 72 | 73 | @Test 74 | void shouldNot_sendVerificationMail_when_userIsAlreadyVerified() { 75 | 76 | // given 77 | user.setRoles(Set.of(Role.VERIFIED)); 78 | given(userService.isSelfOrAdmin(user.getId())).willReturn(true); 79 | given(userRepository.findById(user.getId())).willReturn(Optional.of(user)); 80 | mockProblemBuilder(problemBuilder); 81 | 82 | // when 83 | var possibleProblem = subject.resend(user.getId()); 84 | 85 | // then 86 | assertThat(possibleProblem).isNotEmpty(); 87 | var problem = possibleProblem.orElseThrow(); 88 | assertThat(problem.id()).isNotBlank(); 89 | assertThat(problem.type()).isEqualTo(ProblemType.USER_ALREADY_VERIFIED.getType()); 90 | assertThat(problem.title()).isEqualTo(ProblemType.USER_ALREADY_VERIFIED.getTitle()); 91 | assertThat(problem.status()).isEqualTo(ProblemType.USER_ALREADY_VERIFIED.getStatus().value()); 92 | assertThat(problem.detail()).isEqualTo("given-user-already-verified" + user.getId()); 93 | assertThat(problem.instance()).isNull(); 94 | assertThat(problem.errors()).isEmpty(); 95 | } 96 | } -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/user/features/reset_password/ResetPasswordIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.reset_password; 2 | 3 | import com.naturalprogrammer.springmvc.common.jwt.JweService; 4 | import com.naturalprogrammer.springmvc.helpers.AbstractIntegrationTest; 5 | import com.naturalprogrammer.springmvc.user.repositories.UserRepository; 6 | import org.junit.jupiter.api.Test; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.security.crypto.password.PasswordEncoder; 9 | 10 | import java.util.Date; 11 | import java.util.Map; 12 | 13 | import static com.naturalprogrammer.springmvc.common.Path.RESET_PASSWORD; 14 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.FORGOT_PASSWORD; 15 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.PURPOSE; 16 | import static com.naturalprogrammer.springmvc.helpers.MyTestUtils.futureTime; 17 | import static com.naturalprogrammer.springmvc.user.UserTestUtils.randomUser; 18 | import static org.assertj.core.api.Assertions.assertThat; 19 | import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.EMAIL; 20 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 21 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 22 | 23 | class ResetPasswordIntegrationTest extends AbstractIntegrationTest { 24 | 25 | @Autowired 26 | private UserRepository userRepository; 27 | 28 | @Autowired 29 | private JweService jweService; 30 | 31 | @Autowired 32 | private PasswordEncoder passwordEncoder; 33 | 34 | private final Date future = futureTime(); 35 | 36 | 37 | @Test 38 | void should_resetPassword() throws Exception { 39 | 40 | // given 41 | var user = userRepository.save(randomUser()); 42 | var token = jweService.createToken( 43 | user.getIdStr(), 44 | future, 45 | Map.of(PURPOSE, FORGOT_PASSWORD, EMAIL, user.getEmail()) 46 | ); 47 | var newPassword = "SomeNewPassword9!"; 48 | 49 | // when, then 50 | mvc.perform(post(RESET_PASSWORD) 51 | .contentType(ResetPasswordRequest.CONTENT_TYPE) 52 | .content(""" 53 | { 54 | "token" : "%s", 55 | "newPassword" : "%s" 56 | } 57 | """.formatted(token, newPassword))) 58 | .andExpect(status().isNoContent()); 59 | 60 | user = userRepository.findById(user.getId()).orElseThrow(); 61 | assertThat(passwordEncoder.matches(newPassword, user.getPassword())).isTrue(); 62 | } 63 | 64 | } -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/user/features/signup/BeanValidatorTest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.signup; 2 | 3 | import com.naturalprogrammer.springmvc.common.error.BeanValidator; 4 | import com.naturalprogrammer.springmvc.common.error.ProblemBuilder; 5 | import com.naturalprogrammer.springmvc.common.error.ProblemType; 6 | import org.junit.jupiter.api.BeforeEach; 7 | import org.junit.jupiter.api.Test; 8 | import org.junit.jupiter.api.extension.ExtendWith; 9 | import org.junit.jupiter.params.ParameterizedTest; 10 | import org.junit.jupiter.params.provider.NullSource; 11 | import org.junit.jupiter.params.provider.ValueSource; 12 | import org.mockito.InjectMocks; 13 | import org.mockito.Mock; 14 | import org.mockito.junit.jupiter.MockitoExtension; 15 | import org.springframework.beans.factory.ObjectFactory; 16 | import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; 17 | 18 | import static com.naturalprogrammer.springmvc.common.error.ProblemType.INVALID_DATA; 19 | import static com.naturalprogrammer.springmvc.helpers.MyTestUtils.mockProblemBuilder; 20 | import static com.naturalprogrammer.springmvc.helpers.MyTestUtils.mockValidator; 21 | import static com.naturalprogrammer.springmvc.user.UserTestUtils.RANDOM_USER_PASSWORD; 22 | import static org.assertj.core.api.Assertions.assertThat; 23 | 24 | @ExtendWith(MockitoExtension.class) 25 | class BeanValidatorTest { 26 | 27 | @Mock 28 | private LocalValidatorFactoryBean validator; 29 | 30 | @Mock 31 | private ObjectFactory problemBuilder; 32 | 33 | @InjectMocks 34 | private BeanValidator subject; 35 | 36 | @BeforeEach 37 | void setUp() { 38 | mockValidator(validator); 39 | } 40 | 41 | @ParameterizedTest 42 | @NullSource 43 | @ValueSource(strings = {" ", "99999999", "AA99aa!", "AAA99aaa"}) 44 | void should_preventSignup_when_passwordIsInvalid(String password) { 45 | 46 | // given 47 | var request = new SignupRequest(null, password, null, null); 48 | mockProblemBuilder(problemBuilder); 49 | 50 | // when 51 | var possibleProblem = subject.validate(request); 52 | 53 | // then 54 | assertThat(possibleProblem).isNotEmpty(); 55 | var problem = possibleProblem.orElseThrow(); 56 | assertThat(problem.id()).isNotBlank(); 57 | assertThat(problem.type()).isEqualTo(INVALID_DATA.getType()); 58 | assertThat(problem.title()).isEqualTo(ProblemType.INVALID_DATA.getTitle()); 59 | assertThat(problem.status()).isEqualTo(ProblemType.INVALID_DATA.getStatus().value()); 60 | assertThat(problem.detail()).isEqualTo(request.toString()); 61 | assertThat(problem.instance()).isNull(); 62 | 63 | assertThat(problem.errors()).isNotEmpty(); 64 | var possibleError = problem.errors().stream().filter(e -> e.field().equals("password")).findAny(); 65 | assertThat(possibleError).isNotEmpty(); 66 | var error = possibleError.orElseThrow(); 67 | assertThat(error.code()).isEqualTo("invalid"); 68 | assertThat(error.field()).isEqualTo("password"); 69 | assertThat(error.message()).isEqualTo("Password must have least 1 upper, lower, special characters and digit, min 8 chars, max 50 chars"); 70 | } 71 | 72 | @Test 73 | void should_beNoProblem_when_valid() { 74 | 75 | // given 76 | var request = new SignupRequest("email@example.com", RANDOM_USER_PASSWORD, "Some name", null); 77 | 78 | // when 79 | var possibleProblem = subject.validate(request); 80 | 81 | // then 82 | assertThat(possibleProblem).isEmpty(); 83 | } 84 | } -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/user/features/verification/VerificationMailSenderTest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.features.verification; 2 | 3 | import com.naturalprogrammer.springmvc.common.MessageGetter; 4 | import com.naturalprogrammer.springmvc.common.jwt.JweService; 5 | import com.naturalprogrammer.springmvc.common.mail.MailData; 6 | import com.naturalprogrammer.springmvc.common.mail.MailSender; 7 | import com.naturalprogrammer.springmvc.config.MyProperties; 8 | import org.junit.jupiter.api.Test; 9 | import org.junit.jupiter.api.extension.ExtendWith; 10 | import org.mockito.ArgumentCaptor; 11 | import org.mockito.Captor; 12 | import org.mockito.InjectMocks; 13 | import org.mockito.Mock; 14 | import org.mockito.junit.jupiter.MockitoExtension; 15 | 16 | import java.time.Clock; 17 | import java.time.Instant; 18 | import java.time.temporal.ChronoUnit; 19 | import java.util.Date; 20 | import java.util.Map; 21 | import java.util.UUID; 22 | 23 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.EMAIL_VERIFICATION; 24 | import static com.naturalprogrammer.springmvc.common.jwt.JwtPurpose.PURPOSE; 25 | import static com.naturalprogrammer.springmvc.user.UserTestUtils.FAKER; 26 | import static com.naturalprogrammer.springmvc.user.UserTestUtils.randomUser; 27 | import static com.naturalprogrammer.springmvc.user.features.verification.VerificationMailSender.VERIFICATION_TOKEN_VALID_DAYS; 28 | import static org.assertj.core.api.Assertions.assertThat; 29 | import static org.mockito.ArgumentMatchers.any; 30 | import static org.mockito.BDDMockito.given; 31 | import static org.mockito.BDDMockito.willDoNothing; 32 | import static org.mockito.Mockito.verify; 33 | import static org.springframework.security.oauth2.core.oidc.StandardClaimNames.EMAIL; 34 | 35 | @ExtendWith(MockitoExtension.class) 36 | class VerificationMailSenderTest { 37 | 38 | @Mock 39 | private JweService jweService; 40 | 41 | @Mock 42 | private Clock clock; 43 | 44 | @Mock 45 | private MessageGetter messageGetter; 46 | 47 | @Mock 48 | private MailSender mailSender; 49 | 50 | @Mock 51 | private MyProperties properties; 52 | 53 | @InjectMocks 54 | private VerificationMailSender subject; 55 | 56 | @Captor 57 | private ArgumentCaptor mailCaptor; 58 | 59 | @Test 60 | void should_sendVerificationMail() { 61 | 62 | // given 63 | var user = randomUser(); 64 | var now = Instant.now(); 65 | var verificationToken = UUID.randomUUID().toString(); 66 | var mailSubject = "Verification mail subject"; 67 | var mailBody = "Verification mail body %s %s".formatted(user.getDisplayName(), verificationToken); 68 | var homepage = FAKER.internet().url(); 69 | given(clock.instant()).willReturn(now); 70 | given(jweService.createToken( 71 | user.getIdStr(), 72 | Date.from(now.plus(VERIFICATION_TOKEN_VALID_DAYS, ChronoUnit.DAYS)), 73 | Map.of(PURPOSE, EMAIL_VERIFICATION, EMAIL, user.getEmail()))) 74 | .willReturn(verificationToken); 75 | given(properties.homepage()).willReturn(homepage); 76 | given(messageGetter.getMessage("verification-mail-subject")).willReturn(mailSubject); 77 | given(messageGetter.getMessage("verification-mail-body", 78 | user.getDisplayName(), homepage, verificationToken)) 79 | .willReturn(mailBody); 80 | willDoNothing().given(mailSender).send(any()); 81 | 82 | // when 83 | subject.send(user); 84 | 85 | // then 86 | verify(mailSender).send(mailCaptor.capture()); 87 | var mailData = mailCaptor.getValue(); 88 | assertThat(mailData.to()).isEqualTo(user.getEmail()); 89 | assertThat(mailData.subject()).isEqualTo(mailSubject); 90 | assertThat(mailData.bodyHtml()).isEqualTo(mailBody); 91 | assertThat(mailData.attachment()).isNull(); 92 | } 93 | } -------------------------------------------------------------------------------- /src/test/java/com/naturalprogrammer/springmvc/user/validators/PasswordValidatorTest.java: -------------------------------------------------------------------------------- 1 | package com.naturalprogrammer.springmvc.user.validators; 2 | 3 | import org.junit.jupiter.params.ParameterizedTest; 4 | import org.junit.jupiter.params.provider.ValueSource; 5 | 6 | import static org.assertj.core.api.Assertions.assertThat; 7 | 8 | class PasswordValidatorTest { 9 | 10 | private final PasswordValidator subject = new PasswordValidator(); 11 | 12 | @ParameterizedTest 13 | @ValueSource(strings = { 14 | "Password10!", 15 | "0assworD@" 16 | }) 17 | void testValid(String password) { 18 | assertThat(subject.isValid(password, null)).isTrue(); 19 | } 20 | 21 | @ParameterizedTest 22 | @ValueSource(strings = { 23 | "password10!", 24 | "PASSWORD10@", 25 | "Pas99!@" 26 | }) 27 | void testInvalid(String password) { 28 | assertThat(subject.isValid(password, null)).isFalse(); 29 | } 30 | } -------------------------------------------------------------------------------- /src/test/resources/config/application-test.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | 3 | datasource: 4 | url: jdbc:tc:postgresql:15.1://np-spring-mvc-demo-tc 5 | password: np-password-tc 6 | 7 | security: 8 | oauth2: 9 | client: 10 | registration: 11 | google: 12 | client-id: dummy 13 | client-secret: dummy 14 | 15 | my: 16 | homepage: http://www.example.com 17 | jws: 18 | id: e0498dad-4f5f-40cf-86e3-2726ec78463d 19 | public-key: classpath:/config/rsa-2048-public-key.txt 20 | private-key: classpath:/config/rsa-2048-private-key.txt 21 | jwe: 22 | id: c00f9459-82cb-48bc-882d-66b3b65258b4 23 | key: 841D8A6C80CBA4FCAD32D5367C18C53B 24 | -------------------------------------------------------------------------------- /src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline -------------------------------------------------------------------------------- /src/test/resources/test-data/sql/before-each-test.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM usr; --------------------------------------------------------------------------------