diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/config/security/AuthConfigProperties.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/config/security/AuthConfigProperties.kt index 89aeae6c..228aba48 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/config/security/AuthConfigProperties.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/config/security/AuthConfigProperties.kt @@ -9,5 +9,6 @@ data class AuthConfigProperties( val publicKey: RSAPublicKey, val privateKey: RSAPrivateKey, val jwtAccessExpirationMinutes: Long, - val jwtRefreshExpirationDays: Long + val jwtRefreshExpirationDays: Long, + val jwtRecoveryExpirationMinutes: Long ) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt index e0882b7d..aea8ebe8 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt @@ -1,12 +1,17 @@ package pt.up.fe.ni.website.backend.controller +import jakarta.validation.Valid +import org.springframework.beans.factory.annotation.Value import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import pt.up.fe.ni.website.backend.dto.auth.LoginDto +import pt.up.fe.ni.website.backend.dto.auth.PasswordRecoveryConfirmDto +import pt.up.fe.ni.website.backend.dto.auth.PasswordRecoveryRequestDto import pt.up.fe.ni.website.backend.dto.auth.TokenDto import pt.up.fe.ni.website.backend.model.Account import pt.up.fe.ni.website.backend.service.AuthService @@ -14,6 +19,9 @@ import pt.up.fe.ni.website.backend.service.AuthService @RestController @RequestMapping("/auth") class AuthController(val authService: AuthService) { + @field:Value("\${page.recover-password}") + private lateinit var recoverPasswordPage: String + @PostMapping("/new") fun getNewToken(@RequestBody loginDto: LoginDto): Map { val account = authService.authenticate(loginDto.email, loginDto.password) @@ -28,6 +36,21 @@ class AuthController(val authService: AuthService) { return mapOf("access_token" to accessToken) } + @PostMapping("/password/recovery") + fun generateRecoveryToken(@RequestBody recoveryRequestDto: PasswordRecoveryRequestDto): Map { + val recoveryToken = authService.generateRecoveryToken(recoveryRequestDto.email) + ?: return emptyMap() + // TODO: Change to email service + return mapOf("recovery_url" to "$recoverPasswordPage/$recoveryToken/confirm") + } + + @PostMapping("/password/recovery/{token}/confirm") + fun confirmRecoveryToken( + @Valid @RequestBody + dto: PasswordRecoveryConfirmDto, + @PathVariable token: String + ) = authService.confirmRecoveryToken(token, dto) + @GetMapping @PreAuthorize("hasRole('MEMBER')") fun checkAuthentication(): Map { diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt index c5099fd3..174f32b7 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt @@ -10,6 +10,8 @@ import org.springframework.http.HttpStatus import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.security.access.AccessDeniedException import org.springframework.security.core.AuthenticationException +import org.springframework.security.oauth2.jwt.BadJwtException +import org.springframework.security.oauth2.jwt.JwtValidationException import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RequestMapping @@ -19,6 +21,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.multipart.MaxUploadSizeExceededException import org.springframework.web.multipart.support.MissingServletRequestPartException import pt.up.fe.ni.website.backend.config.Logging +import pt.up.fe.ni.website.backend.service.ErrorMessages data class SimpleError( val message: String, @@ -141,6 +144,22 @@ class ErrorController(private val objectMapper: ObjectMapper) : ErrorController, return wrapSimpleError(e.message ?: "invalid authentication") } + @ExceptionHandler(JwtValidationException::class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + fun invalidAuthentication(e: JwtValidationException): CustomError { + return if (e.message?.contains("expired") == true) { + wrapSimpleError(ErrorMessages.expiredToken) + } else { + wrapSimpleError(ErrorMessages.invalidToken) + } + } + + @ExceptionHandler(BadJwtException::class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + fun invalidAuthentication(e: BadJwtException): CustomError { + return wrapSimpleError(ErrorMessages.invalidToken) + } + fun wrapSimpleError(msg: String, param: String? = null, value: Any? = null) = CustomError( mutableListOf(SimpleError(msg, param, value)) ) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/auth/ChangePasswordDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/auth/ChangePasswordDto.kt index 706e8c0f..f7da3103 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/auth/ChangePasswordDto.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/auth/ChangePasswordDto.kt @@ -1,6 +1,11 @@ package pt.up.fe.ni.website.backend.dto.auth +import jakarta.validation.constraints.Size +import pt.up.fe.ni.website.backend.model.constants.AccountConstants as Constants + data class ChangePasswordDto( val oldPassword: String, + + @field:Size(min = Constants.Password.minSize, max = Constants.Password.maxSize) val newPassword: String ) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/auth/PasswordRecoveryDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/auth/PasswordRecoveryDto.kt new file mode 100644 index 00000000..a70352b9 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/auth/PasswordRecoveryDto.kt @@ -0,0 +1,13 @@ +package pt.up.fe.ni.website.backend.dto.auth + +import jakarta.validation.constraints.Size +import pt.up.fe.ni.website.backend.model.constants.AccountConstants as Constants + +data class PasswordRecoveryConfirmDto( + @field:Size(min = Constants.Password.minSize, max = Constants.Password.maxSize) + val password: String +) + +data class PasswordRecoveryRequestDto( + val email: String +) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AccountService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AccountService.kt index 6cbb525f..509c2bd6 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AccountService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AccountService.kt @@ -58,6 +58,8 @@ class AccountService( return repository.save(newAccount) } + fun updateAccount(account: Account): Account = repository.save(account) + fun getAccountByEmail(email: String): Account = repository.findByEmail(email) ?: throw NoSuchElementException(ErrorMessages.emailNotFound(email)) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt index f11ac5a6..dfef1623 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt @@ -15,6 +15,7 @@ import org.springframework.security.oauth2.jwt.JwtEncoderParameters import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException import org.springframework.stereotype.Service import pt.up.fe.ni.website.backend.config.auth.AuthConfigProperties +import pt.up.fe.ni.website.backend.dto.auth.PasswordRecoveryConfirmDto import pt.up.fe.ni.website.backend.model.Account @Service @@ -44,39 +45,69 @@ class AuthService( } fun refreshAccessToken(refreshToken: String): String { - val jwt = - try { - jwtDecoder.decode(refreshToken) - } catch (e: Exception) { - throw InvalidBearerTokenException(ErrorMessages.invalidRefreshToken) - } - if (jwt.expiresAt?.isBefore(Instant.now()) != false) { - throw InvalidBearerTokenException(ErrorMessages.expiredRefreshToken) - } + val jwt = jwtDecoder.decode(refreshToken) val account = accountService.getAccountByEmail(jwt.subject) return generateAccessToken(account) } + fun generateRecoveryToken(email: String): String? { + val account = try { + accountService.getAccountByEmail(email) + } catch (e: Exception) { + return null + } + return generateToken( + account, + Duration.ofMinutes(authConfigProperties.jwtRecoveryExpirationMinutes), + usePasswordHash = true + ) + } + + fun confirmRecoveryToken(recoveryToken: String, dto: PasswordRecoveryConfirmDto): Account { + val jwt = jwtDecoder.decode(recoveryToken) + val account = accountService.getAccountByEmail(jwt.subject) + + val tokenPasswordHash = jwt.getClaim("passwordHash") + ?: throw InvalidBearerTokenException(ErrorMessages.invalidToken) + + if (account.password != tokenPasswordHash) { + throw InvalidBearerTokenException(ErrorMessages.invalidToken) + } + + account.password = passwordEncoder.encode(dto.password) + return accountService.updateAccount(account) + } + fun getAuthenticatedAccount(): Account { val authentication = SecurityContextHolder.getContext().authentication return accountService.getAccountByEmail(authentication.name) } - private fun generateToken(account: Account, expiration: Duration, isRefresh: Boolean = false): String { + private fun generateToken( + account: Account, + expiration: Duration, + isRefresh: Boolean = false, + usePasswordHash: Boolean = false + ): String { val roles = if (isRefresh) emptyList() else getAuthorities() // TODO: Pass account to getAuthorities() val scope = roles .stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.joining(" ")) val currentInstant = Instant.now() - val claims = JwtClaimsSet + val claimsBuilder = JwtClaimsSet .builder() .issuer("self") .issuedAt(currentInstant) .expiresAt(currentInstant.plus(expiration)) .subject(account.email) .claim("scope", scope) - .build() + + if (usePasswordHash) { + claimsBuilder.claim("passwordHash", account.password) + } + + val claims = claimsBuilder.build() return jwtEncoder.encode(JwtEncoderParameters.from(claims)).tokenValue } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/ErrorMessages.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/ErrorMessages.kt index ca43d82c..aee48fbb 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/ErrorMessages.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/ErrorMessages.kt @@ -7,9 +7,9 @@ object ErrorMessages { const val invalidCredentials = "invalid credentials" - const val invalidRefreshToken = "invalid refresh token" + const val invalidToken = "invalid token" - const val expiredRefreshToken = "refresh token has expired" + const val expiredToken = "token has expired" const val noGenerations = "no generations created yet" diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 449647da..f95e9527 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -25,6 +25,7 @@ auth.private-key=classpath:certs/private.pem auth.public-key=classpath:certs/public.pem auth.jwt-access-expiration-minutes=60 auth.jwt-refresh-expiration-days=7 +auth.jwt-recovery-expiration-minutes=15 # File Upload Config spring.servlet.multipart.max-file-size=500KB @@ -36,5 +37,9 @@ upload.static-path=classpath:static # URL that will serve static content upload.static-serve=http://localhost:3000/static +# Recover password page +# (for now it's the backend endpoint as we don't have the front end page yet) +page.recover-password=http://localhost:8080/auth/password/recovery + # Cors Origin cors.allow-origin = http://localhost:3000 diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AuthControllerTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AuthControllerTest.kt index e046549f..8111e238 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AuthControllerTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AuthControllerTest.kt @@ -1,43 +1,64 @@ package pt.up.fe.ni.website.backend.controller import com.epages.restdocs.apispec.HeaderDescriptorWithType +import com.epages.restdocs.apispec.ResourceDocumentation import com.epages.restdocs.apispec.ResourceDocumentation.headerWithName import com.fasterxml.jackson.databind.ObjectMapper +import java.time.Instant +import java.time.temporal.ChronoUnit import java.util.Calendar import org.hamcrest.Matchers.startsWith +import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post +import org.springframework.restdocs.payload.JsonFieldType import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.oauth2.jwt.JwtClaimsSet +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.security.oauth2.jwt.JwtEncoder +import org.springframework.security.oauth2.jwt.JwtEncoderParameters import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import pt.up.fe.ni.website.backend.dto.auth.LoginDto import pt.up.fe.ni.website.backend.dto.auth.TokenDto import pt.up.fe.ni.website.backend.model.Account import pt.up.fe.ni.website.backend.model.CustomWebsite +import pt.up.fe.ni.website.backend.model.constants.AccountConstants import pt.up.fe.ni.website.backend.repository.AccountRepository import pt.up.fe.ni.website.backend.utils.TestUtils +import pt.up.fe.ni.website.backend.utils.ValidationTester import pt.up.fe.ni.website.backend.utils.annotations.ControllerTest import pt.up.fe.ni.website.backend.utils.annotations.NestedTest +import pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model.PayloadAccount import pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model.PayloadAuthCheck import pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model.PayloadAuthNew import pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model.PayloadAuthRefresh +import pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model.PayloadRecoverPassword +import pt.up.fe.ni.website.backend.utils.documentation.utils.DocumentedJSONField import pt.up.fe.ni.website.backend.utils.documentation.utils.MockMVCExtension.Companion.andDocument +import pt.up.fe.ni.website.backend.utils.documentation.utils.MockMVCExtension.Companion.andDocumentCustomRequestSchema +import pt.up.fe.ni.website.backend.utils.documentation.utils.MockMVCExtension.Companion.andDocumentCustomRequestSchemaErrorResponse import pt.up.fe.ni.website.backend.utils.documentation.utils.MockMVCExtension.Companion.andDocumentErrorResponse import pt.up.fe.ni.website.backend.utils.documentation.utils.ModelDocumentation +import pt.up.fe.ni.website.backend.utils.documentation.utils.PayloadSchema @ControllerTest class AuthControllerTest @Autowired constructor( val repository: AccountRepository, val mockMvc: MockMvc, val objectMapper: ObjectMapper, + val jwtEncoder: JwtEncoder, + val jwtDecoder: JwtDecoder, passwordEncoder: PasswordEncoder ) { final val testPassword = "testPassword" @@ -148,7 +169,7 @@ class AuthControllerTest @Autowired constructor( ) .andExpectAll( status().isUnauthorized, - jsonPath("$.errors[0].message").value("invalid refresh token") + jsonPath("$.errors[0].message").value("invalid token") ) .andDocumentErrorResponse(documentation, hasRequestPayload = true) } @@ -180,6 +201,367 @@ class AuthControllerTest @Autowired constructor( } } + @NestedTest + @DisplayName("POST /auth/password/recovery") + inner class RecoverPasswordRequest { + var documentation: ModelDocumentation = PayloadRecoverPassword() + + @BeforeEach + fun setup() { + repository.save(testAccount) + } + + @Test + fun `should return empty if email is not found`() { + mockMvc.perform( + post("/auth/password/recovery") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("email" to "dont@exist.com"))) + ) + .andExpectAll( + status().isOk(), + jsonPath("$.recovery_url").doesNotExist() + ) + .andDocument( + documentation, + "Recover password", + "This endpoint operation allows the recovery of the password of an account, " + + "sending an email with a link to reset the password.", + documentRequestPayload = true + ) + } + + @Test + fun `should return password recovery link`() { + mockMvc.perform( + post("/auth/password/recovery") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("email" to testAccount.email))) + ) + .andExpectAll( + status().isOk(), + jsonPath("$.recovery_url").exists() + ).andDocument( + documentation, + "Recover password", + "This endpoint operation allows the recovery of the password of an account, " + + "sending an email with a link to reset the password.", + documentRequestPayload = true + ) + } + } + + @NestedTest + @DisplayName("POST /auth/password/recovery/{token}/confirm") + inner class RecoverPasswordConfirm { + @field:Value("\${page.recover-password}") + private lateinit var recoverPasswordPage: String + + private val newPassword = "new-password" + + private val parameters = listOf( + ResourceDocumentation.parameterWithName("token").description("The recovery token sent to the user's email.") + ) + + private val passwordRecoveryPayload = PayloadSchema( + "password-recover", + mutableListOf( + DocumentedJSONField("password", "The new password.", JsonFieldType.STRING) + ) + ) + + private val documentation: ModelDocumentation = PayloadAccount() + + @BeforeEach + fun setup() { + repository.save(testAccount) + } + + @Test + fun `should update the password`() { + mockMvc.perform( + post("/auth/password/recovery") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "email" to testAccount.email + ) + ) + ) + ).andReturn().response.let { authResponse -> + val token = objectMapper.readTree(authResponse.contentAsString)["recovery_url"].asText() + .removePrefix("$recoverPasswordPage/") + .removeSuffix("/confirm") + mockMvc.perform( + post("/auth/password/recovery/{token}/confirm", token) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "password" to newPassword + ) + ) + ) + ).andExpectAll( + status().isOk(), + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.id").value(testAccount.id) + ).andDocumentCustomRequestSchema( + documentation, + passwordRecoveryPayload, + "Recover password", + "Update the password of an account using a recovery token.", + urlParameters = parameters, + documentRequestPayload = true + ) + } + + mockMvc.post("/auth/new") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString( + mapOf( + "email" to testAccount.email, + "password" to newPassword + ) + ) + }.andExpect { status { isOk() } } + } + + @Test + fun `should fail when token is invalid`() { + mockMvc.perform( + post("/auth/password/recovery/{token}/confirm", "invalid-token") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "password" to newPassword + ) + ) + ) + ).andExpectAll( + status().isUnauthorized(), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("invalid token") + ).andDocumentCustomRequestSchemaErrorResponse( + documentation, + passwordRecoveryPayload, + "Recover password", + "Update the password of an account using a recovery token.", + urlParameters = parameters, + documentRequestPayload = true + ) + + mockMvc.post("/auth/new") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString( + mapOf( + "email" to testAccount.email, + "password" to newPassword + ) + ) + }.andExpect { status { isUnauthorized() } } + } + + @Test + fun `should fail when token is expired`() { + mockMvc.perform( + post("/auth/password/recovery") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "email" to testAccount.email + ) + ) + ) + ) + .andReturn().response.let { authResponse -> + val token = objectMapper.readTree(authResponse.contentAsString)["recovery_url"].asText() + .removePrefix("$recoverPasswordPage/") + .removeSuffix("/confirm") + + val decoded = jwtDecoder.decode(token) + val newClaims = mutableMapOf() + newClaims.putAll(decoded.claims) + + val claimsBuilder = JwtClaimsSet + .builder() + .issuer("self") + .issuedAt(Instant.now().minus(2, ChronoUnit.DAYS)) + .expiresAt(Instant.now().minus(1, ChronoUnit.DAYS)) + .subject(decoded.subject) + .claim("scope", decoded.claims["scope"]) + + val newToken = jwtEncoder.encode(JwtEncoderParameters.from(claimsBuilder.build())).tokenValue + + mockMvc.perform( + post("/auth/password/recovery/{token}/confirm", newToken) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "password" to newPassword + ) + ) + ) + ).andExpectAll( + status().isUnauthorized(), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("token has expired") + ) + } + } + + @Test + fun `should fail when password hash claim is missing`() { + mockMvc.perform( + post("/auth/password/recovery") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "email" to testAccount.email + ) + ) + ) + ) + .andReturn().response.let { authResponse -> + val token = objectMapper.readTree(authResponse.contentAsString)["recovery_url"].asText() + .removePrefix("$recoverPasswordPage/") + .removeSuffix("/confirm") + + val decoded = jwtDecoder.decode(token) + val newClaims = mutableMapOf() + newClaims.putAll(decoded.claims) + + val claimsBuilder = JwtClaimsSet + .builder() + .issuer("self") + .issuedAt(decoded.issuedAt) + .expiresAt(decoded.expiresAt) + .subject(decoded.subject) + .claim("scope", decoded.claims["scope"]) + + val newToken = jwtEncoder.encode(JwtEncoderParameters.from(claimsBuilder.build())).tokenValue + + mockMvc.perform( + post("/auth/password/recovery/{token}/confirm", newToken) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "password" to newPassword + ) + ) + ) + ).andExpectAll( + status().isUnauthorized(), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("invalid token") + ) + } + } + + @Test + fun `should fail when using recovery token twice`() { + mockMvc.perform( + post("/auth/password/recovery") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "email" to testAccount.email + ) + ) + ) + ) + .andReturn().response.let { authResponse -> + val token = objectMapper.readTree(authResponse.contentAsString)["recovery_url"].asText() + .removePrefix("$recoverPasswordPage/") + .removeSuffix("/confirm") + mockMvc.perform( + post("/auth/password/recovery/{token}/confirm", token) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "password" to newPassword + ) + ) + ) + ).andExpectAll( + status().isOk(), + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.id").value(testAccount.id) + ) + mockMvc.perform( + post("/auth/password/recovery/{token}/confirm", token) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "password" to "not using password" + ) + ) + ) + ).andExpectAll( + status().isUnauthorized(), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("invalid token") + ).andDocumentCustomRequestSchemaErrorResponse( + documentation, + passwordRecoveryPayload, + "Recover password", + "Update the password of an account using a recovery token.", + urlParameters = parameters, + documentRequestPayload = true + ) + } + } + + @NestedTest + @DisplayName("Input Validation") + inner class InputValidation { + private val validationTester = ValidationTester( + req = { params: Map -> + mockMvc.perform( + post("/auth/password/recovery/random-token/confirm") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(params)) + ) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) + }, + requiredFields = mapOf( + "password" to "new-password" + ) + ) + + @NestedTest + @DisplayName("password") + inner class PasswordValidation { + @BeforeAll + fun setParam() { + validationTester.param = "password" + } + + @Test + fun `should be required`() = validationTester.isRequired() + + @Test + @DisplayName( + "size should be between ${AccountConstants.Password.minSize}" + + " and ${AccountConstants.Password.maxSize}()" + ) + fun size() = validationTester.hasSizeBetween( + AccountConstants.Password.minSize, + AccountConstants.Password.maxSize + ) + } + } + } + @NestedTest @DisplayName("GET /auth") inner class CheckToken { diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadAuth.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadAuth.kt index 21a0e7c8..510f0b4f 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadAuth.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadAuth.kt @@ -56,6 +56,26 @@ class PayloadAuthRefresh : ModelDocumentation( ) ) +class PayloadRecoverPassword : ModelDocumentation( + Tag.AUTH.name.lowercase() + "-recover", + Tag.AUTH, + mutableListOf( + DocumentedJSONField( + "email", + "Email of the account", + JsonFieldType.STRING, + isInResponse = false + ), + DocumentedJSONField( + "recovery_url", + "URL to recover the password", + JsonFieldType.STRING, + isInRequest = false, + optional = true // TODO change this when email service is implemented + ) + ) +) + class PayloadAuthCheck : ModelDocumentation( Tag.AUTH.name.lowercase() + "-check", Tag.AUTH,