Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/recover password #113

Open
wants to merge 13 commits into
base: develop
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import pt.up.fe.ni.website.backend.dto.auth.ChangePasswordDto
import pt.up.fe.ni.website.backend.dto.auth.PassRecoveryDto
import pt.up.fe.ni.website.backend.dto.entity.account.CreateAccountDto
import pt.up.fe.ni.website.backend.dto.entity.account.UpdateAccountDto
import pt.up.fe.ni.website.backend.model.Account
Expand All @@ -29,6 +30,10 @@ class AccountController(private val service: AccountService) {
@GetMapping("/{id}")
fun getAccountById(@PathVariable id: Long) = service.getAccountById(id)

@PutMapping("/recoverPassword/{recoveryToken}")
fun recoverPassword(@RequestBody dto: PassRecoveryDto, @PathVariable recoveryToken: String) =
service.recoverPassword(recoveryToken, dto)

@PostMapping("/changePassword/{id}")
fun changePassword(@PathVariable id: Long, @RequestBody dto: ChangePasswordDto): Map<String, String> {
service.changePassword(id, dto)
BrunoRosendo marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package pt.up.fe.ni.website.backend.controller

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
Expand All @@ -14,6 +16,9 @@ import pt.up.fe.ni.website.backend.service.AuthService
@RestController
@RequestMapping("/auth")
class AuthController(val authService: AuthService) {
@field:Value("\${backend.url}")
private lateinit var backendUrl: String

@PostMapping("/new")
fun getNewToken(@RequestBody loginDto: LoginDto): Map<String, String> {
val account = authService.authenticate(loginDto.email, loginDto.password)
Expand All @@ -28,6 +33,13 @@ class AuthController(val authService: AuthService) {
return mapOf("access_token" to accessToken)
}

@PostMapping("/recoverPassword/{id}")
fun generateRecoveryToken(@PathVariable id: Long): Map<String, String> {
BrunoRosendo marked this conversation as resolved.
Show resolved Hide resolved
val recoveryToken = authService.generateRecoveryToken(id)
BrunoRosendo marked this conversation as resolved.
Show resolved Hide resolved
// TODO: Change URL Later
return mapOf("recovery_url" to "$backendUrl/accounts/recoverPassword/$recoveryToken")
BrunoRosendo marked this conversation as resolved.
Show resolved Hide resolved
}

@GetMapping
@PreAuthorize("hasRole('MEMBER')")
fun checkAuthentication(): Map<String, Account> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package pt.up.fe.ni.website.backend.dto.auth

data class PassRecoveryDto(
jamcunha marked this conversation as resolved.
Show resolved Hide resolved
val password: String
jamcunha marked this conversation as resolved.
Show resolved Hide resolved
)
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package pt.up.fe.ni.website.backend.service

import java.time.Instant
import java.util.UUID
import org.springframework.data.repository.findByIdOrNull
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import pt.up.fe.ni.website.backend.dto.auth.ChangePasswordDto
import pt.up.fe.ni.website.backend.dto.auth.PassRecoveryDto
import pt.up.fe.ni.website.backend.dto.entity.account.CreateAccountDto
import pt.up.fe.ni.website.backend.dto.entity.account.UpdateAccountDto
import pt.up.fe.ni.website.backend.model.Account
Expand All @@ -17,6 +21,7 @@ import pt.up.fe.ni.website.backend.utils.extensions.filenameExtension
class AccountService(
private val repository: AccountRepository,
private val encoder: PasswordEncoder,
private val jwtDecoder: JwtDecoder,
private val fileUploader: FileUploader
) {
fun getAllAccounts(): List<Account> = repository.findAll().toList()
Expand Down Expand Up @@ -65,6 +70,22 @@ class AccountService(
fun getAccountByEmail(email: String): Account = repository.findByEmail(email)
?: throw NoSuchElementException(ErrorMessages.emailNotFound(email))

fun recoverPassword(recoveryToken: String, dto: PassRecoveryDto): Account {
val jwt =
try {
jwtDecoder.decode(recoveryToken)
} catch (e: Exception) {
throw InvalidBearerTokenException(ErrorMessages.invalidRecoveryToken)
BrunoRosendo marked this conversation as resolved.
Show resolved Hide resolved
}
if (jwt.expiresAt?.isBefore(Instant.now()) != false) {
throw InvalidBearerTokenException(ErrorMessages.expiredRecoveryToken)
}
val account = getAccountByEmail(jwt.subject)

account.password = encoder.encode(dto.password)
return repository.save(account)
}

fun changePassword(id: Long, dto: ChangePasswordDto) {
val account = getAccountById(id)
if (!encoder.matches(dto.oldPassword, account.password)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ class AuthService(
return generateAccessToken(account)
}

fun generateRecoveryToken(id: Long): String {
BrunoRosendo marked this conversation as resolved.
Show resolved Hide resolved
val account = accountService.getAccountById(id)
return generateToken(account, Duration.ofMinutes(authConfigProperties.jwtRecoveryExpirationMinutes))
}

fun getAuthenticatedAccount(): Account {
val authentication = SecurityContextHolder.getContext().authentication
return accountService.getAccountByEmail(authentication.name)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ object ErrorMessages {

const val expiredRefreshToken = "refresh token has expired"

const val invalidRecoveryToken = "invalid password recovery token"

const val expiredRecoveryToken = "password recovery token has expired"

const val noGenerations = "no generations created yet"

const val noGenerationsToInferYear = "no generations created yet, please specify school year"
Expand Down
4 changes: 4 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,5 +37,8 @@ upload.static-path=classpath:static
# URL that will serve static content
upload.static-serve=http://localhost:3000/static

# Backend URL
backend.url=http://localhost:8080

# Cors Origin
cors.allow-origin = http://localhost:3000
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,19 @@ import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.MediaType
import org.springframework.mock.web.MockPart
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put
import org.springframework.restdocs.payload.JsonFieldType
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.post
import org.springframework.test.web.servlet.put
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
Expand All @@ -38,6 +41,7 @@ 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.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.andDocumentCustomRequestSchemaEmptyResponse
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.andDocumentEmptyObjectResponse
Expand Down Expand Up @@ -901,6 +905,83 @@ class AccountControllerTest @Autowired constructor(
}
}

@NestedTest
@DisplayName("PUT /recoverPassword/{recoveryToken}")
inner class RecoverPassword {
@field:Value("\${backend.url}")
private lateinit var backendUrl: String

private val newPassword = "new-password"

private val parameters = listOf(
parameterWithName("recoveryToken").description("The recovery token sent to the user's email.")
)

private val passwordRecoveryPayload = PayloadSchema(
"password-recover",
mutableListOf(
DocumentedJSONField("password", "The new password.", JsonFieldType.STRING)
)
)

@BeforeEach
fun setup() {
repository.save(testAccount)
}

@Test
fun `should update the password`() {
jamcunha marked this conversation as resolved.
Show resolved Hide resolved
mockMvc.perform(post("/auth/recoverPassword/${testAccount.id}"))
.andReturn().response.let { authResponse ->
val recoveryToken = objectMapper.readTree(authResponse.contentAsString)["recovery_url"].asText()
.removePrefix("$backendUrl/accounts/recoverPassword/")
mockMvc.perform(
put("/accounts/recoverPassword/{recoveryToken}", recoveryToken)
.contentType(MediaType.APPLICATION_JSON)
.content(
objectMapper.writeValueAsString(
mapOf(
"password" to newPassword
)
)
)
).andExpectAll(
status().isOk()
).andDocumentCustomRequestSchema(
documentation,
passwordRecoveryPayload,
"Recover password",
"Update the password of an account using a recovery token.",
urlParameters = parameters,
documentRequestPayload = true
)
}
}
BrunoRosendo marked this conversation as resolved.
Show resolved Hide resolved

@Test
fun `should fail when token is invalid`() {
mockMvc.perform(
put("/accounts/recoverPassword/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 password recovery token")
).andDocumentCustomRequestSchemaErrorResponse(
documentation,
passwordRecoveryPayload,
hasRequestPayload = true
)
}
}

fun Date?.toJson(): String {
val quotedDate = objectMapper.writeValueAsString(this)
// objectMapper adds quotes to the date, so remove them
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import pt.up.fe.ni.website.backend.utils.annotations.NestedTest
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.MockMVCExtension.Companion.andDocument
import pt.up.fe.ni.website.backend.utils.documentation.utils.MockMVCExtension.Companion.andDocumentErrorResponse
import pt.up.fe.ni.website.backend.utils.documentation.utils.ModelDocumentation
Expand Down Expand Up @@ -180,6 +181,48 @@ class AuthControllerTest @Autowired constructor(
}
}

@NestedTest
@DisplayName("POST /auth/recoverPassword/{id}")
inner class RecoverPassword {
var documentation: ModelDocumentation = PayloadRecoverPassword()

@BeforeEach
fun setup() {
repository.save(testAccount)
}

@Test
fun `should fail if id is not found`() {
mockMvc.perform(post("/auth/recoverPassword/1234"))
.andExpectAll(
status().isNotFound(),
jsonPath("$.errors.length()").value(1),
jsonPath("$.errors[0].message").value("account not found with id 1234")
).andDocumentErrorResponse(
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 = false
)
}

@Test
fun `should return password recovery link`() {
mockMvc.perform(post("/auth/recoverPassword/${testAccount.id}"))
.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 = false
)
}
}

@NestedTest
@DisplayName("GET /auth")
inner class CheckToken {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,25 @@ class PayloadAuthRefresh : ModelDocumentation(
)
)

class PayloadRecoverPassword : ModelDocumentation(
Tag.AUTH.name.lowercase() + "-recover",
Tag.AUTH,
mutableListOf(
DocumentedJSONField(
"id",
"Id of the account",
JsonFieldType.NUMBER,
isInResponse = false
),
DocumentedJSONField(
"recovery_url",
"URL to recover the password",
JsonFieldType.STRING,
isInRequest = false
)
)
)

class PayloadAuthCheck : ModelDocumentation(
Tag.AUTH.name.lowercase() + "-check",
Tag.AUTH,
Expand Down