Skip to content

Commit

Permalink
MAIN-T-117 Create endpoint to send Restore Password email (#92)
Browse files Browse the repository at this point in the history
* Add password reset feature with token generation and email notification

 - Implemented a password reset functionality that checks user existence, generates a reset password token, and sends an email notification with the reset password token.
 - Created relevant test cases and updated user service and user facade.
 - Added endpoint to reset password and updated JwtAuthorizationFilter for password reset endpoint.
 - Included new database table for storing reset password tokens.

* Add logging and exception handling to reset password process

In the PasswordFacade class, a log statement and exception handling have been added to the reset password function. Now, when the password reset email is sent, it's wrapped in a try/catch block to handle any potential exceptions. Additionally, a debug-level log is generated whenever a rest password token is created.

* Refactor EmailService and remove unused variables

Refactored the EmailService class to extract redundant code into a separate 'sendEmail' function. This makes the code easier to read and maintain. Additionally, removed unused variables in 'restore-password.html' and 'PasswordSpec.kt'.
  • Loading branch information
hanna-eismant authored Apr 25, 2024
1 parent 8f3b7a0 commit 463a396
Show file tree
Hide file tree
Showing 29 changed files with 563 additions and 32 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,4 @@ bin

/doky-*.env
/logs/
/.run/
49 changes: 49 additions & 0 deletions server/src/apiTest/java/org/hkurh/doky/PasswordSpec.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package org.hkurh.doky

import io.restassured.RestAssured.given
import org.hkurh.doky.password.api.ResetPasswordRequest
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.http.HttpStatus

@DisplayName("Password API test")
class PasswordSpec : RestSpec() {

private val endpoint = "/password"
private val resetEndpoint = "$endpoint/reset"
private val nonExistUserEmail = "[email protected]"


@Test
@DisplayName("Should return Not Fount if no user with provided email")
fun shouldReturnNotFount_whenNoUserWithProvidedEmail() {
// given
val requestBody = ResetPasswordRequest().apply {
email = nonExistUserEmail
}
val requestSpec = prepareRequestSpec().setBody(requestBody).build()

// when
val response = given(requestSpec).post(resetEndpoint)

// then
response.then().statusCode(HttpStatus.NOT_FOUND.value())
}

@Test
@DisplayName("Should process if user with provided email exists")
fun shouldProcess_whenUserExists() {
// given
val requestBody = ResetPasswordRequest().apply {
email = validUserUid
}
val requestSpec = prepareRequestSpec().setBody(requestBody).build()

// when
val response = given(requestSpec).post(resetEndpoint)

// then
response.then().statusCode(HttpStatus.NO_CONTENT.value())
}

}
3 changes: 3 additions & 0 deletions server/src/apiTest/java/org/hkurh/doky/RestSpec.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package org.hkurh.doky
import io.restassured.RestAssured.given
import io.restassured.builder.RequestSpecBuilder
import org.hkurh.doky.authorization.AuthenticationRequest
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Tag
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
Expand All @@ -11,6 +13,7 @@ import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.jdbc.Sql
import org.springframework.test.context.jdbc.SqlMergeMode
import org.springframework.transaction.annotation.Transactional

@ActiveProfiles("test")
@Tag("api")
Expand Down
10 changes: 5 additions & 5 deletions server/src/apiTest/resources/sql/DocumentSpec/setup.sql
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
insert into document (name, description, file_name, created_date, modified_date, creator_id )
replace into document (name, description, file_name, created_date, modified_date, creator_id )
select "Test_1", "That is a test document", "test.txt", '2024-01-15 13:00:00', '2024-01-15 13:00:00', u.id from user u where u.uid = "[email protected]";

insert into document (name, description, created_date, modified_date, creator_id )
replace into document (name, description, created_date, modified_date, creator_id )
select "Test_2", "That is a second test document", '2024-01-15 16:00:00', '2024-01-15 16:00:00', u.id from user u where u.uid = "[email protected]";

insert into user (uid, name, password) values ( "[email protected]", "Hanna", "$2a$10$bdZSuBncZqaM4XwHcjxbpeQuXeOxk6vCEsFrTwa91xh3M3JpvW41m" );
replace into user (uid, name, password) values ( "[email protected]", "Hanna", "$2a$10$bdZSuBncZqaM4XwHcjxbpeQuXeOxk6vCEsFrTwa91xh3M3JpvW41m" );

insert into document (name, description, created_date, modified_date, creator_id )
replace into document (name, description, created_date, modified_date, creator_id )
select "Test_3", "That is a test document", '2024-02-13 13:00:00', '2024-03-15 13:24:00', u.id from user u where u.uid = "[email protected]";

insert into document (name, description, created_date, modified_date, creator_id )
replace into document (name, description, created_date, modified_date, creator_id )
select "Test_4", "That is a second test document", '2024-01-15 13:00:00', '2024-01-15 13:00:00', u.id from user u where u.uid = "[email protected]";
10 changes: 5 additions & 5 deletions server/src/apiTest/resources/sql/SearchSpec/setup.sql
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
insert into document (name, description, file_name, created_date, modified_date, creator_id )
replace into document (name, description, file_name, created_date, modified_date, creator_id )
select "Test note 1", "That is a test document", "test.txt", '2024-01-15 13:00:00', '2024-01-15 13:00:00', u.id from user u where u.uid = "[email protected]";

insert into document (name, description, created_date, modified_date, creator_id )
replace into document (name, description, created_date, modified_date, creator_id )
select "Lorem", "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed vitae augue et tortor dapibus finibus.", '2024-01-15 16:00:00', '2024-01-15 16:00:00', u.id from user u where u.uid = "[email protected]";

insert into user (uid, name, password) values ( "[email protected]", "Hanna", "$2a$10$bdZSuBncZqaM4XwHcjxbpeQuXeOxk6vCEsFrTwa91xh3M3JpvW41m" );
replace into user (uid, name, password) values ( "[email protected]", "Hanna", "$2a$10$bdZSuBncZqaM4XwHcjxbpeQuXeOxk6vCEsFrTwa91xh3M3JpvW41m" );

insert into document (name, description, created_date, modified_date, creator_id )
replace into document (name, description, created_date, modified_date, creator_id )
select "Test note 2", "That is a test document", '2024-02-13 13:00:00', '2024-03-15 13:24:00', u.id from user u where u.uid = "[email protected]";

insert into document (name, description, created_date, modified_date, creator_id )
replace into document (name, description, created_date, modified_date, creator_id )
select "Cras at nulla ex", "Phasellus vestibulum nisl augue, a pharetra nunc molestie ut. Integer mollis ex fringilla vulputate facilisis.", '2024-01-15 13:00:00', '2024-01-15 13:00:00', u.id from user u where u.uid = "[email protected]";
2 changes: 2 additions & 0 deletions server/src/apiTest/resources/sql/cleanup_base_test_data.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
delete d from document d inner join user u on d.creator_id = u.id where u.uid = "[email protected]";

delete from reset_password_token;

delete u from user u where u.uid = "[email protected]";
2 changes: 1 addition & 1 deletion server/src/apiTest/resources/sql/create_base_test_data.sql
Original file line number Diff line number Diff line change
@@ -1 +1 @@
insert into user (uid, name, password) values ( "[email protected]", "Hanna", "$2a$10$bdZSuBncZqaM4XwHcjxbpeQuXeOxk6vCEsFrTwa91xh3M3JpvW41m" );
replace into user (uid, name, password) values ( "[email protected]", "Hanna", "$2a$10$bdZSuBncZqaM4XwHcjxbpeQuXeOxk6vCEsFrTwa91xh3M3JpvW41m" );
52 changes: 43 additions & 9 deletions server/src/main/java/org/hkurh/doky/email/EmailService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import org.springframework.mail.javamail.MimeMessageHelper
import org.springframework.stereotype.Service
import org.thymeleaf.context.Context
import org.thymeleaf.spring6.SpringTemplateEngine
import java.nio.charset.StandardCharsets


@Service
Expand All @@ -16,29 +17,62 @@ class EmailService(
val templateEngine: SpringTemplateEngine
) {

@Value("\${doky.app.host}")
lateinit var host: String

@Value("\${doky.email.from}")
lateinit var fromEmail: String

@Value("classpath:/mail/img/logo-white-no-bg.svg")
lateinit var logoFile: Resource

fun sendRegistrationConfirmationEmail(user: UserEntity) {
val htmlBody = prepareRegistrationConfirmationEmail(user)
sendEmail(htmlBody, user.uid, "Doky Registration")
}

fun sendRestorePasswordEmail(user: UserEntity, token: String) {
val htmlBody = prepareRestorePasswordEmail(user, token)
sendEmail(htmlBody, user.uid, "Doky Restore Password")
}

private fun sendEmail(emailTemplate: String, toEmail: String, subject: String) {
val message = emailSender.createMimeMessage()
MimeMessageHelper(
message,
MimeMessageHelper.MULTIPART_MODE_MIXED_RELATED,
StandardCharsets.UTF_8.name()
).apply {
setFrom(fromEmail)
setTo(toEmail)
setSubject(subject)
setText(emailTemplate, true)
addInline("logo-white-no-bg.svg", logoFile)
}
emailSender.send(message)
}

private fun prepareRegistrationConfirmationEmail(user: UserEntity): String {
val template = "registration.html"
val variables = HashMap<String, Any>().apply {
user.name?.let { put("username", it) }
}
val context = Context().apply {
setVariables(variables)
}
val htmlBody: String = templateEngine.process(template, context)
val message = emailSender.createMimeMessage()
MimeMessageHelper(message, true, "UTF-8").apply {
setFrom(fromEmail)
setTo(user.uid)
setSubject("Doky Registration")
setText(htmlBody, true)
addInline("logo-white-no-bg.svg", logoFile)
return templateEngine.process(template, context)
}

private fun prepareRestorePasswordEmail(user: UserEntity, token: String): String {
val template = "restore-password.html"
val variables = HashMap<String, Any>().apply {
user.name?.let { put("username", it) }
put("restoreLink", "$host/password/update?token=$token")
put("mailto", fromEmail)
}
emailSender.send(message)
val context = Context().apply {
setVariables(variables)
}
return templateEngine.process(template, context)
}
}
33 changes: 33 additions & 0 deletions server/src/main/java/org/hkurh/doky/password/PasswordFacade.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package org.hkurh.doky.password

import org.hkurh.doky.email.EmailService
import org.hkurh.doky.errorhandling.DokyNotFoundException
import org.hkurh.doky.users.UserService
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component

@Component
class PasswordFacade(
private val userService: UserService,
private val resetPasswordService: ResetPasswordService,
private val emailService: EmailService
) {

fun reset(email: String) {
if (!userService.exists(email)) throw DokyNotFoundException("User does not exist")

val user = userService.findUserByUid(email)
val token = resetPasswordService.generateAndSaveResetToken(user!!)
LOG.debug("Generate reset password token for user ${user.id}")
try {
emailService.sendRestorePasswordEmail(user, token)
} catch (e: Exception) {
LOG.error("Error sending reset password email for user ${user.id}", e)
}
}


companion object {
private val LOG = LoggerFactory.getLogger(PasswordFacade::class.java)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.hkurh.doky.password

import org.hkurh.doky.password.db.ResetPasswordTokenEntity
import org.hkurh.doky.password.db.ResetPasswordTokenRepository
import org.hkurh.doky.users.db.UserEntity
import org.springframework.stereotype.Service

@Service
class ResetPasswordService(
private var tokenUtil: TokenUtil,
private val resetPasswordTokenRepository: ResetPasswordTokenRepository
) {

fun generateAndSaveResetToken(user: UserEntity): String {
resetPasswordTokenRepository.findByUser(user)?.let {
resetPasswordTokenRepository.delete(it)
}
val token = tokenUtil.generateToken()
val expirationDate = tokenUtil.calculateExpirationDate()
val resetPasswordTokenEntity = ResetPasswordTokenEntity().apply {
this.token = token
this.user = user
this.expirationDate = expirationDate
}
val savedPasswordTokenEntity = resetPasswordTokenRepository.save(resetPasswordTokenEntity)
return savedPasswordTokenEntity.token!!
}
}
28 changes: 28 additions & 0 deletions server/src/main/java/org/hkurh/doky/password/TokenUtil.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.hkurh.doky.password

import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.*

@Component
class TokenUtil {

private val zoneId = ZoneId.of("UTC")

@Value("\${doky.password.reset.token.duration}")
var resetTokenDuration: Long = 10

fun calculateExpirationDate(): Date {
val currentDate = LocalDateTime.ofInstant(Instant.now(), zoneId)
val expiredDate = currentDate.plusMinutes(resetTokenDuration)
return Date.from(expiredDate.toInstant(ZoneOffset.UTC))
}

fun generateToken() : String {
return UUID.randomUUID().toString()
}
}
26 changes: 26 additions & 0 deletions server/src/main/java/org/hkurh/doky/password/api/PasswordApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package org.hkurh.doky.password.api

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Pattern
import jakarta.validation.constraints.Size
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestBody

@Tag(name = "Password")
@SecurityRequirement(name = "Bearer Token")
interface PasswordApi {


@Operation(summary = "Send a request (email) to restore password for user")
@ApiResponses(
ApiResponse(responseCode = "204", description = "Reset password request is applied successfully"),
ApiResponse(responseCode = "404", description = "There no registered user with provided email"),
ApiResponse(responseCode = "400", description = "Provided email is null, empty or has incorrect format")
)
fun reset(@RequestBody resetPasswordRequest: ResetPasswordRequest): ResponseEntity<Any>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.hkurh.doky.password.api

import jakarta.validation.Valid
import org.hkurh.doky.password.PasswordFacade
import org.springframework.http.ResponseEntity
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

@RestController
@RequestMapping("/password")
class PasswordController(
val passwordFacade: PasswordFacade
) : PasswordApi {

@PostMapping("/reset")
override fun reset(@RequestBody @Valid resetPasswordRequest: ResetPasswordRequest): ResponseEntity<Any> {
passwordFacade.reset(resetPasswordRequest.email)
return ResponseEntity.noContent().build()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.hkurh.doky.password.api

import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Pattern
import jakarta.validation.constraints.Size

class ResetPasswordRequest {
@NotBlank(message = "Email is required")
@Size(min = 4, max = 32, message = "Length should be from 4 to 32 characters")
@Pattern(
regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}\$",
message = "Should be an valid email address"
)
var email: String = ""
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package org.hkurh.doky.password.db

import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Index
import jakarta.persistence.JoinColumn
import jakarta.persistence.OneToOne
import jakarta.persistence.Table
import jakarta.persistence.UniqueConstraint
import org.hkurh.doky.users.db.UserEntity
import java.util.*

@Entity
@Table(
name = "reset_password_token",
indexes = [Index(name = "idx_reset_password_token_token", columnList = "token")],
uniqueConstraints = [UniqueConstraint(name = "uq_reset_password_token_token", columnNames = ["token"])]
)
class ResetPasswordTokenEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
var id: Long = -1

@OneToOne
@JoinColumn(name = "user")
var user: UserEntity? = null

@Column(name = "token")
var token: String? = null

@Column(name = "expiration_date")
var expirationDate: Date? = null
}
Loading

0 comments on commit 463a396

Please sign in to comment.