├── .gitattributes ├── .github └── workflows │ ├── branch.yml │ ├── mac.yml │ ├── main.yml │ ├── pull.yml │ └── release.yml ├── .gitignore ├── .mvn └── wrapper │ ├── MavenWrapperDownloader.java │ ├── maven-wrapper.jar │ └── maven-wrapper.properties ├── Dockerfile ├── LICENSE ├── README.md ├── architecture.png ├── demo_admin.png ├── demo_login.png ├── docker-compose.yaml ├── env └── application.env.example ├── jwtRS256.sh ├── mvnw ├── mvnw.cmd ├── nginx ├── data │ └── .gitignore ├── nginx-certbot.env.example └── user_conf.d │ └── server.conf ├── pom.xml ├── postgresql └── .gitignore ├── redis └── data │ └── .gitignore ├── src ├── main │ ├── java │ │ └── com │ │ │ └── joejoe2 │ │ │ └── demo │ │ │ ├── DemoApplication.java │ │ │ ├── config │ │ │ ├── CorsConfig.java │ │ │ ├── InterceptorConfig.java │ │ │ ├── JwtConfig.java │ │ │ ├── LoginConfig.java │ │ │ ├── PrivateKeyConverter.java │ │ │ ├── PublicKeyConverter.java │ │ │ ├── RedisConfig.java │ │ │ ├── ResetPasswordURL.java │ │ │ ├── RetryConfig.java │ │ │ ├── SecurityConfig.java │ │ │ └── SpringDocConfig.java │ │ │ ├── controller │ │ │ ├── AdminController.java │ │ │ ├── AuthController.java │ │ │ ├── GlobalExceptionHandler.java │ │ │ ├── UserController.java │ │ │ └── constraint │ │ │ │ ├── auth │ │ │ │ ├── ApiAllowsTo.java │ │ │ │ ├── ApiRejectTo.java │ │ │ │ └── AuthenticatedApi.java │ │ │ │ ├── checker │ │ │ │ ├── ControllerAuthConstraintChecker.java │ │ │ │ └── ControllerRateConstraintChecker.java │ │ │ │ └── rate │ │ │ │ ├── LimitTarget.java │ │ │ │ └── RateLimit.java │ │ │ ├── data │ │ │ ├── ErrorMessageResponse.java │ │ │ ├── InvalidRequestResponse.java │ │ │ ├── PageList.java │ │ │ ├── PageOfUserProfile.java │ │ │ ├── PageRequest.java │ │ │ ├── admin │ │ │ │ └── request │ │ │ │ │ ├── ChangeUserRoleRequest.java │ │ │ │ │ └── UserIdRequest.java │ │ │ ├── auth │ │ │ │ ├── AccessTokenSpec.java │ │ │ │ ├── RefreshTokenSpec.java │ │ │ │ ├── TokenPair.java │ │ │ │ ├── TokenResponse.java │ │ │ │ ├── UserDetail.java │ │ │ │ ├── VerificationKey.java │ │ │ │ ├── VerificationPair.java │ │ │ │ └── request │ │ │ │ │ ├── ChangePasswordRequest.java │ │ │ │ │ ├── ForgetPasswordRequest.java │ │ │ │ │ ├── IntrospectionRequest.java │ │ │ │ │ ├── IssueVerificationCodeRequest.java │ │ │ │ │ ├── LoginRequest.java │ │ │ │ │ ├── RefreshRequest.java │ │ │ │ │ ├── RegisterRequest.java │ │ │ │ │ └── ResetPasswordRequest.java │ │ │ └── user │ │ │ │ └── UserProfile.java │ │ │ ├── exception │ │ │ ├── AlreadyExist.java │ │ │ ├── ControllerConstraintViolation.java │ │ │ ├── InvalidOperation.java │ │ │ ├── InvalidTokenException.java │ │ │ ├── UserDoesNotExist.java │ │ │ └── ValidationError.java │ │ │ ├── filter │ │ │ └── JwtAuthenticationFilter.java │ │ │ ├── init │ │ │ ├── DefaultAdminInitializer.java │ │ │ └── JobRunrInitializer.java │ │ │ ├── interceptor │ │ │ └── ControllerConstraintInterceptor.java │ │ │ ├── job │ │ │ ├── handler │ │ │ │ ├── CleanUpJWTTokensHandler.java │ │ │ │ └── CleanUpVerificationsHandler.java │ │ │ └── request │ │ │ │ ├── CleanUpJWTTokensJob.java │ │ │ │ └── CleanUpVerificationsJob.java │ │ │ ├── model │ │ │ ├── Base.java │ │ │ ├── auth │ │ │ │ ├── AccessToken.java │ │ │ │ ├── LoginAttempt.java │ │ │ │ ├── RefreshToken.java │ │ │ │ ├── Role.java │ │ │ │ ├── User.java │ │ │ │ ├── VerificationCode.java │ │ │ │ └── VerifyToken.java │ │ │ └── generator │ │ │ │ └── UUIDv7Generator.java │ │ │ ├── repository │ │ │ ├── jwt │ │ │ │ ├── AccessTokenRepository.java │ │ │ │ └── RefreshTokenRepository.java │ │ │ ├── user │ │ │ │ └── UserRepository.java │ │ │ └── verification │ │ │ │ ├── VerificationCodeRepository.java │ │ │ │ └── VerifyTokenRepository.java │ │ │ ├── service │ │ │ ├── email │ │ │ │ ├── EmailService.java │ │ │ │ └── EmailServiceImpl.java │ │ │ ├── jwt │ │ │ │ ├── JwtService.java │ │ │ │ └── JwtServiceImpl.java │ │ │ ├── redis │ │ │ │ ├── RedisService.java │ │ │ │ └── RedisServiceImpl.java │ │ │ ├── user │ │ │ │ ├── UserDetailService.java │ │ │ │ ├── auth │ │ │ │ │ ├── ActivationService.java │ │ │ │ │ ├── ActivationServiceImpl.java │ │ │ │ │ ├── LoginService.java │ │ │ │ │ ├── LoginServiceImpl.java │ │ │ │ │ ├── PasswordService.java │ │ │ │ │ ├── PasswordServiceImpl.java │ │ │ │ │ ├── RegistrationService.java │ │ │ │ │ ├── RegistrationServiceImpl.java │ │ │ │ │ ├── RoleService.java │ │ │ │ │ └── RoleServiceImpl.java │ │ │ │ └── profile │ │ │ │ │ ├── ProfileService.java │ │ │ │ │ └── ProfileServiceImpl.java │ │ │ └── verification │ │ │ │ ├── VerificationService.java │ │ │ │ └── VerificationServiceImpl.java │ │ │ ├── utils │ │ │ ├── AuthUtil.java │ │ │ ├── CookieUtils.java │ │ │ ├── IPUtils.java │ │ │ ├── JwtUtil.java │ │ │ └── Utils.java │ │ │ └── validation │ │ │ ├── constraint │ │ │ ├── Email.java │ │ │ ├── Password.java │ │ │ ├── Role.java │ │ │ ├── UUID.java │ │ │ └── Username.java │ │ │ └── validator │ │ │ ├── EmailValidator.java │ │ │ ├── PasswordValidator.java │ │ │ ├── RoleValidator.java │ │ │ ├── UUIDValidator.java │ │ │ ├── UserNameValidator.java │ │ │ └── Validator.java │ └── resources │ │ ├── application-dev.properties │ │ ├── application-dev.yml │ │ ├── application-test.properties │ │ ├── application-test.yml │ │ └── db │ │ ├── changelog │ │ ├── 2023 │ │ │ ├── 01 │ │ │ │ └── 02-01-changelog.yaml │ │ │ └── 03 │ │ │ │ └── 03-01-changelog.yaml │ │ └── db.changelog-master.yaml │ │ └── test │ │ └── changelog │ │ ├── 2023 │ │ ├── 01 │ │ │ └── 02-01-changelog.yaml │ │ └── 03 │ │ │ └── 03-01-changelog.yaml │ │ └── db.changelog-master.yaml └── test │ └── java │ └── com │ └── joejoe2 │ └── demo │ ├── DemoApplicationTests.java │ ├── TestContext.java │ ├── controller │ ├── AdminControllerTest.java │ ├── AuthControllerTest.java │ ├── UserControllerTest.java │ └── constraint │ │ └── checker │ │ ├── ControllerAuthConstraintCheckerTest.java │ │ └── ControllerRateConstraintCheckerTest.java │ ├── service │ ├── email │ │ └── EmailServiceTest.java │ ├── jwt │ │ └── JwtServiceTest.java │ ├── redis │ │ └── RedisServiceTest.java │ ├── user │ │ ├── UserDetailServiceTest.java │ │ ├── auth │ │ │ ├── ActivationServiceTest.java │ │ │ ├── LoginServiceTest.java │ │ │ ├── PasswordServiceTest.java │ │ │ ├── RegistrationServiceTest.java │ │ │ └── RoleServiceTest.java │ │ └── profile │ │ │ └── ProfileServiceTest.java │ └── verification │ │ └── VerificationServiceTest.java │ ├── utils │ ├── AuthUtilTest.java │ ├── IPUtilsTest.java │ ├── JwtUtilTest.java │ └── UtilsTest.java │ └── validation │ └── validator │ ├── EmailValidatorTest.java │ ├── PasswordValidatorTest.java │ ├── UUIDValidatorTest.java │ └── UserNameValidatorTest.java ├── start.sh └── wait-for-it.sh /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/branch.yml: -------------------------------------------------------------------------------- 1 | name: branch-ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'v**' 7 | paths: 8 | - 'src/**' 9 | - 'pom.xml' 10 | - 'Dockerfile' 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v2 # downloads a copy of repository 19 | - name: Setup JDK 17 20 | uses: actions/setup-java@v2 21 | with: 22 | java-version: '17' 23 | distribution: 'adopt' 24 | 25 | - name: Test 26 | run: mvn test 27 | 28 | - name: Build jar 29 | run: mvn package -Dmaven.test.skip=true 30 | 31 | - name: Codecov 32 | uses: codecov/codecov-action@v2 33 | with: 34 | token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos 35 | files: ./target/site/jacoco/jacoco.xml # optional 36 | fail_ci_if_error: true # optional (default = false) 37 | verbose: true # optional (default = false) 38 | -------------------------------------------------------------------------------- /.github/workflows/mac.yml: -------------------------------------------------------------------------------- 1 | name: mac 2 | 3 | on: workflow_dispatch 4 | 5 | 6 | 7 | 8 | 9 | 10 | jobs: 11 | build: 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 # downloads a copy of repository 17 | - name: Setup JDK 17 18 | uses: actions/setup-java@v2 19 | with: 20 | java-version: '17' 21 | distribution: 'adopt' 22 | - name: Test 23 | run: mvn test 24 | - name: Build jar 25 | run: mvn package -Dmaven.test.skip=true 26 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: workflow 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'src/**' 9 | - 'pom.xml' 10 | - 'Dockerfile' 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v2 # downloads a copy of repository 19 | - name: Setup JDK 17 20 | uses: actions/setup-java@v2 21 | with: 22 | java-version: '17' 23 | distribution: 'adopt' 24 | - name: Test 25 | run: mvn test 26 | - name: Build jar 27 | run: mvn package -Dmaven.test.skip=true 28 | - name: Login to Docker Hub 29 | uses: docker/login-action@v1 30 | with: 31 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 32 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 33 | - name: Build and Push to Docker Hub 34 | uses: docker/build-push-action@v2 35 | with: 36 | context: . 37 | file: ./Dockerfile 38 | push: true 39 | tags: ${{ secrets.DOCKER_HUB_USERNAME }}/spring-jwt-template:latest 40 | - name: Codecov 41 | uses: codecov/codecov-action@v2 42 | with: 43 | token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos 44 | files: ./target/site/jacoco/jacoco.xml # optional 45 | fail_ci_if_error: true # optional (default = false) 46 | verbose: true # optional (default = false) 47 | -------------------------------------------------------------------------------- /.github/workflows/pull.yml: -------------------------------------------------------------------------------- 1 | name: pull 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | check: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v2 # downloads a copy of repository 11 | 12 | - name: Setup JDK 17 13 | uses: actions/setup-java@v2 14 | with: 15 | java-version: '17' 16 | distribution: 'adopt' 17 | 18 | - name: Test 19 | run: mvn test 20 | 21 | - name: Build jar 22 | run: mvn package -Dmaven.test.skip=true 23 | 24 | - name: Codecov 25 | uses: codecov/codecov-action@v2 26 | with: 27 | token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos 28 | files: ./target/site/jacoco/jacoco.xml # optional 29 | fail_ci_if_error: true # optional (default = false) 30 | verbose: true # optional (default = false) -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 # downloads a copy of repository 15 | 16 | - name: Setup JDK 17 17 | uses: actions/setup-java@v2 18 | with: 19 | java-version: '17' 20 | distribution: 'adopt' 21 | 22 | - name: Test 23 | run: mvn test 24 | 25 | - name: Build jar 26 | run: mvn package -Dmaven.test.skip=true 27 | 28 | - name: Login to Docker Hub 29 | uses: docker/login-action@v1 30 | with: 31 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 32 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 33 | 34 | - name: Write release version 35 | run: | 36 | VERSION=${GITHUB_REF_NAME#v} 37 | echo Version: $VERSION 38 | echo "VERSION=$VERSION" >> $GITHUB_ENV 39 | 40 | - name: Build and Push to Docker Hub 41 | uses: docker/build-push-action@v2 42 | with: 43 | context: . 44 | file: ./Dockerfile 45 | push: true 46 | tags: ${{ secrets.DOCKER_HUB_USERNAME }}/spring-jwt-template:${{ env.VERSION }} 47 | -------------------------------------------------------------------------------- /.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 | .jpb 22 | 23 | ### NetBeans ### 24 | /nbproject/private/ 25 | /nbbuild/ 26 | /dist/ 27 | /nbdist/ 28 | /.nb-gradle/ 29 | build/ 30 | !**/src/main/**/build/ 31 | !**/src/test/**/build/ 32 | 33 | ### VS Code ### 34 | .vscode/ 35 | 36 | ### other files ### 37 | src/main/resources/application.properties 38 | *.key 39 | *.pem 40 | src/main/resources/application.yml 41 | *.env 42 | -------------------------------------------------------------------------------- /.mvn/wrapper/MavenWrapperDownloader.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2007-present the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this 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 | import java.net.*; 17 | import java.io.*; 18 | import java.nio.channels.*; 19 | import java.util.Properties; 20 | 21 | public class MavenWrapperDownloader { 22 | 23 | private static final String WRAPPER_VERSION = "0.5.6"; 24 | /** 25 | * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided. 26 | */ 27 | private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/" 28 | + WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar"; 29 | 30 | /** 31 | * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to 32 | * use instead of the default one. 33 | */ 34 | private static final String MAVEN_WRAPPER_PROPERTIES_PATH = 35 | ".mvn/wrapper/maven-wrapper.properties"; 36 | 37 | /** 38 | * Path where the maven-wrapper.jar will be saved to. 39 | */ 40 | private static final String MAVEN_WRAPPER_JAR_PATH = 41 | ".mvn/wrapper/maven-wrapper.jar"; 42 | 43 | /** 44 | * Name of the property which should be used to override the default download url for the wrapper. 45 | */ 46 | private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; 47 | 48 | public static void main(String args[]) { 49 | System.out.println("- Downloader started"); 50 | File baseDirectory = new File(args[0]); 51 | System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath()); 52 | 53 | // If the maven-wrapper.properties exists, read it and check if it contains a custom 54 | // wrapperUrl parameter. 55 | File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH); 56 | String url = DEFAULT_DOWNLOAD_URL; 57 | if(mavenWrapperPropertyFile.exists()) { 58 | FileInputStream mavenWrapperPropertyFileInputStream = null; 59 | try { 60 | mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile); 61 | Properties mavenWrapperProperties = new Properties(); 62 | mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream); 63 | url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url); 64 | } catch (IOException e) { 65 | System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'"); 66 | } finally { 67 | try { 68 | if(mavenWrapperPropertyFileInputStream != null) { 69 | mavenWrapperPropertyFileInputStream.close(); 70 | } 71 | } catch (IOException e) { 72 | // Ignore ... 73 | } 74 | } 75 | } 76 | System.out.println("- Downloading from: " + url); 77 | 78 | File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH); 79 | if(!outputFile.getParentFile().exists()) { 80 | if(!outputFile.getParentFile().mkdirs()) { 81 | System.out.println( 82 | "- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'"); 83 | } 84 | } 85 | System.out.println("- Downloading to: " + outputFile.getAbsolutePath()); 86 | try { 87 | downloadFileFromURL(url, outputFile); 88 | System.out.println("Done"); 89 | System.exit(0); 90 | } catch (Throwable e) { 91 | System.out.println("- Error downloading"); 92 | e.printStackTrace(); 93 | System.exit(1); 94 | } 95 | } 96 | 97 | private static void downloadFileFromURL(String urlString, File destination) throws Exception { 98 | if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) { 99 | String username = System.getenv("MVNW_USERNAME"); 100 | char[] password = System.getenv("MVNW_PASSWORD").toCharArray(); 101 | Authenticator.setDefault(new Authenticator() { 102 | @Override 103 | protected PasswordAuthentication getPasswordAuthentication() { 104 | return new PasswordAuthentication(username, password); 105 | } 106 | }); 107 | } 108 | URL website = new URL(urlString); 109 | ReadableByteChannel rbc; 110 | rbc = Channels.newChannel(website.openStream()); 111 | FileOutputStream fos = new FileOutputStream(destination); 112 | fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); 113 | fos.close(); 114 | rbc.close(); 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joejoe2/spring-jwt-template/14c62610941872014865cffff199f1502eeef09e/.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.3/apache-maven-3.8.3-bin.zip 2 | wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM eclipse-temurin:17-jre 2 | EXPOSE 8080 3 | COPY start.sh wait-for-it.sh . 4 | RUN chmod +x start.sh && chmod +x wait-for-it.sh 5 | COPY ./target/jwt.jar jwt.jar 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 joejoe2 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joejoe2/spring-jwt-template/14c62610941872014865cffff199f1502eeef09e/architecture.png -------------------------------------------------------------------------------- /demo_admin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joejoe2/spring-jwt-template/14c62610941872014865cffff199f1502eeef09e/demo_admin.png -------------------------------------------------------------------------------- /demo_login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joejoe2/spring-jwt-template/14c62610941872014865cffff199f1502eeef09e/demo_login.png -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | redis: 5 | image: redis:6.2.7-alpine 6 | restart: always 7 | volumes: 8 | - ./redis/data:/data 9 | networks: 10 | - spring-net 11 | 12 | db: 13 | image: postgres:15.1 14 | restart: always 15 | environment: 16 | POSTGRES_PASSWORD: root_password # please change it 17 | POSTGRES_DB: spring 18 | volumes: 19 | - ./postgresql/data:/var/lib/postgresql/data 20 | networks: 21 | - spring-net 22 | 23 | web: 24 | image: joejoe2/spring-jwt-template:latest 25 | restart: always 26 | command: "bash ./wait-for-it.sh -t 0 db:5432 -- bash start.sh" 27 | env_file: 28 | - ./env/application.env 29 | networks: 30 | - spring-net 31 | depends_on: 32 | - redis 33 | - db 34 | 35 | nginx: 36 | image: jonasal/nginx-certbot:latest 37 | restart: unless-stopped 38 | env_file: 39 | - ./nginx/nginx-certbot.env 40 | ports: 41 | - 80:80 42 | - 443:443 43 | volumes: 44 | - ./nginx/data:/etc/letsencrypt 45 | - ./nginx/user_conf.d:/etc/nginx/user_conf.d 46 | networks: 47 | - spring-net 48 | depends_on: 49 | - web 50 | 51 | 52 | networks: 53 | spring-net: 54 | 55 | -------------------------------------------------------------------------------- /env/application.env.example: -------------------------------------------------------------------------------- 1 | # db related settings 2 | spring.datasource.url=jdbc:postgresql://db:5432/spring 3 | spring.datasource.username=postgres 4 | spring.datasource.password=root_password 5 | spring.datasource.driver-class-name=org.postgresql.Driver 6 | spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect 7 | spring.jpa.properties.hibernate.hbm2ddl.auto=none 8 | spring.jpa.properties.hibernate.jdbc.batch_size=10 9 | spring.jpa.open-in-view=false 10 | spring.liquibase.enabled=true 11 | 12 | # redis related settings 13 | spring.data.redis.host=redis 14 | spring.data.redis.port=6379 15 | 16 | # create default admin account 17 | default.admin.username=admin 18 | default.admin.password=pa55ward 19 | default.admin.email=admin@email.com 20 | 21 | # jwt related settings 22 | jwt.issuer=joejoe2.com 23 | # domain for access/refresh tokens in cookie(if you are using web login api) 24 | # can be exact domain or example.com for all subdomains 25 | jwt.cookie.domain=example.com 26 | jwt.secret.privateKey= 27 | jwt.secret.publicKey= 28 | # in seconds 29 | jwt.access.token.lifetime=900 30 | jwt.refresh.token.lifetime=1800 31 | 32 | # set allow host (frontend) 33 | allow.host=http://localhost:[*] 34 | # set reset password url 35 | reset.password.url=http://localhost:8888/resetPassword?token= 36 | 37 | # login max attempt settings 38 | login.maxAttempts=5 39 | # in seconds 40 | login.attempts.coolTime=900 41 | 42 | # mail sender 43 | spring.mail.host=smtp.gmail.com 44 | spring.mail.port=587 45 | spring.mail.username=test@gmail.com 46 | spring.mail.password=pa55ward 47 | spring.mail.properties.mail.smtp.auth=true 48 | spring.mail.properties.mail.smtp.starttls.enable=true 49 | 50 | # for nginx 51 | server.forward-headers-strategy=native 52 | server.tomcat.remote-ip-header=x-forwarded-for 53 | server.tomcat.protocol-header=x-forwarded-proto 54 | 55 | # jobrunr 56 | org.jobrunr.background-job-server.enabled=true 57 | org.jobrunr.dashboard.enabled=false 58 | org.jobrunr.database.type=sql 59 | init.recurrent-job=true 60 | 61 | # open api 62 | springdoc.api-docs.enabled=false 63 | springdoc.swagger-ui.enabled=false 64 | -------------------------------------------------------------------------------- /jwtRS256.sh: -------------------------------------------------------------------------------- 1 | # generate private key 2 | openssl genrsa -out private.pem 2048 3 | # extatract public key from it 4 | openssl rsa -in private.pem -pubout > public.key 5 | openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private.pem -out private.key -------------------------------------------------------------------------------- /nginx/data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /nginx/nginx-certbot.env.example: -------------------------------------------------------------------------------- 1 | # Required 2 | CERTBOT_EMAIL= 3 | 4 | # Optional (Defaults) 5 | STAGING=0 6 | DHPARAM_SIZE=2048 7 | RSA_KEY_SIZE=2048 8 | ELLIPTIC_CURVE=secp256r1 9 | USE_ECDSA=0 10 | RENEWAL_INTERVAL=1d 11 | 12 | # Advanced (Defaults) 13 | DEBUG=0 14 | USE_LOCAL_CA=0 15 | -------------------------------------------------------------------------------- /nginx/user_conf.d/server.conf: -------------------------------------------------------------------------------- 1 | server { 2 | # Listen to port 443 on both IPv4 and IPv6. 3 | listen 443 ssl default_server reuseport; 4 | listen [::]:443 ssl default_server reuseport; 5 | 6 | # Domain names this server should respond to. 7 | server_name example.com; 8 | 9 | # Load the certificate files. 10 | ssl_certificate /etc/letsencrypt/live/test-name/fullchain.pem; 11 | ssl_certificate_key /etc/letsencrypt/live/test-name/privkey.pem; 12 | ssl_trusted_certificate /etc/letsencrypt/live/test-name/chain.pem; 13 | 14 | # Load the Diffie-Hellman parameter. 15 | ssl_dhparam /etc/letsencrypt/dhparams/dhparam.pem; 16 | 17 | # proxy 18 | location / { 19 | proxy_pass http://web:8080; 20 | proxy_set_header Host $host; 21 | proxy_set_header X-Real-IP $remote_addr; 22 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 23 | proxy_set_header REMOTE-HOST $remote_addr; 24 | proxy_set_header X-Forwarded-Proto $scheme; 25 | proxy_redirect off; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /postgresql/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /redis/data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/DemoApplication.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo; 2 | 3 | import jakarta.annotation.PostConstruct; 4 | import java.util.Locale; 5 | import java.util.TimeZone; 6 | import org.springframework.boot.SpringApplication; 7 | import org.springframework.boot.autoconfigure.SpringBootApplication; 8 | import org.springframework.scheduling.annotation.EnableAsync; 9 | 10 | @SpringBootApplication 11 | @EnableAsync 12 | public class DemoApplication { 13 | public static void main(String[] args) { 14 | Locale.setDefault(Locale.ENGLISH); 15 | SpringApplication.run(DemoApplication.class, args); 16 | } 17 | 18 | @PostConstruct 19 | public void init() { 20 | TimeZone.setDefault(TimeZone.getTimeZone("UTC")); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/config/CorsConfig.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.config; 2 | 3 | import lombok.Data; 4 | import org.springframework.beans.factory.annotation.Value; 5 | import org.springframework.context.annotation.Configuration; 6 | 7 | @Data 8 | @Configuration 9 | public class CorsConfig { 10 | @Value("${allow.host}") 11 | private String allowOrigin; 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/config/InterceptorConfig.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.config; 2 | 3 | import com.joejoe2.demo.interceptor.ControllerConstraintInterceptor; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.web.servlet.config.annotation.EnableWebMvc; 7 | import org.springframework.web.servlet.config.annotation.InterceptorRegistry; 8 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 9 | 10 | @Configuration 11 | @EnableWebMvc 12 | public class InterceptorConfig implements WebMvcConfigurer { 13 | @Autowired ControllerConstraintInterceptor controllerConstraintInterceptor; 14 | 15 | @Override 16 | public void addInterceptors(InterceptorRegistry registry) { 17 | registry.addInterceptor(controllerConstraintInterceptor).addPathPatterns("/**"); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/config/JwtConfig.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.config; 2 | 3 | import java.security.interfaces.RSAPrivateKey; 4 | import java.security.interfaces.RSAPublicKey; 5 | import lombok.Data; 6 | import org.springframework.beans.factory.annotation.Value; 7 | import org.springframework.context.annotation.Configuration; 8 | 9 | @Data 10 | @Configuration 11 | public class JwtConfig { 12 | @Value("${jwt.secret.privateKey}") 13 | private RSAPrivateKey privateKey; 14 | 15 | @Value("${jwt.secret.publicKey}") 16 | private RSAPublicKey publicKey; 17 | 18 | @Value("${jwt.access.token.lifetime:600}") 19 | private int accessTokenLifetimeSec; 20 | 21 | @Value("${jwt.refresh.token.lifetime:900}") 22 | private int refreshTokenLifetimeSec; 23 | 24 | @Value("${jwt.issuer:issuer}") 25 | private String issuer; 26 | 27 | @Value("${jwt.cookie.domain:.${jwt.issuer}}") 28 | private String cookieDomain; 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/config/LoginConfig.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.config; 2 | 3 | import lombok.Data; 4 | import org.springframework.beans.factory.annotation.Value; 5 | import org.springframework.context.annotation.Configuration; 6 | 7 | @Data 8 | @Configuration 9 | public class LoginConfig { 10 | @Value("${login.maxAttempts:5}") 11 | private int maxAttempts; 12 | 13 | @Value("${login.attempts.coolTime:900}") 14 | private int coolTime; 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/config/PrivateKeyConverter.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.config; 2 | 3 | import java.security.KeyFactory; 4 | import java.security.PrivateKey; 5 | import java.security.spec.PKCS8EncodedKeySpec; 6 | import java.util.Base64; 7 | import lombok.SneakyThrows; 8 | import org.springframework.boot.context.properties.ConfigurationPropertiesBinding; 9 | import org.springframework.core.convert.converter.Converter; 10 | import org.springframework.stereotype.Component; 11 | 12 | @Component 13 | @ConfigurationPropertiesBinding 14 | public class PrivateKeyConverter implements Converter { 15 | @SneakyThrows 16 | @Override 17 | public PrivateKey convert(String from) { 18 | byte[] bytes = Base64.getDecoder().decode(from.getBytes()); 19 | PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(bytes); 20 | KeyFactory factory = KeyFactory.getInstance("RSA"); 21 | return factory.generatePrivate(spec); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/config/PublicKeyConverter.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.config; 2 | 3 | import java.io.ByteArrayInputStream; 4 | import java.security.PublicKey; 5 | import lombok.SneakyThrows; 6 | import org.springframework.boot.context.properties.ConfigurationPropertiesBinding; 7 | import org.springframework.core.convert.converter.Converter; 8 | import org.springframework.security.converter.RsaKeyConverters; 9 | import org.springframework.stereotype.Component; 10 | 11 | @Component 12 | @ConfigurationPropertiesBinding 13 | public class PublicKeyConverter implements Converter { 14 | @SneakyThrows 15 | @Override 16 | public PublicKey convert(String from) { 17 | return RsaKeyConverters.x509().convert(new ByteArrayInputStream(from.getBytes())); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/config/RedisConfig.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.config; 2 | 3 | import java.util.Map; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.context.annotation.Bean; 6 | import org.springframework.context.annotation.Configuration; 7 | import org.springframework.data.redis.connection.RedisConnectionFactory; 8 | import org.springframework.data.redis.core.RedisTemplate; 9 | import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; 10 | import org.springframework.data.redis.serializer.StringRedisSerializer; 11 | 12 | @Configuration 13 | public class RedisConfig { 14 | @Autowired RedisConnectionFactory redisConnectionFactory; 15 | 16 | @Bean 17 | public RedisTemplate> hashRedisTemplate() { 18 | RedisTemplate> redisTemplate = new RedisTemplate<>(); 19 | redisTemplate.setConnectionFactory(redisConnectionFactory); 20 | redisTemplate.setKeySerializer(new StringRedisSerializer()); 21 | redisTemplate.setHashKeySerializer(new StringRedisSerializer()); 22 | redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); 23 | redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); 24 | redisTemplate.afterPropertiesSet(); 25 | return redisTemplate; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/config/ResetPasswordURL.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.config; 2 | 3 | import lombok.Data; 4 | import org.springframework.beans.factory.annotation.Value; 5 | import org.springframework.context.annotation.Configuration; 6 | 7 | @Data 8 | @Configuration 9 | public class ResetPasswordURL { 10 | @Value("${reset.password.url}") 11 | String UrlPrefix; 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/config/RetryConfig.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.config; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | import org.springframework.core.Ordered; 5 | import org.springframework.retry.annotation.EnableRetry; 6 | 7 | @Configuration 8 | @EnableRetry(order = Ordered.HIGHEST_PRECEDENCE) 9 | public class RetryConfig {} 10 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/config/SecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.config; 2 | 3 | import com.joejoe2.demo.filter.JwtAuthenticationFilter; 4 | import com.joejoe2.demo.service.user.UserDetailService; 5 | import java.util.List; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.context.annotation.Bean; 8 | import org.springframework.context.annotation.Configuration; 9 | import org.springframework.security.authentication.AuthenticationManager; 10 | import org.springframework.security.config.Customizer; 11 | import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; 12 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 13 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 14 | import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; 15 | import org.springframework.security.config.http.SessionCreationPolicy; 16 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 17 | import org.springframework.security.crypto.password.PasswordEncoder; 18 | import org.springframework.security.web.SecurityFilterChain; 19 | import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; 20 | import org.springframework.web.cors.CorsConfiguration; 21 | import org.springframework.web.cors.CorsConfigurationSource; 22 | import org.springframework.web.cors.UrlBasedCorsConfigurationSource; 23 | 24 | @EnableWebSecurity 25 | @Configuration 26 | public class SecurityConfig { 27 | @Autowired UserDetailService userDetailService; 28 | @Autowired JwtAuthenticationFilter jwtAuthenticationFilter; 29 | @Autowired CorsConfig corsConfig; 30 | 31 | @Bean 32 | PasswordEncoder passwordEncoder() { 33 | return new BCryptPasswordEncoder(); 34 | } 35 | 36 | @Bean 37 | public SecurityFilterChain configure(HttpSecurity http) throws Exception { 38 | // blank will allow any request 39 | return http.cors(Customizer.withDefaults()) 40 | .csrf(AbstractHttpConfigurer::disable) 41 | .sessionManagement( 42 | session -> 43 | session.sessionCreationPolicy( 44 | SessionCreationPolicy.NEVER)) // use jwt instead of session 45 | .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) 46 | .formLogin(AbstractHttpConfigurer::disable) 47 | .build(); 48 | } 49 | 50 | @Bean 51 | public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception { 52 | // retrieve builder from httpSecurity 53 | AuthenticationManagerBuilder authenticationManagerBuilder = 54 | http.getSharedObject(AuthenticationManagerBuilder.class); 55 | authenticationManagerBuilder 56 | .userDetailsService(userDetailService) 57 | .passwordEncoder(passwordEncoder()); 58 | return authenticationManagerBuilder.build(); 59 | } 60 | 61 | @Bean 62 | CorsConfigurationSource corsConfigurationSource() { 63 | CorsConfiguration apiConfiguration = new CorsConfiguration(); 64 | apiConfiguration.setAllowedOriginPatterns(List.of(corsConfig.getAllowOrigin())); 65 | apiConfiguration.setAllowCredentials(true); 66 | apiConfiguration.addAllowedHeader(CorsConfiguration.ALL); 67 | apiConfiguration.addAllowedMethod(CorsConfiguration.ALL); 68 | UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); 69 | source.registerCorsConfiguration("/api/**", apiConfiguration); 70 | source.registerCorsConfiguration("/web/api/**", apiConfiguration); 71 | return source; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/config/SpringDocConfig.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.config; 2 | 3 | import io.swagger.v3.oas.annotations.OpenAPIDefinition; 4 | import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; 5 | import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; 6 | import io.swagger.v3.oas.annotations.info.Info; 7 | import io.swagger.v3.oas.annotations.security.SecurityScheme; 8 | import io.swagger.v3.oas.annotations.security.SecuritySchemes; 9 | import org.springframework.context.annotation.Configuration; 10 | 11 | @OpenAPIDefinition(info = @Info(title = "Spring JWT Template API", version = "v0.0.1")) 12 | @SecuritySchemes({ 13 | @SecurityScheme( 14 | name = "jwt", 15 | scheme = "bearer", 16 | bearerFormat = "jwt", 17 | type = SecuritySchemeType.HTTP, 18 | in = SecuritySchemeIn.HEADER), 19 | @SecurityScheme( 20 | name = "jwt-in-cookie", 21 | paramName = "access_token", 22 | scheme = "bearer", 23 | bearerFormat = "jwt", 24 | type = SecuritySchemeType.HTTP, 25 | in = SecuritySchemeIn.COOKIE) 26 | }) 27 | @Configuration 28 | public class SpringDocConfig {} 29 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/controller/GlobalExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.controller; 2 | 3 | import com.joejoe2.demo.data.ErrorMessageResponse; 4 | import com.joejoe2.demo.data.InvalidRequestResponse; 5 | import io.swagger.v3.oas.annotations.media.Content; 6 | import io.swagger.v3.oas.annotations.media.ExampleObject; 7 | import io.swagger.v3.oas.annotations.media.Schema; 8 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 9 | import java.util.HashMap; 10 | import java.util.Map; 11 | import java.util.TreeSet; 12 | import org.springframework.http.HttpHeaders; 13 | import org.springframework.http.HttpStatus; 14 | import org.springframework.http.HttpStatusCode; 15 | import org.springframework.http.ResponseEntity; 16 | import org.springframework.http.converter.HttpMessageNotReadableException; 17 | import org.springframework.validation.FieldError; 18 | import org.springframework.web.bind.MethodArgumentNotValidException; 19 | import org.springframework.web.bind.annotation.ControllerAdvice; 20 | import org.springframework.web.bind.annotation.ExceptionHandler; 21 | import org.springframework.web.context.request.WebRequest; 22 | import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; 23 | 24 | @ControllerAdvice 25 | public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { 26 | 27 | @Override 28 | protected ResponseEntity handleHttpMessageNotReadable( 29 | HttpMessageNotReadableException ex, 30 | HttpHeaders headers, 31 | HttpStatusCode status, 32 | WebRequest request) { 33 | return super.handleHttpMessageNotReadable(ex, headers, status, request); 34 | } 35 | 36 | @Override 37 | @ApiResponse( 38 | responseCode = "400", 39 | description = "field errors in request body/param", 40 | content = 41 | @Content( 42 | mediaType = "application/json", 43 | schema = @Schema(implementation = InvalidRequestResponse.class), 44 | examples = 45 | @ExampleObject( 46 | value = 47 | "{\"errors\":{\"field1\":[\"msg1\",\"msg2\"], " 48 | + "\"field2\":[...], ...}}"))) 49 | protected ResponseEntity handleMethodArgumentNotValid( 50 | MethodArgumentNotValidException ex, 51 | HttpHeaders headers, 52 | HttpStatusCode status, 53 | WebRequest request) { 54 | Map> errors = new HashMap<>(); 55 | for (FieldError error : ex.getFieldErrors()) { 56 | TreeSet messages = errors.getOrDefault(error.getField(), new TreeSet<>()); 57 | messages.add(error.getDefaultMessage()); 58 | errors.put(error.getField(), messages); 59 | } 60 | return ResponseEntity.badRequest().body(new InvalidRequestResponse(errors)); 61 | } 62 | 63 | @ExceptionHandler(RuntimeException.class) 64 | @ApiResponse( 65 | responseCode = "500", 66 | description = "internal server error", 67 | content = 68 | @Content( 69 | mediaType = "application/json", 70 | schema = @Schema(implementation = ErrorMessageResponse.class))) 71 | public ResponseEntity handleRuntimeException(Exception ex, WebRequest request) { 72 | ex.printStackTrace(); 73 | return new ResponseEntity<>( 74 | new ErrorMessageResponse("unknown error, please try again later !"), 75 | HttpStatus.INTERNAL_SERVER_ERROR); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/controller/UserController.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.controller; 2 | 3 | import com.joejoe2.demo.controller.constraint.auth.AuthenticatedApi; 4 | import com.joejoe2.demo.data.user.UserProfile; 5 | import com.joejoe2.demo.exception.UserDoesNotExist; 6 | import com.joejoe2.demo.service.user.profile.ProfileService; 7 | import com.joejoe2.demo.utils.AuthUtil; 8 | import io.swagger.v3.oas.annotations.Operation; 9 | import io.swagger.v3.oas.annotations.media.Content; 10 | import io.swagger.v3.oas.annotations.media.Schema; 11 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 12 | import io.swagger.v3.oas.annotations.responses.ApiResponses; 13 | import io.swagger.v3.oas.annotations.security.SecurityRequirement; 14 | import io.swagger.v3.oas.annotations.security.SecurityRequirements; 15 | import java.util.HashMap; 16 | import java.util.Map; 17 | import org.springframework.beans.factory.annotation.Autowired; 18 | import org.springframework.http.HttpStatus; 19 | import org.springframework.http.ResponseEntity; 20 | import org.springframework.stereotype.Controller; 21 | import org.springframework.web.bind.annotation.RequestMapping; 22 | import org.springframework.web.bind.annotation.RequestMethod; 23 | 24 | @Controller 25 | @RequestMapping(path = "/api/user") // path prefix 26 | public class UserController { 27 | @Autowired ProfileService profileService; 28 | 29 | @Operation( 30 | summary = "get profile of login user", 31 | description = "this is allowed to any authenticated user") 32 | @AuthenticatedApi 33 | @SecurityRequirements({ 34 | @SecurityRequirement(name = "jwt"), 35 | @SecurityRequirement(name = "jwt-in-cookie") 36 | }) 37 | @ApiResponses( 38 | value = { 39 | @ApiResponse( 40 | responseCode = "200", 41 | description = "user profile", 42 | content = 43 | @Content( 44 | mediaType = "application/json", 45 | schema = @Schema(implementation = UserProfile.class))), 46 | }) 47 | @RequestMapping(path = "/profile", method = RequestMethod.GET) 48 | public ResponseEntity profile() { 49 | Map response = new HashMap<>(); 50 | try { 51 | return ResponseEntity.ok(profileService.getProfile(AuthUtil.currentUserDetail().getId())); 52 | } catch (UserDoesNotExist ex) { 53 | // will occur if user is not in db but the userDetail is loaded before this method 54 | // with JwtAuthenticationFilter, so only the db corrupt will cause this 55 | response.put("message", "unknown error, please try again later !"); 56 | return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/controller/constraint/auth/ApiAllowsTo.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.controller.constraint.auth; 2 | 3 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 4 | 5 | import com.joejoe2.demo.model.auth.Role; 6 | import java.lang.annotation.ElementType; 7 | import java.lang.annotation.Retention; 8 | import java.lang.annotation.Target; 9 | 10 | @Target({ElementType.METHOD}) 11 | @Retention(RUNTIME) 12 | @AuthenticatedApi 13 | public @interface ApiAllowsTo { 14 | Role[] roles() default {}; 15 | 16 | String rejectMessage() default "you don't have enough permission !"; 17 | 18 | int rejectStatus() default 403; 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/controller/constraint/auth/ApiRejectTo.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.controller.constraint.auth; 2 | 3 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 4 | 5 | import com.joejoe2.demo.model.auth.Role; 6 | import java.lang.annotation.ElementType; 7 | import java.lang.annotation.Retention; 8 | import java.lang.annotation.Target; 9 | 10 | @Target({ElementType.METHOD}) 11 | @Retention(RUNTIME) 12 | @AuthenticatedApi 13 | public @interface ApiRejectTo { 14 | Role[] roles() default {}; 15 | 16 | String rejectMessage() default "you don't have enough permission !"; 17 | 18 | int rejectStatus() default 403; 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/controller/constraint/auth/AuthenticatedApi.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.controller.constraint.auth; 2 | 3 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 4 | 5 | import java.lang.annotation.ElementType; 6 | import java.lang.annotation.Retention; 7 | import java.lang.annotation.Target; 8 | 9 | @Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE}) 10 | @Retention(RUNTIME) 11 | public @interface AuthenticatedApi { 12 | String rejectMessage() default ""; 13 | 14 | int rejectStatus() default 401; 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/controller/constraint/checker/ControllerAuthConstraintChecker.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.controller.constraint.checker; 2 | 3 | import com.joejoe2.demo.controller.constraint.auth.ApiAllowsTo; 4 | import com.joejoe2.demo.controller.constraint.auth.ApiRejectTo; 5 | import com.joejoe2.demo.controller.constraint.auth.AuthenticatedApi; 6 | import com.joejoe2.demo.exception.ControllerConstraintViolation; 7 | import com.joejoe2.demo.utils.AuthUtil; 8 | import java.lang.annotation.Annotation; 9 | import java.lang.reflect.Method; 10 | import java.util.Arrays; 11 | import org.springframework.stereotype.Component; 12 | 13 | @Component 14 | public class ControllerAuthConstraintChecker { 15 | 16 | public void checkWithMethod(Method method) throws ControllerConstraintViolation { 17 | checkAuthenticatedApiConstraint(method); 18 | checkRoleConstraints(method); 19 | } 20 | 21 | private static void checkAuthenticatedApiConstraint(Method method) 22 | throws ControllerConstraintViolation { 23 | boolean requireAuthentication = false; 24 | // why AnnotatedElementUtils not work at here ? 25 | AuthenticatedApi constraint = method.getAnnotation(AuthenticatedApi.class); 26 | // direct check 27 | if (constraint != null) { 28 | requireAuthentication = true; 29 | if (!AuthUtil.isAuthenticated()) 30 | throw new ControllerConstraintViolation( 31 | constraint.rejectStatus(), constraint.rejectMessage()); 32 | } 33 | // check for one level in composed annotations 34 | for (Annotation annotation : method.getAnnotations()) { 35 | constraint = annotation.annotationType().getAnnotation(AuthenticatedApi.class); 36 | if (constraint != null) { 37 | requireAuthentication = true; 38 | if (!AuthUtil.isAuthenticated()) 39 | throw new ControllerConstraintViolation( 40 | constraint.rejectStatus(), constraint.rejectMessage()); 41 | else break; 42 | } 43 | } 44 | if (!requireAuthentication) AuthUtil.removeAuthentication(); 45 | } 46 | 47 | private static void checkRoleConstraints(Method method) throws ControllerConstraintViolation { 48 | for (Annotation constraint : method.getAnnotations()) { 49 | if (constraint instanceof ApiAllowsTo apiAllowsTo) { 50 | if (Arrays.stream(apiAllowsTo.roles()) 51 | .noneMatch((role) -> role == AuthUtil.currentUserDetail().getRole())) 52 | throw new ControllerConstraintViolation( 53 | apiAllowsTo.rejectStatus(), apiAllowsTo.rejectMessage()); 54 | else break; 55 | } else if (constraint instanceof ApiRejectTo apiRejectTo) { 56 | if (Arrays.stream(apiRejectTo.roles()) 57 | .anyMatch((role -> role == AuthUtil.currentUserDetail().getRole()))) 58 | throw new ControllerConstraintViolation( 59 | apiRejectTo.rejectStatus(), apiRejectTo.rejectMessage()); 60 | else break; 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/controller/constraint/checker/ControllerRateConstraintChecker.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.controller.constraint.checker; 2 | 3 | import com.joejoe2.demo.controller.constraint.rate.LimitTarget; 4 | import com.joejoe2.demo.controller.constraint.rate.RateLimit; 5 | import com.joejoe2.demo.exception.ControllerConstraintViolation; 6 | import com.joejoe2.demo.utils.AuthUtil; 7 | import com.joejoe2.demo.utils.IPUtils; 8 | import java.lang.reflect.Method; 9 | import java.time.Duration; 10 | import java.util.HashMap; 11 | import java.util.List; 12 | import java.util.Map; 13 | import org.slf4j.Logger; 14 | import org.slf4j.LoggerFactory; 15 | import org.springframework.beans.factory.annotation.Autowired; 16 | import org.springframework.dao.DataAccessException; 17 | import org.springframework.data.redis.core.RedisOperations; 18 | import org.springframework.data.redis.core.RedisTemplate; 19 | import org.springframework.data.redis.core.SessionCallback; 20 | import org.springframework.stereotype.Component; 21 | 22 | @Component 23 | public class ControllerRateConstraintChecker { 24 | private static final Logger logger = 25 | LoggerFactory.getLogger(ControllerAuthConstraintChecker.class); 26 | private static final int MAX_RETRY = 3; 27 | 28 | @Autowired private RedisTemplate> redisTemplate; 29 | 30 | public void checkWithMethod(Method method) throws ControllerConstraintViolation { 31 | RateLimit rateLimit = method.getAnnotation(RateLimit.class); 32 | if (rateLimit == null) return; 33 | if (rateLimit.target() == LimitTarget.USER && !AuthUtil.isAuthenticated()) return; 34 | 35 | String targetIdentifier = 36 | switch (rateLimit.target()) { 37 | case USER -> AuthUtil.currentUserDetail().getId(); 38 | default -> IPUtils.getRequestIP(); 39 | }; 40 | checkRateLimitByTokenBucket( 41 | rateLimit.key(), targetIdentifier, rateLimit.limit(), rateLimit.period()); 42 | } 43 | 44 | private void checkRateLimitByTokenBucket( 45 | String key, String targetIdentifier, long limit, long window) 46 | throws ControllerConstraintViolation { 47 | final boolean[] isLockFailed = {false}; 48 | final boolean[] isExceed = {false}; 49 | 50 | // retry rate limit because we use optimistic lock 51 | for (int retry = MAX_RETRY; retry > 0; retry--) { 52 | redisTemplate.execute( 53 | new SessionCallback<>() { 54 | @Override 55 | public List execute(RedisOperations operations) throws DataAccessException { 56 | operations.watch(key + "_bucket_for_" + targetIdentifier); 57 | Map bucket = operations.opsForHash().entries(key + "_bucket_for_" + targetIdentifier); 58 | 59 | long currentTime = System.currentTimeMillis(); 60 | 61 | if (bucket == null || bucket.isEmpty()) { 62 | bucket = new HashMap<>(); 63 | bucket.put("token", limit - 1); 64 | bucket.put("access_time", currentTime); 65 | } else { 66 | long tokens = ((Number) bucket.get("token")).longValue(); 67 | long refill = 68 | (long) 69 | (((currentTime - (long) bucket.get("access_time")) / 1000) 70 | / (window * 1.0 / limit)); 71 | tokens = Math.min(tokens + refill, limit); 72 | if (tokens > 0) { 73 | bucket.put("token", tokens - 1); 74 | bucket.put("access_time", currentTime); 75 | } else { 76 | isExceed[0] = true; 77 | } 78 | } 79 | 80 | operations.multi(); 81 | operations.opsForHash().putAll(key + "_bucket_for_" + targetIdentifier, bucket); 82 | operations.expire( 83 | key + "_bucket_for_" + targetIdentifier, Duration.ofSeconds(window)); 84 | 85 | isLockFailed[0] = operations.exec().isEmpty(); 86 | return null; 87 | } 88 | }); 89 | // optimistic lock success 90 | if (!isLockFailed[0]) break; 91 | 92 | // still cannot get/set rate limit in last retry 93 | if (retry - 1 <= 0) 94 | throw new RuntimeException( 95 | "cannot obtain rate limit from redis after retry for " + MAX_RETRY + " times !"); 96 | } 97 | 98 | // exceed rate limit or not 99 | if (isExceed[0]) { 100 | throw new ControllerConstraintViolation( 101 | 429, "You have sent too many request, please try again later !"); 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/controller/constraint/rate/LimitTarget.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.controller.constraint.rate; 2 | 3 | public enum LimitTarget { 4 | USER, 5 | IP; 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/controller/constraint/rate/RateLimit.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.controller.constraint.rate; 2 | 3 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 4 | 5 | import java.lang.annotation.ElementType; 6 | import java.lang.annotation.Retention; 7 | import java.lang.annotation.Target; 8 | 9 | /** 10 | * use token bucket algo to apply rate limit on method of any controller, the key is to specify the 11 | * scope of rate limit(typically you should set this to api path). if you are using default key="", 12 | * please keep all @RateLimit having same target and key with same limit and period !!! otherwise, 13 | * the bucket will be refilled at different speed !!! 14 | */ 15 | @Target({ElementType.METHOD}) 16 | @Retention(RUNTIME) 17 | public @interface RateLimit { 18 | String key() default ""; 19 | 20 | LimitTarget target() default LimitTarget.IP; 21 | 22 | long limit() default 10; 23 | 24 | long period() default 60; 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/ErrorMessageResponse.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import lombok.Data; 5 | 6 | @Data 7 | public class ErrorMessageResponse { 8 | @Schema(description = "error message") 9 | String message; 10 | 11 | public ErrorMessageResponse(String message) { 12 | this.message = message; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/InvalidRequestResponse.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import java.util.Map; 5 | import java.util.TreeSet; 6 | import lombok.Data; 7 | 8 | @Data 9 | public class InvalidRequestResponse { 10 | @Schema( 11 | description = "errors in each field of request payload", 12 | title = "errors in each field of request payload") 13 | Map> errors; 14 | 15 | public InvalidRequestResponse(Map> errors) { 16 | this.errors = errors; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/PageList.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data; 2 | 3 | import java.util.List; 4 | import lombok.Data; 5 | 6 | @Data 7 | public class PageList { 8 | private long totalItems; 9 | private int currentPage; 10 | private int totalPages; 11 | private int pageSize; 12 | private List list; 13 | 14 | public PageList(long totalItems, int currentPage, int totalPages, int pageSize, List list) { 15 | this.totalItems = totalItems; 16 | this.currentPage = currentPage; 17 | this.totalPages = totalPages; 18 | this.pageSize = pageSize; 19 | this.list = list; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/PageOfUserProfile.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data; 2 | 3 | import com.joejoe2.demo.data.user.UserProfile; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import java.util.List; 6 | import lombok.Data; 7 | 8 | @Data 9 | public class PageOfUserProfile { 10 | @Schema(description = "num of items in all pages") 11 | private long totalItems; 12 | 13 | @Schema(description = "current page") 14 | private int currentPage; 15 | 16 | @Schema(description = "num of total pages") 17 | private int totalPages; 18 | 19 | @Schema(description = "size of the page") 20 | private int pageSize; 21 | 22 | @Schema(description = "items in the page") 23 | private List profiles; 24 | 25 | public PageOfUserProfile(PageList pageList) { 26 | this.totalItems = pageList.getTotalItems(); 27 | this.currentPage = pageList.getCurrentPage(); 28 | this.totalPages = pageList.getTotalPages(); 29 | this.pageSize = pageList.getPageSize(); 30 | this.profiles = pageList.getList(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/PageRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data; 2 | 3 | import io.swagger.v3.oas.annotations.Parameter; 4 | import jakarta.validation.constraints.Min; 5 | import jakarta.validation.constraints.NotNull; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Builder; 8 | import lombok.Data; 9 | import lombok.NoArgsConstructor; 10 | 11 | @Data 12 | @Builder 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | public class PageRequest { 16 | @Parameter(description = "must >= 0") 17 | @Min(value = 0, message = "page must >= 0 !") 18 | @NotNull(message = "page is missing !") 19 | private Integer page; 20 | 21 | @Parameter(description = "must >= 0") 22 | @Min(value = 1, message = "page must >= 1 !") 23 | @NotNull(message = "size is missing !") 24 | private Integer size; 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/admin/request/ChangeUserRoleRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data.admin.request; 2 | 3 | import com.joejoe2.demo.validation.constraint.Role; 4 | import com.joejoe2.demo.validation.constraint.UUID; 5 | import io.swagger.v3.oas.annotations.media.Schema; 6 | import jakarta.validation.constraints.NotEmpty; 7 | import lombok.AllArgsConstructor; 8 | import lombok.Builder; 9 | import lombok.Data; 10 | import lombok.NoArgsConstructor; 11 | 12 | /** request for change the role of target user */ 13 | @Data 14 | @Builder 15 | @NoArgsConstructor 16 | @AllArgsConstructor 17 | public class ChangeUserRoleRequest { 18 | @Schema(description = "id of target user") 19 | @UUID(message = "invalid user id !") 20 | @NotEmpty(message = "user id cannot be empty !") 21 | private String id; 22 | 23 | @Schema(description = "the role that target user want to change to") 24 | @Role 25 | private String role; 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/admin/request/UserIdRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data.admin.request; 2 | 3 | import com.joejoe2.demo.validation.constraint.UUID; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import jakarta.validation.constraints.NotEmpty; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Builder; 8 | import lombok.Data; 9 | import lombok.NoArgsConstructor; 10 | 11 | /** request with target user id */ 12 | @Data 13 | @Builder 14 | @NoArgsConstructor 15 | @AllArgsConstructor 16 | public class UserIdRequest { 17 | @Schema(description = "id of target user") 18 | @UUID(message = "invalid user id !") 19 | @NotEmpty(message = "user id cannot be empty !") 20 | private String id; 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/auth/AccessTokenSpec.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data.auth; 2 | 3 | import com.joejoe2.demo.model.auth.Role; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import lombok.Data; 6 | 7 | @Data 8 | public class AccessTokenSpec { 9 | @Schema(description = "user id") 10 | String id; 11 | 12 | @Schema(description = "username") 13 | String username; 14 | 15 | @Schema(description = "role of user", implementation = Role.class) 16 | String role; 17 | 18 | @Schema(description = "state of user") 19 | Boolean isActive; 20 | 21 | @Schema(description = "expiration date in number of seconds since Epoch") 22 | long exp; 23 | 24 | @Schema(description = "issuer") 25 | String iss; 26 | 27 | @Schema(description = "access token id") 28 | String jti; 29 | 30 | @Schema(description = "token type", example = "access_token") 31 | String type; 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/auth/RefreshTokenSpec.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data.auth; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import lombok.Data; 5 | 6 | @Data 7 | public class RefreshTokenSpec { 8 | @Schema(description = "expiration date in number of seconds since Epoch") 9 | long exp; 10 | 11 | @Schema(description = "issuer") 12 | String iss; 13 | 14 | @Schema(description = "access token id") 15 | String jti; 16 | 17 | @Schema(description = "token type", example = "refresh_token") 18 | String type; 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/auth/TokenPair.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data.auth; 2 | 3 | import com.joejoe2.demo.model.auth.AccessToken; 4 | import com.joejoe2.demo.model.auth.RefreshToken; 5 | import lombok.Data; 6 | 7 | @Data 8 | public class TokenPair { 9 | private AccessToken accessToken; 10 | private RefreshToken refreshToken; 11 | 12 | public TokenPair(AccessToken accessToken, RefreshToken refreshToken) { 13 | this.accessToken = accessToken; 14 | this.refreshToken = refreshToken; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/auth/TokenResponse.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data.auth; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import lombok.Data; 5 | 6 | @Data 7 | public class TokenResponse { 8 | @Schema(description = "access token") 9 | private String access_token; 10 | 11 | @Schema(description = "refresh token") 12 | private String refresh_token; 13 | 14 | public TokenResponse(TokenPair tokenPair) { 15 | this.access_token = tokenPair.getAccessToken().getToken(); 16 | this.refresh_token = tokenPair.getRefreshToken().getToken(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/auth/UserDetail.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data.auth; 2 | 3 | import com.joejoe2.demo.model.auth.Role; 4 | import com.joejoe2.demo.model.auth.User; 5 | import java.util.*; 6 | import java.util.stream.Collectors; 7 | import org.springframework.security.core.GrantedAuthority; 8 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 9 | import org.springframework.security.core.userdetails.UserDetails; 10 | 11 | public class UserDetail implements UserDetails { 12 | private String id; 13 | private String currentAccessToken; 14 | private String currentAccessTokenID; 15 | private String username; 16 | private String password; 17 | private boolean isActive; 18 | private Role role; 19 | private List authorities; 20 | 21 | public UserDetail(User user) { 22 | this.id = user.getId().toString(); 23 | this.username = user.getUserName(); 24 | this.password = user.getPassword(); 25 | this.isActive = user.isActive(); 26 | this.role = user.getRole(); 27 | this.authorities = 28 | (List) mapRolesToAuthorities(Collections.singleton(user.getRole())); 29 | } 30 | 31 | public UserDetail( 32 | String id, 33 | String username, 34 | boolean isActive, 35 | Role role, 36 | String accessToken, 37 | String accessTokenID) { 38 | this.id = id; 39 | this.username = username; 40 | this.isActive = isActive; 41 | this.currentAccessToken = accessToken; 42 | this.currentAccessTokenID = accessTokenID; 43 | this.role = role; 44 | this.authorities = (List) mapRolesToAuthorities(Collections.singleton(role)); 45 | } 46 | 47 | @Override 48 | public boolean equals(Object o) { 49 | if (this == o) return true; 50 | if (!(o instanceof UserDetail)) return false; 51 | UserDetail that = (UserDetail) o; 52 | return isActive == that.isActive 53 | && id.equals(that.id) 54 | && username.equals(that.username) 55 | && role == that.role 56 | && authorities.equals(that.authorities); 57 | } 58 | 59 | @Override 60 | public int hashCode() { 61 | return Objects.hash(id, username, isActive, role, authorities); 62 | } 63 | 64 | public String getId() { 65 | return this.id; 66 | } 67 | 68 | public boolean isActive() { 69 | return this.isActive; 70 | } 71 | 72 | public Role getRole() { 73 | return this.role; 74 | } 75 | 76 | public String getCurrentAccessToken() { 77 | return currentAccessToken; 78 | } 79 | 80 | public String getCurrentAccessTokenID() { 81 | return currentAccessTokenID; 82 | } 83 | 84 | private Collection mapRolesToAuthorities(Set roles) { 85 | return roles.stream() 86 | .map(role -> new SimpleGrantedAuthority(role.toString())) 87 | .collect(Collectors.toList()); 88 | } 89 | 90 | @Override 91 | public Collection getAuthorities() { 92 | return authorities; 93 | } 94 | 95 | @Override 96 | public String getPassword() { 97 | return password; 98 | } 99 | 100 | @Override 101 | public String getUsername() { 102 | return username; 103 | } 104 | 105 | @Override 106 | public boolean isAccountNonExpired() { 107 | return true; 108 | } 109 | 110 | @Override 111 | public boolean isAccountNonLocked() { 112 | return true; 113 | } 114 | 115 | @Override 116 | public boolean isCredentialsNonExpired() { 117 | return true; 118 | } 119 | 120 | @Override 121 | public boolean isEnabled() { 122 | return isActive; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/auth/VerificationKey.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data.auth; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import lombok.Data; 5 | 6 | @Data 7 | public class VerificationKey { 8 | @Schema(description = "used along with verification code to pass the verification") 9 | String key; 10 | 11 | public VerificationKey(String key) { 12 | this.key = key; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/auth/VerificationPair.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data.auth; 2 | 3 | import com.joejoe2.demo.validation.constraint.UUID; 4 | import jakarta.validation.constraints.NotEmpty; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | @Data 9 | @NoArgsConstructor 10 | public class VerificationPair { 11 | @UUID(message = "invalid verification key !") 12 | @NotEmpty(message = "verification key cannot be empty !") 13 | private String key; 14 | 15 | @NotEmpty(message = "verification code cannot be empty !") 16 | private String code; 17 | 18 | public VerificationPair(String key, String verificationCode) { 19 | this.key = key; 20 | this.code = verificationCode; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/auth/request/ChangePasswordRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data.auth.request; 2 | 3 | import com.joejoe2.demo.validation.constraint.Password; 4 | import jakarta.validation.constraints.NotEmpty; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Builder; 7 | import lombok.Data; 8 | import lombok.NoArgsConstructor; 9 | 10 | @Data 11 | @Builder 12 | @NoArgsConstructor 13 | @AllArgsConstructor 14 | public class ChangePasswordRequest { 15 | @NotEmpty(message = "password cannot be empty !") 16 | String oldPassword; 17 | 18 | @Password String newPassword; 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/auth/request/ForgetPasswordRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data.auth.request; 2 | 3 | import com.joejoe2.demo.validation.constraint.Email; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Builder; 7 | import lombok.Data; 8 | import lombok.NoArgsConstructor; 9 | 10 | @Data 11 | @Builder 12 | @NoArgsConstructor 13 | @AllArgsConstructor 14 | public class ForgetPasswordRequest { 15 | @Schema(description = "email of the user") 16 | @Email 17 | private String email; 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/auth/request/IntrospectionRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data.auth.request; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import jakarta.validation.constraints.NotEmpty; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Builder; 7 | import lombok.Data; 8 | import lombok.NoArgsConstructor; 9 | 10 | @Data 11 | @Builder 12 | @NoArgsConstructor 13 | @AllArgsConstructor 14 | public class IntrospectionRequest { 15 | @Schema(description = "access token") 16 | @NotEmpty(message = "access token cannot be empty !") 17 | private String token; 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/auth/request/IssueVerificationCodeRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data.auth.request; 2 | 3 | import com.joejoe2.demo.validation.constraint.Email; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Builder; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | 9 | @Data 10 | @Builder 11 | @NoArgsConstructor 12 | @AllArgsConstructor 13 | public class IssueVerificationCodeRequest { 14 | @Email private String email; 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/auth/request/LoginRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data.auth.request; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import jakarta.validation.constraints.NotEmpty; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Builder; 7 | import lombok.Data; 8 | import lombok.NoArgsConstructor; 9 | 10 | @Data 11 | @Builder 12 | @NoArgsConstructor 13 | @AllArgsConstructor 14 | public class LoginRequest { 15 | @Schema(description = "name of the user") 16 | @NotEmpty(message = "username cannot be empty !") 17 | private String username; 18 | 19 | @Schema(description = "password of the user") 20 | @NotEmpty(message = "password cannot be empty !") 21 | private String password; 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/auth/request/RefreshRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data.auth.request; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import jakarta.validation.constraints.NotEmpty; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Builder; 7 | import lombok.Data; 8 | import lombok.NoArgsConstructor; 9 | 10 | @Data 11 | @Builder 12 | @NoArgsConstructor 13 | @AllArgsConstructor 14 | public class RefreshRequest { 15 | @Schema(description = "refresh token") 16 | @NotEmpty(message = "refresh token cannot be empty !") 17 | private String token; 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/auth/request/RegisterRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data.auth.request; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import com.joejoe2.demo.data.auth.VerificationPair; 5 | import com.joejoe2.demo.validation.constraint.Email; 6 | import com.joejoe2.demo.validation.constraint.Password; 7 | import com.joejoe2.demo.validation.constraint.Username; 8 | import io.swagger.v3.oas.annotations.media.Schema; 9 | import jakarta.validation.Valid; 10 | import jakarta.validation.constraints.NotNull; 11 | import lombok.AllArgsConstructor; 12 | import lombok.Builder; 13 | import lombok.Data; 14 | import lombok.NoArgsConstructor; 15 | 16 | @Data 17 | @Builder 18 | @NoArgsConstructor 19 | @AllArgsConstructor 20 | public class RegisterRequest { 21 | @Schema(description = "name of the user") 22 | @Username 23 | private String username; 24 | 25 | @Schema(description = "email of the user") 26 | @Email 27 | private String email; 28 | 29 | @Schema(description = "password of the user") 30 | @Password 31 | private String password; 32 | 33 | @Schema(description = "verification for the Registration") 34 | @Valid 35 | @NotNull(message = "verification cannot be empty !") 36 | @JsonProperty("verification") 37 | private VerificationPair verification; 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/auth/request/ResetPasswordRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data.auth.request; 2 | 3 | import com.joejoe2.demo.validation.constraint.Password; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import jakarta.validation.constraints.NotEmpty; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Builder; 8 | import lombok.Data; 9 | import lombok.NoArgsConstructor; 10 | 11 | @Data 12 | @Builder 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | public class ResetPasswordRequest { 16 | @Schema(description = "token after password reset link") 17 | @NotEmpty(message = "token can not be empty !") 18 | String token; 19 | 20 | @Password String newPassword; 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/data/user/UserProfile.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.data.user; 2 | 3 | import com.joejoe2.demo.model.auth.Role; 4 | import com.joejoe2.demo.model.auth.User; 5 | import io.swagger.v3.oas.annotations.media.Schema; 6 | import lombok.Data; 7 | 8 | @Data 9 | public class UserProfile { 10 | @Schema(description = "id of the user") 11 | private String id; 12 | 13 | @Schema(description = "name of the user") 14 | private String username; 15 | 16 | @Schema(description = "email of the user") 17 | private String email; 18 | 19 | @Schema(description = "role of the user") 20 | private Role role; 21 | 22 | @Schema(description = "status of the user") 23 | private Boolean isActive; 24 | 25 | @Schema(description = "register time of the user") 26 | private String registeredAt; 27 | 28 | public UserProfile(User user) { 29 | this.id = user.getId().toString(); 30 | this.username = user.getUserName(); 31 | this.email = user.getEmail(); 32 | this.role = user.getRole(); 33 | this.isActive = user.isActive(); 34 | this.registeredAt = user.getCreateAt().toString(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/exception/AlreadyExist.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.exception; 2 | 3 | public class AlreadyExist extends Exception { 4 | public AlreadyExist(String message) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/exception/ControllerConstraintViolation.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.exception; 2 | 3 | public class ControllerConstraintViolation extends Exception { 4 | private final int rejectStatus; 5 | private final String rejectMessage; 6 | 7 | public ControllerConstraintViolation(int rejectStatus, String rejectMessage) { 8 | this.rejectStatus = rejectStatus; 9 | this.rejectMessage = rejectMessage; 10 | } 11 | 12 | public int getRejectStatus() { 13 | return rejectStatus; 14 | } 15 | 16 | public String getRejectMessage() { 17 | return rejectMessage; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/exception/InvalidOperation.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.exception; 2 | 3 | public class InvalidOperation extends Exception { 4 | public InvalidOperation(String message) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/exception/InvalidTokenException.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.exception; 2 | 3 | public class InvalidTokenException extends Exception { 4 | public InvalidTokenException(String message) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/exception/UserDoesNotExist.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.exception; 2 | 3 | public class UserDoesNotExist extends Exception { 4 | public UserDoesNotExist(String message) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/exception/ValidationError.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.exception; 2 | 3 | public class ValidationError extends IllegalArgumentException { 4 | public ValidationError(String msg) { 5 | super(msg); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/filter/JwtAuthenticationFilter.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.filter; 2 | 3 | import com.joejoe2.demo.data.auth.UserDetail; 4 | import com.joejoe2.demo.exception.InvalidTokenException; 5 | import com.joejoe2.demo.service.jwt.JwtService; 6 | import jakarta.servlet.FilterChain; 7 | import jakarta.servlet.ServletException; 8 | import jakarta.servlet.http.Cookie; 9 | import jakarta.servlet.http.HttpServletRequest; 10 | import jakarta.servlet.http.HttpServletResponse; 11 | import java.io.IOException; 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.http.HttpHeaders; 14 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 15 | import org.springframework.security.core.Authentication; 16 | import org.springframework.security.core.context.SecurityContextHolder; 17 | import org.springframework.stereotype.Component; 18 | import org.springframework.web.filter.OncePerRequestFilter; 19 | import org.springframework.web.util.WebUtils; 20 | 21 | @Component 22 | public class JwtAuthenticationFilter extends OncePerRequestFilter { 23 | @Autowired JwtService jwtService; 24 | 25 | @Override 26 | protected void doFilterInternal( 27 | HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) 28 | throws ServletException, IOException { 29 | String accessToken = null; 30 | 31 | // auth by header 32 | String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); 33 | if (authHeader != null) accessToken = authHeader.replace("Bearer ", ""); 34 | 35 | // auth by cookie 36 | if (accessToken == null) { 37 | Cookie cookie = WebUtils.getCookie(request, "access_token"); 38 | if (cookie != null && cookie.getValue() != null) accessToken = cookie.getValue(); 39 | } 40 | 41 | // try to auth 42 | if (accessToken != null) { 43 | try { 44 | if (jwtService.isAccessTokenInBlackList(accessToken)) 45 | throw new InvalidTokenException("invalid token !"); 46 | 47 | UserDetail userDetail = jwtService.getUserDetailFromAccessToken(accessToken); 48 | Authentication authentication = 49 | new UsernamePasswordAuthenticationToken(userDetail, null, userDetail.getAuthorities()); 50 | SecurityContextHolder.getContext().setAuthentication(authentication); 51 | } catch (Exception e) { 52 | response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); 53 | return; 54 | } 55 | } 56 | 57 | filterChain.doFilter(request, response); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/init/DefaultAdminInitializer.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.init; 2 | 3 | import com.joejoe2.demo.exception.AlreadyExist; 4 | import com.joejoe2.demo.model.auth.Role; 5 | import com.joejoe2.demo.service.user.auth.RegistrationService; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.boot.CommandLineRunner; 10 | import org.springframework.core.env.Environment; 11 | import org.springframework.stereotype.Component; 12 | 13 | @Component 14 | public class DefaultAdminInitializer implements CommandLineRunner { 15 | @Autowired private Environment env; 16 | @Autowired private RegistrationService registrationService; 17 | 18 | private static final Logger logger = LoggerFactory.getLogger(DefaultAdminInitializer.class); 19 | 20 | // run after application start 21 | @Override 22 | public void run(String... args) throws Exception { 23 | createDefaultAdmin(env); 24 | } 25 | 26 | private void createDefaultAdmin(Environment env) { 27 | String adminName = env.getProperty("default.admin.username", ""); 28 | String adminPassword = env.getProperty("default.admin.password", ""); 29 | String adminEmail = env.getProperty("default.admin.email", ""); 30 | if (adminName.length() > 0 && adminPassword.length() > 0 && adminEmail.length() > 0) { 31 | try { 32 | registrationService.createUser(adminName, adminPassword, adminEmail, Role.ADMIN); 33 | logger.info("create admin user from env !"); 34 | } catch (AlreadyExist e) { 35 | logger.info("default admin already exist, skip creation !"); 36 | } catch (Exception e) { 37 | logger.error("cannot create default admin: " + e.getMessage()); 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/init/JobRunrInitializer.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.init; 2 | 3 | import com.joejoe2.demo.job.request.CleanUpJWTTokensJob; 4 | import com.joejoe2.demo.job.request.CleanUpVerificationsJob; 5 | import java.time.Duration; 6 | import org.jobrunr.scheduling.BackgroundJobRequest; 7 | import org.jobrunr.storage.StorageProvider; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.boot.CommandLineRunner; 10 | import org.springframework.core.env.Environment; 11 | import org.springframework.stereotype.Component; 12 | 13 | @Component 14 | public class JobRunrInitializer implements CommandLineRunner { 15 | @Autowired private Environment env; 16 | @Autowired StorageProvider storageProvider; 17 | 18 | @Override 19 | public void run(String... args) throws Exception { 20 | createRecurrentJob(env); 21 | } 22 | 23 | private void createRecurrentJob(Environment env) { 24 | if (!env.getProperty("init.recurrent-job", Boolean.class, true)) { 25 | storageProvider 26 | .getRecurringJobs() 27 | .forEach((recurringJob -> BackgroundJobRequest.delete(recurringJob.getId()))); 28 | return; 29 | } 30 | BackgroundJobRequest.scheduleRecurrently( 31 | "CleanUpJWTTokens", Duration.ofSeconds(1800), new CleanUpJWTTokensJob()); 32 | BackgroundJobRequest.scheduleRecurrently( 33 | "CleanUpVerifications", Duration.ofSeconds(1800), new CleanUpVerificationsJob()); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/interceptor/ControllerConstraintInterceptor.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.interceptor; 2 | 3 | import com.joejoe2.demo.controller.constraint.checker.ControllerAuthConstraintChecker; 4 | import com.joejoe2.demo.controller.constraint.checker.ControllerRateConstraintChecker; 5 | import com.joejoe2.demo.exception.ControllerConstraintViolation; 6 | import com.joejoe2.demo.utils.IPUtils; 7 | import jakarta.servlet.http.HttpServletRequest; 8 | import jakarta.servlet.http.HttpServletResponse; 9 | import java.io.IOException; 10 | import org.slf4j.Logger; 11 | import org.slf4j.LoggerFactory; 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.stereotype.Component; 14 | import org.springframework.web.method.HandlerMethod; 15 | import org.springframework.web.servlet.HandlerInterceptor; 16 | 17 | @Component 18 | public class ControllerConstraintInterceptor implements HandlerInterceptor { 19 | @Autowired ControllerAuthConstraintChecker authConstraintChecker; 20 | @Autowired ControllerRateConstraintChecker rateConstraintChecker; 21 | 22 | private static final Logger logger = 23 | LoggerFactory.getLogger(ControllerConstraintInterceptor.class); 24 | 25 | @Override 26 | public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) 27 | throws Exception { 28 | IPUtils.setRequestIP(request.getRemoteAddr()); 29 | 30 | try { 31 | if (handler instanceof HandlerMethod) { 32 | authConstraintChecker.checkWithMethod(((HandlerMethod) handler).getMethod()); 33 | rateConstraintChecker.checkWithMethod(((HandlerMethod) handler).getMethod()); 34 | } 35 | } catch (ControllerConstraintViolation ex) { 36 | setJsonResponse(response, ex.getRejectStatus(), ex.getRejectMessage()); 37 | return false; 38 | } catch (Exception e) { 39 | logger.error(e.getMessage()); 40 | setJsonResponse(response, 500, ""); 41 | return false; 42 | } 43 | 44 | return true; 45 | } 46 | 47 | private void setJsonResponse(HttpServletResponse response, int status, String message) { 48 | if (message != null && !message.isEmpty()) { 49 | try { 50 | response.getWriter().write("{ \"message\": \"" + message + "\"}"); 51 | } catch (IOException e) { 52 | e.printStackTrace(); 53 | } 54 | } 55 | response.setContentType("application/json"); 56 | response.setCharacterEncoding("UTF-8"); 57 | response.setStatus(status); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/job/handler/CleanUpJWTTokensHandler.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.job.handler; 2 | 3 | import com.joejoe2.demo.job.request.CleanUpJWTTokensJob; 4 | import com.joejoe2.demo.service.jwt.JwtService; 5 | import org.jobrunr.jobs.annotations.Job; 6 | import org.jobrunr.jobs.lambdas.JobRequestHandler; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.stereotype.Component; 9 | 10 | @Component 11 | public class CleanUpJWTTokensHandler implements JobRequestHandler { 12 | @Autowired private JwtService jwtService; 13 | 14 | @Job(name = "delete all expired refresh tokens and related access tokens") 15 | @Override 16 | public void run(CleanUpJWTTokensJob job) throws Exception { 17 | jwtService.deleteExpiredTokens(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/job/handler/CleanUpVerificationsHandler.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.job.handler; 2 | 3 | import com.joejoe2.demo.job.request.CleanUpVerificationsJob; 4 | import com.joejoe2.demo.service.verification.VerificationService; 5 | import org.jobrunr.jobs.annotations.Job; 6 | import org.jobrunr.jobs.lambdas.JobRequestHandler; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.stereotype.Component; 9 | 10 | @Component 11 | public class CleanUpVerificationsHandler implements JobRequestHandler { 12 | @Autowired VerificationService verificationService; 13 | 14 | @Job(name = "delete all expired verification codes and tokens") 15 | @Override 16 | public void run(CleanUpVerificationsJob job) throws Exception { 17 | verificationService.deleteExpiredVerificationCodes(); 18 | verificationService.deleteExpiredVerifyTokens(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/job/request/CleanUpJWTTokensJob.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.job.request; 2 | 3 | import com.joejoe2.demo.job.handler.CleanUpJWTTokensHandler; 4 | import lombok.Data; 5 | import org.jobrunr.jobs.lambdas.JobRequest; 6 | 7 | @Data 8 | public class CleanUpJWTTokensJob implements JobRequest { 9 | @Override 10 | public Class getJobRequestHandler() { 11 | return CleanUpJWTTokensHandler.class; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/job/request/CleanUpVerificationsJob.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.job.request; 2 | 3 | import com.joejoe2.demo.job.handler.CleanUpVerificationsHandler; 4 | import lombok.Data; 5 | import org.jobrunr.jobs.lambdas.JobRequest; 6 | 7 | @Data 8 | public class CleanUpVerificationsJob implements JobRequest { 9 | @Override 10 | public Class getJobRequestHandler() { 11 | return CleanUpVerificationsHandler.class; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/model/Base.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.model; 2 | 3 | import com.joejoe2.demo.model.generator.UUIDv7Generator; 4 | import jakarta.persistence.Column; 5 | import jakarta.persistence.GeneratedValue; 6 | import jakarta.persistence.Id; 7 | import jakarta.persistence.MappedSuperclass; 8 | import java.util.UUID; 9 | import lombok.Data; 10 | import org.hibernate.annotations.GenericGenerator; 11 | 12 | @MappedSuperclass 13 | @Data 14 | public class Base { 15 | @Id 16 | @GeneratedValue(generator = "UUIDv7") 17 | @GenericGenerator(name = "UUIDv7", type = UUIDv7Generator.class) 18 | @Column(unique = true, updatable = false, nullable = false) 19 | protected UUID id; 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/model/auth/AccessToken.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.model.auth; 2 | 3 | import com.fasterxml.uuid.Generators; 4 | import com.joejoe2.demo.config.JwtConfig; 5 | import com.joejoe2.demo.utils.JwtUtil; 6 | import jakarta.persistence.*; 7 | import java.time.Instant; 8 | import java.util.Calendar; 9 | import java.util.Objects; 10 | import java.util.UUID; 11 | import lombok.AllArgsConstructor; 12 | import lombok.Data; 13 | import lombok.NoArgsConstructor; 14 | import org.hibernate.annotations.OnDelete; 15 | import org.hibernate.annotations.OnDeleteAction; 16 | 17 | @Data 18 | @Entity 19 | @NoArgsConstructor 20 | @AllArgsConstructor 21 | public class AccessToken { 22 | @Id 23 | @Column(unique = true, updatable = false, nullable = false) 24 | private UUID id = Generators.timeBasedEpochGenerator().generate(); 25 | 26 | @Column(unique = true, updatable = false, nullable = false, columnDefinition = "TEXT") 27 | private String token; 28 | 29 | @Column(updatable = false, nullable = false) 30 | private Instant expireAt; 31 | 32 | @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY, optional = false) 33 | @OnDelete(action = OnDeleteAction.CASCADE) // if delete refreshToken => also delete this 34 | RefreshToken refreshToken; 35 | 36 | @ManyToOne(optional = false) 37 | @OnDelete(action = OnDeleteAction.CASCADE) 38 | private User user; 39 | 40 | public AccessToken(JwtConfig jwtConfig, User user) { 41 | Calendar exp = Calendar.getInstance(); 42 | exp.add(Calendar.SECOND, jwtConfig.getAccessTokenLifetimeSec()); 43 | this.token = 44 | JwtUtil.generateAccessToken( 45 | jwtConfig.getPrivateKey(), getId().toString(), jwtConfig.getIssuer(), user, exp); 46 | this.expireAt = exp.toInstant(); 47 | this.user = user; 48 | } 49 | 50 | @Override 51 | public boolean equals(Object o) { 52 | if (this == o) return true; 53 | if (o == null || getClass() != o.getClass()) return false; 54 | AccessToken that = (AccessToken) o; 55 | return id.equals(that.id) && token.equals(that.token) && expireAt.equals(that.expireAt); 56 | } 57 | 58 | @Override 59 | public int hashCode() { 60 | return Objects.hash(id, token, expireAt); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/model/auth/LoginAttempt.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.model.auth; 2 | 3 | import com.joejoe2.demo.config.LoginConfig; 4 | import com.joejoe2.demo.data.auth.UserDetail; 5 | import com.joejoe2.demo.exception.InvalidOperation; 6 | import com.joejoe2.demo.utils.AuthUtil; 7 | import jakarta.persistence.Column; 8 | import jakarta.persistence.Embeddable; 9 | import java.time.Instant; 10 | import lombok.AccessLevel; 11 | import lombok.Data; 12 | import lombok.Setter; 13 | import org.springframework.security.authentication.AuthenticationManager; 14 | import org.springframework.security.authentication.BadCredentialsException; 15 | 16 | @Data 17 | @Embeddable 18 | public class LoginAttempt { 19 | @Column(nullable = false, columnDefinition = "integer default 0") 20 | @Setter(AccessLevel.PACKAGE) 21 | int attempts; 22 | 23 | @Column(nullable = true) 24 | @Setter(AccessLevel.PACKAGE) 25 | Instant lastAttempt; 26 | 27 | private boolean isExceedLimit(LoginConfig loginConfig) { 28 | return getAttempts() >= loginConfig.getMaxAttempts(); 29 | } 30 | 31 | private boolean canAttempt(LoginConfig loginConfig) { 32 | if (getLastAttempt() != null 33 | && getLastAttempt().plusSeconds(loginConfig.getCoolTime()).isBefore(Instant.now())) { 34 | return true; 35 | } 36 | return !isExceedLimit(loginConfig); 37 | } 38 | 39 | private void attempt(LoginConfig loginConfig, boolean success) throws InvalidOperation { 40 | // check there is too many recent failure attempts 41 | if (!canAttempt(loginConfig)) throw new InvalidOperation("cannot login anymore !"); 42 | if (success) { 43 | // clear after success 44 | setAttempts(0); 45 | } else { 46 | // reset older failure attempts before increment 47 | if (isExceedLimit(loginConfig)) setAttempts(0); 48 | setAttempts(getAttempts() + 1); 49 | } 50 | setLastAttempt(Instant.now()); 51 | } 52 | 53 | public UserDetail login( 54 | LoginConfig loginConfig, 55 | AuthenticationManager authenticationManager, 56 | String username, 57 | String password) 58 | throws InvalidOperation { 59 | try { 60 | UserDetail userDetail = AuthUtil.authenticate(authenticationManager, username, password); 61 | attempt(loginConfig, true); 62 | return userDetail; 63 | } catch (BadCredentialsException e) { 64 | attempt(loginConfig, false); 65 | throw e; 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/model/auth/RefreshToken.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.model.auth; 2 | 3 | import com.fasterxml.uuid.Generators; 4 | import com.joejoe2.demo.config.JwtConfig; 5 | import com.joejoe2.demo.utils.JwtUtil; 6 | import jakarta.persistence.*; 7 | import java.time.Instant; 8 | import java.util.Calendar; 9 | import java.util.Objects; 10 | import java.util.UUID; 11 | import lombok.AllArgsConstructor; 12 | import lombok.Data; 13 | import lombok.NoArgsConstructor; 14 | import org.hibernate.annotations.OnDelete; 15 | import org.hibernate.annotations.OnDeleteAction; 16 | 17 | @Data 18 | @Entity 19 | @NoArgsConstructor 20 | @AllArgsConstructor 21 | public class RefreshToken { 22 | @Id 23 | @Column(unique = true, updatable = false, nullable = false) 24 | private UUID id = Generators.timeBasedEpochGenerator().generate(); 25 | 26 | @Column(unique = true, updatable = false, nullable = false, columnDefinition = "TEXT") 27 | private String token; 28 | 29 | @Column(updatable = false, nullable = false) 30 | private Instant expireAt; 31 | 32 | @OneToOne(mappedBy = "refreshToken", cascade = CascadeType.ALL) 33 | // cascade delete not work for jpql/sql, so we use @OnDelete on child ! 34 | private AccessToken accessToken; 35 | 36 | @ManyToOne(optional = false) 37 | @OnDelete(action = OnDeleteAction.CASCADE) 38 | private User user; 39 | 40 | public RefreshToken(JwtConfig jwtConfig, AccessToken accessToken) { 41 | Calendar exp = Calendar.getInstance(); 42 | exp.add(Calendar.SECOND, jwtConfig.getRefreshTokenLifetimeSec()); 43 | this.token = 44 | JwtUtil.generateRefreshToken( 45 | jwtConfig.getPrivateKey(), getId().toString(), jwtConfig.getIssuer(), exp); 46 | accessToken.setRefreshToken(this); 47 | this.accessToken = accessToken; 48 | this.user = accessToken.getUser(); 49 | this.expireAt = exp.toInstant(); 50 | } 51 | 52 | @Override 53 | public boolean equals(Object o) { 54 | if (this == o) return true; 55 | if (o == null || getClass() != o.getClass()) return false; 56 | RefreshToken that = (RefreshToken) o; 57 | return id.equals(that.id) && token.equals(that.token) && expireAt.equals(that.expireAt); 58 | } 59 | 60 | @Override 61 | public int hashCode() { 62 | return Objects.hash(id, token, expireAt); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/model/auth/Role.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.model.auth; 2 | 3 | public enum Role { 4 | ADMIN("ADMIN"), 5 | STAFF("STAFF"), 6 | NORMAL("NORMAL"); 7 | private final String value; 8 | 9 | Role(String role) { 10 | this.value = role; 11 | } 12 | 13 | public String toString() { 14 | return value; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/model/auth/User.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.model.auth; 2 | 3 | import com.joejoe2.demo.model.Base; 4 | import jakarta.persistence.*; 5 | import java.time.Instant; 6 | import java.util.Objects; 7 | import lombok.Getter; 8 | import lombok.RequiredArgsConstructor; 9 | import lombok.Setter; 10 | import lombok.ToString; 11 | import org.hibernate.annotations.CreationTimestamp; 12 | import org.hibernate.annotations.UpdateTimestamp; 13 | 14 | @Getter 15 | @Setter 16 | @ToString 17 | @RequiredArgsConstructor 18 | @Entity 19 | @Table(name = "account_user") 20 | public class User extends Base { 21 | @Version 22 | @Column(nullable = false, columnDefinition = "TIMESTAMP DEFAULT now()") 23 | private Instant version; 24 | 25 | @Column(unique = true, length = 32, nullable = false) 26 | private String userName; 27 | 28 | @Column(length = 64, nullable = false) 29 | private String password; 30 | 31 | @Column(unique = true, length = 128, nullable = false) 32 | private String email; 33 | 34 | @Column(length = 32, nullable = false) 35 | @Enumerated(EnumType.STRING) 36 | private Role role = Role.NORMAL; // code level default 37 | 38 | @Column(nullable = false, columnDefinition = "boolean default true") // db level default 39 | private boolean isActive = true; // code level default 40 | 41 | @CreationTimestamp private Instant createAt; 42 | 43 | @UpdateTimestamp private Instant updateAt; 44 | 45 | @Column(nullable = true) 46 | private Instant authAt; 47 | 48 | @Embedded LoginAttempt loginAttempt = new LoginAttempt(); 49 | 50 | @Override 51 | public boolean equals(Object o) { 52 | if (this == o) return true; 53 | if (!(o instanceof User)) return false; 54 | User user = (User) o; 55 | return isActive() == user.isActive() 56 | && getId().equals(user.getId()) 57 | && getUserName().equals(user.getUserName()) 58 | && getEmail().equals(user.getEmail()) 59 | && getRole() == user.getRole(); 60 | } 61 | 62 | @Override 63 | public int hashCode() { 64 | return Objects.hash(getId(), getUserName(), getEmail(), getRole(), isActive()); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/model/auth/VerificationCode.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.model.auth; 2 | 3 | import com.joejoe2.demo.model.Base; 4 | import com.joejoe2.demo.utils.Utils; 5 | import jakarta.persistence.Column; 6 | import jakarta.persistence.Entity; 7 | import java.time.Instant; 8 | import lombok.Data; 9 | 10 | @Data 11 | @Entity 12 | public class VerificationCode extends Base { 13 | @Column(length = 128, nullable = false) 14 | private String email; 15 | 16 | @Column(length = 5, nullable = false) 17 | private String code = Utils.randomNumericCode(5); 18 | 19 | @Column(updatable = false, nullable = false) 20 | private Instant expireAt; 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/model/auth/VerifyToken.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.model.auth; 2 | 3 | import com.joejoe2.demo.model.Base; 4 | import jakarta.persistence.*; 5 | import java.time.Instant; 6 | import lombok.Data; 7 | import org.hibernate.annotations.OnDelete; 8 | import org.hibernate.annotations.OnDeleteAction; 9 | 10 | @Data 11 | @Entity 12 | public class VerifyToken extends Base { 13 | @Column(unique = true, updatable = false, nullable = false, columnDefinition = "TEXT") 14 | private String token; 15 | 16 | @Column(updatable = false, nullable = false) 17 | private Instant expireAt; 18 | 19 | @OneToOne 20 | @JoinColumn(unique = true) // unidirectional one to one 21 | @OnDelete(action = OnDeleteAction.CASCADE) 22 | private User user; 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/model/generator/UUIDv7Generator.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.model.generator; 2 | 3 | import com.fasterxml.uuid.Generators; 4 | import java.io.Serializable; 5 | import org.hibernate.HibernateException; 6 | import org.hibernate.engine.spi.SharedSessionContractImplementor; 7 | import org.hibernate.id.IdentifierGenerator; 8 | 9 | public class UUIDv7Generator implements IdentifierGenerator { 10 | @Override 11 | public Serializable generate(SharedSessionContractImplementor session, Object object) 12 | throws HibernateException { 13 | return Generators.timeBasedEpochGenerator().generate(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/repository/jwt/AccessTokenRepository.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.repository.jwt; 2 | 3 | import com.joejoe2.demo.model.auth.AccessToken; 4 | import com.joejoe2.demo.model.auth.User; 5 | import java.time.Instant; 6 | import java.util.List; 7 | import java.util.Optional; 8 | import java.util.UUID; 9 | import org.springframework.data.jpa.repository.JpaRepository; 10 | 11 | public interface AccessTokenRepository extends JpaRepository { 12 | 13 | Optional getByIdAndExpireAtGreaterThan(UUID id, Instant dateTime); 14 | 15 | List getByUser(User user); 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/repository/jwt/RefreshTokenRepository.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.repository.jwt; 2 | 3 | import com.joejoe2.demo.model.auth.RefreshToken; 4 | import java.time.Instant; 5 | import java.util.Optional; 6 | import java.util.UUID; 7 | import org.springframework.data.jpa.repository.JpaRepository; 8 | import org.springframework.data.jpa.repository.Modifying; 9 | import org.springframework.data.jpa.repository.Query; 10 | 11 | public interface RefreshTokenRepository extends JpaRepository { 12 | @Modifying 13 | @Query("delete from RefreshToken r where r.expireAt < ?1") 14 | void deleteAllByExpireAtLessThan(Instant dateTime); 15 | 16 | Optional getByIdAndExpireAtGreaterThan(UUID id, Instant dateTime); 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/repository/user/UserRepository.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.repository.user; 2 | 3 | import com.joejoe2.demo.model.auth.Role; 4 | import com.joejoe2.demo.model.auth.User; 5 | import java.util.List; 6 | import java.util.Optional; 7 | import java.util.UUID; 8 | import org.springframework.data.domain.Page; 9 | import org.springframework.data.domain.Pageable; 10 | import org.springframework.data.jpa.repository.JpaRepository; 11 | 12 | public interface UserRepository extends JpaRepository { 13 | Optional findById(UUID id); 14 | 15 | Optional getByUserName(String username); 16 | 17 | Optional getByEmail(String email); 18 | 19 | List getByRole(Role role); 20 | 21 | Page findAll(Pageable pageable); 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/repository/verification/VerificationCodeRepository.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.repository.verification; 2 | 3 | import com.joejoe2.demo.model.auth.VerificationCode; 4 | import java.time.Instant; 5 | import java.util.UUID; 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | 8 | public interface VerificationCodeRepository extends JpaRepository { 9 | void deleteByExpireAtLessThan(Instant dateTime); 10 | 11 | long deleteByIdAndEmailAndCodeAndExpireAtGreaterThan( 12 | UUID id, String email, String code, Instant dateTime); 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/repository/verification/VerifyTokenRepository.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.repository.verification; 2 | 3 | import com.joejoe2.demo.model.auth.User; 4 | import com.joejoe2.demo.model.auth.VerifyToken; 5 | import java.time.Instant; 6 | import java.util.Optional; 7 | import java.util.UUID; 8 | import org.springframework.data.jpa.repository.JpaRepository; 9 | 10 | public interface VerifyTokenRepository extends JpaRepository { 11 | void deleteByExpireAtLessThan(Instant dateTime); 12 | 13 | Optional getByTokenAndExpireAtGreaterThan(String token, Instant dateTime); 14 | 15 | Optional getByUser(User user); 16 | 17 | void deleteByUser(User user); 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/service/email/EmailService.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.email; 2 | 3 | public interface EmailService { 4 | /** 5 | * send email to someone 6 | * 7 | * @param to the destination email address 8 | * @param subject the subject of email 9 | * @param text the content of email 10 | */ 11 | void sendSimpleEmail(String to, String subject, String text); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/service/email/EmailServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.email; 2 | 3 | import com.joejoe2.demo.exception.ValidationError; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.mail.SimpleMailMessage; 6 | import org.springframework.mail.javamail.JavaMailSender; 7 | import org.springframework.scheduling.annotation.Async; 8 | import org.springframework.stereotype.Service; 9 | 10 | @Service 11 | public class EmailServiceImpl implements EmailService { 12 | @Autowired private JavaMailSender emailSender; 13 | 14 | @Async 15 | @Override 16 | public void sendSimpleEmail(String to, String subject, String text) { 17 | if (to == null || subject == null || text == null) 18 | throw new ValidationError("to, subject, or text cannot be null !"); 19 | 20 | SimpleMailMessage message = new SimpleMailMessage(); 21 | message.setFrom("noreply@joejoe2.com"); 22 | message.setTo(to); 23 | message.setSubject(subject); 24 | message.setText(text); 25 | emailSender.send(message); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/service/jwt/JwtService.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.jwt; 2 | 3 | import com.joejoe2.demo.data.auth.AccessTokenSpec; 4 | import com.joejoe2.demo.data.auth.TokenPair; 5 | import com.joejoe2.demo.data.auth.UserDetail; 6 | import com.joejoe2.demo.exception.InvalidOperation; 7 | import com.joejoe2.demo.exception.InvalidTokenException; 8 | import com.joejoe2.demo.exception.UserDoesNotExist; 9 | import com.joejoe2.demo.model.auth.AccessToken; 10 | import java.util.List; 11 | 12 | public interface JwtService { 13 | /** 14 | * issue access and refresh token with userDetail 15 | * 16 | * @param userDetail 17 | * @return generated access and refresh tokens 18 | * @throws UserDoesNotExist if user of userDetail does not exist 19 | */ 20 | TokenPair issueTokens(UserDetail userDetail) throws UserDoesNotExist; 21 | 22 | /** 23 | * use refresh token(in plaintext) to exchange new access and refresh token, then the old refresh 24 | * token will be deleted and the related access token will also be revoked 25 | * 26 | * @param refreshPlainToken refresh token(in plaintext) 27 | * @return generated access and refresh token 28 | * @throws InvalidTokenException if the refresh token(in plaintext) is invalid 29 | * @throws InvalidOperation if the user is inactive 30 | */ 31 | TokenPair refreshTokens(String refreshPlainToken) throws InvalidTokenException, InvalidOperation; 32 | 33 | /** 34 | * retrieve UserDetail from access token 35 | * 36 | * @param token access token in plaintext 37 | * @return related UserDetail with the access token 38 | * @throws InvalidTokenException if the access token is invalid 39 | */ 40 | UserDetail getUserDetailFromAccessToken(String token) throws InvalidTokenException; 41 | 42 | /** 43 | * decode and check access token 44 | * 45 | * @param token access token in plaintext 46 | * @return decoded access token 47 | * @throws InvalidTokenException if the access token is invalid 48 | */ 49 | AccessTokenSpec introspect(String token) throws InvalidTokenException; 50 | 51 | /** 52 | * delete access token in db then add it to the redis blacklist 53 | * 54 | * @param token access token in plaintext 55 | * @throws InvalidTokenException if the access token does not exist 56 | */ 57 | void revokeAccessToken(String token) throws InvalidTokenException; 58 | 59 | /** 60 | * delete access token in db then add it to the redis blacklist 61 | * 62 | * @param accessToken access token 63 | */ 64 | void revokeAccessToken(AccessToken accessToken); 65 | 66 | /** 67 | * delete access tokens in db then add them to the redis blacklist 68 | * 69 | * @param accessTokens access tokens 70 | */ 71 | void revokeAccessToken(List accessTokens); 72 | 73 | /** 74 | * add access token to the redis blacklist 75 | * 76 | * @param accessToken access token 77 | */ 78 | void addAccessTokenToBlackList(AccessToken accessToken); 79 | 80 | /** 81 | * check whether the access token is in redis blacklist 82 | * 83 | * @param accessPlainToken access token in plaintext 84 | * @return whether the access token is in redis blacklist 85 | */ 86 | boolean isAccessTokenInBlackList(String accessPlainToken); 87 | 88 | /** delete all expired refresh tokens and their related access tokens */ 89 | void deleteExpiredTokens(); 90 | } 91 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/service/redis/RedisService.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.redis; 2 | 3 | import java.time.Duration; 4 | import java.util.Optional; 5 | 6 | public interface RedisService { 7 | void set(String key, String value, Duration duration); 8 | 9 | Optional get(String key); 10 | 11 | boolean has(String key); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/service/redis/RedisServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.redis; 2 | 3 | import java.time.Duration; 4 | import java.util.Optional; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.data.redis.core.StringRedisTemplate; 7 | import org.springframework.stereotype.Service; 8 | 9 | @Service 10 | public class RedisServiceImpl implements RedisService { 11 | @Autowired private StringRedisTemplate redisTemplate; 12 | 13 | @Override 14 | public void set(String key, String value, Duration duration) { 15 | redisTemplate.opsForValue().setIfAbsent(key, value, duration); 16 | } 17 | 18 | @Override 19 | public Optional get(String key) { 20 | return Optional.ofNullable(redisTemplate.opsForValue().get(key)); 21 | } 22 | 23 | @Override 24 | public boolean has(String key) { 25 | return redisTemplate.hasKey(key); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/service/user/UserDetailService.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.user; 2 | 3 | import com.joejoe2.demo.data.auth.UserDetail; 4 | import com.joejoe2.demo.model.auth.User; 5 | import com.joejoe2.demo.repository.user.UserRepository; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.security.core.userdetails.UserDetails; 8 | import org.springframework.security.core.userdetails.UserDetailsService; 9 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 10 | import org.springframework.stereotype.Service; 11 | 12 | @Service 13 | public class UserDetailService implements UserDetailsService { 14 | @Autowired UserRepository userRepository; 15 | 16 | @Override 17 | public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { 18 | User user = 19 | userRepository 20 | .getByUserName(username) 21 | .orElseThrow(() -> new UsernameNotFoundException("user does not exist !")); 22 | return new UserDetail(user); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/service/user/auth/ActivationService.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.user.auth; 2 | 3 | import com.joejoe2.demo.exception.InvalidOperation; 4 | import com.joejoe2.demo.exception.UserDoesNotExist; 5 | 6 | public interface ActivationService { 7 | /** 8 | * activate user with userId 9 | * 10 | * @param userId target user id 11 | * @throws InvalidOperation if target user is already active, or you are trying to activate 12 | * yourself 13 | * @throws UserDoesNotExist if target user is not exist 14 | */ 15 | void activateUser(String userId) throws InvalidOperation, UserDoesNotExist; 16 | 17 | /** 18 | * deactivate user with userId 19 | * 20 | * @param userId target user id, this will also revoke all access tokens related to the user(in 21 | * order to logout user) 22 | * @throws InvalidOperation if target user is not exist, already inactive, or you are trying to 23 | * deactivate yourself 24 | * @throws UserDoesNotExist if target user is not exist 25 | */ 26 | void deactivateUser(String userId) throws InvalidOperation, UserDoesNotExist; 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/service/user/auth/ActivationServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.user.auth; 2 | 3 | import com.joejoe2.demo.exception.InvalidOperation; 4 | import com.joejoe2.demo.exception.UserDoesNotExist; 5 | import com.joejoe2.demo.model.auth.Role; 6 | import com.joejoe2.demo.model.auth.User; 7 | import com.joejoe2.demo.repository.jwt.AccessTokenRepository; 8 | import com.joejoe2.demo.repository.user.UserRepository; 9 | import com.joejoe2.demo.service.jwt.JwtService; 10 | import com.joejoe2.demo.utils.AuthUtil; 11 | import com.joejoe2.demo.validation.validator.UUIDValidator; 12 | import java.util.UUID; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.dao.OptimisticLockingFailureException; 15 | import org.springframework.retry.annotation.Backoff; 16 | import org.springframework.retry.annotation.Retryable; 17 | import org.springframework.stereotype.Service; 18 | import org.springframework.transaction.annotation.Transactional; 19 | 20 | @Service 21 | public class ActivationServiceImpl implements ActivationService { 22 | @Autowired UserRepository userRepository; 23 | @Autowired JwtService jwtService; 24 | @Autowired AccessTokenRepository accessTokenRepository; 25 | UUIDValidator uuidValidator = UUIDValidator.getInstance(); 26 | 27 | @Retryable(value = OptimisticLockingFailureException.class, backoff = @Backoff(delay = 100)) 28 | @Override 29 | public void activateUser(String userId) throws InvalidOperation, UserDoesNotExist { 30 | UUID id = uuidValidator.validate(userId); 31 | 32 | User user = 33 | userRepository.findById(id).orElseThrow(() -> new UserDoesNotExist("user is not exist !")); 34 | if (AuthUtil.isAuthenticated() && AuthUtil.currentUserDetail().getId().equals(id.toString())) 35 | throw new InvalidOperation("cannot activate yourself !"); 36 | if (user.isActive()) throw new InvalidOperation("target user is already active !"); 37 | 38 | user.setActive(true); 39 | userRepository.save(user); 40 | } 41 | 42 | @Retryable(value = OptimisticLockingFailureException.class, backoff = @Backoff(delay = 100)) 43 | @Transactional(rollbackFor = Exception.class) 44 | @Override 45 | public void deactivateUser(String userId) throws InvalidOperation, UserDoesNotExist { 46 | UUID id = uuidValidator.validate(userId); 47 | 48 | User user = 49 | userRepository.findById(id).orElseThrow(() -> new UserDoesNotExist("user is not exist !")); 50 | if (AuthUtil.isAuthenticated() && AuthUtil.currentUserDetail().getId().equals(id.toString())) 51 | throw new InvalidOperation("cannot deactivate yourself !"); 52 | if (user.getRole() == Role.ADMIN) throw new InvalidOperation("cannot deactivate an admin !"); 53 | if (!user.isActive()) throw new InvalidOperation("target user is already inactive !"); 54 | 55 | user.setActive(false); 56 | userRepository.save(user); 57 | 58 | // need to logout user after deactivate 59 | jwtService.revokeAccessToken(accessTokenRepository.getByUser(user)); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/service/user/auth/LoginService.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.user.auth; 2 | 3 | import com.joejoe2.demo.data.auth.UserDetail; 4 | import org.springframework.security.core.AuthenticationException; 5 | 6 | public interface LoginService { 7 | UserDetail login(String username, String password) throws AuthenticationException; 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/service/user/auth/LoginServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.user.auth; 2 | 3 | import com.joejoe2.demo.config.LoginConfig; 4 | import com.joejoe2.demo.data.auth.UserDetail; 5 | import com.joejoe2.demo.exception.InvalidOperation; 6 | import com.joejoe2.demo.model.auth.LoginAttempt; 7 | import com.joejoe2.demo.model.auth.User; 8 | import com.joejoe2.demo.repository.user.UserRepository; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.dao.OptimisticLockingFailureException; 11 | import org.springframework.retry.annotation.Backoff; 12 | import org.springframework.retry.annotation.Retryable; 13 | import org.springframework.security.authentication.AuthenticationManager; 14 | import org.springframework.security.authentication.AuthenticationServiceException; 15 | import org.springframework.security.core.AuthenticationException; 16 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 17 | import org.springframework.stereotype.Service; 18 | import org.springframework.transaction.annotation.Transactional; 19 | 20 | @Service 21 | public class LoginServiceImpl implements LoginService { 22 | @Autowired AuthenticationManager authenticationManager; 23 | @Autowired UserRepository userRepository; 24 | @Autowired LoginConfig loginConfig; 25 | 26 | @Retryable(value = OptimisticLockingFailureException.class, backoff = @Backoff(delay = 100)) 27 | @Transactional(noRollbackFor = AuthenticationException.class) 28 | @Override 29 | public UserDetail login(String username, String password) throws AuthenticationException { 30 | User user = 31 | userRepository 32 | .getByUserName(username) 33 | .orElseThrow(() -> new UsernameNotFoundException("Username is not exist !")); 34 | 35 | LoginAttempt loginAttempt = user.getLoginAttempt(); 36 | try { 37 | return loginAttempt.login(loginConfig, authenticationManager, username, password); 38 | } catch (InvalidOperation ex) { 39 | throw new AuthenticationServiceException( 40 | "You have try too many times, please try again later"); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/service/user/auth/PasswordService.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.user.auth; 2 | 3 | import com.joejoe2.demo.exception.InvalidOperation; 4 | import com.joejoe2.demo.exception.UserDoesNotExist; 5 | import com.joejoe2.demo.model.auth.VerifyToken; 6 | 7 | public interface PasswordService { 8 | /** 9 | * change the password of user with userId, this will also revoke all access tokens related to the 10 | * user(in order to logout user) 11 | * 12 | * @param userId 13 | * @param oldPassword 14 | * @param newPassword 15 | * @throws InvalidOperation if target user is old password is not correct, old password equals to 16 | * new password 17 | * @throws UserDoesNotExist if target user is not exist 18 | */ 19 | void changePasswordOf(String userId, String oldPassword, String newPassword) 20 | throws InvalidOperation, UserDoesNotExist; 21 | 22 | /** 23 | * generate VerifyToken related to the email (VerifyToken has an expiration time). this will 24 | * provide verification of resetPassword 25 | * 26 | * @param email 27 | * @return VerifyToken, is used for verification of resetPassword 28 | * @throws InvalidOperation if target user is inactive, or there is a non-expired VerifyToken 29 | * already in db 30 | * @throws UserDoesNotExist if target user is not exist 31 | */ 32 | VerifyToken requestResetPasswordToken(String email) throws InvalidOperation, UserDoesNotExist; 33 | 34 | /** 35 | * reset password of the related user to a non-expired verifyToken in db, this will also revoke 36 | * all access tokens related to the user(in order to logout user) and the verifyToken will be 37 | * deleted after password reset 38 | * 39 | * @param verifyToken 40 | * @param newPassword 41 | * @throws InvalidOperation if target user is inactive or there is not any active VerifyToken in 42 | * db 43 | */ 44 | void resetPassword(String verifyToken, String newPassword) throws InvalidOperation; 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/service/user/auth/RegistrationService.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.user.auth; 2 | 3 | import com.joejoe2.demo.data.auth.VerificationPair; 4 | import com.joejoe2.demo.exception.AlreadyExist; 5 | import com.joejoe2.demo.exception.InvalidOperation; 6 | import com.joejoe2.demo.model.auth.Role; 7 | import com.joejoe2.demo.model.auth.User; 8 | 9 | public interface RegistrationService { 10 | /** 11 | * create a user with given params 12 | * 13 | * @param username 14 | * @param password 15 | * @param email 16 | * @param role 17 | * @return created user 18 | * @throws AlreadyExist if target user(username or email) is already taken 19 | */ 20 | User createUser(String username, String password, String email, Role role) throws AlreadyExist; 21 | 22 | /** 23 | * this will first verify verification info via VerificationService, then create a user with given 24 | * params if pass the verification 25 | * 26 | * @param username 27 | * @param password 28 | * @param email 29 | * @param verification verification info 30 | * @return created user 31 | * @throws AlreadyExist if target user(username or email) is already taken 32 | * @throws InvalidOperation if you do not pass the verification via VerificationService 33 | */ 34 | User registerUser(String username, String password, String email, VerificationPair verification) 35 | throws AlreadyExist, InvalidOperation; 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/service/user/auth/RegistrationServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.user.auth; 2 | 3 | import com.joejoe2.demo.data.auth.VerificationPair; 4 | import com.joejoe2.demo.exception.AlreadyExist; 5 | import com.joejoe2.demo.exception.InvalidOperation; 6 | import com.joejoe2.demo.model.auth.Role; 7 | import com.joejoe2.demo.model.auth.User; 8 | import com.joejoe2.demo.repository.user.UserRepository; 9 | import com.joejoe2.demo.service.verification.VerificationService; 10 | import com.joejoe2.demo.validation.validator.EmailValidator; 11 | import com.joejoe2.demo.validation.validator.PasswordValidator; 12 | import com.joejoe2.demo.validation.validator.UserNameValidator; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.security.crypto.password.PasswordEncoder; 15 | import org.springframework.stereotype.Service; 16 | import org.springframework.transaction.annotation.Transactional; 17 | 18 | @Service 19 | public class RegistrationServiceImpl implements RegistrationService { 20 | @Autowired UserRepository userRepository; 21 | @Autowired PasswordEncoder passwordEncoder; 22 | @Autowired VerificationService verificationService; 23 | EmailValidator emailValidator = EmailValidator.getInstance(); 24 | PasswordValidator passwordValidator = PasswordValidator.getInstance(); 25 | UserNameValidator userNameValidator = UserNameValidator.getInstance(); 26 | 27 | @Override 28 | public User createUser(String username, String password, String email, Role role) 29 | throws AlreadyExist { 30 | username = userNameValidator.validate(username); 31 | password = passwordValidator.validate(password); 32 | email = emailValidator.validate(email); 33 | 34 | if (userRepository.getByUserName(username).isPresent() 35 | || userRepository.getByEmail(email).isPresent()) 36 | throw new AlreadyExist("username or email is already taken !"); 37 | 38 | User user = new User(); 39 | user.setUserName(username); 40 | user.setPassword(passwordEncoder.encode(password)); 41 | user.setEmail(email); 42 | user.setRole(role); 43 | userRepository.save(user); 44 | 45 | return user; 46 | } 47 | 48 | @Override 49 | @Transactional(rollbackFor = Exception.class) 50 | public User registerUser( 51 | String username, String password, String email, VerificationPair verificationPair) 52 | throws AlreadyExist, InvalidOperation { 53 | verificationService.verify(verificationPair.getKey(), email, verificationPair.getCode()); 54 | return createUser(username, password, email, Role.NORMAL); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/service/user/auth/RoleService.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.user.auth; 2 | 3 | import com.joejoe2.demo.exception.InvalidOperation; 4 | import com.joejoe2.demo.exception.UserDoesNotExist; 5 | import com.joejoe2.demo.model.auth.Role; 6 | 7 | public interface RoleService { 8 | /** 9 | * change the role of user with userId, this will also revoke all access tokens related to the 10 | * user(in order to logout user) 11 | * 12 | * @param userId target user id 13 | * @param role target role you want to change to 14 | * @throws InvalidOperation if target user is already in that role, you are trying to change the 15 | * role of yourself, or the target user is the only admin in db 16 | * @throws UserDoesNotExist if target user is not exist 17 | */ 18 | void changeRoleOf(String userId, Role role) throws InvalidOperation, UserDoesNotExist; 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/service/user/auth/RoleServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.user.auth; 2 | 3 | import com.joejoe2.demo.exception.InvalidOperation; 4 | import com.joejoe2.demo.exception.UserDoesNotExist; 5 | import com.joejoe2.demo.model.auth.Role; 6 | import com.joejoe2.demo.model.auth.User; 7 | import com.joejoe2.demo.repository.jwt.AccessTokenRepository; 8 | import com.joejoe2.demo.repository.user.UserRepository; 9 | import com.joejoe2.demo.service.jwt.JwtService; 10 | import com.joejoe2.demo.utils.AuthUtil; 11 | import com.joejoe2.demo.validation.validator.UUIDValidator; 12 | import java.util.UUID; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.dao.OptimisticLockingFailureException; 15 | import org.springframework.retry.annotation.Backoff; 16 | import org.springframework.retry.annotation.Retryable; 17 | import org.springframework.stereotype.Service; 18 | import org.springframework.transaction.annotation.Transactional; 19 | 20 | @Service 21 | public class RoleServiceImpl implements RoleService { 22 | @Autowired UserRepository userRepository; 23 | @Autowired JwtService jwtService; 24 | @Autowired AccessTokenRepository accessTokenRepository; 25 | UUIDValidator uuidValidator = UUIDValidator.getInstance(); 26 | 27 | @Retryable(value = OptimisticLockingFailureException.class, backoff = @Backoff(delay = 100)) 28 | @Transactional(rollbackFor = Exception.class) 29 | @Override 30 | public void changeRoleOf(String userId, Role role) throws InvalidOperation, UserDoesNotExist { 31 | UUID id = uuidValidator.validate(userId); 32 | 33 | User user = 34 | userRepository.findById(id).orElseThrow(() -> new UserDoesNotExist("user is not exist !")); 35 | if (AuthUtil.isAuthenticated() && AuthUtil.currentUserDetail().getId().equals(id.toString())) 36 | throw new InvalidOperation("cannot change the role of yourself !"); 37 | Role originalRole = user.getRole(); 38 | if (role.equals(originalRole)) throw new InvalidOperation("role doesn't change !"); 39 | 40 | user.setRole(role); 41 | userRepository.save(user); 42 | 43 | if (Role.ADMIN.equals(originalRole) && userRepository.getByRole(Role.ADMIN).size() == 0) 44 | throw new InvalidOperation("cannot change the role of the only ADMIN !"); 45 | 46 | // need to logout user after role change 47 | jwtService.revokeAccessToken(accessTokenRepository.getByUser(user)); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/service/user/profile/ProfileService.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.user.profile; 2 | 3 | import com.joejoe2.demo.data.PageList; 4 | import com.joejoe2.demo.data.user.UserProfile; 5 | import com.joejoe2.demo.exception.UserDoesNotExist; 6 | import java.util.List; 7 | 8 | public interface ProfileService { 9 | /** 10 | * load UserProfile with given userId 11 | * 12 | * @param userId 13 | * @return 14 | * @throws UserDoesNotExist if target user is not exist 15 | */ 16 | UserProfile getProfile(String userId) throws UserDoesNotExist; 17 | 18 | /** 19 | * get all user profiles 20 | * 21 | * @return all user profiles 22 | */ 23 | List getAllUserProfiles(); 24 | 25 | /** 26 | * get all user profiles with page request 27 | * 28 | * @param page must>=0 29 | * @param size must>0 30 | * @return paged user profiles 31 | */ 32 | PageList getAllUserProfilesWithPage(int page, int size); 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/service/user/profile/ProfileServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.user.profile; 2 | 3 | import com.joejoe2.demo.data.PageList; 4 | import com.joejoe2.demo.data.user.UserProfile; 5 | import com.joejoe2.demo.exception.UserDoesNotExist; 6 | import com.joejoe2.demo.model.auth.User; 7 | import com.joejoe2.demo.repository.user.UserRepository; 8 | import java.util.Comparator; 9 | import java.util.List; 10 | import java.util.UUID; 11 | import java.util.stream.Collectors; 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.data.domain.Page; 14 | import org.springframework.data.domain.PageRequest; 15 | import org.springframework.data.domain.Sort; 16 | import org.springframework.stereotype.Service; 17 | 18 | @Service 19 | public class ProfileServiceImpl implements ProfileService { 20 | @Autowired UserRepository userRepository; 21 | 22 | @Override 23 | public UserProfile getProfile(String userId) throws UserDoesNotExist { 24 | User user = 25 | userRepository 26 | .findById(UUID.fromString(userId)) 27 | .orElseThrow(() -> new UserDoesNotExist("user is not exist !")); 28 | return new UserProfile(user); 29 | } 30 | 31 | @Override 32 | public List getAllUserProfiles() { 33 | return userRepository.findAll().stream() 34 | .sorted(Comparator.comparing(User::getCreateAt)) 35 | .map((UserProfile::new)) 36 | .collect(Collectors.toList()); 37 | } 38 | 39 | @Override 40 | public PageList getAllUserProfilesWithPage(int page, int size) { 41 | if (page < 0 || size <= 0) throw new IllegalArgumentException("invalid page or size !"); 42 | Page paging = 43 | userRepository.findAll(PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "createAt"))); 44 | List profiles = 45 | paging.getContent().stream().map((UserProfile::new)).collect(Collectors.toList()); 46 | return new PageList<>( 47 | paging.getTotalElements(), 48 | paging.getNumber(), 49 | paging.getTotalPages(), 50 | paging.getSize(), 51 | profiles); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/service/verification/VerificationService.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.verification; 2 | 3 | import com.joejoe2.demo.data.auth.VerificationPair; 4 | import com.joejoe2.demo.exception.InvalidOperation; 5 | 6 | public interface VerificationService { 7 | /** 8 | * issue verification code related to the param email and send it out. note that the verification 9 | * code has an expiration time. 10 | * 11 | * @param email the email address related to the verification code 12 | * @return an object containing verification key and verification code 13 | */ 14 | VerificationPair issueVerificationCode(String email); 15 | 16 | /** 17 | * try to verify the verification code with the verification key and email. one must pass 18 | * key,email, and code to check whether an VerificationCode object is in db and deleted it if 19 | * existed and not expired 20 | * 21 | * @param key verification key 22 | * @param email related email 23 | * @param code verification code 24 | * @throws InvalidOperation if there is no matching VerificationCode object in db, or it has been 25 | * already expired 26 | */ 27 | void verify(String key, String email, String code) throws InvalidOperation; 28 | 29 | /** delete all expired verification codes */ 30 | void deleteExpiredVerificationCodes(); 31 | 32 | /** delete all expired verify tokens */ 33 | void deleteExpiredVerifyTokens(); 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/service/verification/VerificationServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.verification; 2 | 3 | import com.joejoe2.demo.data.auth.VerificationPair; 4 | import com.joejoe2.demo.exception.InvalidOperation; 5 | import com.joejoe2.demo.exception.ValidationError; 6 | import com.joejoe2.demo.model.auth.VerificationCode; 7 | import com.joejoe2.demo.repository.verification.VerificationCodeRepository; 8 | import com.joejoe2.demo.repository.verification.VerifyTokenRepository; 9 | import com.joejoe2.demo.service.email.EmailService; 10 | import com.joejoe2.demo.validation.validator.EmailValidator; 11 | import com.joejoe2.demo.validation.validator.UUIDValidator; 12 | import java.time.Instant; 13 | import java.util.Calendar; 14 | import java.util.UUID; 15 | import org.slf4j.Logger; 16 | import org.slf4j.LoggerFactory; 17 | import org.springframework.beans.factory.annotation.Autowired; 18 | import org.springframework.stereotype.Service; 19 | import org.springframework.transaction.annotation.Transactional; 20 | 21 | @Service 22 | public class VerificationServiceImpl implements VerificationService { 23 | @Autowired VerificationCodeRepository verificationRepository; 24 | @Autowired VerifyTokenRepository verifyTokenRepository; 25 | @Autowired EmailService emailService; 26 | EmailValidator emailValidator = EmailValidator.getInstance(); 27 | UUIDValidator uuidValidator = UUIDValidator.getInstance(); 28 | private static final Logger logger = LoggerFactory.getLogger(VerificationService.class); 29 | 30 | @Override 31 | public VerificationPair issueVerificationCode(String email) { 32 | email = emailValidator.validate(email); 33 | 34 | Calendar exp = Calendar.getInstance(); 35 | exp.add(Calendar.SECOND, 300); 36 | VerificationCode emailVerification = new VerificationCode(); 37 | emailVerification.setEmail(email); 38 | emailVerification.setExpireAt(exp.toInstant()); 39 | verificationRepository.save(emailVerification); 40 | 41 | emailService.sendSimpleEmail( 42 | emailVerification.getEmail(), 43 | "Verification", 44 | "your verification code is " + emailVerification.getCode()); 45 | return new VerificationPair(emailVerification.getId().toString(), emailVerification.getCode()); 46 | } 47 | 48 | @Override 49 | public void verify(String key, String email, String code) throws InvalidOperation { 50 | UUID keyId = uuidValidator.validate(key); 51 | email = emailValidator.validate(email); 52 | if (code == null) throw new ValidationError("code cannot be null !"); 53 | 54 | if (verificationRepository.deleteByIdAndEmailAndCodeAndExpireAtGreaterThan( 55 | keyId, email, code, Instant.now()) 56 | == 0) { 57 | throw new InvalidOperation("verification fail !"); 58 | } 59 | } 60 | 61 | @Transactional // jobrunr error 62 | @Override 63 | public void deleteExpiredVerificationCodes() { 64 | logger.info("delete expired verification codes"); 65 | verificationRepository.deleteByExpireAtLessThan(Instant.now()); 66 | } 67 | 68 | @Transactional // jobrunr error 69 | @Override 70 | public void deleteExpiredVerifyTokens() { 71 | logger.info("delete expired verification tokens"); 72 | verifyTokenRepository.deleteByExpireAtLessThan(Instant.now()); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/utils/AuthUtil.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.utils; 2 | 3 | import com.joejoe2.demo.data.auth.UserDetail; 4 | import org.springframework.security.authentication.AnonymousAuthenticationToken; 5 | import org.springframework.security.authentication.AuthenticationManager; 6 | import org.springframework.security.authentication.InternalAuthenticationServiceException; 7 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 8 | import org.springframework.security.core.Authentication; 9 | import org.springframework.security.core.AuthenticationException; 10 | import org.springframework.security.core.context.SecurityContextHolder; 11 | 12 | public class AuthUtil { 13 | public static UserDetail authenticate( 14 | AuthenticationManager authenticationManager, String username, String password) 15 | throws AuthenticationException { 16 | Authentication authentication = 17 | authenticationManager.authenticate( 18 | new UsernamePasswordAuthenticationToken(username, password)); 19 | SecurityContextHolder.getContext().setAuthentication(authentication); 20 | return ((UserDetail) authentication.getPrincipal()); 21 | } 22 | 23 | public static boolean isAuthenticated() { 24 | Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); 25 | return authentication != null && !(authentication instanceof AnonymousAuthenticationToken); 26 | } 27 | 28 | public static UserDetail currentUserDetail() throws AuthenticationException { 29 | if (!isAuthenticated()) 30 | throw new InternalAuthenticationServiceException("has not been authenticated !"); 31 | Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); 32 | return (UserDetail) authentication.getPrincipal(); 33 | } 34 | 35 | public static void removeAuthentication() { 36 | SecurityContextHolder.getContext().setAuthentication(null); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/utils/CookieUtils.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.utils; 2 | 3 | import jakarta.servlet.http.Cookie; 4 | 5 | public class CookieUtils { 6 | public static Cookie create( 7 | String key, String value, String domain, int maxAge, boolean isHttpOnly) { 8 | Cookie cookie = new Cookie(key, value); 9 | cookie.setMaxAge(maxAge); 10 | cookie.setDomain(domain); 11 | cookie.setPath("/"); 12 | cookie.setHttpOnly(isHttpOnly); 13 | return cookie; 14 | } 15 | 16 | public static Cookie removed(String key, String domain, boolean isHttpOnly) { 17 | Cookie cookie = new Cookie(key, null); 18 | cookie.setMaxAge(0); 19 | cookie.setDomain(domain); 20 | cookie.setPath("/"); 21 | cookie.setHttpOnly(isHttpOnly); 22 | return cookie; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/utils/IPUtils.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.utils; 2 | 3 | import org.springframework.web.context.request.RequestAttributes; 4 | import org.springframework.web.context.request.RequestContextHolder; 5 | 6 | public class IPUtils { 7 | private static final String REQUEST_IP_ATTRIBUTE = "REQUEST_IP"; 8 | 9 | public static void setRequestIP(String ip) { 10 | RequestContextHolder.currentRequestAttributes() 11 | .setAttribute(REQUEST_IP_ATTRIBUTE, ip, RequestAttributes.SCOPE_REQUEST); 12 | } 13 | 14 | public static String getRequestIP() { 15 | return (String) 16 | RequestContextHolder.currentRequestAttributes() 17 | .getAttribute(REQUEST_IP_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/utils/JwtUtil.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.utils; 2 | 3 | import com.joejoe2.demo.data.auth.AccessTokenSpec; 4 | import com.joejoe2.demo.data.auth.RefreshTokenSpec; 5 | import com.joejoe2.demo.data.auth.UserDetail; 6 | import com.joejoe2.demo.exception.InvalidTokenException; 7 | import com.joejoe2.demo.model.auth.Role; 8 | import com.joejoe2.demo.model.auth.User; 9 | import io.jsonwebtoken.Claims; 10 | import io.jsonwebtoken.JwtException; 11 | import io.jsonwebtoken.JwtParser; 12 | import io.jsonwebtoken.Jwts; 13 | import java.lang.reflect.Field; 14 | import java.security.interfaces.RSAPrivateKey; 15 | import java.security.interfaces.RSAPublicKey; 16 | import java.util.Calendar; 17 | 18 | public class JwtUtil { 19 | public static String generateAccessToken( 20 | RSAPrivateKey key, String jti, String issuer, User user, Calendar exp) { 21 | Claims claims = Jwts.claims(); 22 | claims.put("type", "access_token"); 23 | claims.put("id", user.getId().toString()); 24 | claims.put("username", user.getUserName()); 25 | claims.put("role", user.getRole().toString()); 26 | claims.put("isActive", user.isActive()); 27 | claims.setExpiration(exp.getTime()); 28 | claims.setIssuer(issuer); 29 | claims.setId(jti); 30 | return Jwts.builder().setClaims(claims).signWith(key).compact(); 31 | } 32 | 33 | public static UserDetail extractUserDetailFromAccessToken(RSAPublicKey publicKey, String token) 34 | throws InvalidTokenException { 35 | try { 36 | AccessTokenSpec data = JwtUtil.parseAccessToken(publicKey, token); 37 | if (!data.getType().equals("access_token")) { 38 | throw new InvalidTokenException("invalid token !"); 39 | } 40 | return new UserDetail( 41 | data.getId(), 42 | data.getUsername(), 43 | data.getIsActive(), 44 | Role.valueOf(data.getRole()), 45 | token, 46 | data.getJti()); 47 | } catch (Exception ex) { 48 | throw new InvalidTokenException("invalid token !"); 49 | } 50 | } 51 | 52 | public static String generateRefreshToken( 53 | RSAPrivateKey key, String jti, String issuer, Calendar exp) { 54 | Claims claims = Jwts.claims(); 55 | claims.put("type", "refresh_token"); 56 | claims.setExpiration(exp.getTime()); 57 | claims.setIssuer(issuer); 58 | claims.setId(jti); 59 | return Jwts.builder().setClaims(claims).signWith(key).compact(); 60 | } 61 | 62 | public static AccessTokenSpec parseAccessToken(RSAPublicKey key, String token) 63 | throws JwtException { 64 | try { 65 | JwtParser parser = Jwts.parserBuilder().setSigningKey(key).build(); 66 | 67 | Claims claims = parser.parseClaimsJws(token).getBody(); 68 | 69 | AccessTokenSpec accessTokenSpec = new AccessTokenSpec(); 70 | 71 | for (Field field : AccessTokenSpec.class.getDeclaredFields()) { 72 | try { 73 | field.setAccessible(true); 74 | String k = field.getName(); 75 | Object v = claims.get(k); 76 | if (v == null) throw new Exception("Missing field %s in access token !".formatted(k)); 77 | field.set(accessTokenSpec, v); 78 | } catch (Exception e) { 79 | throw new RuntimeException(e); 80 | } 81 | } 82 | return accessTokenSpec; 83 | } catch (Exception e) { 84 | throw new JwtException(e.getMessage()); 85 | } 86 | } 87 | 88 | public static RefreshTokenSpec parseRefreshToken(RSAPublicKey key, String token) 89 | throws JwtException { 90 | try { 91 | JwtParser parser = Jwts.parserBuilder().setSigningKey(key).build(); 92 | 93 | Claims claims = parser.parseClaimsJws(token).getBody(); 94 | 95 | RefreshTokenSpec refreshTokenSpec = new RefreshTokenSpec(); 96 | 97 | for (Field field : RefreshTokenSpec.class.getDeclaredFields()) { 98 | try { 99 | field.setAccessible(true); 100 | String k = field.getName(); 101 | Object v = claims.get(k); 102 | if (v == null) throw new Exception("Missing field %s in refresh token !".formatted(k)); 103 | field.set(refreshTokenSpec, v); 104 | } catch (Exception e) { 105 | throw new RuntimeException(e); 106 | } 107 | } 108 | return refreshTokenSpec; 109 | } catch (Exception e) { 110 | throw new JwtException(e.getMessage()); 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/utils/Utils.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.utils; 2 | 3 | import java.util.Random; 4 | 5 | public class Utils { 6 | public static String randomNumericCode(int length) { 7 | if (length <= 0) throw new IllegalArgumentException("length range must be > 0 !"); 8 | 9 | StringBuilder buffer = new StringBuilder(length); 10 | for (int d : new Random().ints(length, 0, 10).toArray()) { 11 | buffer.append(d); 12 | } 13 | return buffer.toString(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/validation/constraint/Email.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.validation.constraint; 2 | 3 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 4 | 5 | import com.joejoe2.demo.validation.validator.EmailValidator; 6 | import jakarta.validation.Constraint; 7 | import jakarta.validation.Payload; 8 | import jakarta.validation.constraints.NotEmpty; 9 | import jakarta.validation.constraints.Pattern; 10 | import jakarta.validation.constraints.Size; 11 | import java.lang.annotation.ElementType; 12 | import java.lang.annotation.Retention; 13 | import java.lang.annotation.Target; 14 | 15 | @Target(ElementType.FIELD) 16 | @Constraint(validatedBy = {}) 17 | @Retention(RUNTIME) 18 | @Size(max = 64, message = "email length is at most 64 !") 19 | @NotEmpty(message = "email cannot be empty !") 20 | @Pattern(regexp = EmailValidator.REGEX, message = EmailValidator.NOT_MATCH_MSG) 21 | public @interface Email { 22 | String message() default EmailValidator.NOT_MATCH_MSG; 23 | 24 | Class[] groups() default {}; 25 | 26 | Class[] payload() default {}; 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/validation/constraint/Password.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.validation.constraint; 2 | 3 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 4 | 5 | import com.joejoe2.demo.validation.validator.PasswordValidator; 6 | import jakarta.validation.Constraint; 7 | import jakarta.validation.Payload; 8 | import jakarta.validation.constraints.NotEmpty; 9 | import jakarta.validation.constraints.Pattern; 10 | import jakarta.validation.constraints.Size; 11 | import java.lang.annotation.ElementType; 12 | import java.lang.annotation.Retention; 13 | import java.lang.annotation.Target; 14 | 15 | @Target(ElementType.FIELD) 16 | @Constraint(validatedBy = {}) 17 | @Retention(RUNTIME) 18 | @Size(min = 8, message = "password length is at least 8 !") 19 | @Size(max = 32, message = "password length is at most 32 !") 20 | @NotEmpty(message = "password cannot be empty !") 21 | @Pattern(regexp = PasswordValidator.REGEX, message = PasswordValidator.NOT_MATCH_MSG) 22 | public @interface Password { 23 | String message() default PasswordValidator.NOT_MATCH_MSG; 24 | 25 | Class[] groups() default {}; 26 | 27 | Class[] payload() default {}; 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/validation/constraint/Role.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.validation.constraint; 2 | 3 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 4 | 5 | import com.joejoe2.demo.validation.validator.RoleValidator; 6 | import jakarta.validation.Constraint; 7 | import jakarta.validation.Payload; 8 | import jakarta.validation.ReportAsSingleViolation; 9 | import jakarta.validation.constraints.NotEmpty; 10 | import java.lang.annotation.ElementType; 11 | import java.lang.annotation.Retention; 12 | import java.lang.annotation.Target; 13 | 14 | @Target(ElementType.FIELD) 15 | @Constraint(validatedBy = {RoleValidator.class}) 16 | @Retention(RUNTIME) 17 | @NotEmpty(message = "role cannot be empty !") 18 | @ReportAsSingleViolation 19 | public @interface Role { 20 | String message() default "invalid role !"; 21 | 22 | Class[] groups() default {}; 23 | 24 | Class[] payload() default {}; 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/validation/constraint/UUID.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.validation.constraint; 2 | 3 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 4 | 5 | import jakarta.validation.Constraint; 6 | import jakarta.validation.Payload; 7 | import jakarta.validation.ReportAsSingleViolation; 8 | import jakarta.validation.constraints.Pattern; 9 | import java.lang.annotation.ElementType; 10 | import java.lang.annotation.Retention; 11 | import java.lang.annotation.Target; 12 | 13 | @Target(ElementType.FIELD) 14 | @Constraint(validatedBy = {}) 15 | @Retention(RUNTIME) 16 | @Pattern(regexp = "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") 17 | @ReportAsSingleViolation 18 | public @interface UUID { 19 | String message() default "invalid uuid !"; 20 | 21 | Class[] groups() default {}; 22 | 23 | Class[] payload() default {}; 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/validation/constraint/Username.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.validation.constraint; 2 | 3 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 4 | 5 | import com.joejoe2.demo.validation.validator.UserNameValidator; 6 | import jakarta.validation.Constraint; 7 | import jakarta.validation.Payload; 8 | import jakarta.validation.constraints.NotEmpty; 9 | import jakarta.validation.constraints.Pattern; 10 | import jakarta.validation.constraints.Size; 11 | import java.lang.annotation.ElementType; 12 | import java.lang.annotation.Retention; 13 | import java.lang.annotation.Target; 14 | 15 | @Target(ElementType.FIELD) 16 | @Constraint(validatedBy = {}) 17 | @Retention(RUNTIME) 18 | @Size(max = 32, message = "username length is at most 32 !") 19 | @NotEmpty(message = "username cannot be empty !") 20 | @Pattern(regexp = UserNameValidator.REGEX, message = UserNameValidator.NOT_MATCH_MSG) 21 | public @interface Username { 22 | String message() default UserNameValidator.NOT_MATCH_MSG; 23 | 24 | Class[] groups() default {}; 25 | 26 | Class[] payload() default {}; 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/validation/validator/EmailValidator.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.validation.validator; 2 | 3 | import com.joejoe2.demo.exception.ValidationError; 4 | import java.util.regex.Pattern; 5 | 6 | public class EmailValidator implements Validator { 7 | // ref: https://www.baeldung.com/java-email-validation-regex 8 | public static final String REGEX = "^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+$"; 9 | private static final Pattern pattern = Pattern.compile(REGEX); 10 | public static final String NOT_MATCH_MSG = "invalid email format !"; 11 | 12 | @Override 13 | public String validate(String data) throws ValidationError { 14 | String email = data; 15 | if (email == null) throw new ValidationError("email can not be null !"); 16 | email = email.trim(); 17 | 18 | if (email.length() == 0) throw new ValidationError("email can not be empty !"); 19 | if (email.length() > 64) throw new ValidationError("the length of email is at most 64 !"); 20 | if (!pattern.matcher(email).matches()) throw new ValidationError(NOT_MATCH_MSG); 21 | 22 | return email; 23 | } 24 | 25 | private static final EmailValidator instance = new EmailValidator(); 26 | 27 | public static EmailValidator getInstance() { 28 | return instance; 29 | } 30 | 31 | private EmailValidator() {} 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/validation/validator/PasswordValidator.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.validation.validator; 2 | 3 | import com.joejoe2.demo.exception.ValidationError; 4 | import java.util.regex.Pattern; 5 | 6 | public class PasswordValidator implements Validator { 7 | public static final String REGEX = "[a-zA-Z0-9]+"; 8 | public static final String NOT_MATCH_MSG = "password can only contain a-z, A-Z, and 0-9 !"; 9 | 10 | private static final Pattern pattern = Pattern.compile(REGEX); 11 | 12 | @Override 13 | public String validate(String data) throws ValidationError { 14 | String password = data; 15 | if (password == null) throw new ValidationError("password can not be null !"); 16 | password = password.trim(); 17 | 18 | if (password.length() == 0) throw new ValidationError("password can not be empty !"); 19 | if (password.length() < 8) throw new ValidationError("the length of password is at least 8 !"); 20 | if (password.length() > 32) throw new ValidationError("the length of password is at most 32 !"); 21 | if (!pattern.matcher(password).matches()) throw new ValidationError(NOT_MATCH_MSG); 22 | 23 | return password; 24 | } 25 | 26 | private static final PasswordValidator instance = new PasswordValidator(); 27 | 28 | public static PasswordValidator getInstance() { 29 | return instance; 30 | } 31 | 32 | private PasswordValidator() {} 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/validation/validator/RoleValidator.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.validation.validator; 2 | 3 | import com.joejoe2.demo.exception.ValidationError; 4 | import com.joejoe2.demo.validation.constraint.Role; 5 | import jakarta.validation.ConstraintValidator; 6 | import jakarta.validation.ConstraintValidatorContext; 7 | 8 | public class RoleValidator 9 | implements ConstraintValidator, 10 | Validator { 11 | @Override 12 | public boolean isValid(String value, ConstraintValidatorContext context) { 13 | try { 14 | com.joejoe2.demo.model.auth.Role.valueOf(value); 15 | return true; 16 | } catch (IllegalArgumentException e) { 17 | return false; 18 | } 19 | } 20 | 21 | @Override 22 | public com.joejoe2.demo.model.auth.Role validate(String data) throws ValidationError { 23 | try { 24 | return com.joejoe2.demo.model.auth.Role.valueOf(data); 25 | } catch (IllegalArgumentException e) { 26 | throw new ValidationError("role " + data + " is not exist !"); 27 | } 28 | } 29 | 30 | private static final RoleValidator instance = new RoleValidator(); 31 | 32 | public static RoleValidator getInstance() { 33 | return instance; 34 | } 35 | 36 | private RoleValidator() {} 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/validation/validator/UUIDValidator.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.validation.validator; 2 | 3 | import com.joejoe2.demo.exception.ValidationError; 4 | import java.util.UUID; 5 | 6 | public class UUIDValidator implements Validator { 7 | @Override 8 | public UUID validate(String data) throws ValidationError { 9 | if (data == null) throw new ValidationError("uuid can not be null !"); 10 | 11 | try { 12 | return UUID.fromString(data); 13 | } catch (Exception e) { 14 | throw new ValidationError(e.getMessage()); 15 | } 16 | } 17 | 18 | private static final UUIDValidator instance = new UUIDValidator(); 19 | 20 | public static UUIDValidator getInstance() { 21 | return instance; 22 | } 23 | 24 | private UUIDValidator() {} 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/validation/validator/UserNameValidator.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.validation.validator; 2 | 3 | import com.joejoe2.demo.exception.ValidationError; 4 | import java.util.regex.Pattern; 5 | 6 | public class UserNameValidator implements Validator { 7 | public static final String REGEX = "[a-zA-Z0-9]+"; 8 | public static final String NOT_MATCH_MSG = "username can only contain a-z, A-Z, and 0-9 !"; 9 | 10 | private static final Pattern pattern = Pattern.compile(REGEX); 11 | 12 | @Override 13 | public String validate(String data) throws ValidationError { 14 | String username = data; 15 | if (username == null) throw new ValidationError("username can not be null !"); 16 | username = username.trim(); 17 | 18 | if (username.length() == 0) throw new ValidationError("username can not be empty !"); 19 | if (username.length() > 32) throw new ValidationError("the length of username is at most 32 !"); 20 | if (!pattern.matcher(username).matches()) throw new ValidationError(NOT_MATCH_MSG); 21 | 22 | return username; 23 | } 24 | 25 | private static final UserNameValidator instance = new UserNameValidator(); 26 | 27 | public static UserNameValidator getInstance() { 28 | return instance; 29 | } 30 | 31 | private UserNameValidator() {} 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/demo/validation/validator/Validator.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.validation.validator; 2 | 3 | import com.joejoe2.demo.exception.ValidationError; 4 | 5 | public interface Validator { 6 | O validate(I data) throws ValidationError; 7 | } 8 | -------------------------------------------------------------------------------- /src/main/resources/application-dev.properties: -------------------------------------------------------------------------------- 1 | # db related settings 2 | spring.datasource.url=jdbc:postgresql://localhost:5432/spring-test 3 | spring.datasource.username=postgres 4 | spring.datasource.password=pa55ward 5 | spring.datasource.driver-class-name=org.postgresql.Driver 6 | spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect 7 | spring.jpa.properties.hibernate.hbm2ddl.auto=none 8 | spring.jpa.properties.hibernate.jdbc.batch_size=10 9 | spring.jpa.open-in-view=false 10 | spring.liquibase.enabled=true 11 | spring.liquibase.change-log=classpath:/db/changelog/db.changelog-master.yaml 12 | # redis related settings 13 | spring.data.redis.host=localhost 14 | spring.data.redis.port=6379 15 | # create default admin account 16 | default.admin.username=admin 17 | default.admin.password=pa55ward 18 | default.admin.email=admin@email.com 19 | # jwt related settings 20 | jwt.issuer=joejoe2.com 21 | # domain for access/refresh tokens in cookie(if you are using web login api) 22 | # can be exact domain or example.com for all subdomains 23 | jwt.cookie.domain=localhost 24 | # in seconds 25 | jwt.access.token.lifetime=900 26 | jwt.refresh.token.lifetime=1800 27 | # set allow host (frontend) 28 | allow.host=http://localhost:[*] 29 | # set reset password url 30 | reset.password.url=http://localhost:8888/resetPassword?token= 31 | # login max attempt settings 32 | login.maxAttempts=5 33 | # in seconds 34 | login.attempts.coolTime=900 35 | # mail sender 36 | spring.mail.host=smtp.gmail.com 37 | spring.mail.port=587 38 | spring.mail.username=test@gmail.com 39 | spring.mail.password=pa55ward 40 | spring.mail.properties.mail.smtp.auth=true 41 | spring.mail.properties.mail.smtp.starttls.enable=true 42 | # jobrunr 43 | org.jobrunr.background-job-server.enabled=true 44 | org.jobrunr.dashboard.enabled=true 45 | org.jobrunr.database.type=sql 46 | init.recurrent-job=true 47 | -------------------------------------------------------------------------------- /src/main/resources/application-dev.yml: -------------------------------------------------------------------------------- 1 | jwt: 2 | secret: 3 | privateKey: | 4 | -----BEGIN PRIVATE KEY----- 5 | ... your PRIVATE KEY ... 6 | -----END PRIVATE KEY----- 7 | publicKey: | 8 | -----BEGIN PUBLIC KEY----- 9 | ... your PUBLIC KEY ... 10 | -----END PUBLIC KEY----- 11 | -------------------------------------------------------------------------------- /src/main/resources/application-test.properties: -------------------------------------------------------------------------------- 1 | spring.datasource.url=jdbc:postgresql://localhost:5430/spring-test 2 | spring.datasource.username=postgres 3 | spring.datasource.password=pa55ward 4 | spring.datasource.driver-class-name=org.postgresql.Driver 5 | spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect 6 | spring.jpa.properties.hibernate.hbm2ddl.auto=none 7 | spring.jpa.properties.hibernate.jdbc.batch_size=10 8 | spring.jpa.open-in-view=false 9 | spring.liquibase.enabled=true 10 | spring.liquibase.change-log=classpath:/db/test/changelog/db.changelog-master.yaml 11 | # log sql 12 | logging.level.org.hibernate.SQL=DEBUG 13 | logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE 14 | # do not change these 15 | spring.data.redis.host=localhost 16 | # open port 6370 instead of 6379 for test only 17 | spring.data.redis.port=6370 18 | # create default admin account 19 | default.admin.username=admin 20 | default.admin.password=pa55ward 21 | default.admin.email=admin@email.com 22 | # jwt related settings 23 | jwt.issuer=joejoe2.com 24 | # domain for access/refresh tokens in cookie(if you are using web login api) 25 | # can be exact domain or example.com for all subdomains 26 | jwt.cookie.domain=example.com 27 | # in seconds 28 | jwt.access.token.lifetime=900 29 | jwt.refresh.token.lifetime=1800 30 | # set allow host (frontend) 31 | allow.host=http://localhost:[*] 32 | # set reset password url 33 | reset.password.url=http://localhost:8888/resetPassword?token= 34 | # login max attempt settings 35 | login.maxAttempts=5 36 | # in seconds 37 | login.attempts.coolTime=900 38 | # mail sender 39 | spring.mail.host=smtp.gmail.com 40 | spring.mail.port=587 41 | spring.mail.username=test@gmail.com 42 | spring.mail.password=pa55ward 43 | spring.mail.properties.mail.smtp.auth=true 44 | spring.mail.properties.mail.smtp.starttls.enable=true 45 | # jobrunr 46 | org.jobrunr.background-job-server.enabled=true 47 | org.jobrunr.dashboard.enabled=false 48 | org.jobrunr.database.type=sql 49 | org.jobrunr.background-job-server.poll-interval-in-seconds=5 50 | init.recurrent-job=true 51 | -------------------------------------------------------------------------------- /src/main/resources/application-test.yml: -------------------------------------------------------------------------------- 1 | jwt: 2 | secret: 3 | privateKey: | 4 | -----BEGIN PRIVATE KEY----- 5 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDMVVoiJm0UomAi 6 | utdr1lB8d4a7ej8IUYCHJsZddxTpdZmnIgc1NR3HtqHXP6kx5xdao4iSYpU33aS2 7 | BX5MAQGYH3VMyvbd0xT3kkLnDPsfWzFSY77Qe4DleQZRNd/oaqkgBgu8qc02F3x4 8 | IWb9gARL0syxEFVmqABvsYMkNv4Hg1PNQa1afvX/atKcOxeDX5pXbvUwzufbQBy9 9 | Gu5cNbFvPlbHmQqdNvCs5K9DVyEKj+CD637EJR1nvxLJPZtG5Qh753SofKp1jqLi 10 | W9uJfMKehlHKAgxh9HLGHTLnYzDo1cu+jA5nYMNi1Ax4mCXKhw4V5ipKCqJGxoQu 11 | +hD/ljzXAgMBAAECggEAR8T26qXKjIPX9nrf7V2SWZV1+mWevCI8Xbwt0mhgLPwE 12 | YyLdmz+z3RD12W/f0spTdp+X+aqstLmh/9kAGlwEHlV2Uum7OgDJDYgO/a6eic3z 13 | DfhA7mNiy7btlBqzMaQ9ESVue+68SHKJYnyA+ys61xMMmGifRnZd6N1VraOvKB3E 14 | 5s3CSLpbAaAJbXew/UxiShzxC1mswYdQqT+uwv4BGWSKrtNKWUP6Vd7PNiOSVDCq 15 | mPMBxj3Y2JYZr9TBEaGD3XAN+mRC2EuyZjFvpR7jPgPdLFLE63IAcq7rUtJEYucC 16 | 9NoDpH6UPlXJLNJt2hyiAx0YLcN0iNwyyUlMpdC1IQKBgQDmUcTW3qZnycrmEeK5 17 | 9Zh39+VKfClAzZNbM3BRuJ0RxareWYrvwEbOnChlJWIgvjKokFKhkU0YJ50mD+Cv 18 | BqmZCbG7B74r4GB19V/PMwRXvoBcso0rMZ1mP0AoYKXPrjrvNsqBm/8UZZLL3vWr 19 | ZFI1XVEdLZ0bM0ys9rJKH94T5wKBgQDjHdbitjP/UurnIT+BHqObQXDrmEqAansY 20 | 93wlrb3KNTfrYts2ULIJVVocOF9WW274bmb/3FusQOOtERrO4vOnPfDebFO3e+VP 21 | /gNVnAch5gcOaaHRYhYUbSh80oCT34orhqqfo6pGXI1zD8TbhrVcwVsP0KmwteTD 22 | qc4cc1xxkQKBgQCJVHY+/HFSb2MY/c8nvIYV+mzwlcnvRuS3O5ucTqzxHOC+Rbvv 23 | KsHNjhUUAk9ZYK9KDQwIJGBIp84vFMaO9jUH+FzOPVaqSNabXxyqqivLud5F53z/ 24 | JU1J2ysBKGeVxriDTDNBRue4nLwD7cSkVmQiR6sG79y+jD8K3un+ArRjPwKBgHvj 25 | GgV/CCwdad98JmzjbrFQ6CzLXNBhxRYgYcsX0/BKSV+QBC3DpOosccP1CCROKeFA 26 | L9Ufua3jk44jR3FVIT24LvzVMHFlFvgkgmMfglB+bpjxDADwNUUdKjm0hcij5nXJ 27 | tqbwGwDYmZwLHQH2oFWhb2/YDchD4C7PIIwqbWHRAoGAK70oggnIysqOy0SalAAb 28 | 1XJQy3RopmVwhoh2VDodfe5DWvJKJVgSHv7gN8/uUFWGW2v7aiylsqH8pCi6Mywz 29 | mocETa/9vy9WAXhrkz19Ui07j6wObApnhthvCAoVerXetmv/hhXlyACm2tWeRYhE 30 | FOgtIo7gTMxb2dBgGpOxq+o= 31 | -----END PRIVATE KEY----- 32 | publicKey: | 33 | -----BEGIN PUBLIC KEY----- 34 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzFVaIiZtFKJgIrrXa9ZQ 35 | fHeGu3o/CFGAhybGXXcU6XWZpyIHNTUdx7ah1z+pMecXWqOIkmKVN92ktgV+TAEB 36 | mB91TMr23dMU95JC5wz7H1sxUmO+0HuA5XkGUTXf6GqpIAYLvKnNNhd8eCFm/YAE 37 | S9LMsRBVZqgAb7GDJDb+B4NTzUGtWn71/2rSnDsXg1+aV271MM7n20AcvRruXDWx 38 | bz5Wx5kKnTbwrOSvQ1chCo/gg+t+xCUdZ78SyT2bRuUIe+d0qHyqdY6i4lvbiXzC 39 | noZRygIMYfRyxh0y52Mw6NXLvowOZ2DDYtQMeJglyocOFeYqSgqiRsaELvoQ/5Y8 40 | 1wIDAQAB 41 | -----END PUBLIC KEY----- 42 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/2023/03/03-01-changelog.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - changeSet: 3 | id: 1679020702810-1 4 | author: joejoe2 (generated) 5 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 6 | changes: 7 | - addColumn: 8 | columns: 9 | - column: 10 | name: refresh_token_id 11 | type: UUID 12 | tableName: access_token 13 | - changeSet: 14 | id: 1679020702810-2 15 | author: joejoe2 (generated) 16 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 17 | changes: 18 | - sql: 19 | dbms: postgresql 20 | endDelimiter: ';' 21 | splitStatements: true 22 | sql: | 23 | UPDATE access_token SET refresh_token_id = refresh_token.id FROM refresh_token WHERE refresh_token.access_token_id = access_token_id; 24 | - changeSet: 25 | id: 1679020702810-3 26 | author: joejoe2 (generated) 27 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 28 | changes: 29 | - addNotNullConstraint: 30 | columnName: refresh_token_id 31 | tableName: access_token 32 | - changeSet: 33 | id: 1679020702810-4 34 | author: joejoe2 (generated) 35 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 36 | changes: 37 | - addForeignKeyConstraint: 38 | baseColumnNames: refresh_token_id 39 | baseTableName: access_token 40 | constraintName: FK_ACCESSTOKEN_ON_REFRESHTOKEN 41 | onDelete: CASCADE 42 | referencedColumnNames: id 43 | referencedTableName: refresh_token 44 | - changeSet: 45 | id: 1679020702810-5 46 | author: joejoe2 (generated) 47 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 48 | changes: 49 | - dropForeignKeyConstraint: 50 | baseTableName: refresh_token 51 | constraintName: fk_refreshtoken_on_accesstoken 52 | - changeSet: 53 | id: 1679020702810-6 54 | author: joejoe2 (generated) 55 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 56 | changes: 57 | - dropColumn: 58 | columnName: access_token_id 59 | tableName: refresh_token 60 | 61 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/db.changelog-master.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - include: 3 | file: db/changelog/2023/01/02-01-changelog.yaml 4 | - include: 5 | file: db/changelog/2023/03/03-01-changelog.yaml -------------------------------------------------------------------------------- /src/main/resources/db/test/changelog/2023/03/03-01-changelog.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - changeSet: 3 | id: 1679020702810-1 4 | author: joejoe2 (generated) 5 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 6 | changes: 7 | - addColumn: 8 | columns: 9 | - column: 10 | name: refresh_token_id 11 | type: UUID 12 | tableName: access_token 13 | - changeSet: 14 | id: 1679020702810-2 15 | author: joejoe2 (generated) 16 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 17 | changes: 18 | - sql: 19 | dbms: postgresql 20 | endDelimiter: ';' 21 | splitStatements: true 22 | sql: | 23 | UPDATE access_token SET refresh_token_id = refresh_token.id FROM refresh_token WHERE refresh_token.access_token_id = access_token_id; 24 | - changeSet: 25 | id: 1679020702810-3 26 | author: joejoe2 (generated) 27 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 28 | changes: 29 | - addNotNullConstraint: 30 | columnName: refresh_token_id 31 | tableName: access_token 32 | - changeSet: 33 | id: 1679020702810-4 34 | author: joejoe2 (generated) 35 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 36 | changes: 37 | - addForeignKeyConstraint: 38 | baseColumnNames: refresh_token_id 39 | baseTableName: access_token 40 | constraintName: FK_ACCESSTOKEN_ON_REFRESHTOKEN 41 | onDelete: CASCADE 42 | referencedColumnNames: id 43 | referencedTableName: refresh_token 44 | - changeSet: 45 | id: 1679020702810-5 46 | author: joejoe2 (generated) 47 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 48 | changes: 49 | - dropForeignKeyConstraint: 50 | baseTableName: refresh_token 51 | constraintName: fk_refreshtoken_on_accesstoken 52 | - changeSet: 53 | id: 1679020702810-6 54 | author: joejoe2 (generated) 55 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 56 | changes: 57 | - dropColumn: 58 | columnName: access_token_id 59 | tableName: refresh_token 60 | 61 | -------------------------------------------------------------------------------- /src/main/resources/db/test/changelog/db.changelog-master.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - include: 3 | file: db/test/changelog/2023/01/02-01-changelog.yaml 4 | - include: 5 | file: db/test/changelog/2023/03/03-01-changelog.yaml -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/demo/DemoApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.junit.jupiter.api.extension.ExtendWith; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | import org.springframework.test.context.ActiveProfiles; 7 | 8 | @SpringBootTest 9 | @ActiveProfiles("test") 10 | @ExtendWith(TestContext.class) 11 | class DemoApplicationTests { 12 | @Test 13 | void contextLoads() {} 14 | } 15 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/demo/TestContext.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo; 2 | 3 | import org.junit.jupiter.api.extension.BeforeAllCallback; 4 | import org.junit.jupiter.api.extension.ExtensionContext; 5 | import org.testcontainers.containers.GenericContainer; 6 | 7 | public class TestContext implements BeforeAllCallback, ExtensionContext.Store.CloseableResource { 8 | 9 | static { 10 | GenericContainer redis = new GenericContainer("redis:6.2.7-alpine").withExposedPorts(6379); 11 | redis.getPortBindings().add("6370:6379"); 12 | redis.start(); 13 | 14 | GenericContainer postgres = 15 | new GenericContainer("postgres:15.1") 16 | .withExposedPorts(5432) 17 | .withEnv("POSTGRES_PASSWORD", "pa55ward") 18 | .withEnv("POSTGRES_DB", "spring-test"); 19 | postgres.getPortBindings().add("5430:5432"); 20 | postgres.start(); 21 | } 22 | 23 | @Override 24 | public void beforeAll(ExtensionContext context) { 25 | // Your "before all tests" startup logic goes here 26 | } 27 | 28 | @Override 29 | public void close() { 30 | // Your "after all tests" logic goes here 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/demo/controller/UserControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.controller; 2 | 3 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 4 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 5 | 6 | import com.joejoe2.demo.TestContext; 7 | import com.joejoe2.demo.config.JwtConfig; 8 | import com.joejoe2.demo.data.auth.UserDetail; 9 | import com.joejoe2.demo.data.user.UserProfile; 10 | import com.joejoe2.demo.exception.InvalidTokenException; 11 | import com.joejoe2.demo.exception.UserDoesNotExist; 12 | import com.joejoe2.demo.model.auth.Role; 13 | import com.joejoe2.demo.model.auth.User; 14 | import com.joejoe2.demo.repository.user.UserRepository; 15 | import com.joejoe2.demo.service.jwt.JwtService; 16 | import com.joejoe2.demo.service.user.profile.ProfileService; 17 | import com.joejoe2.demo.utils.JwtUtil; 18 | import java.util.Calendar; 19 | import org.junit.jupiter.api.AfterEach; 20 | import org.junit.jupiter.api.BeforeEach; 21 | import org.junit.jupiter.api.Test; 22 | import org.junit.jupiter.api.extension.ExtendWith; 23 | import org.mockito.Mockito; 24 | import org.springframework.beans.factory.annotation.Autowired; 25 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 26 | import org.springframework.boot.test.context.SpringBootTest; 27 | import org.springframework.boot.test.mock.mockito.MockBean; 28 | import org.springframework.http.HttpHeaders; 29 | import org.springframework.http.MediaType; 30 | import org.springframework.test.context.ActiveProfiles; 31 | import org.springframework.test.web.servlet.MockMvc; 32 | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; 33 | 34 | @SpringBootTest 35 | @AutoConfigureMockMvc 36 | @ActiveProfiles("test") 37 | @ExtendWith(TestContext.class) 38 | class UserControllerTest { 39 | @MockBean ProfileService profileService; 40 | @Autowired UserRepository userRepository; 41 | @MockBean JwtService jwtService; 42 | @Autowired JwtConfig jwtConfig; 43 | 44 | User user; 45 | String userAccessToken; 46 | 47 | @Autowired MockMvc mockMvc; 48 | 49 | @BeforeEach 50 | void createUser() throws InvalidTokenException { 51 | user = new User(); 52 | user.setUserName("testUser"); 53 | user.setRole(Role.NORMAL); 54 | user.setEmail("testUser@email.com"); 55 | user.setPassword("pa55ward"); 56 | userRepository.save(user); 57 | userRepository.flush(); 58 | 59 | Calendar exp = Calendar.getInstance(); 60 | exp.add(Calendar.SECOND, 900); 61 | userAccessToken = 62 | JwtUtil.generateAccessToken( 63 | jwtConfig.getPrivateKey(), "jti", jwtConfig.getIssuer(), user, exp); 64 | Mockito.doReturn(false).when(jwtService).isAccessTokenInBlackList(Mockito.any()); 65 | Mockito.doReturn(new UserDetail(user)) 66 | .when(jwtService) 67 | .getUserDetailFromAccessToken(userAccessToken); 68 | } 69 | 70 | @AfterEach 71 | void deleteUser() { 72 | userRepository.deleteById(user.getId()); 73 | } 74 | 75 | @Test 76 | void profile() throws Exception { 77 | // test not authenticated 78 | mockMvc 79 | .perform(MockMvcRequestBuilders.get("/api/user/profile")) 80 | .andExpect(status().isUnauthorized()); 81 | // test success 82 | Mockito.when(profileService.getProfile(Mockito.any())).thenReturn(new UserProfile(user)); 83 | mockMvc 84 | .perform( 85 | MockMvcRequestBuilders.get("/api/user/profile") 86 | .header(HttpHeaders.AUTHORIZATION, userAccessToken) 87 | .accept(MediaType.APPLICATION_JSON)) 88 | .andExpect(status().isOk()) 89 | .andExpect(jsonPath("$.id").value(user.getId().toString())) 90 | .andExpect(jsonPath("$.username").value(user.getUserName())) 91 | .andExpect(jsonPath("$.email").value(user.getEmail())) 92 | .andExpect(jsonPath("$.role").value(user.getRole().toString())) 93 | .andExpect(jsonPath("$.isActive").value(user.isActive())) 94 | .andExpect(jsonPath("$.registeredAt").value(user.getCreateAt().toString())); 95 | } 96 | 97 | @Test 98 | void profileWithError() throws Exception { 99 | // test 500 100 | Mockito.when(profileService.getProfile(Mockito.any())).thenThrow(new UserDoesNotExist("")); 101 | mockMvc 102 | .perform( 103 | MockMvcRequestBuilders.get("/api/user/profile") 104 | .header(HttpHeaders.AUTHORIZATION, userAccessToken) 105 | .accept(MediaType.APPLICATION_JSON)) 106 | .andExpect(status().isInternalServerError()); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/demo/controller/constraint/checker/ControllerRateConstraintCheckerTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.controller.constraint.checker; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | 6 | import com.joejoe2.demo.TestContext; 7 | import com.joejoe2.demo.controller.constraint.rate.LimitTarget; 8 | import com.joejoe2.demo.controller.constraint.rate.RateLimit; 9 | import com.joejoe2.demo.data.auth.UserDetail; 10 | import com.joejoe2.demo.exception.ControllerConstraintViolation; 11 | import com.joejoe2.demo.model.auth.Role; 12 | import com.joejoe2.demo.model.auth.User; 13 | import com.joejoe2.demo.repository.user.UserRepository; 14 | import com.joejoe2.demo.utils.AuthUtil; 15 | import com.joejoe2.demo.utils.IPUtils; 16 | import org.junit.jupiter.api.BeforeEach; 17 | import org.junit.jupiter.api.Test; 18 | import org.junit.jupiter.api.extension.ExtendWith; 19 | import org.mockito.MockedStatic; 20 | import org.mockito.Mockito; 21 | import org.springframework.beans.factory.annotation.Autowired; 22 | import org.springframework.boot.test.context.SpringBootTest; 23 | import org.springframework.test.context.ActiveProfiles; 24 | import org.springframework.transaction.annotation.Transactional; 25 | 26 | @SpringBootTest 27 | @ActiveProfiles("test") 28 | @ExtendWith(TestContext.class) 29 | class ControllerRateConstraintCheckerTest { 30 | @Autowired ControllerRateConstraintChecker rateConstraintChecker; 31 | @Autowired UserRepository userRepository; 32 | 33 | class TestMethod { 34 | @RateLimit(target = LimitTarget.USER, key = "limitByUser", limit = 3, period = 30) 35 | public void limitByUser() {} 36 | 37 | @RateLimit(target = LimitTarget.IP, key = "limitByIp", limit = 3, period = 30) 38 | public void limitByIp() {} 39 | } 40 | 41 | TestMethod testMethod; 42 | 43 | @BeforeEach 44 | void setup() { 45 | testMethod = new TestMethod(); 46 | } 47 | 48 | @Test 49 | @Transactional 50 | void checkWithMethodLimitByUser() throws Exception { 51 | User testUser = new User(); 52 | testUser.setUserName("testUser"); 53 | testUser.setRole(Role.NORMAL); 54 | testUser.setEmail("testUser@email.com"); 55 | testUser.setPassword("pa55ward"); 56 | userRepository.save(testUser); 57 | // mock login 58 | MockedStatic mockedStatic = Mockito.mockStatic(AuthUtil.class); 59 | mockedStatic.when(AuthUtil::isAuthenticated).thenReturn(true); 60 | mockedStatic.when(AuthUtil::currentUserDetail).thenReturn(new UserDetail(testUser)); 61 | // test for normal request 62 | assertDoesNotThrow( 63 | () -> 64 | rateConstraintChecker.checkWithMethod(testMethod.getClass().getMethod("limitByUser"))); 65 | assertDoesNotThrow( 66 | () -> 67 | rateConstraintChecker.checkWithMethod(testMethod.getClass().getMethod("limitByUser"))); 68 | assertDoesNotThrow( 69 | () -> 70 | rateConstraintChecker.checkWithMethod(testMethod.getClass().getMethod("limitByUser"))); 71 | // test when exceed rate limit 72 | assertThrows( 73 | ControllerConstraintViolation.class, 74 | () -> 75 | rateConstraintChecker.checkWithMethod(testMethod.getClass().getMethod("limitByUser"))); 76 | // test token refill 77 | Thread.sleep(10000); 78 | assertDoesNotThrow( 79 | () -> 80 | rateConstraintChecker.checkWithMethod(testMethod.getClass().getMethod("limitByUser"))); 81 | // clear mock login 82 | mockedStatic.close(); 83 | } 84 | 85 | @Test 86 | void checkWithMethodLimitByIp() throws Exception { 87 | // mock ip 88 | MockedStatic mockedStatic = Mockito.mockStatic(IPUtils.class); 89 | mockedStatic.when(IPUtils::getRequestIP).thenReturn("127.0.0.1"); 90 | // test for normal request 91 | assertDoesNotThrow( 92 | () -> rateConstraintChecker.checkWithMethod(testMethod.getClass().getMethod("limitByIp"))); 93 | assertDoesNotThrow( 94 | () -> rateConstraintChecker.checkWithMethod(testMethod.getClass().getMethod("limitByIp"))); 95 | assertDoesNotThrow( 96 | () -> rateConstraintChecker.checkWithMethod(testMethod.getClass().getMethod("limitByIp"))); 97 | // test when exceed rate limit 98 | assertThrows( 99 | ControllerConstraintViolation.class, 100 | () -> rateConstraintChecker.checkWithMethod(testMethod.getClass().getMethod("limitByIp"))); 101 | // test token refill 102 | Thread.sleep(10000); 103 | assertDoesNotThrow( 104 | () -> rateConstraintChecker.checkWithMethod(testMethod.getClass().getMethod("limitByIp"))); 105 | // clear mock 106 | mockedStatic.close(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/demo/service/email/EmailServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.email; 2 | 3 | import com.joejoe2.demo.TestContext; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.api.extension.ExtendWith; 6 | import org.springframework.boot.test.context.SpringBootTest; 7 | import org.springframework.test.context.ActiveProfiles; 8 | 9 | @SpringBootTest 10 | @ActiveProfiles("test") 11 | @ExtendWith(TestContext.class) 12 | class EmailServiceTest { 13 | /*@MockBean 14 | JavaMailSender emailSender;*/ 15 | 16 | /*@Autowired 17 | EmailService emailService;*/ 18 | 19 | @Test 20 | void sendSimpleEmail() { 21 | /* 22 | SimpleMailMessage message = new SimpleMailMessage(); 23 | message.setFrom("noreply@joejoe2.com"); 24 | message.setTo("to"); 25 | message.setSubject("subject"); 26 | message.setText("content"); 27 | 28 | Mockito.doNothing().when(emailSender).send(Mockito.any(SimpleMailMessage.class)); 29 | emailService.sendSimpleEmail("to", "subject", "content"); 30 | Mockito.verify(emailSender, Mockito.timeout(3000).times(1)).send(Mockito.any(SimpleMailMessage.class)); 31 | */ 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/demo/service/redis/RedisServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.redis; 2 | 3 | import static org.junit.jupiter.api.Assertions.*; 4 | 5 | import com.joejoe2.demo.TestContext; 6 | import java.time.Duration; 7 | import java.util.concurrent.TimeUnit; 8 | import org.junit.jupiter.api.Test; 9 | import org.junit.jupiter.api.extension.ExtendWith; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.test.context.SpringBootTest; 12 | import org.springframework.dao.DataAccessException; 13 | import org.springframework.data.redis.core.RedisOperations; 14 | import org.springframework.data.redis.core.SessionCallback; 15 | import org.springframework.data.redis.core.StringRedisTemplate; 16 | import org.springframework.test.context.ActiveProfiles; 17 | 18 | @SpringBootTest 19 | @ActiveProfiles("test") 20 | @ExtendWith(TestContext.class) 21 | class RedisServiceTest { 22 | @Autowired RedisService redisService; 23 | @Autowired private StringRedisTemplate redisTemplate; 24 | 25 | @Test 26 | void set() { 27 | redisService.set("key1", "test", Duration.ofSeconds(30)); 28 | assertTrue(redisTemplate.hasKey("key1")); 29 | assert redisTemplate.getExpire("key1", TimeUnit.SECONDS) < 30; 30 | } 31 | 32 | @Test 33 | void get() { 34 | redisTemplate.opsForValue().set("key2", "test", Duration.ofSeconds(30)); 35 | redisService.get("key2").get().equals("test"); 36 | } 37 | 38 | @Test 39 | void has() { 40 | redisTemplate.opsForValue().set("key3", "test", Duration.ofSeconds(30)); 41 | assert redisService.has("key3"); 42 | } 43 | 44 | @Test 45 | void testOptimisticLock() throws Exception { 46 | String key = "testOptimisticLock"; 47 | boolean[] lock = {false}; 48 | Thread thread1 = 49 | new Thread( 50 | () -> 51 | redisTemplate.execute( 52 | new SessionCallback<>() { 53 | @Override 54 | public Object execute(RedisOperations outerOperations) 55 | throws DataAccessException { 56 | outerOperations.watch(key); 57 | outerOperations.multi(); 58 | try { 59 | Thread.sleep(10000); 60 | } catch (InterruptedException e) { 61 | throw new RuntimeException(e); 62 | } 63 | outerOperations.opsForValue().set(key, "xxx"); 64 | lock[0] = outerOperations.exec().isEmpty(); 65 | return null; 66 | } 67 | })); 68 | 69 | Thread thread2 = 70 | new Thread( 71 | () -> 72 | redisTemplate.execute( 73 | new SessionCallback<>() { 74 | @Override 75 | public Object execute(RedisOperations outerOperations) 76 | throws DataAccessException { 77 | try { 78 | Thread.sleep(3000); 79 | } catch (InterruptedException e) { 80 | throw new RuntimeException(e); 81 | } 82 | outerOperations.watch(key); 83 | outerOperations.multi(); 84 | outerOperations.opsForValue().set(key, "xxx"); 85 | outerOperations.exec(); 86 | return null; 87 | } 88 | })); 89 | thread1.start(); 90 | thread2.start(); 91 | thread1.join(); 92 | thread2.join(); 93 | 94 | assertTrue(lock[0]); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/demo/service/user/UserDetailServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.user; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | 6 | import com.joejoe2.demo.TestContext; 7 | import com.joejoe2.demo.data.auth.UserDetail; 8 | import com.joejoe2.demo.model.auth.Role; 9 | import com.joejoe2.demo.model.auth.User; 10 | import com.joejoe2.demo.repository.user.UserRepository; 11 | import org.junit.jupiter.api.Test; 12 | import org.junit.jupiter.api.extension.ExtendWith; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.boot.test.context.SpringBootTest; 15 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 16 | import org.springframework.test.context.ActiveProfiles; 17 | import org.springframework.transaction.annotation.Transactional; 18 | 19 | @SpringBootTest 20 | @ActiveProfiles("test") 21 | @ExtendWith(TestContext.class) 22 | class UserDetailServiceTest { 23 | @Autowired UserDetailService userDetailService; 24 | @Autowired UserRepository userRepository; 25 | 26 | @Test 27 | @Transactional 28 | void loadUserByNotFoundUsername() { 29 | assertThrows( 30 | UsernameNotFoundException.class, 31 | () -> userDetailService.loadUserByUsername("not exist name")); 32 | } 33 | 34 | @Test 35 | @Transactional 36 | void loadUserByUsername() { 37 | User user = new User(); 38 | user.setUserName("test"); 39 | user.setEmail("test@email.com"); 40 | user.setPassword("pa55ward"); 41 | user.setRole(Role.NORMAL); 42 | userRepository.save(user); 43 | assertEquals(new UserDetail(user), userDetailService.loadUserByUsername(user.getUserName())); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/demo/service/user/auth/ActivationServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.user.auth; 2 | 3 | import static org.junit.jupiter.api.Assertions.*; 4 | 5 | import com.joejoe2.demo.TestContext; 6 | import com.joejoe2.demo.data.auth.UserDetail; 7 | import com.joejoe2.demo.exception.InvalidOperation; 8 | import com.joejoe2.demo.model.auth.Role; 9 | import com.joejoe2.demo.model.auth.User; 10 | import com.joejoe2.demo.repository.user.UserRepository; 11 | import com.joejoe2.demo.utils.AuthUtil; 12 | import org.junit.jupiter.api.Test; 13 | import org.junit.jupiter.api.extension.ExtendWith; 14 | import org.mockito.MockedStatic; 15 | import org.mockito.Mockito; 16 | import org.springframework.beans.factory.annotation.Autowired; 17 | import org.springframework.boot.test.context.SpringBootTest; 18 | import org.springframework.test.context.ActiveProfiles; 19 | import org.springframework.transaction.annotation.Transactional; 20 | 21 | @SpringBootTest 22 | @ActiveProfiles("test") 23 | @ExtendWith(TestContext.class) 24 | class ActivationServiceTest { 25 | @Autowired ActivationService activationService; 26 | @Autowired UserRepository userRepository; 27 | 28 | @Test 29 | @Transactional 30 | void activateUserWithIllegalArgument() { 31 | User user = new User(); 32 | user.setUserName("test"); 33 | user.setPassword("pa55ward"); 34 | user.setEmail("test@email.com"); 35 | userRepository.save(user); 36 | 37 | // test IllegalArgument 38 | assertThrows( 39 | IllegalArgumentException.class, () -> activationService.activateUser("invalid uid")); 40 | } 41 | 42 | @Test 43 | @Transactional 44 | void activateUserWithInvalidOperation() { 45 | User user = new User(); 46 | user.setUserName("test"); 47 | user.setPassword("pa55ward"); 48 | user.setEmail("test@email.com"); 49 | userRepository.save(user); 50 | 51 | // test if target is already active 52 | assertThrows( 53 | InvalidOperation.class, () -> activationService.activateUser(user.getId().toString())); 54 | // test if user try to activate himself 55 | // mock login 56 | MockedStatic mockedStatic = Mockito.mockStatic(AuthUtil.class); 57 | mockedStatic.when(AuthUtil::isAuthenticated).thenReturn(true); 58 | mockedStatic.when(AuthUtil::currentUserDetail).thenReturn(new UserDetail(user)); 59 | assertThrows( 60 | InvalidOperation.class, () -> activationService.activateUser(user.getId().toString())); 61 | // clear mock login 62 | mockedStatic.close(); 63 | } 64 | 65 | @Test 66 | @Transactional 67 | void activateUser() { 68 | User user = new User(); 69 | user.setUserName("test"); 70 | user.setPassword("pa55ward"); 71 | user.setEmail("test@email.com"); 72 | user.setActive(false); 73 | userRepository.save(user); 74 | // test success 75 | assertDoesNotThrow(() -> activationService.activateUser(user.getId().toString())); 76 | assertTrue(user.isActive()); 77 | } 78 | 79 | @Test 80 | @Transactional 81 | void deactivateUserWithIllegalArgument() { 82 | User user = new User(); 83 | user.setUserName("test"); 84 | user.setPassword("pa55ward"); 85 | user.setEmail("test@email.com"); 86 | userRepository.save(user); 87 | 88 | // test IllegalArgument 89 | assertThrows( 90 | IllegalArgumentException.class, () -> activationService.activateUser("invalid uid")); 91 | } 92 | 93 | @Test 94 | @Transactional 95 | void deactivateUserWithInvalidOperation() { 96 | User user = new User(); 97 | user.setUserName("test"); 98 | user.setPassword("pa55ward"); 99 | user.setEmail("test@email.com"); 100 | userRepository.save(user); 101 | 102 | // test deactivate an admin 103 | user.setRole(Role.ADMIN); 104 | userRepository.save(user); 105 | assertThrows( 106 | InvalidOperation.class, () -> activationService.deactivateUser(user.getId().toString())); 107 | // test if target is already inactive 108 | user.setRole(Role.NORMAL); 109 | user.setActive(false); 110 | userRepository.save(user); 111 | assertThrows( 112 | InvalidOperation.class, () -> activationService.deactivateUser(user.getId().toString())); 113 | // test if user try to activate himself 114 | // mock login 115 | MockedStatic mockedStatic = Mockito.mockStatic(AuthUtil.class); 116 | mockedStatic.when(AuthUtil::isAuthenticated).thenReturn(true); 117 | mockedStatic.when(AuthUtil::currentUserDetail).thenReturn(new UserDetail(user)); 118 | assertThrows( 119 | InvalidOperation.class, () -> activationService.deactivateUser(user.getId().toString())); 120 | // clear mock login 121 | mockedStatic.close(); 122 | } 123 | 124 | @Test 125 | @Transactional 126 | void deactivateUser() { 127 | User user = new User(); 128 | user.setUserName("test"); 129 | user.setPassword("pa55ward"); 130 | user.setEmail("test@email.com"); 131 | userRepository.save(user); 132 | 133 | // test success 134 | assertDoesNotThrow(() -> activationService.deactivateUser(user.getId().toString())); 135 | assertFalse(user.isActive()); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/demo/service/user/auth/LoginServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.user.auth; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | 6 | import com.joejoe2.demo.TestContext; 7 | import com.joejoe2.demo.config.LoginConfig; 8 | import com.joejoe2.demo.model.auth.User; 9 | import com.joejoe2.demo.repository.user.UserRepository; 10 | import org.junit.jupiter.api.AfterEach; 11 | import org.junit.jupiter.api.BeforeEach; 12 | import org.junit.jupiter.api.Test; 13 | import org.junit.jupiter.api.extension.ExtendWith; 14 | import org.springframework.beans.factory.annotation.Autowired; 15 | import org.springframework.boot.test.context.SpringBootTest; 16 | import org.springframework.security.authentication.BadCredentialsException; 17 | import org.springframework.security.core.AuthenticationException; 18 | import org.springframework.security.crypto.password.PasswordEncoder; 19 | import org.springframework.test.context.ActiveProfiles; 20 | 21 | @SpringBootTest 22 | @ActiveProfiles("test") 23 | @ExtendWith(TestContext.class) 24 | class LoginServiceTest { 25 | @Autowired LoginConfig loginConfig; 26 | @Autowired UserRepository userRepository; 27 | @Autowired LoginService loginService; 28 | @Autowired PasswordEncoder passwordEncoder; 29 | 30 | User user, incative; 31 | 32 | @BeforeEach 33 | void setUp() { 34 | loginConfig.setMaxAttempts(2); 35 | loginConfig.setCoolTime(15); 36 | 37 | user = new User(); 38 | user.setUserName("test"); 39 | user.setPassword(passwordEncoder.encode("pa55ward")); 40 | user.setEmail("test@email.com"); 41 | userRepository.save(user); 42 | incative = new User(); 43 | incative.setActive(false); 44 | incative.setUserName("incative"); 45 | incative.setPassword(passwordEncoder.encode("pa55ward")); 46 | incative.setEmail("incative@email.com"); 47 | userRepository.save(incative); 48 | } 49 | 50 | @AfterEach 51 | void tearDown() { 52 | userRepository.deleteById(user.getId()); 53 | userRepository.deleteById(incative.getId()); 54 | } 55 | 56 | @Test 57 | void login() throws Exception { 58 | assertThrows( 59 | BadCredentialsException.class, 60 | () -> { 61 | loginService.login(user.getUserName(), "error"); 62 | }); 63 | assertEquals(1, userRepository.findById(user.getId()).get().getLoginAttempt().getAttempts()); 64 | 65 | assertThrows( 66 | BadCredentialsException.class, 67 | () -> { 68 | loginService.login(user.getUserName(), "error"); 69 | }); 70 | assertEquals(2, userRepository.findById(user.getId()).get().getLoginAttempt().getAttempts()); 71 | 72 | assertThrows( 73 | AuthenticationException.class, 74 | () -> { 75 | loginService.login(user.getUserName(), "error"); 76 | }); 77 | assertEquals(2, userRepository.findById(user.getId()).get().getLoginAttempt().getAttempts()); 78 | 79 | assertThrows( 80 | AuthenticationException.class, 81 | () -> { 82 | loginService.login(user.getUserName(), "pa55ward"); 83 | }); 84 | assertEquals(2, userRepository.findById(user.getId()).get().getLoginAttempt().getAttempts()); 85 | 86 | Thread.sleep(loginConfig.getCoolTime() * 1000L); 87 | 88 | assertThrows( 89 | BadCredentialsException.class, 90 | () -> { 91 | loginService.login(user.getUserName(), "error"); 92 | }); 93 | assertEquals(1, userRepository.findById(user.getId()).get().getLoginAttempt().getAttempts()); 94 | 95 | loginService.login(user.getUserName(), "pa55ward"); 96 | assertEquals(0, userRepository.findById(user.getId()).get().getLoginAttempt().getAttempts()); 97 | 98 | assertThrows( 99 | BadCredentialsException.class, 100 | () -> { 101 | loginService.login(user.getUserName(), "error"); 102 | }); 103 | assertEquals(1, userRepository.findById(user.getId()).get().getLoginAttempt().getAttempts()); 104 | 105 | assertThrows( 106 | BadCredentialsException.class, 107 | () -> { 108 | loginService.login(user.getUserName(), "error"); 109 | }); 110 | assertEquals(2, userRepository.findById(user.getId()).get().getLoginAttempt().getAttempts()); 111 | 112 | assertThrows( 113 | AuthenticationException.class, 114 | () -> { 115 | loginService.login(user.getUserName(), "error"); 116 | }); 117 | assertEquals(2, userRepository.findById(user.getId()).get().getLoginAttempt().getAttempts()); 118 | 119 | assertThrows( 120 | AuthenticationException.class, 121 | () -> { 122 | loginService.login(user.getUserName(), "pa55ward"); 123 | }); 124 | assertEquals(2, userRepository.findById(user.getId()).get().getLoginAttempt().getAttempts()); 125 | } 126 | 127 | @Test 128 | void loginInactive() { 129 | // inactive user cannot login, will never increase attempts 130 | assertThrows( 131 | AuthenticationException.class, 132 | () -> { 133 | loginService.login(incative.getUserName(), "error"); 134 | }); 135 | assertEquals( 136 | 0, userRepository.findById(incative.getId()).get().getLoginAttempt().getAttempts()); 137 | 138 | assertThrows( 139 | AuthenticationException.class, 140 | () -> { 141 | loginService.login(incative.getUserName(), "pa55ward"); 142 | }); 143 | assertEquals( 144 | 0, userRepository.findById(incative.getId()).get().getLoginAttempt().getAttempts()); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/demo/service/user/auth/RegistrationServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.user.auth; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | 6 | import com.joejoe2.demo.TestContext; 7 | import com.joejoe2.demo.exception.AlreadyExist; 8 | import com.joejoe2.demo.model.auth.Role; 9 | import com.joejoe2.demo.model.auth.User; 10 | import com.joejoe2.demo.service.verification.VerificationService; 11 | import org.junit.jupiter.api.Test; 12 | import org.junit.jupiter.api.extension.ExtendWith; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.boot.test.context.SpringBootTest; 15 | import org.springframework.boot.test.mock.mockito.MockBean; 16 | import org.springframework.test.context.ActiveProfiles; 17 | import org.springframework.transaction.annotation.Transactional; 18 | 19 | @SpringBootTest 20 | @ActiveProfiles("test") 21 | @ExtendWith(TestContext.class) 22 | class RegistrationServiceTest { 23 | @MockBean VerificationService verificationService; 24 | @Autowired RegistrationService registrationService; 25 | 26 | @Test 27 | @Transactional 28 | // roll back after test 29 | void createUserWithIllegalArgument() throws Exception { 30 | // test IllegalArgument 31 | assertThrows( 32 | IllegalArgumentException.class, 33 | () -> registrationService.createUser("**-@#", "pa55ward", "test@email.com", Role.NORMAL)); 34 | assertThrows( 35 | IllegalArgumentException.class, 36 | () -> registrationService.createUser("test", "**-@#", "test@email.com", Role.NORMAL)); 37 | assertThrows( 38 | IllegalArgumentException.class, 39 | () -> registrationService.createUser("test", "pa55ward", "not a email", Role.NORMAL)); 40 | } 41 | 42 | @Test 43 | @Transactional 44 | // roll back after test 45 | void createUserWithAlreadyExist() throws Exception { 46 | // test with duplicated username or email 47 | registrationService.createUser("test1", "pa55ward", "test1@email.com", Role.NORMAL); 48 | assertThrows( 49 | AlreadyExist.class, 50 | () -> registrationService.createUser("test1", "pa55ward", "test11@email.com", Role.NORMAL)); 51 | assertThrows( 52 | AlreadyExist.class, 53 | () -> registrationService.createUser("test12", "pa55ward", "test1@email.com", Role.NORMAL)); 54 | } 55 | 56 | @Test 57 | @Transactional 58 | // roll back after test 59 | void createUser() throws Exception { 60 | // test whether users are created 61 | User test1, test2, test3; 62 | test1 = registrationService.createUser("test1", "pa55ward", "test1@email.com", Role.NORMAL); 63 | test2 = registrationService.createUser("test2", "pa55ward", "test2@email.com", Role.STAFF); 64 | test3 = registrationService.createUser("test3", "pa55ward", "test3@email.com", Role.ADMIN); 65 | 66 | assertEquals("test1", test1.getUserName()); 67 | assertEquals("test2", test2.getUserName()); 68 | assertEquals("test3", test3.getUserName()); 69 | assertEquals("test1@email.com", test1.getEmail()); 70 | assertEquals("test2@email.com", test2.getEmail()); 71 | assertEquals("test3@email.com", test3.getEmail()); 72 | assertEquals(Role.NORMAL, test1.getRole()); 73 | assertEquals(Role.STAFF, test2.getRole()); 74 | assertEquals(Role.ADMIN, test3.getRole()); 75 | } 76 | 77 | @Test 78 | void registerUser() {} 79 | } 80 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/demo/service/user/auth/RoleServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.user.auth; 2 | 3 | import static org.junit.jupiter.api.Assertions.*; 4 | 5 | import com.joejoe2.demo.TestContext; 6 | import com.joejoe2.demo.data.auth.UserDetail; 7 | import com.joejoe2.demo.exception.InvalidOperation; 8 | import com.joejoe2.demo.exception.UserDoesNotExist; 9 | import com.joejoe2.demo.model.auth.Role; 10 | import com.joejoe2.demo.model.auth.User; 11 | import com.joejoe2.demo.repository.user.UserRepository; 12 | import com.joejoe2.demo.utils.AuthUtil; 13 | import java.util.UUID; 14 | import org.junit.jupiter.api.Test; 15 | import org.junit.jupiter.api.extension.ExtendWith; 16 | import org.mockito.MockedStatic; 17 | import org.mockito.Mockito; 18 | import org.springframework.beans.factory.annotation.Autowired; 19 | import org.springframework.boot.test.context.SpringBootTest; 20 | import org.springframework.test.context.ActiveProfiles; 21 | import org.springframework.transaction.annotation.Transactional; 22 | 23 | @SpringBootTest 24 | @ActiveProfiles("test") 25 | @ExtendWith(TestContext.class) 26 | class RoleServiceTest { 27 | @Autowired RoleService roleService; 28 | @Autowired UserRepository userRepository; 29 | 30 | @Test 31 | @Transactional 32 | void changeRoleOfWithIllegalArgument() { 33 | // test IllegalArgument 34 | assertThrows( 35 | IllegalArgumentException.class, () -> roleService.changeRoleOf("invalid_uid", Role.ADMIN)); 36 | // test with not exist user 37 | assertThrows( 38 | UserDoesNotExist.class, 39 | () -> roleService.changeRoleOf(UUID.randomUUID().toString(), Role.STAFF)); 40 | // test the only(default) admin 41 | UUID id = userRepository.getByRole(Role.ADMIN).get(0).getId(); 42 | assertThrows( 43 | InvalidOperation.class, () -> roleService.changeRoleOf(id.toString(), Role.NORMAL)); 44 | } 45 | 46 | @Test 47 | @Transactional 48 | void changeRoleOfWithDoesNotExist() { 49 | // test with not exist user 50 | assertThrows( 51 | UserDoesNotExist.class, 52 | () -> roleService.changeRoleOf(UUID.randomUUID().toString(), Role.STAFF)); 53 | } 54 | 55 | @Test 56 | void changeRoleOfWithInvalidOperation() { 57 | // test the only(default) admin 58 | User user = userRepository.getByRole(Role.ADMIN).get(0); 59 | assertThrows( 60 | InvalidOperation.class, 61 | () -> roleService.changeRoleOf(user.getId().toString(), Role.NORMAL)); 62 | // test if role does not change 63 | assertThrows( 64 | InvalidOperation.class, 65 | () -> roleService.changeRoleOf(user.getId().toString(), Role.ADMIN)); 66 | // test if user try to change himself 67 | // mock login 68 | MockedStatic mockedStatic = Mockito.mockStatic(AuthUtil.class); 69 | mockedStatic.when(AuthUtil::isAuthenticated).thenReturn(true); 70 | mockedStatic.when(AuthUtil::currentUserDetail).thenReturn(new UserDetail(user)); 71 | assertThrows( 72 | InvalidOperation.class, 73 | () -> roleService.changeRoleOf(user.getId().toString(), Role.NORMAL)); 74 | // clear mock login 75 | mockedStatic.close(); 76 | } 77 | 78 | @Test 79 | @Transactional 80 | void changeRoleOf() { 81 | // test with exist user 82 | User user = new User(); 83 | user.setUserName("test"); 84 | user.setEmail("test@email.com"); 85 | user.setPassword("pa55ward"); 86 | user.setRole(Role.NORMAL); 87 | userRepository.save(user); 88 | User finalUser1 = user; 89 | assertDoesNotThrow(() -> roleService.changeRoleOf(finalUser1.getId().toString(), Role.STAFF)); 90 | user = userRepository.findById(user.getId()).get(); 91 | assertEquals(user.getRole(), Role.STAFF); 92 | User finalUser = user; 93 | assertDoesNotThrow(() -> roleService.changeRoleOf(finalUser.getId().toString(), Role.ADMIN)); 94 | user = userRepository.findById(user.getId()).get(); 95 | assertEquals(user.getRole(), Role.ADMIN); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/demo/service/user/profile/ProfileServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.user.profile; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | 6 | import com.joejoe2.demo.TestContext; 7 | import com.joejoe2.demo.data.PageList; 8 | import com.joejoe2.demo.data.user.UserProfile; 9 | import com.joejoe2.demo.exception.UserDoesNotExist; 10 | import com.joejoe2.demo.model.auth.Role; 11 | import com.joejoe2.demo.model.auth.User; 12 | import com.joejoe2.demo.repository.user.UserRepository; 13 | import java.util.Random; 14 | import org.junit.jupiter.api.Test; 15 | import org.junit.jupiter.api.extension.ExtendWith; 16 | import org.springframework.beans.factory.annotation.Autowired; 17 | import org.springframework.boot.test.context.SpringBootTest; 18 | import org.springframework.test.context.ActiveProfiles; 19 | import org.springframework.transaction.annotation.Transactional; 20 | 21 | @SpringBootTest 22 | @ActiveProfiles("test") 23 | @ExtendWith(TestContext.class) 24 | class ProfileServiceTest { 25 | @Autowired ProfileService profileService; 26 | @Autowired UserRepository userRepository; 27 | 28 | @Test 29 | @Transactional 30 | void getProfileWithIllegalArgumentException() { 31 | // test IllegalArgument 32 | assertThrows(IllegalArgumentException.class, () -> profileService.getProfile("invalid uid")); 33 | } 34 | 35 | @Test 36 | @Transactional 37 | void getProfileWith() { 38 | User user = new User(); 39 | user.setUserName("test"); 40 | user.setEmail("test@email.com"); 41 | user.setPassword("pa55ward"); 42 | user.setRole(Role.NORMAL); 43 | userRepository.save(user); 44 | userRepository.deleteById(user.getId()); 45 | // test with a not exist user 46 | assertThrows(UserDoesNotExist.class, () -> profileService.getProfile(user.getId().toString())); 47 | } 48 | 49 | @Test 50 | @Transactional 51 | void getProfile() { 52 | User user = new User(); 53 | user.setUserName("test"); 54 | user.setEmail("test@email.com"); 55 | user.setPassword("pa55ward"); 56 | user.setRole(Role.NORMAL); 57 | userRepository.save(user); 58 | userRepository.flush(); 59 | UserProfile profile; 60 | // test success 61 | try { 62 | profile = profileService.getProfile(user.getId().toString()); 63 | } catch (Exception e) { 64 | throw new AssertionError(e); 65 | } 66 | assertEquals(new UserProfile(user), profile); 67 | } 68 | 69 | @Test 70 | @Transactional 71 | void getAllUserProfiles() { 72 | Random random = new Random(); 73 | long count = userRepository.count(); 74 | long r = random.nextInt(100); 75 | for (int i = 0; i < r; i++) { 76 | User user = new User(); 77 | user.setUserName("test" + i); 78 | user.setPassword("pa55ward"); 79 | user.setEmail("test" + i + "@email.com"); 80 | userRepository.save(user); 81 | } 82 | assertEquals(count + r, profileService.getAllUserProfiles().size()); 83 | } 84 | 85 | @Test 86 | @Transactional 87 | void getAllUserProfilesWithIllegalPage() { 88 | // test IllegalArgument 89 | assertThrows( 90 | IllegalArgumentException.class, () -> profileService.getAllUserProfilesWithPage(-1, 1)); 91 | assertThrows( 92 | IllegalArgumentException.class, () -> profileService.getAllUserProfilesWithPage(0, -1)); 93 | assertThrows( 94 | IllegalArgumentException.class, () -> profileService.getAllUserProfilesWithPage(0, 0)); 95 | } 96 | 97 | @Test 98 | @Transactional 99 | void getAllUserProfilesWithPage() { 100 | // test success 101 | PageList pageList; 102 | try { 103 | pageList = profileService.getAllUserProfilesWithPage(5, 10); 104 | } catch (Exception e) { 105 | throw new AssertionError(e); 106 | } 107 | assertEquals(5, pageList.getCurrentPage()); 108 | assertEquals(10, pageList.getPageSize()); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/demo/service/verification/VerificationServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.service.verification; 2 | 3 | import static org.junit.jupiter.api.Assertions.*; 4 | 5 | import com.joejoe2.demo.TestContext; 6 | import com.joejoe2.demo.data.auth.VerificationPair; 7 | import com.joejoe2.demo.exception.InvalidOperation; 8 | import com.joejoe2.demo.model.auth.VerificationCode; 9 | import com.joejoe2.demo.repository.verification.VerificationCodeRepository; 10 | import java.time.Instant; 11 | import java.util.UUID; 12 | import org.junit.jupiter.api.Test; 13 | import org.junit.jupiter.api.extension.ExtendWith; 14 | import org.springframework.beans.factory.annotation.Autowired; 15 | import org.springframework.boot.test.context.SpringBootTest; 16 | import org.springframework.test.context.ActiveProfiles; 17 | import org.springframework.transaction.annotation.Transactional; 18 | 19 | @SpringBootTest 20 | @ActiveProfiles("test") 21 | @ExtendWith(TestContext.class) 22 | class VerificationServiceTest { 23 | @Autowired VerificationService verificationService; 24 | @Autowired VerificationCodeRepository verificationCodeRepository; 25 | 26 | @Test 27 | void issueVerificationCodeWithIllegalArgument() { 28 | // test IllegalArgument 29 | assertThrows( 30 | IllegalArgumentException.class, 31 | () -> verificationService.issueVerificationCode("not a email")); 32 | } 33 | 34 | @Test 35 | @Transactional 36 | void issueVerificationCode() { 37 | // test whether verification code is created 38 | VerificationPair verificationPair = verificationService.issueVerificationCode("test@email.com"); 39 | assertEquals( 40 | verificationPair.getCode(), 41 | verificationCodeRepository 42 | .findById(UUID.fromString(verificationPair.getKey())) 43 | .get() 44 | .getCode()); 45 | } 46 | 47 | @Test 48 | void verifyWithIllegalArgument() { 49 | // test IllegalArgument 50 | assertThrows( 51 | IllegalArgumentException.class, 52 | () -> verificationService.verify("invalid key", "test@email.com", "1234")); 53 | assertThrows( 54 | IllegalArgumentException.class, 55 | () -> verificationService.verify(UUID.randomUUID().toString(), "not a email", "1234")); 56 | assertThrows( 57 | IllegalArgumentException.class, 58 | () -> verificationService.verify(UUID.randomUUID().toString(), "test@email.com", null)); 59 | } 60 | 61 | @Test 62 | void verifyWithInvalidOperation() { 63 | // test verification fail with not exist code 64 | assertThrows( 65 | InvalidOperation.class, 66 | () -> verificationService.verify(UUID.randomUUID().toString(), "test@email.com", "1234")); 67 | } 68 | 69 | @Test 70 | @Transactional 71 | void verify() { 72 | // test verification with exist code 73 | VerificationCode verificationCode = new VerificationCode(); 74 | verificationCode.setEmail("test@email.com"); 75 | verificationCode.setCode("1234"); 76 | verificationCode.setExpireAt(Instant.now().plusSeconds(3600)); 77 | verificationCodeRepository.save(verificationCode); 78 | assertDoesNotThrow( 79 | () -> 80 | verificationService.verify( 81 | verificationCode.getId().toString(), 82 | verificationCode.getEmail(), 83 | verificationCode.getCode())); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/demo/utils/AuthUtilTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.utils; 2 | 3 | import static org.junit.jupiter.api.Assertions.*; 4 | 5 | import com.joejoe2.demo.TestContext; 6 | import com.joejoe2.demo.model.auth.Role; 7 | import com.joejoe2.demo.model.auth.User; 8 | import com.joejoe2.demo.repository.user.UserRepository; 9 | import org.junit.jupiter.api.AfterEach; 10 | import org.junit.jupiter.api.BeforeEach; 11 | import org.junit.jupiter.api.Test; 12 | import org.junit.jupiter.api.extension.ExtendWith; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.boot.test.context.SpringBootTest; 15 | import org.springframework.security.authentication.AuthenticationManager; 16 | import org.springframework.security.core.AuthenticationException; 17 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 18 | import org.springframework.test.context.ActiveProfiles; 19 | import org.springframework.transaction.annotation.Transactional; 20 | 21 | @SpringBootTest 22 | @ActiveProfiles("test") 23 | @ExtendWith(TestContext.class) 24 | class AuthUtilTest { 25 | @Autowired AuthenticationManager authenticationManager; 26 | 27 | @Autowired UserRepository userRepository; 28 | 29 | User user; 30 | 31 | @BeforeEach 32 | void setUp() throws Exception { 33 | user = new User(); 34 | user.setUserName("test"); 35 | user.setPassword(new BCryptPasswordEncoder().encode("pa55ward")); 36 | user.setEmail("test@email.com"); 37 | user.setRole(Role.NORMAL); 38 | userRepository.save(user); 39 | } 40 | 41 | @AfterEach 42 | void tearDown() { 43 | userRepository.deleteById(user.getId()); 44 | } 45 | 46 | @Test 47 | @Transactional 48 | void authenticate() { 49 | // test a not exist username 50 | assertThrows( 51 | AuthenticationException.class, 52 | () -> AuthUtil.authenticate(authenticationManager, "not exist", "pa55ward")); 53 | assertFalse(() -> AuthUtil.isAuthenticated()); 54 | assertThrows(AuthenticationException.class, () -> AuthUtil.currentUserDetail()); 55 | // test with incorrect password 56 | assertThrows( 57 | AuthenticationException.class, 58 | () -> AuthUtil.authenticate(authenticationManager, "not exist", "12345678")); 59 | assertFalse(() -> AuthUtil.isAuthenticated()); 60 | assertThrows(AuthenticationException.class, () -> AuthUtil.currentUserDetail()); 61 | // test with correct username and password 62 | AuthUtil.authenticate(authenticationManager, "test", "pa55ward"); 63 | assertTrue(() -> AuthUtil.isAuthenticated()); 64 | assertDoesNotThrow(() -> AuthUtil.currentUserDetail()); 65 | // test with inactive user 66 | user.setActive(false); 67 | assertThrows( 68 | AuthenticationException.class, 69 | () -> AuthUtil.authenticate(authenticationManager, "test", "pa55ward")); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/demo/utils/IPUtilsTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.utils; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | 5 | import com.joejoe2.demo.TestContext; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.extension.ExtendWith; 8 | import org.springframework.boot.test.context.SpringBootTest; 9 | import org.springframework.test.context.ActiveProfiles; 10 | import org.springframework.web.context.request.RequestAttributes; 11 | import org.springframework.web.context.request.RequestContextHolder; 12 | 13 | @SpringBootTest 14 | @ActiveProfiles("test") 15 | @ExtendWith(TestContext.class) 16 | class IPUtilsTest { 17 | 18 | @Test 19 | void setRequestIP() { 20 | IPUtils.setRequestIP("127.0.0.1"); 21 | assertEquals( 22 | "127.0.0.1", 23 | RequestContextHolder.currentRequestAttributes() 24 | .getAttribute("REQUEST_IP", RequestAttributes.SCOPE_REQUEST)); 25 | } 26 | 27 | @Test 28 | void getRequestIP() { 29 | RequestContextHolder.currentRequestAttributes() 30 | .setAttribute("REQUEST_IP", "127.0.0.1", RequestAttributes.SCOPE_REQUEST); 31 | assertEquals("127.0.0.1", IPUtils.getRequestIP()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/demo/utils/UtilsTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.utils; 2 | 3 | import static org.junit.jupiter.api.Assertions.*; 4 | 5 | import java.util.regex.Pattern; 6 | import org.junit.jupiter.api.Test; 7 | 8 | class UtilsTest { 9 | 10 | @Test 11 | void randomNumericCode() { 12 | assertThrows(IllegalArgumentException.class, () -> Utils.randomNumericCode(-1)); 13 | assertThrows(IllegalArgumentException.class, () -> Utils.randomNumericCode(0)); 14 | 15 | Pattern pattern = Pattern.compile("[0-9]+"); 16 | for (int i = 1; i <= 10; i++) { 17 | String code = Utils.randomNumericCode(i); 18 | assertEquals(i, code.length()); 19 | assertTrue(pattern.matcher(code).matches()); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/demo/validation/validator/EmailValidatorTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.validation.validator; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | 6 | import com.joejoe2.demo.exception.ValidationError; 7 | import org.junit.jupiter.api.Test; 8 | 9 | class EmailValidatorTest { 10 | 11 | @Test 12 | void validate() { 13 | EmailValidator emailValidator = EmailValidator.getInstance(); 14 | 15 | assertThrows(ValidationError.class, () -> emailValidator.validate(null)); 16 | assertThrows(ValidationError.class, () -> emailValidator.validate("")); 17 | assertThrows(ValidationError.class, () -> emailValidator.validate(" ")); 18 | assertThrows( 19 | ValidationError.class, 20 | () -> 21 | emailValidator.validate( 22 | "aaaaaaaaaaaaaaaaaaaaaaaaaa" 23 | + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 24 | + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); 25 | assertThrows(ValidationError.class, () -> emailValidator.validate("not a email")); 26 | 27 | assertDoesNotThrow(() -> emailValidator.validate("test@email.com")); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/demo/validation/validator/PasswordValidatorTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.validation.validator; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | 6 | import com.joejoe2.demo.exception.ValidationError; 7 | import org.junit.jupiter.api.Test; 8 | 9 | class PasswordValidatorTest { 10 | 11 | @Test 12 | void validate() { 13 | PasswordValidator passwordValidator = PasswordValidator.getInstance(); 14 | 15 | assertThrows(ValidationError.class, () -> passwordValidator.validate(null)); 16 | assertThrows(ValidationError.class, () -> passwordValidator.validate("")); 17 | assertThrows(ValidationError.class, () -> passwordValidator.validate(" ")); 18 | assertThrows(ValidationError.class, () -> passwordValidator.validate("1234567")); 19 | assertThrows( 20 | ValidationError.class, 21 | () -> passwordValidator.validate("12345671234567123456712345671234567")); 22 | assertThrows(ValidationError.class, () -> passwordValidator.validate("********")); 23 | 24 | assertDoesNotThrow(() -> passwordValidator.validate("12345678")); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/demo/validation/validator/UUIDValidatorTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.validation.validator; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | 6 | import com.joejoe2.demo.exception.ValidationError; 7 | import java.util.UUID; 8 | import org.junit.jupiter.api.Test; 9 | 10 | class UUIDValidatorTest { 11 | 12 | @Test 13 | void validate() { 14 | UUIDValidator uuidValidator = UUIDValidator.getInstance(); 15 | 16 | assertThrows(ValidationError.class, () -> uuidValidator.validate(null)); 17 | assertThrows(ValidationError.class, () -> uuidValidator.validate("")); 18 | assertThrows(ValidationError.class, () -> uuidValidator.validate(" ")); 19 | 20 | assertDoesNotThrow(() -> uuidValidator.validate(UUID.randomUUID().toString())); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/demo/validation/validator/UserNameValidatorTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.demo.validation.validator; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | 6 | import com.joejoe2.demo.exception.ValidationError; 7 | import org.junit.jupiter.api.Test; 8 | 9 | class UserNameValidatorTest { 10 | 11 | @Test 12 | void validate() { 13 | UserNameValidator userNameValidator = UserNameValidator.getInstance(); 14 | 15 | assertThrows(ValidationError.class, () -> userNameValidator.validate(null)); 16 | assertThrows(ValidationError.class, () -> userNameValidator.validate("")); 17 | assertThrows(ValidationError.class, () -> userNameValidator.validate(" ")); 18 | assertThrows( 19 | ValidationError.class, 20 | () -> 21 | userNameValidator.validate( 22 | "aaaaaaaa" 23 | + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); 24 | assertThrows(ValidationError.class, () -> userNameValidator.validate("***/-*-")); 25 | 26 | assertDoesNotThrow(() -> userNameValidator.validate("test")); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | java -jar ./jwt.jar 2 | --------------------------------------------------------------------------------