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
@@ -1,5 +1,6 @@
package pt.up.fe.ni.website.backend.controller

import jakarta.validation.Valid
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
Expand All @@ -13,6 +14,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.PasswordRecoveryDto
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,8 +31,20 @@ class AccountController(private val service: AccountService) {
@GetMapping("/{id}")
fun getAccountById(@PathVariable id: Long) = service.getAccountById(id)

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

@PostMapping("/changePassword/{id}")
fun changePassword(@PathVariable id: Long, @RequestBody dto: ChangePasswordDto): Map<String, String> {
fun changePassword(
@Valid @RequestBody
dto: ChangePasswordDto,
@PathVariable id: Long
): Map<String, String> {
BrunoRosendo marked this conversation as resolved.
Show resolved Hide resolved
service.changePassword(id, dto)
return emptyMap()
}
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("\${page.recover-password}")
private lateinit var recoverPasswordPage: String
BrunoRosendo marked this conversation as resolved.
Show resolved Hide resolved

BrunoRosendo marked this conversation as resolved.
Show resolved Hide resolved
@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 to email service
return mapOf("recovery_url" to "$recoverPasswordPage/$recoveryToken")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For security reasons, users should not be able to know if a email is registered in a website, so the request should always be Ok 200

}

@GetMapping
@PreAuthorize("hasRole('MEMBER')")
fun checkAuthentication(): Map<String, Account> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
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(
@field:Size(min = Constants.Password.minSize, max = Constants.Password.maxSize)
BrunoRosendo marked this conversation as resolved.
Show resolved Hide resolved
val oldPassword: String,

@field:Size(min = Constants.Password.minSize, max = Constants.Password.maxSize)
LuisDuarte1 marked this conversation as resolved.
Show resolved Hide resolved
val newPassword: String
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
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 PasswordRecoveryDto(
@field:Size(min = Constants.Password.minSize, max = Constants.Password.maxSize)
LuisDuarte1 marked this conversation as resolved.
Show resolved Hide resolved
val password: String
)
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.PasswordRecoveryDto
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: PasswordRecoveryDto): 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
5 changes: 5 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,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/accounts/recoverPassword

# Cors Origin
cors.allow-origin = http://localhost:3000
Loading