diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 94116fe0a..cf24e9135 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -45,7 +45,7 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_USER }} run: | git fetch --unshallow - mvn clean org.jacoco:jacoco-maven-plugin:prepare-agent install sonar:sonar -Dsonar.projectKey=ita-social-projects-green-city-user -Dsonar.organization=ita-social-projects -Dsonar.host.url=https://sonarcloud.io -Dsonar.sources='src/main/java/greencity' -Dsonar.binaries=target/classes -Dsonar.dynamicAnalysis=reuseReports -Dsonar.coverage.exclusions=**/config/* + mvn clean org.jacoco:jacoco-maven-plugin:prepare-agent install sonar:sonar -Dsonar.projectKey=ita-social-projects-green-city-user -Dsonar.organization=ita-social-projects -Dsonar.host.url=https://sonarcloud.io -Dsonar.sources='src/main/java/greencity' -Dsonar.binaries=target/classes -Dsonar.dynamicAnalysis=reuseReports -Dsonar.coverage.exclusions=**/config/*,**/dto/security/* # Checks-out your greencity.repository under $GITHUB_WORKSPACE, so your job can access it - name: Test Reporter diff --git a/core/pom.xml b/core/pom.xml index e8ffd257a..eaa07a517 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -183,7 +183,20 @@ 1.9.20 + + org.springframework.cloud + spring-cloud-starter-openfeign + 4.1.3 + + + + org.springframework.cloud + spring-cloud-dependencies + 2023.0.3 + pom + + diff --git a/core/src/main/java/greencity/UserApplication.java b/core/src/main/java/greencity/UserApplication.java index 9ec1112c2..180d0b143 100644 --- a/core/src/main/java/greencity/UserApplication.java +++ b/core/src/main/java/greencity/UserApplication.java @@ -2,10 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling +@EnableFeignClients public class UserApplication { /** * Main method of SpringBoot app. diff --git a/core/src/main/java/greencity/config/SecurityConfig.java b/core/src/main/java/greencity/config/SecurityConfig.java index 01a3788ce..7b4c53ce1 100644 --- a/core/src/main/java/greencity/config/SecurityConfig.java +++ b/core/src/main/java/greencity/config/SecurityConfig.java @@ -124,7 +124,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(HttpMethod.POST, "/ownSecurity/signUp", "/ownSecurity/signIn", - "/ownSecurity/updatePassword") + "/ownSecurity/updatePassword", + "/ownSecurity/unblockAccount") .permitAll() .requestMatchers(HttpMethod.GET, "/user/shopping-list-items/habits/{habitId}/shopping-list", diff --git a/core/src/main/java/greencity/exception/handler/CustomExceptionHandler.java b/core/src/main/java/greencity/exception/handler/CustomExceptionHandler.java index 77b3c33f3..87e94a8f0 100644 --- a/core/src/main/java/greencity/exception/handler/CustomExceptionHandler.java +++ b/core/src/main/java/greencity/exception/handler/CustomExceptionHandler.java @@ -15,6 +15,8 @@ import greencity.exception.exceptions.PasswordsDoNotMatchesException; import greencity.exception.exceptions.UserAlreadyHasPasswordException; import greencity.exception.exceptions.UserAlreadyRegisteredException; +import greencity.exception.exceptions.UserBlockedException; +import greencity.exception.exceptions.WrongCaptchaException; import greencity.exception.exceptions.WrongEmailException; import greencity.exception.exceptions.WrongIdException; import greencity.exception.exceptions.WrongPasswordException; @@ -499,4 +501,38 @@ public ResponseEntity handleBase64DecodedException(Base64DecodedExceptio return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(exceptionResponse); } + + /** + * Handles exceptions of type {@link UserBlockedException}. + * + * @param exception the UserBlockedException instance + * @param request the current web request + * @return a ResponseEntity containing the HTTP status code and error response + * body + */ + @ExceptionHandler(UserBlockedException.class) + public ResponseEntity handleUserBlockedException(UserBlockedException exception, + WebRequest request) { + log.error(exception.getMessage()); + ExceptionResponse exceptionResponse = new ExceptionResponse(getErrorAttributes(request)); + + return ResponseEntity.status(HttpStatus.LOCKED).body(exceptionResponse); + } + + /** + * Handles exceptions of type {@link WrongCaptchaException}. + * + * @param exception the WrongCaptchaException instance + * @param request the current web request + * @return a ResponseEntity containing the HTTP status code and error response + * body + */ + @ExceptionHandler(WrongCaptchaException.class) + public ResponseEntity handleWrongCaptchaException(WrongCaptchaException exception, + WebRequest request) { + log.error(exception.getMessage()); + ExceptionResponse exceptionResponse = new ExceptionResponse(getErrorAttributes(request)); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(exceptionResponse); + } } diff --git a/core/src/main/java/greencity/security/controller/OwnSecurityController.java b/core/src/main/java/greencity/security/controller/OwnSecurityController.java index bc85dd370..6cd5a01df 100644 --- a/core/src/main/java/greencity/security/controller/OwnSecurityController.java +++ b/core/src/main/java/greencity/security/controller/OwnSecurityController.java @@ -22,6 +22,7 @@ import greencity.security.dto.ownsecurity.OwnSignUpDto; import greencity.security.dto.ownsecurity.PasswordStatusDto; import greencity.security.dto.ownsecurity.SetPasswordDto; +import greencity.security.dto.ownsecurity.UnblockAccountDto; import greencity.security.dto.ownsecurity.UpdatePasswordDto; import greencity.security.service.OwnSecurityService; import greencity.security.service.PasswordRecoveryService; @@ -298,4 +299,22 @@ public ResponseEntity deleteUser() { service.deleteUserByEmail(email); return ResponseEntity.ok().build(); } + + /** + * Unblocks user account by provided token. + * + * @param token {@link String} token for unblocking user account. + * @return {@link ResponseEntity} with 200 status if unblocking is successful. + */ + @Operation(summary = "Unblock user account") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = HttpStatuses.OK), + @ApiResponse(responseCode = "400", description = HttpStatuses.BAD_REQUEST), + @ApiResponse(responseCode = "404", description = ErrorMessage.USER_NOT_FOUND_BY_EMAIL) + }) + @PostMapping("/unblockAccount") + public ResponseEntity unblockAccount(@RequestBody UnblockAccountDto token) { + service.unblockAccount(token.token()); + return ResponseEntity.ok().build(); + } } diff --git a/core/src/main/resources/application-dev.properties b/core/src/main/resources/application-dev.properties index 1fb5b7f22..b65fb3476 100644 --- a/core/src/main/resources/application-dev.properties +++ b/core/src/main/resources/application-dev.properties @@ -64,4 +64,12 @@ greencityubs.server.address = http://localhost:8050 greencitychat.server.address = ${CHAT_LINK} #Swagger -springdoc.swagger-ui.doc-expansion=none \ No newline at end of file +springdoc.swagger-ui.doc-expansion=none + +#BruteForceSettings +bruteForceSettings.maxAttempts=${MAX_ATTEMPTS:5} +bruteForceSettings.blockTimeInHours=${BLOCK_TIME_IN_HOURS:1} +bruteForceSettings.blockTimeInMinutes=${BLOCK_TIME_IN_MINUTES:15} + +#CloudFlare +cloud-flare.secret-key=${CLOUD_FLARE_SECRET_KEY:0x4AAAAAAAxJcrEzmJE2s9LRh2ftN4vIby0} diff --git a/core/src/main/resources/application-docker.properties b/core/src/main/resources/application-docker.properties index 2138b1187..a3caf6b17 100644 --- a/core/src/main/resources/application-docker.properties +++ b/core/src/main/resources/application-docker.properties @@ -66,3 +66,11 @@ greencitychat.server.address = ${CHAT_LINK} #Swagger springdoc.swagger-ui.doc-expansion=none + +#BruteForceSettings +bruteForceSettings.maxAttempts=${MAX_ATTEMPTS:5} +bruteForceSettings.blockTimeInHours=${BLOCK_TIME_IN_HOURS:1} +bruteForceSettings.blockTimeInMinutes=${BLOCK_TIME_IN_MINUTES:15} + +#CloudFlare +cloud-flare.secret-key=${CLOUD_FLARE_SECRET_KEY:0x4AAAAAAAxJcrEzmJE2s9LRh2ftN4vIby0} diff --git a/core/src/main/resources/application-prod.properties b/core/src/main/resources/application-prod.properties index e54ce03b0..86bdb65fd 100644 --- a/core/src/main/resources/application-prod.properties +++ b/core/src/main/resources/application-prod.properties @@ -76,4 +76,12 @@ spring.web.resources.static-locations=classpath:/static/ greencity.authorization.googleApiKey=${GOOGLE_API_KEY:default-key} #Swagger -springdoc.swagger-ui.doc-expansion=none \ No newline at end of file +springdoc.swagger-ui.doc-expansion=none + +#BruteForceSettings +bruteForceSettings.maxAttempts=${MAX_ATTEMPTS:5} +bruteForceSettings.blockTimeInHours=${BLOCK_TIME_IN_HOURS:1} +bruteForceSettings.blockTimeInMinutes=${BLOCK_TIME_IN_MINUTES:15} + +#CloudFlare +cloud-flare.secret-key=${CLOUD_FLARE_SECRET_KEY:0x4AAAAAAAxJcrEzmJE2s9LRh2ftN4vIby0} \ No newline at end of file diff --git a/core/src/main/resources/messages_en.properties b/core/src/main/resources/messages_en.properties index 6065227ce..dc2e8020b 100644 --- a/core/src/main/resources/messages_en.properties +++ b/core/src/main/resources/messages_en.properties @@ -42,6 +42,15 @@ sincerely.yours.greenCity=Sincerely yours, Green City team. sincerely.yours.Ubs=Sincerely yours, Pick Up City team. unsubscribe.text=If you no longer wish to receive these emails, you can unsubscribe=unsubscribe +unlock=Unlock account +block.account=Account blocked +text.account.ban=Your account has been locked for security reasons due to a possible hacking attempt. For your safety, we recommend changing your password after restoring your account. +warning=Warning! +block.user=Your account is blocked +security.alert=Security Alert: Account Locked +condition.restore=If the restore button does not work, just paste this link into your browser: +condition.unblock=If the unblock button does not work, just paste this link into your browser: +advice.for.block=You received this email for security reasons. To protect your account, we recommend changing your password after unlocking your account. profile.text=If you no longer wish to receive these emails, you can unsubscribe from them in your profile=profile read.more=READ MORE \ No newline at end of file diff --git a/core/src/main/resources/messages_uk.properties b/core/src/main/resources/messages_uk.properties index dc3140db1..0f136ba5e 100644 --- a/core/src/main/resources/messages_uk.properties +++ b/core/src/main/resources/messages_uk.properties @@ -42,6 +42,15 @@ sincerely.yours.greenCity=\u0429\u0438\u0440\u043e\u0020\u0432\u0430\u0448\u0430 sincerely.yours.Ubs=\u0429\u0438\u0440\u043e\u0020\u0432\u0430\u0448\u0430\u002c\u0020\u043a\u043e\u043c\u0430\u043d\u0434\u0430\u0020\u0050\u0069\u0063\u006b\u0020\u0055\u0070\u0020\u0043\u0069\u0074\u0079\u002e unsubscribe.text=\u042f\u043a\u0449\u043e \u0432\u0438 \u0431\u0456\u043b\u044c\u0448\u0435 \u043d\u0435 \u0445\u043e\u0447\u0435\u0442\u0435 \u043e\u0442\u0440\u0438\u043c\u0443\u0432\u0430\u0442\u0438 \u0440\u043e\u0437\u0441\u0438\u043b\u043a\u0443\u002c \u0432\u0438 \u043c\u043e\u0436\u0435\u0442\u0435 unsubscribe=\u0432\u0456\u0434\u043f\u0438\u0441\u0430\u0442\u0438\u0441\u044f +unlock=\u0420\u043E\u0437\u0431\u043B\u043E\u043A\u0443\u0432\u0430\u0442\u0438\u0020\u043E\u0431\u043B\u0456\u043A\u043E\u0432\u0438\u0439\u0020\u0437\u0430\u043F\u0438\u0441 +block.account=\u041E\u0431\u043B\u0456\u043A\u043E\u0432\u0438\u0439\u0020\u0437\u0430\u043F\u0438\u0441\u0020\u0437\u0430\u0431\u043B\u043E\u043A\u043E\u0432\u0430\u043D\u043E +text.account.ban=\u0412\u0430\u0448\u0020\u043E\u0431\u043B\u0456\u043A\u043E\u0432\u0438\u0439\u0020\u0437\u0430\u043F\u0438\u0441\u0020\u0437\u0430\u0431\u043B\u043E\u043A\u043E\u0432\u0430\u043D\u043E\u0020\u0437\u0020\u043C\u0456\u0440\u043A\u0443\u0432\u0430\u043D\u044C\u0020\u0431\u0435\u0437\u043F\u0435\u043A\u0438\u0020\u0447\u0435\u0440\u0435\u0437\u0020\u043C\u043E\u0436\u043B\u0438\u0432\u0443\u0020\u0441\u043F\u0440\u043E\u0431\u0443\u0020\u0437\u043B\u043E\u043C\u0443\u002E\u0020\u0414\u043B\u044F\u0020\u0432\u0430\u0448\u043E\u0457\u0020\u0431\u0435\u0437\u043F\u0435\u043A\u0438\u0020\u0440\u0435\u043A\u043E\u043C\u0435\u043D\u0434\u0443\u0454\u043C\u043E\u0020\u0437\u043C\u0456\u043D\u0438\u0442\u0438\u0020\u043F\u0430\u0440\u043E\u043B\u044C\u0020\u043F\u0456\u0441\u043B\u044F\u0020\u0432\u0456\u0434\u043D\u043E\u0432\u043B\u0435\u043D\u043D\u044F\u0020\u043E\u0431\u043B\u0456\u043A\u043E\u0432\u043E\u0433\u043E\u0020\u0437\u0430\u043F\u0438\u0441\u0443\u002E +warning=\u0423\u0432\u0430\u0433\u0430\u0021\u000D\u000A +block.user=\u0412\u0430\u0448\u0020\u043E\u0431\u043B\u0456\u043A\u043E\u0432\u0438\u0439\u0020\u0437\u0430\u043F\u0438\u0441\u0020\u0437\u0430\u0431\u043B\u043E\u043A\u043E\u0432\u0430\u043D\u043E +security.alert=\u0421\u043F\u043E\u0432\u0456\u0449\u0435\u043D\u043D\u044F\u0020\u0431\u0435\u0437\u043F\u0435\u043A\u0438\u003A\u0020\u043E\u0431\u043B\u0456\u043A\u043E\u0432\u0438\u0439\u0020\u0437\u0430\u043F\u0438\u0441\u0020\u0437\u0430\u0431\u043B\u043E\u043A\u043E\u0432\u0430\u043D\u043E +condition.restore=\u042F\u043A\u0449\u043E\u0020\u043A\u043D\u043E\u043F\u043A\u0430\u0020\u0432\u0456\u0434\u043D\u043E\u0432\u043B\u0435\u043D\u043D\u044F\u0020\u043D\u0435\u0020\u043F\u0440\u0430\u0446\u044E\u0454\u002C\u0020\u043F\u0440\u043E\u0441\u0442\u043E\u0020\u0432\u0441\u0442\u0430\u0432\u0442\u0435\u0020\u0446\u0435\u0020\u043F\u043E\u0441\u0438\u043B\u0430\u043D\u043D\u044F\u0020\u0443\u0020\u0441\u0432\u0456\u0439\u0020\u0431\u0440\u0430\u0443\u0437\u0435\u0440\u003A +condition.unblock=\u042F\u043A\u0449\u043E\u0020\u043A\u043D\u043E\u043F\u043A\u0430\u0020\u0440\u043E\u0437\u0431\u043B\u043E\u043A\u0443\u0432\u0430\u043D\u043D\u044F\u0020\u043D\u0435\u0020\u043F\u0440\u0430\u0446\u044E\u0454\u002C\u0020\u043F\u0440\u043E\u0441\u0442\u043E\u0020\u0432\u0441\u0442\u0430\u0432\u0442\u0435\u0020\u0446\u0435\u0020\u043F\u043E\u0441\u0438\u043B\u0430\u043D\u043D\u044F\u0020\u0443\u0020\u0441\u0432\u0456\u0439\u0020\u0431\u0440\u0430\u0443\u0437\u0435\u0440\u003A +advice.for.block=\u0412\u0438\u0020\u043E\u0442\u0440\u0438\u043C\u0430\u043B\u0438\u0020\u0446\u0435\u0439\u0020\u0435\u043B\u0435\u043A\u0442\u0440\u043E\u043D\u043D\u0438\u0439\u0020\u043B\u0438\u0441\u0442\u0020\u0437\u0020\u043C\u0456\u0440\u043A\u0443\u0432\u0430\u043D\u044C\u0020\u0431\u0435\u0437\u043F\u0435\u043A\u0438\u002E\u0020\u0429\u043E\u0431\u0020\u0437\u0430\u0445\u0438\u0441\u0442\u0438\u0442\u0438\u0020\u0441\u0432\u0456\u0439\u0020\u043E\u0431\u043B\u0456\u043A\u043E\u0432\u0438\u0439\u0020\u0437\u0430\u043F\u0438\u0441\u002C\u0020\u0440\u0435\u043A\u043E\u043C\u0435\u043D\u0434\u0443\u0454\u043C\u043E\u0020\u0437\u043C\u0456\u043D\u0438\u0442\u0438\u0020\u043F\u0430\u0440\u043E\u043B\u044C\u0020\u043F\u0456\u0441\u043B\u044F\u0020\u0440\u043E\u0437\u0431\u043B\u043E\u043A\u0443\u0432\u0430\u043D\u043D\u044F\u0020\u043E\u0431\u043B\u0456\u043A\u043E\u0432\u043E\u0433\u043E\u0020\u0437\u0430\u043F\u0438\u0441\u0443\u002E profile.text=\u042f\u043a\u0449\u043e\u0020\u0432\u0438\u0020\u0431\u0456\u043b\u044c\u0448\u0435\u0020\u043d\u0435\u0020\u0445\u043e\u0447\u0435\u0442\u0435\u0020\u043e\u0442\u0440\u0438\u043c\u0443\u0432\u0430\u0442\u0438\u0020\u0440\u043e\u0437\u0441\u0438\u043b\u043a\u0443\u002c\u0020\u0432\u0438\u0020\u043c\u043e\u0436\u0435\u0442\u0435\u0020\u0432\u0456\u0434\u043f\u0438\u0441\u0430\u0442\u0438\u0441\u044f\u0020\u0432\u0456\u0434\u0020\u043d\u0435\u0457\u0020\u0443\u0020\u0441\u0432\u043e\u0454\u043c\u0443 profile=\u043f\u0440\u043e\u0444\u0456\u043b\u0456 read.more=\u0427\u0418\u0422\u0410\u0422\u0418 \u0414\u0410\u041b\u0406 \ No newline at end of file diff --git a/core/src/main/resources/templates/core/management_login.html b/core/src/main/resources/templates/core/management_login.html index 94d6931f8..e203f3f8b 100644 --- a/core/src/main/resources/templates/core/management_login.html +++ b/core/src/main/resources/templates/core/management_login.html @@ -45,60 +45,62 @@ } +
-
-
-
-
-
З поверненням!
-
-
Будь ласка, внеси свої дані для - входу +
-
-
-
-
-
Електронна пошта
- -

Validation error

-
-
-
Пароль
- -

Validation error

-
- -
-
-
або
+
+
+
З поверненням!
+
+
Будь ласка, внеси свої дані для + входу +
+
+
+
+
+
Електронна пошта
+ +

Validation error

+
+
+
Пароль
+ +

Validation error

+
+
+ +
+
+
або
- -
-
- + \ No newline at end of file diff --git a/core/src/main/resources/templates/email/blocked-user-page.html b/core/src/main/resources/templates/email/blocked-user-page.html new file mode 100644 index 000000000..b33419147 --- /dev/null +++ b/core/src/main/resources/templates/email/blocked-user-page.html @@ -0,0 +1,154 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/src/test/java/greencity/exception/handler/CustomExceptionHandlerTest.java b/core/src/test/java/greencity/exception/handler/CustomExceptionHandlerTest.java index 41ce9fdda..b19ed97a6 100644 --- a/core/src/test/java/greencity/exception/handler/CustomExceptionHandlerTest.java +++ b/core/src/test/java/greencity/exception/handler/CustomExceptionHandlerTest.java @@ -9,6 +9,8 @@ import greencity.exception.exceptions.LanguageNotSupportedException; import greencity.exception.exceptions.NotFoundException; import greencity.exception.exceptions.UserAlreadyRegisteredException; +import greencity.exception.exceptions.UserBlockedException; +import greencity.exception.exceptions.WrongCaptchaException; import greencity.exception.exceptions.WrongEmailException; import greencity.exception.exceptions.WrongIdException; import greencity.exception.exceptions.WrongPasswordException; @@ -301,4 +303,24 @@ void handleBase64DecodedExceptionTest() { assertEquals(customExceptionHandler.handleBase64DecodedException(actual, webRequest), ResponseEntity.status(HttpStatus.BAD_REQUEST).body(exceptionResponse)); } + + @Test + void handleUserBlockedExceptionTest() { + UserBlockedException actual = new UserBlockedException("Some string"); + ExceptionResponse exceptionResponse = new ExceptionResponse(objectMap); + when(errorAttributes.getErrorAttributes(eq(webRequest), + any(ErrorAttributeOptions.class))).thenReturn(objectMap); + assertEquals(customExceptionHandler.handleUserBlockedException(actual, webRequest), + ResponseEntity.status(HttpStatus.LOCKED).body(exceptionResponse)); + } + + @Test + void handleWrongCaptchaExceptionTest() { + WrongCaptchaException actual = new WrongCaptchaException("Some string"); + ExceptionResponse exceptionResponse = new ExceptionResponse(objectMap); + when(errorAttributes.getErrorAttributes(eq(webRequest), + any(ErrorAttributeOptions.class))).thenReturn(objectMap); + assertEquals(customExceptionHandler.handleWrongCaptchaException(actual, webRequest), + ResponseEntity.status(HttpStatus.BAD_REQUEST).body(exceptionResponse)); + } } \ No newline at end of file diff --git a/core/src/test/java/greencity/security/controller/ManagementSecurityControllerTest.java b/core/src/test/java/greencity/security/controller/ManagementSecurityControllerTest.java index ce5cc2731..f0930b96b 100644 --- a/core/src/test/java/greencity/security/controller/ManagementSecurityControllerTest.java +++ b/core/src/test/java/greencity/security/controller/ManagementSecurityControllerTest.java @@ -31,6 +31,7 @@ @ExtendWith(MockitoExtension.class) class ManagementSecurityControllerTest { private static final String LINK = "/management"; + private static final String CAPTCHA_TOKEN = "token"; private MockMvc mockMvc; private SuccessSignInDto successDto; @@ -76,7 +77,7 @@ void loginPageInvalidDto() throws Exception { @Test void signIn() throws Exception { - OwnSignInDto dto = new OwnSignInDto("test@gmail.com", "Vovk@1998"); + OwnSignInDto dto = new OwnSignInDto("test@gmail.com", "Vovk@1998", CAPTCHA_TOKEN); when(ownSecurityService.signIn(any())).thenReturn(successDto); when(userService.findAdminById(successDto.getUserId())).thenReturn(TEST_USER_VO); @@ -87,7 +88,7 @@ void signIn() throws Exception { @Test void signInWrongEmail() throws Exception { - OwnSignInDto dto = new OwnSignInDto("tesssweqwest@gmail.com", "Vovk@1998"); + OwnSignInDto dto = new OwnSignInDto("tesssweqwest@gmail.com", "Vovk@1998", CAPTCHA_TOKEN); when(ownSecurityService.signIn(any())).thenThrow(WrongEmailException.class); mockMvc.perform(post(LINK + "/login") @@ -97,7 +98,7 @@ void signInWrongEmail() throws Exception { @Test void signInWrongPassword() throws Exception { - OwnSignInDto dto = new OwnSignInDto("tesssweqwest@gmail.com", "Vovk@1998"); + OwnSignInDto dto = new OwnSignInDto("tesssweqwest@gmail.com", "Vovk@1998", CAPTCHA_TOKEN); when(ownSecurityService.signIn(any())).thenThrow(WrongPasswordException.class); mockMvc.perform(post(LINK + "/login") @@ -107,7 +108,7 @@ void signInWrongPassword() throws Exception { @Test void signInEmailNotVerified() throws Exception { - OwnSignInDto dto = new OwnSignInDto("tesssweqwest@gmail.com", "Vovk@1998"); + OwnSignInDto dto = new OwnSignInDto("tesssweqwest@gmail.com", "Vovk@1998", CAPTCHA_TOKEN); when(ownSecurityService.signIn(any())).thenThrow(EmailNotVerified.class); mockMvc.perform(post(LINK + "/login") @@ -117,7 +118,7 @@ void signInEmailNotVerified() throws Exception { @Test void signInUserDeactivated() throws Exception { - OwnSignInDto dto = new OwnSignInDto("tesssweqwest@gmail.com", "Vovk@1998"); + OwnSignInDto dto = new OwnSignInDto("tesssweqwest@gmail.com", "Vovk@1998", CAPTCHA_TOKEN); when(ownSecurityService.signIn(any())).thenThrow(UserDeactivatedException.class); mockMvc.perform(post(LINK + "/login") @@ -127,7 +128,7 @@ void signInUserDeactivated() throws Exception { @Test void signInUserDoNotHaveAuthorities() throws Exception { - OwnSignInDto dto = new OwnSignInDto("test@mail.com", "Vovk@1998"); + OwnSignInDto dto = new OwnSignInDto("test@mail.com", "Vovk@1998", CAPTCHA_TOKEN); when(ownSecurityService.signIn(any())).thenReturn(successDto); when(userService.findAdminById(1L)).thenThrow(LowRoleLevelException.class); diff --git a/core/src/test/java/greencity/security/controller/OwnSecurityControllerTest.java b/core/src/test/java/greencity/security/controller/OwnSecurityControllerTest.java index 6fafdf205..31c17f15a 100644 --- a/core/src/test/java/greencity/security/controller/OwnSecurityControllerTest.java +++ b/core/src/test/java/greencity/security/controller/OwnSecurityControllerTest.java @@ -1,11 +1,13 @@ package greencity.security.controller; +import com.fasterxml.jackson.databind.ObjectMapper; import greencity.ModelUtils; import greencity.security.dto.ownsecurity.EmployeeSignUpDto; import greencity.security.dto.ownsecurity.OwnRestoreDto; import greencity.security.dto.ownsecurity.OwnSignInDto; import greencity.security.dto.ownsecurity.OwnSignUpDto; import greencity.security.dto.ownsecurity.SetPasswordDto; +import greencity.security.dto.ownsecurity.UnblockAccountDto; import greencity.security.dto.ownsecurity.UpdatePasswordDto; import greencity.security.service.OwnSecurityService; import greencity.security.service.PasswordRecoveryService; @@ -16,6 +18,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; + +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.web.PageableHandlerMethodArgumentResolver; @@ -226,4 +231,18 @@ void deleteUser() { verify(ownSecurityService).deleteUserByEmail(email); } + + @Test + void unblockUserTest() throws Exception { + UnblockAccountDto accountDto = new UnblockAccountDto("token"); + + doNothing().when(ownSecurityService).unblockAccount(accountDto.token()); + + mockMvc.perform(post(LINK + "/unblockAccount") + .contentType(MediaType.APPLICATION_JSON) + .content(new ObjectMapper().writeValueAsString(accountDto))) + .andExpect(status().isOk()); + + verify(ownSecurityService, times(1)).unblockAccount(accountDto.token()); + } } diff --git a/service-api/pom.xml b/service-api/pom.xml index 7cf84bbd1..9fd5319da 100644 --- a/service-api/pom.xml +++ b/service-api/pom.xml @@ -13,6 +13,7 @@ 0.12.3 1.6.0 4.4 + 32.0.0-jre service-api @@ -156,7 +157,21 @@ 2.2.19 compile + + com.google.guava + guava + ${guava.version} + + + org.springframework.cloud + spring-cloud-starter-openfeign + 4.1.3 + + + org.springframework.cloud + spring-cloud-dependencies + 2023.0.3 + pom + - - diff --git a/service-api/src/main/java/greencity/client/CloudFlareClient.java b/service-api/src/main/java/greencity/client/CloudFlareClient.java new file mode 100644 index 000000000..cb88d1d81 --- /dev/null +++ b/service-api/src/main/java/greencity/client/CloudFlareClient.java @@ -0,0 +1,18 @@ +package greencity.client; + +import greencity.client.config.CloudFlareClientFallbackFactory; +import greencity.dto.security.CloudFlareResponse; +import greencity.dto.security.CloudFlareRequest; +import jakarta.validation.Valid; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@FeignClient( + name = "cloudflare", + url = "https://challenges.cloudflare.com/turnstile/v0/siteverify", + fallbackFactory = CloudFlareClientFallbackFactory.class) +public interface CloudFlareClient { + @PostMapping(consumes = "application/json", produces = "application/json") + CloudFlareResponse getCloudFlareResponse(@Valid @RequestBody CloudFlareRequest request); +} diff --git a/service-api/src/main/java/greencity/client/config/CloudFlareClientFallbackFactory.java b/service-api/src/main/java/greencity/client/config/CloudFlareClientFallbackFactory.java new file mode 100644 index 000000000..c32ba5b8d --- /dev/null +++ b/service-api/src/main/java/greencity/client/config/CloudFlareClientFallbackFactory.java @@ -0,0 +1,17 @@ +package greencity.client.config; + +import greencity.client.CloudFlareClient; +import greencity.constant.ErrorMessage; +import greencity.exception.exceptions.RemoteServerUnavailableException; +import org.springframework.cloud.openfeign.FallbackFactory; +import org.springframework.stereotype.Component; + +@Component +public class CloudFlareClientFallbackFactory implements FallbackFactory { + @Override + public CloudFlareClient create(Throwable cause) { + return request -> { + throw new RemoteServerUnavailableException(ErrorMessage.COULD_NOT_RETRIEVE_CHECKOUT_RESPONSE, cause); + }; + } +} diff --git a/service-api/src/main/java/greencity/constant/EmailConstants.java b/service-api/src/main/java/greencity/constant/EmailConstants.java index 27a02b4b5..a4301cff1 100644 --- a/service-api/src/main/java/greencity/constant/EmailConstants.java +++ b/service-api/src/main/java/greencity/constant/EmailConstants.java @@ -13,6 +13,11 @@ public class EmailConstants { public static final String CONFIRM_CREATING_PASS = "creating.new.password"; public static final String CONFIRM_CREATING_PASS_UBS = "creating.new.passwordUbs"; public static final String APPROVE_REGISTRATION_SUBJECT = "Approve your registration"; + public static final String HABIT_ASSIGN_FRIEND_REQUEST = "habit.friend.request"; + public static final String USER_TAGGED_IN_COMMENT_REQUEST = "user.tagged.request"; + public static final String USER_RECEIVED_COMMENT_REQUEST = "user.received.comment.request"; + public static final String USER_RECEIVED_COMMENT_REPLY_REQUEST = "user.received.comment.reply.request"; + public static final String BLOCKED_USER = "block.user"; public static final String DEACTIVATION = "user.deactivation.subject"; public static final String ACTIVATION = "user.activation.subject"; public static final String VIOLATION_EMAIL = "user.violation.subject"; @@ -33,6 +38,7 @@ public class EmailConstants { public static final String IS_UBS = "isUbs"; public static final String TITLE = "title"; public static final String BODY = "body"; + public static final String UNLOCK_USER_LINK = "unlockUserLink"; // templates public static final String VERIFY_EMAIL_PAGE = "verify-email-page"; public static final String RESTORE_EMAIL_PAGE = "restore-email-page"; @@ -45,4 +51,5 @@ public class EmailConstants { public static final String USER_VIOLATION_PAGE = "user-violation-mail"; public static final String SCHEDULED_NOTIFICATION_PAGE = "scheduled-notification-email-page"; public static final String RECEIVE_INTERESTING_NEWS_EMAIL_PAGE = "receive-interesting-news-email-page"; + public static final String BLOCKED_USER_PAGE = "blocked-user-page"; } diff --git a/service-api/src/main/java/greencity/constant/ErrorMessage.java b/service-api/src/main/java/greencity/constant/ErrorMessage.java index 747b46d4d..b5c6cde22 100644 --- a/service-api/src/main/java/greencity/constant/ErrorMessage.java +++ b/service-api/src/main/java/greencity/constant/ErrorMessage.java @@ -54,4 +54,10 @@ public class ErrorMessage { public static final String YOU_DO_NOT_HAVE_PERMISSIONS_TO_DEACTIVATE_THIS_USER = "You do not have permission to deactivate this user"; public static final String BASE64_DECODE_MESSAGE = "Can't decode from base64 format"; + public static final String BRUTEFORCE_PROTECTION_MESSAGE = + "User account is blocked due to too many failed login attempts."; + public static final String COULD_NOT_RETRIEVE_CHECKOUT_RESPONSE = "Could not retrieve checkout response"; + public static final String WRONG_CAPTCHA = "Wrong captcha"; + public static final String BRUTEFORCE_PROTECTION_MESSAGE_WRONG_PASS = + "User account is blocked due to too many failed login attempts. Try again in %s minutes"; } diff --git a/service-api/src/main/java/greencity/dto/security/CloudFlareRequest.java b/service-api/src/main/java/greencity/dto/security/CloudFlareRequest.java new file mode 100644 index 000000000..20c8c4501 --- /dev/null +++ b/service-api/src/main/java/greencity/dto/security/CloudFlareRequest.java @@ -0,0 +1,12 @@ +package greencity.dto.security; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; + +@Builder +public record CloudFlareRequest( + @NotBlank String secret, + @NotBlank String response, + @JsonProperty("remoteip") String remoteIp) { +} diff --git a/service-api/src/main/java/greencity/dto/security/CloudFlareResponse.java b/service-api/src/main/java/greencity/dto/security/CloudFlareResponse.java new file mode 100644 index 000000000..a61ac2566 --- /dev/null +++ b/service-api/src/main/java/greencity/dto/security/CloudFlareResponse.java @@ -0,0 +1,10 @@ +package greencity.dto.security; + +import java.util.List; + +public record CloudFlareResponse( + boolean success, + List errorCodes, + String challenge_ts, + String hostname) { +} diff --git a/service-api/src/main/java/greencity/exception/exceptions/RemoteServerUnavailableException.java b/service-api/src/main/java/greencity/exception/exceptions/RemoteServerUnavailableException.java new file mode 100644 index 000000000..eaec59322 --- /dev/null +++ b/service-api/src/main/java/greencity/exception/exceptions/RemoteServerUnavailableException.java @@ -0,0 +1,7 @@ +package greencity.exception.exceptions; + +import lombok.experimental.StandardException; + +@StandardException +public class RemoteServerUnavailableException extends RuntimeException { +} diff --git a/service-api/src/main/java/greencity/exception/exceptions/WrongCaptchaException.java b/service-api/src/main/java/greencity/exception/exceptions/WrongCaptchaException.java new file mode 100644 index 000000000..0c7740c44 --- /dev/null +++ b/service-api/src/main/java/greencity/exception/exceptions/WrongCaptchaException.java @@ -0,0 +1,7 @@ +package greencity.exception.exceptions; + +import lombok.experimental.StandardException; + +@StandardException +public class WrongCaptchaException extends RuntimeException { +} diff --git a/service-api/src/main/java/greencity/security/dto/ownsecurity/OwnSignInDto.java b/service-api/src/main/java/greencity/security/dto/ownsecurity/OwnSignInDto.java index ea485ace5..0f5525606 100644 --- a/service-api/src/main/java/greencity/security/dto/ownsecurity/OwnSignInDto.java +++ b/service-api/src/main/java/greencity/security/dto/ownsecurity/OwnSignInDto.java @@ -19,4 +19,6 @@ public class OwnSignInDto { @NotBlank private String password; + + private String captchaToken; } diff --git a/service-api/src/main/java/greencity/security/dto/ownsecurity/UnblockAccountDto.java b/service-api/src/main/java/greencity/security/dto/ownsecurity/UnblockAccountDto.java new file mode 100644 index 000000000..5609837cf --- /dev/null +++ b/service-api/src/main/java/greencity/security/dto/ownsecurity/UnblockAccountDto.java @@ -0,0 +1,4 @@ +package greencity.security.dto.ownsecurity; + +public record UnblockAccountDto(String token) { +} diff --git a/service-api/src/main/java/greencity/security/jwt/JwtTool.java b/service-api/src/main/java/greencity/security/jwt/JwtTool.java index f12db0402..5fd4af6d1 100644 --- a/service-api/src/main/java/greencity/security/jwt/JwtTool.java +++ b/service-api/src/main/java/greencity/security/jwt/JwtTool.java @@ -186,4 +186,20 @@ public String generateTokenKeyWithCodedDate() { String input = dateLong + "." + UUID.randomUUID(); return Base64.getEncoder().encodeToString(input.getBytes()); } + + /** + * Generates a token for unblocking user account. + * + * @param email this is user email. + * @return token for unblocking user account. + */ + public String generateUnblockToken(String email) { + ClaimsBuilder claims = Jwts.claims().subject(email); + return Jwts.builder() + .claims(claims.build()) + .signWith(Keys.hmacShaKeyFor( + accessTokenKey.getBytes(StandardCharsets.UTF_8)), + Jwts.SIG.HS256) + .compact(); + } } diff --git a/service-api/src/main/java/greencity/security/service/LoginAttemptService.java b/service-api/src/main/java/greencity/security/service/LoginAttemptService.java new file mode 100644 index 000000000..39d093dc6 --- /dev/null +++ b/service-api/src/main/java/greencity/security/service/LoginAttemptService.java @@ -0,0 +1,42 @@ +package greencity.security.service; + +/** + * Service to control brute-force attacks. + * + * @author Kizerov Dmytro + * @version 1.0 + */ +public interface LoginAttemptService { + /** + * Method to increment the amount of wrong captcha for the given {@code email}. + * This method is called when a user provides wrong captcha. + * + * @param email identifies the user. + * + */ + void loginFailedByCaptcha(String email); + + /** + * Method to check if user is blocked by wrong captcha. + * + * @param email identifies user, usually email. + * @return true if user is blocked, false otherwise. + */ + boolean isBlockedByCaptcha(String email); + + /** + * Method to increment the amount of wrong password for the given {@code email}. + * This method is called when a user provides wrong password. + * + * @param email identifies the user. + */ + void loginFailedByWrongPassword(String email); + + /** + * Method to check if user is blocked by wrong password. + * + * @param email identifies user, usually email. + * @return true if user is blocked, false otherwise. + */ + boolean isBlockedByWrongPassword(String email); +} diff --git a/service-api/src/main/java/greencity/security/service/OwnSecurityService.java b/service-api/src/main/java/greencity/security/service/OwnSecurityService.java index ad8b04ea3..1e02de056 100644 --- a/service-api/src/main/java/greencity/security/service/OwnSecurityService.java +++ b/service-api/src/main/java/greencity/security/service/OwnSecurityService.java @@ -91,4 +91,11 @@ public interface OwnSecurityService { * @param email {@link String} email of the user to be deleted. */ void deleteUserByEmail(String email); + + /** + * Unblocks user account by provided token. + * + * @param token {@link String} token for unblocking user account. + */ + void unblockAccount(String token); } diff --git a/service-api/src/main/java/greencity/service/EmailService.java b/service-api/src/main/java/greencity/service/EmailService.java index 161b72db4..08c259d34 100644 --- a/service-api/src/main/java/greencity/service/EmailService.java +++ b/service-api/src/main/java/greencity/service/EmailService.java @@ -122,4 +122,20 @@ void sendCreateNewPasswordForEmployee(Long employeeId, String employeeFistName, * @param message {@link ScheduledEmailMessage} */ void sendScheduledNotificationEmail(ScheduledEmailMessage message); + + /** + * Sends an email notification user that his account has been blocked and + * includes link for unblocking. + * + * @param userId the user id is used for recovery link building + * @param userFistName user first name is used in email model constants + * @param userEmail user email which will be used for sending recovery letter + * @param token token for password save(restoration) + * @param language language code used for email notification + * @param isUbs {@code true} if user is from UBS, {@code false} if user + * is from admin panel + */ + void sendBlockAccountNotificationWithUnblockLinkEmail( + Long userId, String userFistName, String userEmail, String token, String language, + boolean isUbs); } diff --git a/service-api/src/test/java/greencity/security/jwt/JwtToolTest.java b/service-api/src/test/java/greencity/security/jwt/JwtToolTest.java index 41f15e8f5..87b3740fc 100644 --- a/service-api/src/test/java/greencity/security/jwt/JwtToolTest.java +++ b/service-api/src/test/java/greencity/security/jwt/JwtToolTest.java @@ -169,4 +169,20 @@ void generateTokenKeyWithCodedDateLengthTest() { int tokenLength = 68; assertEquals(tokenLength, jwtTool.generateTokenKeyWithCodedDate().length()); } + + @Test + void testGenerateUnblockToken() { + final String token = jwtTool.generateUnblockToken(expectedEmail); + + SecretKey key = Keys.hmacShaKeyFor(jwtTool.getAccessTokenKey().getBytes()); + + String actualEmail = Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject(); + + assertEquals(expectedEmail, actualEmail); + } } diff --git a/service/src/main/java/greencity/security/service/LoginAttemptServiceImpl.java b/service/src/main/java/greencity/security/service/LoginAttemptServiceImpl.java new file mode 100644 index 000000000..e8cf070fc --- /dev/null +++ b/service/src/main/java/greencity/security/service/LoginAttemptServiceImpl.java @@ -0,0 +1,77 @@ +package greencity.security.service; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class LoginAttemptServiceImpl implements LoginAttemptService { + private final LoadingCache attemptsByCaptchaCache; + private final LoadingCache attemptsByWrongPasswordCache; + @Value("${bruteForceSettings.maxAttempts}") + private int maxAttempt; + + public LoginAttemptServiceImpl(@Value("${bruteForceSettings.blockTimeInHours}") int blockTimeInHours, + @Value("${bruteForceSettings.blockTimeInMinutes}") int blockTimeInMinutes) { + this.attemptsByCaptchaCache = CacheBuilder.newBuilder() + .expireAfterWrite(blockTimeInHours, TimeUnit.HOURS) + .build(new CacheLoader<>() { + @Override + public Integer load(final String key) { + return 0; + } + }); + this.attemptsByWrongPasswordCache = CacheBuilder.newBuilder() + .expireAfterWrite(blockTimeInMinutes, TimeUnit.MINUTES) + .build(new CacheLoader<>() { + @Override + public Integer load(final String key) { + return 0; + } + }); + } + + /** + * {@inheritDoc} + */ + @Override + public void loginFailedByCaptcha(final String key) { + attemptsByCaptchaCache.asMap().merge(key, 1, Integer::sum); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isBlockedByCaptcha(String email) { + try { + return attemptsByCaptchaCache.get(email) >= maxAttempt; + } catch (final ExecutionException e) { + return false; + } + } + + /** + * {@inheritDoc} + */ + @Override + public void loginFailedByWrongPassword(String email) { + attemptsByWrongPasswordCache.asMap().merge(email, 1, Integer::sum); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isBlockedByWrongPassword(String email) { + try { + return attemptsByWrongPasswordCache.get(email) >= maxAttempt; + } catch (final ExecutionException e) { + return false; + } + } +} diff --git a/service/src/main/java/greencity/security/service/OwnSecurityServiceImpl.java b/service/src/main/java/greencity/security/service/OwnSecurityServiceImpl.java index bacff3dff..839c0aea6 100644 --- a/service/src/main/java/greencity/security/service/OwnSecurityServiceImpl.java +++ b/service/src/main/java/greencity/security/service/OwnSecurityServiceImpl.java @@ -1,7 +1,10 @@ package greencity.security.service; +import greencity.client.CloudFlareClient; import greencity.constant.AppConstant; import greencity.constant.ErrorMessage; +import greencity.dto.security.CloudFlareResponse; +import greencity.dto.security.CloudFlareRequest; import greencity.dto.user.UserAdminRegistrationDto; import greencity.dto.user.UserManagementDto; import greencity.dto.user.UserVO; @@ -63,8 +66,14 @@ public class OwnSecurityServiceImpl implements OwnSecurityService { private final UserRepo userRepo; private final EmailService emailService; private final AuthorityRepo authorityRepo; + private final LoginAttemptService loginAttemptService; + private final CloudFlareClient cloudFlareClient; @Value("${verifyEmailTimeHour}") private Integer expirationTime; + @Value("${bruteForceSettings.blockTimeInMinutes}") + private String blockTimeInMinutes; + @Value("${cloud-flare.secret-key}") + private String cloudFlareSecretKey; /** * {@inheritDoc} @@ -184,24 +193,156 @@ private LocalDateTime calculateExpirationDateTime() { */ @Override public SuccessSignInDto signIn(final OwnSignInDto dto) { + String email = dto.getEmail(); + UserVO user = validateUser(dto); + + handleUserStatus(user.getUserStatus()); + handleBruteForceProtection(email); + + verifyCaptcha(dto, email); + + validatePassword(dto, user); + + if (!isEmailVerified(user)) { + throw new EmailNotVerified("You should verify the email first, check your email box!"); + } + + return createSuccessSignInResponse(user, email); + } + + private UserVO validateUser(final OwnSignInDto dto) { UserVO user = userService.findByEmail(dto.getEmail()); if (user == null) { throw new WrongEmailException(ErrorMessage.USER_NOT_FOUND_BY_EMAIL + dto.getEmail()); } + return user; + } + + /** + * Checks if user is blocked by brute-force protection (captcha or wrong + * password). If user is blocked, logs error and blocks user by email. If user + * exceeded wrong password attempts, throws WrongPasswordException. + * + * @param email user email + */ + private void handleBruteForceProtection(String email) { + if (loginAttemptService.isBlockedByCaptcha(email)) { + log.error("Brute force protection, user with email is blocked - {}", email); + blockUserByEmail(email); + } + + if (loginAttemptService.isBlockedByWrongPassword(email)) { + log.error("Too many failed login attempts - {}, account is blocked for {} minutes", email, + blockTimeInMinutes); + throw new WrongPasswordException( + String.format(ErrorMessage.BRUTEFORCE_PROTECTION_MESSAGE_WRONG_PASS, blockTimeInMinutes)); + } + } + + /** + * Checks if captcha is valid. If captcha is not valid, logs error, increments + * wrong captcha attempts and throws WrongCaptchaException. + * + * @param dto - {@link OwnSignInDto} that have sign-in information + * @param email - user email + */ + private void verifyCaptcha(final OwnSignInDto dto, String email) { + if (!getCloudFlareResponse(dto).success()) { + loginAttemptService.loginFailedByCaptcha(email); + throw new WrongCaptchaException(ErrorMessage.WRONG_CAPTCHA); + } + } + + /** + * Validates password for user. If password is not correct, logs error, + * increments wrong password attempts and throws WrongPasswordException. + * + * @param dto - {@link OwnSignInDto} that have sign-in information + * @param user - user with password to be validated + */ + private void validatePassword(final OwnSignInDto dto, UserVO user) { if (!isPasswordCorrect(dto, user)) { + loginAttemptService.loginFailedByWrongPassword(dto.getEmail()); throw new WrongPasswordException(ErrorMessage.BAD_PASSWORD); } - if (user.getVerifyEmail() != null) { - throw new EmailNotVerified("You should verify the email first, check your email box!"); - } + } - handleUserStatus(user.getUserStatus()); + /** + * Checks if user has verified email. User is considered verified if there is no + * VerifyEmail entity associated with his/her account. + * + * @param user - user to be checked + * @return true if user has verified email, false otherwise + */ + private boolean isEmailVerified(UserVO user) { + return user.getVerifyEmail() == null; + } - String accessToken = jwtTool.createAccessToken(user.getEmail(), user.getRole()); + /** + * Creates a {@link SuccessSignInDto} that is used to sign in user. Creates a + * new access token and a new refresh token and returns them in the + * {@link SuccessSignInDto} object. + * + * @param user user that is being signed in + * @param email user's email + * @return {@link SuccessSignInDto} with access token, refresh token and user's + * name + */ + private SuccessSignInDto createSuccessSignInResponse(UserVO user, String email) { + String accessToken = jwtTool.createAccessToken(email, user.getRole()); String refreshToken = jwtTool.createRefreshToken(user); return new SuccessSignInDto(user.getId(), accessToken, refreshToken, user.getName(), true); } + /** + * Blocks user by email. Sets user status to {@link UserStatus#BLOCKED}, saves + * user and logs info about blocking. Then sends email with link to unblock and + * restore password page and throws {@link UserBlockedException} with message + * that contains time for which account is blocked. + * + * @param email email of user to be blocked + * @throws UserBlockedException if user is blocked + * @throws NotFoundException if user with given email is not found + */ + private void blockUserByEmail(String email) { + User user = userRepo.findByEmail(email) + .orElseThrow(() -> new NotFoundException(ErrorMessage.USER_NOT_FOUND_BY_EMAIL)); + + user.setUserStatus(UserStatus.BLOCKED); + userRepo.save(user); + log.info("User with email {} is blocked", user.getEmail()); + + emailService.sendBlockAccountNotificationWithUnblockLinkEmail( + user.getId(), user.getName(), user.getEmail(), + jwtTool.generateUnblockToken(email), getLanguageFromUser(user), false); + + throw new UserBlockedException(ErrorMessage.BRUTEFORCE_PROTECTION_MESSAGE); + } + + /** + * Calls CloudFlare api to check if given captcha is valid. + * + * @param dto - {@link OwnSignInDto} that contains captcha token + * @return {@link CloudFlareResponse} with result of captcha validation + */ + private CloudFlareResponse getCloudFlareResponse(OwnSignInDto dto) { + return cloudFlareClient.getCloudFlareResponse(CloudFlareRequest.builder() + .secret(cloudFlareSecretKey) + .response(dto.getCaptchaToken()) + .build()); + } + + /** + * Gets user language from user object. If user language code is "1", method + * returns "ua", otherwise - "en". + * + * @param user user to get language from + * @return "ua" or "en" depending on user language code + */ + private String getLanguageFromUser(User user) { + return user.getLanguage().getCode().equals("1") ? "ua" : "en"; + } + private boolean isPasswordCorrect(OwnSignInDto signInDto, UserVO user) { if (user.getOwnSecurity() == null) { return false; @@ -294,6 +435,25 @@ public void deleteUserByEmail(String email) { userRepo.save(user); } + /** + * {@inheritDoc} + */ + @Transactional + @Override + public void unblockAccount(String token) { + String email; + try { + email = jwtTool.getEmailOutOfAccessToken(token); + } catch (IllegalArgumentException e) { + throw new BadRequestException(ErrorMessage.TOKEN_FOR_RESTORE_IS_INVALID); + } + User user = userRepo.findByEmail(email) + .orElseThrow(() -> new NotFoundException(ErrorMessage.USER_NOT_FOUND_BY_EMAIL)); + user.setUserStatus(UserStatus.ACTIVATED); + userRepo.save(user); + log.info("User {} unblocked", user.getEmail()); + } + private User managementCreateNewRegisteredUser(UserManagementDto dto, String refreshTokenKey) { return User.builder() .name(dto.getName()) diff --git a/service/src/main/java/greencity/service/EmailServiceImpl.java b/service/src/main/java/greencity/service/EmailServiceImpl.java index 1fe5fd22d..295bf06b2 100644 --- a/service/src/main/java/greencity/service/EmailServiceImpl.java +++ b/service/src/main/java/greencity/service/EmailServiceImpl.java @@ -34,6 +34,7 @@ @Slf4j @Service public class EmailServiceImpl implements EmailService { + private static final String UNBLOCK_ACCOUNT_URL = "/auth/unblock?token="; private final JavaMailSender javaMailSender; private final ITemplateEngine templateEngine; private final Executor executor; @@ -282,6 +283,27 @@ public void sendScheduledNotificationEmail(ScheduledEmailMessage message) { sendEmail(message.getEmail(), message.getSubject(), template); } + @Override + public void sendBlockAccountNotificationWithUnblockLinkEmail(Long userId, String userFistName, + String userEmail, String token, String language, boolean isUbs) { + Map modelForRestorePassword = buildModelForUnblockAccount(userFistName, token, language, isUbs); + + String template = createEmailTemplate(modelForRestorePassword, EmailConstants.BLOCKED_USER_PAGE); + + sendEmail(userEmail, messageSource.getMessage(EmailConstants.BLOCKED_USER, null, getLocale(language)), + template); + } + + private Map buildModelForUnblockAccount(String name, String token, String language, boolean isUbs) { + Map model = new HashMap<>(); + model.put(EmailConstants.CLIENT_LINK, getClientLinkByIsUbs(isUbs)); + model.put(EmailConstants.USER_NAME, name); + model.put(EmailConstants.IS_UBS, isUbs); + model.put(EmailConstants.LANGUAGE, language); + model.put(EmailConstants.UNLOCK_USER_LINK, getClientLinkByIsUbs(true) + UNBLOCK_ACCOUNT_URL + token); + return model; + } + private Map buildModelMapForPasswordRestore(Long userId, String name, String token, String language, boolean isUbs) { Map model = new HashMap<>(); diff --git a/service/src/test/java/greencity/security/service/LoginAttemptServiceImplTest.java b/service/src/test/java/greencity/security/service/LoginAttemptServiceImplTest.java new file mode 100644 index 000000000..66c0d392b --- /dev/null +++ b/service/src/test/java/greencity/security/service/LoginAttemptServiceImplTest.java @@ -0,0 +1,134 @@ +package greencity.security.service; + +import com.google.common.cache.LoadingCache; +import java.lang.reflect.Field; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutionException; +import java.util.function.BiFunction; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LoginAttemptServiceImplTest { + @Mock + private LoadingCache attemptsByCaptchaCache; + @Mock + private LoadingCache attemptsByWrongPasswordCache; + private LoginAttemptServiceImpl loginAttemptService; + + @BeforeEach + void setUp() throws IllegalAccessException, NoSuchFieldException { + MockitoAnnotations.openMocks(this); + + ConcurrentMap mockMap = Mockito.mock(ConcurrentMap.class); + when(attemptsByCaptchaCache.asMap()).thenReturn(mockMap); + when(attemptsByWrongPasswordCache.asMap()).thenReturn(mockMap); + + loginAttemptService = new LoginAttemptServiceImpl(1, 15); + + Field byCaptchaCache = LoginAttemptServiceImpl.class + .getDeclaredField("attemptsByCaptchaCache"); + byCaptchaCache.setAccessible(true); + byCaptchaCache.set(this.loginAttemptService, this.attemptsByCaptchaCache); + + Field byWrongPasswordCache = LoginAttemptServiceImpl.class + .getDeclaredField("attemptsByWrongPasswordCache"); + byWrongPasswordCache.setAccessible(true); + byWrongPasswordCache.set(this.loginAttemptService, this.attemptsByWrongPasswordCache); + + Field maxAttemptField = LoginAttemptServiceImpl.class.getDeclaredField("maxAttempt"); + maxAttemptField.setAccessible(true); + maxAttemptField.set(this.loginAttemptService, 5); + } + + @Test + void testLoginFailedByCaptcha() throws ExecutionException { + when(attemptsByCaptchaCache.get(anyString())).thenReturn(0); + + loginAttemptService.loginFailedByCaptcha("test@mail.com"); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(BiFunction.class); + + verify(attemptsByCaptchaCache.asMap(), Mockito.times(1)).merge(eq("test@mail.com"), + eq(1), captor.capture()); + + BiFunction capturedFunction = captor.getValue(); + Integer result = capturedFunction.apply(0, 1); + assertEquals(1, result); + } + + @Test + void testLoginFailedByPassword() throws ExecutionException { + when(attemptsByWrongPasswordCache.get(anyString())).thenReturn(0); + + loginAttemptService.loginFailedByWrongPassword("test@mail.com"); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(BiFunction.class); + + verify(attemptsByWrongPasswordCache.asMap(), Mockito.times(1)).merge(eq("test@mail.com"), + eq(1), captor.capture()); + + BiFunction capturedFunction = captor.getValue(); + Integer result = capturedFunction.apply(0, 1); + assertEquals(1, result); + } + + @Test + void testIsBlockedNotBlockedByCaptcha() throws ExecutionException { + when(attemptsByCaptchaCache.get(anyString())).thenReturn(1); + + assertFalse(loginAttemptService.isBlockedByCaptcha(anyString())); + } + + @Test + void testIsBlockedAreBlockedByCaptcha() throws ExecutionException { + when(attemptsByCaptchaCache.get(anyString())).thenReturn(5); + + assertTrue(loginAttemptService.isBlockedByCaptcha(anyString())); + } + + @Test + void testIsBlockedByCaptchaExecutionException() throws ExecutionException { + when(attemptsByCaptchaCache.get(anyString())) + .thenThrow(new ExecutionException(new Throwable("Cache error"))); + + assertFalse(loginAttemptService.isBlockedByCaptcha("test@test.com")); + + verify(attemptsByCaptchaCache).get("test@test.com"); + } + + @Test + void testIsBlockedNotBlockedByPassword() throws ExecutionException { + when(attemptsByWrongPasswordCache.get(anyString())).thenReturn(1); + + assertFalse(loginAttemptService.isBlockedByWrongPassword(anyString())); + } + + @Test + void testIsBlockedAreBlockedByPassword() throws ExecutionException { + when(attemptsByWrongPasswordCache.get(anyString())).thenReturn(5); + + assertTrue(loginAttemptService.isBlockedByWrongPassword(anyString())); + } + + @Test + void testIsBlockedByPasswordExecutionException() throws ExecutionException { + when(attemptsByWrongPasswordCache.get(anyString())) + .thenThrow(new ExecutionException(new Throwable("Cache error"))); + + assertFalse(loginAttemptService.isBlockedByWrongPassword("test@test.com")); + + verify(attemptsByWrongPasswordCache).get("test@test.com"); + } +} diff --git a/service/src/test/java/greencity/security/service/OwnSecurityServiceImplTest.java b/service/src/test/java/greencity/security/service/OwnSecurityServiceImplTest.java index 903591a11..c2d442903 100644 --- a/service/src/test/java/greencity/security/service/OwnSecurityServiceImplTest.java +++ b/service/src/test/java/greencity/security/service/OwnSecurityServiceImplTest.java @@ -2,9 +2,12 @@ import greencity.ModelUtils; import greencity.TestConst; +import greencity.client.CloudFlareClient; import greencity.constant.ErrorMessage; import greencity.dto.achievement.AchievementVO; import greencity.dto.ownsecurity.OwnSecurityVO; +import greencity.dto.security.CloudFlareRequest; +import greencity.dto.security.CloudFlareResponse; import greencity.dto.user.UserAdminRegistrationDto; import greencity.dto.user.UserManagementDto; import greencity.dto.user.UserVO; @@ -76,6 +79,12 @@ class OwnSecurityServiceImplTest { @Mock AuthorityRepo authorityRepo; + @Mock + LoginAttemptService loginAttemptService; + + @Mock + CloudFlareClient cloudFlareClient; + private OwnSecurityService ownSecurityService; private UserVO verifiedUser; @@ -83,11 +92,13 @@ class OwnSecurityServiceImplTest { private UserVO notVerifiedUser; private UpdatePasswordDto updatePasswordDto; private UserManagementDto userManagementDto; + private User userForBruteForceTest; @BeforeEach public void init() { ownSecurityService = new OwnSecurityServiceImpl(ownSecurityRepo, positionRepo, userService, passwordEncoder, - jwtTool, restorePasswordEmailRepo, modelMapper, userRepo, emailService, authorityRepo); + jwtTool, restorePasswordEmailRepo, modelMapper, userRepo, emailService, authorityRepo, + loginAttemptService, cloudFlareClient); ReflectionTestUtils.setField(ownSecurityService, "expirationTime", 1); @@ -120,6 +131,16 @@ public void init() { .role(Role.ROLE_USER) .userStatus(UserStatus.BLOCKED) .build(); + userForBruteForceTest = User.builder() + .id(1L) + .email("test@somemail.com") + .name("Test") + .language(Language.builder() + .id(1L) + .code("en") + .build()) + .userStatus(UserStatus.ACTIVATED) + .build(); } @Test @@ -275,6 +296,11 @@ void signUpThrowsUserAlreadyRegisteredExceptionTest() { @Test void signIn() { when(userService.findByEmail(anyString())).thenReturn(verifiedUser); + when(loginAttemptService.isBlockedByCaptcha(anyString())).thenReturn(false); + when(loginAttemptService.isBlockedByWrongPassword(anyString())).thenReturn(false); + when(cloudFlareClient.getCloudFlareResponse(any(CloudFlareRequest.class))) + .thenReturn(new CloudFlareResponse(true, null, null, null)); + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(true); when(jwtTool.createAccessToken(anyString(), any(Role.class))).thenReturn("new-access-token"); when(jwtTool.createRefreshToken(any(UserVO.class))).thenReturn("new-refresh-token"); @@ -285,6 +311,9 @@ void signIn() { verify(passwordEncoder, times(1)).matches(anyString(), anyString()); verify(jwtTool, times(1)).createAccessToken(anyString(), any(Role.class)); verify(jwtTool, times(1)).createRefreshToken(any(UserVO.class)); + verify(loginAttemptService, times(1)).isBlockedByCaptcha(anyString()); + verify(loginAttemptService, times(1)).isBlockedByWrongPassword(anyString()); + verify(cloudFlareClient, times(1)).getCloudFlareResponse(any(CloudFlareRequest.class)); } @Test @@ -293,6 +322,11 @@ void signInNotVerifiedUser() { when(passwordEncoder.matches(anyString(), anyString())).thenReturn(true); when(jwtTool.createAccessToken(anyString(), any(Role.class))).thenReturn("new-access-token"); when(jwtTool.createRefreshToken(any(UserVO.class))).thenReturn("new-refresh-token"); + when(loginAttemptService.isBlockedByCaptcha(anyString())).thenReturn(false); + when(loginAttemptService.isBlockedByWrongPassword(anyString())).thenReturn(false); + when(cloudFlareClient.getCloudFlareResponse(any(CloudFlareRequest.class))) + .thenReturn(new CloudFlareResponse(true, null, null, null)); + assertThrows(EmailNotVerified.class, () -> ownSecurityService.signIn(ownSignInDto)); } @@ -313,6 +347,11 @@ void signInWrongPasswordTest() { .role(Role.ROLE_USER) .build(); when(userService.findByEmail("test@gmail.com")).thenReturn(user); + when(loginAttemptService.isBlockedByCaptcha(anyString())).thenReturn(false); + when(loginAttemptService.isBlockedByWrongPassword(anyString())).thenReturn(false); + when(cloudFlareClient.getCloudFlareResponse(any(CloudFlareRequest.class))) + .thenReturn(new CloudFlareResponse(true, null, null, null)); + assertThrows(WrongPasswordException.class, () -> ownSecurityService.signIn(ownSignInDto)); } @@ -615,4 +654,60 @@ void deleteUserByEmailNotVerifiedUserTest() { verify(userRepo, times(1)).findByEmail(newNotVerifiedUser.getEmail()); } + + @Test + void singInBlockedUser() { + when(userService.findByEmail(anyString())).thenReturn(verifiedUser); + when(loginAttemptService.isBlockedByCaptcha(anyString())).thenReturn(true); + when(userRepo.findByEmail(anyString())) + .thenReturn(Optional.ofNullable(userForBruteForceTest)); + + assertThrows(UserBlockedException.class, + () -> ownSecurityService.signIn(ownSignInDto)); + } + + @Test + void singInBlockedUserByPassword() { + when(userService.findByEmail(anyString())).thenReturn(verifiedUser); + when(loginAttemptService.isBlockedByCaptcha(anyString())).thenReturn(false); + when(loginAttemptService.isBlockedByWrongPassword(anyString())).thenReturn(true); + when(userRepo.findByEmail(anyString())) + .thenReturn(Optional.ofNullable(userForBruteForceTest)); + + assertThrows(WrongPasswordException.class, + () -> ownSecurityService.signIn(ownSignInDto)); + } + + @Test + void throwExceptionWhenCaptchaIsNotValid() { + when(userService.findByEmail(anyString())).thenReturn(verifiedUser); + when(loginAttemptService.isBlockedByCaptcha(anyString())).thenReturn(false); + when(loginAttemptService.isBlockedByWrongPassword(anyString())).thenReturn(false); + when(userRepo.findByEmail(anyString())) + .thenReturn(Optional.ofNullable(userForBruteForceTest)); + when(cloudFlareClient.getCloudFlareResponse(any(CloudFlareRequest.class))) + .thenReturn(new CloudFlareResponse(false, null, null, null)); + + assertThrows(WrongCaptchaException.class, + () -> ownSecurityService.signIn(ownSignInDto)); + } + + @Test + void unblockUserTest() { + when(jwtTool.getEmailOutOfAccessToken(anyString())).thenReturn("test@mail.com"); + when(userRepo.findByEmail(anyString())).thenReturn(Optional.of(userForBruteForceTest)); + + ownSecurityService.unblockAccount("test@mail.com"); + + verify(jwtTool, times(1)).getEmailOutOfAccessToken(anyString()); + verify(userRepo, times(1)).findByEmail(anyString()); + verify(userRepo, times(1)).save(userForBruteForceTest); + } + + @Test + void unblockUserWithInvalidToken() { + when(jwtTool.getEmailOutOfAccessToken(anyString())).thenThrow(IllegalArgumentException.class); + + assertThrows(BadRequestException.class, () -> ownSecurityService.unblockAccount("test@mail.com")); + } } diff --git a/service/src/test/java/greencity/service/EmailServiceImplTest.java b/service/src/test/java/greencity/service/EmailServiceImplTest.java index 1d486434f..002fe6c54 100644 --- a/service/src/test/java/greencity/service/EmailServiceImplTest.java +++ b/service/src/test/java/greencity/service/EmailServiceImplTest.java @@ -227,6 +227,20 @@ void sendCreateNewPasswordForEmployee(Long id, String name, String email, String verify(javaMailSender).createMimeMessage(); } + @ParameterizedTest + @CsvSource(value = {"1, Test, test@gmail.com, token, ua, false", + "1, Test, test@gmail.com, token, en, true"}) + void sendBlockAccountNotificationWithUnblockLinkEmailTest(Long id, String name, String email, + String token, String language, + Boolean isUbs) { + when(messageSource.getMessage(EmailConstants.BLOCKED_USER, null, getLocale(language))) + .thenReturn("Your account is blocked"); + + service.sendBlockAccountNotificationWithUnblockLinkEmail(id, name, email, token, language, isUbs); + + verify(javaMailSender).createMimeMessage(); + } + private static Locale getLocale(String language) { return switch (language) { case "ua" -> UA_LOCALE;