Skip to content

Commit

Permalink
feat: Ability to resend registration email (#2382)
Browse files Browse the repository at this point in the history
  • Loading branch information
huglx authored Jul 16, 2024
1 parent 74a8ddd commit 7017824
Show file tree
Hide file tree
Showing 52 changed files with 729 additions and 112 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.tolgee.api.v2.controllers

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import io.tolgee.activity.ActivityHolder
import io.tolgee.constants.Message
import io.tolgee.dtos.request.SuperTokenRequest
import io.tolgee.dtos.request.UserUpdatePasswordRequestDto
Expand All @@ -18,25 +19,20 @@ import io.tolgee.security.authentication.AuthenticationFacade
import io.tolgee.security.authentication.JwtService
import io.tolgee.security.authentication.RequiresSuperAuthentication
import io.tolgee.security.payload.JwtAuthenticationResponse
import io.tolgee.service.EmailVerificationService
import io.tolgee.service.ImageUploadService
import io.tolgee.service.organization.OrganizationService
import io.tolgee.service.security.MfaService
import io.tolgee.service.security.UserAccountService
import jakarta.servlet.http.HttpServletRequest
import jakarta.validation.Valid
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.hateoas.CollectionModel
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile

@RestController
Expand All @@ -52,7 +48,19 @@ class V2UserController(
private val passwordEncoder: PasswordEncoder,
private val jwtService: JwtService,
private val mfaService: MfaService,
private val emailVerificationService: EmailVerificationService,
@Qualifier("requestActivityHolder") private val request: ActivityHolder,
) {
@Operation(
summary = "Resend email verification",
description = "Resends email verification email to currently authenticated user.",
)
@PostMapping("/send-email-verification")
fun sendEmailVerification(request: HttpServletRequest) {
val user = authenticationFacade.authenticatedUserEntity
emailVerificationService.resendEmailVerification(user, request)
}

@Operation(
summary = "Get user info",
description = "Returns information about currently authenticated user.",
Expand All @@ -71,8 +79,9 @@ class V2UserController(
fun updateUser(
@RequestBody @Valid
dto: UserUpdateRequestDto?,
request: HttpServletRequest,
): PrivateUserAccountModel {
val userAccount = userAccountService.update(authenticationFacade.authenticatedUserEntity, dto!!)
val userAccount = userAccountService.update(authenticationFacade.authenticatedUserEntity, dto!!, request)
val view =
userAccountService.findActiveView(userAccount.id)
?: throw IllegalStateException("User not found")
Expand Down Expand Up @@ -138,7 +147,8 @@ class V2UserController(
fun updateUserOld(
@RequestBody @Valid
dto: UserUpdateRequestDto?,
): PrivateUserAccountModel = updateUser(dto)
request: HttpServletRequest,
): PrivateUserAccountModel = updateUser(dto, request)

@GetMapping("/single-owned-organizations")
@Operation(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,21 @@ import io.tolgee.exceptions.NotFoundException
import io.tolgee.model.UserAccount
import io.tolgee.openApiDocs.OpenApiHideFromPublicDocs
import io.tolgee.security.authentication.JwtService
import io.tolgee.security.authorization.BypassEmailVerification
import io.tolgee.security.payload.JwtAuthenticationResponse
import io.tolgee.security.ratelimit.RateLimited
import io.tolgee.security.thirdParty.GithubOAuthDelegate
import io.tolgee.security.thirdParty.GoogleOAuthDelegate
import io.tolgee.security.thirdParty.OAuth2Delegate
import io.tolgee.service.EmailVerificationService
import io.tolgee.service.security.MfaService
import io.tolgee.service.security.ReCaptchaValidationService
import io.tolgee.service.security.SignUpService
import io.tolgee.service.security.UserAccountService
import io.tolgee.service.security.UserCredentialsService
import io.tolgee.service.security.*
import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull
import org.apache.commons.lang3.RandomStringUtils
import org.springframework.http.MediaType
import org.springframework.transaction.annotation.Transactional
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.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.*
import java.util.*

@RestController
Expand Down Expand Up @@ -67,7 +58,6 @@ class PublicController(
loginRequest: LoginRequest,
): JwtAuthenticationResponse {
val userAccount = userCredentialsService.checkUserCredentials(loginRequest.username, loginRequest.password)
emailVerificationService.check(userAccount)
mfaService.checkMfa(userAccount, loginRequest.otp)

// two factor passed, so we can generate super token
Expand Down Expand Up @@ -157,6 +147,7 @@ class PublicController(
description = "It checks whether the code from email is valid",
)
@OpenApiHideFromPublicDocs
@BypassEmailVerification
fun verifyEmail(
@PathVariable("userId") @NotNull userId: Long,
@PathVariable("code") @NotBlank code: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import io.tolgee.model.UserAccount
import io.tolgee.testing.AuthorizedControllerTest
import io.tolgee.testing.assert
import io.tolgee.util.GitHubAuthUtil
import jakarta.servlet.http.HttpServletRequest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.mockito.Mockito.mock
import org.mockito.kotlin.any
import org.mockito.kotlin.eq
import org.mockito.kotlin.times
Expand Down Expand Up @@ -127,7 +129,8 @@ class MarketingEmailingTest : AuthorizedControllerTest() {
val updatedUser =
executeInNewTransaction {
val updatedUser = userAccountService.get(user.id)
userAccountService.update(userAccountService.get(user.id), updateRequestDto)
val request = mock<HttpServletRequest>()
userAccountService.update(userAccountService.get(user.id), updateRequestDto, request)
updatedUser
}
Thread.sleep(100)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,12 @@ class EmailVerificationTest : AbstractControllerTest() {
}

@Test
fun doesNotLoginWhenNotVerified() {
fun loginWhenNotVerified() {
val createUser = dbPopulator.createUserIfNotExists(initialUsername)
emailVerificationService.createForUser(createUser)

val response = doAuthentication(initialUsername, initialPassword)
assertThat(response.andReturn()).error().hasCode("email_not_verified")
assertThat(response.andReturn().response.status).isEqualTo(200)
}

@Test
Expand Down Expand Up @@ -84,7 +84,7 @@ class EmailVerificationTest : AbstractControllerTest() {
val createUser = dbPopulator.createUserIfNotExists(initialUsername)
val emailVerification = emailVerificationService.createForUser(createUser)
mvc.perform(get("/api/public/verify_email/${createUser.id}/wrong_code"))
.andExpect(status().isNotFound).andReturn()
.andExpect(status().isBadRequest).andReturn()

assertThat(emailVerificationRepository.findById(emailVerification!!.id!!)).isPresent
}
Expand Down Expand Up @@ -141,11 +141,6 @@ class EmailVerificationTest : AbstractControllerTest() {
return emailTestUtil.messageContents.single()
}

@Test
fun signUpDoesNotReturnToken() {
assertThat(perform().response.contentAsString).isEqualTo("")
}

@Test
fun `uses callback url when no frontendUrl provided`() {
signUpDto.callbackUrl = "dummyCallbackUrl"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,7 @@ class RateLimitProperties(
defaultExplanation = "= 1 minute",
)
var userRequestWindow: Long = 1 * 60 * 1000,
var emailVerificationRequestLimit: Int = 5,
var emailVerificationRequestWindow: Long = 1 * 60 * 1000,
var emailVerificationRequestLimitEnabled: Boolean = true,
)
2 changes: 2 additions & 0 deletions backend/data/src/main/kotlin/io/tolgee/constants/Message.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ enum class Message {
THIRD_PARTY_AUTH_NO_EMAIL,
THIRD_PARTY_AUTH_NO_SUB,
THIRD_PARTY_AUTH_UNKNOWN_ERROR,
EMAIL_ALREADY_VERIFIED,
THIRD_PARTY_UNAUTHORIZED,
THIRD_PARTY_GOOGLE_WORKSPACE_MISMATCH,
USERNAME_ALREADY_EXISTS,
Expand Down Expand Up @@ -233,6 +234,7 @@ enum class Message {
SLACK_NOT_CONFIGURED,
SLACK_WORKSPACE_ALREADY_CONNECTED,
SLACK_CONNECTION_ERROR,
EMAIL_VERIFICATION_CODE_NOT_VALID,
;

val code: String
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.tolgee.dtos.cacheable

import io.tolgee.model.EmailVerification
import io.tolgee.model.UserAccount
import java.io.Serializable
import java.util.*
Expand All @@ -13,6 +14,7 @@ data class UserAccountDto(
val avatarHash: String?,
val deleted: Boolean,
val tokensValidNotBefore: Date?,
val emailVerification: EmailVerification? = null,
) : Serializable {
companion object {
fun fromEntity(entity: UserAccount) =
Expand All @@ -25,6 +27,7 @@ data class UserAccountDto(
avatarHash = entity.avatarHash,
deleted = entity.deletedAt != null,
tokensValidNotBefore = entity.tokensValidNotBefore,
emailVerification = entity.emailVerification,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ interface UserAccountRepository : JpaRepository<UserAccount, Long> {
ua.id,
ua.username,
ua.name,
ev.newEmail,
case when ev is not null then coalesce(ev.newEmail, ua.username) else null end,
ua.avatarHash,
ua.accountType,
ua.role,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,23 @@ class RateLimitService(
)
}

fun getIEmailVerificationIpRateLimitPolicy(
request: HttpServletRequest,
email: String?,
): RateLimitPolicy? {
if (!rateLimitProperties.emailVerificationRequestLimitEnabled || email.isNullOrEmpty()) return null

val ip = request.remoteAddr
val key = "global.ip.$ip::auth"

return RateLimitPolicy(
key,
rateLimitProperties.emailVerificationRequestLimit,
Duration.ofMillis(rateLimitProperties.emailVerificationRequestWindow),
true,
)
}

/**
* Consumes a token from a bucket according to the rate limit policy.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package io.tolgee.service

import io.tolgee.component.email.EmailVerificationSender
import io.tolgee.configuration.tolgee.TolgeeProperties
import io.tolgee.constants.Message
import io.tolgee.dtos.cacheable.UserAccountDto
import io.tolgee.events.user.OnUserEmailVerifiedFirst
import io.tolgee.events.user.OnUserUpdated
Expand All @@ -15,7 +16,9 @@ import io.tolgee.exceptions.NotFoundException
import io.tolgee.model.EmailVerification
import io.tolgee.model.UserAccount
import io.tolgee.repository.EmailVerificationRepository
import io.tolgee.security.ratelimit.RateLimitService
import io.tolgee.service.security.UserAccountService
import jakarta.servlet.http.HttpServletRequest
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.ApplicationEventPublisher
import org.springframework.context.annotation.Lazy
Expand All @@ -29,6 +32,7 @@ class EmailVerificationService(
private val emailVerificationRepository: EmailVerificationRepository,
private val applicationEventPublisher: ApplicationEventPublisher,
private val emailVerificationSender: EmailVerificationSender,
private val rateLimitService: RateLimitService,
) {
@Lazy
@Autowired
Expand All @@ -52,6 +56,7 @@ class EmailVerificationService(

emailVerificationRepository.save(emailVerification)
userAccount.emailVerification = emailVerification
userAccountService.saveAndFlush(userAccount)

if (newEmail != null) {
emailVerificationSender.sendEmailVerification(userAccount.id, newEmail, resultCallbackUrl, code, false)
Expand All @@ -63,6 +68,46 @@ class EmailVerificationService(
return null
}

@Transactional
fun resendEmailVerification(
userAccount: UserAccount,
request: HttpServletRequest,
callbackUrl: String? = null,
newEmail: String? = null,
) {
if (newEmail == null && isVerified(userAccount)) {
throw BadRequestException(io.tolgee.constants.Message.EMAIL_ALREADY_VERIFIED)
}

val email = newEmail ?: getEmail(userAccount)
val policy = rateLimitService.getIEmailVerificationIpRateLimitPolicy(request, email)

if (policy != null) {
rateLimitService.consumeBucketUnless(policy) {
createForUser(userAccount, callbackUrl, email)
isVerified(userAccount)
}
}
}

fun getEmail(userAccount: UserAccount): String {
return userAccount.emailVerification?.newEmail ?: userAccount.username
}

fun isVerified(userAccount: UserAccountDto): Boolean {
return !(
tolgeeProperties.authentication.needsEmailVerification &&
userAccount.emailVerification != null
)
}

fun isVerified(userAccount: UserAccount): Boolean {
return !(
tolgeeProperties.authentication.needsEmailVerification &&
userAccount.emailVerification != null
)
}

fun check(userAccount: UserAccount) {
if (
tolgeeProperties.authentication.needsEmailVerification &&
Expand All @@ -79,10 +124,10 @@ class EmailVerificationService(
) {
val user = userAccountService.findActive(userId) ?: throw NotFoundException()
val old = UserAccountDto.fromEntity(user)
val emailVerification = user.emailVerification
val emailVerification = user.emailVerification ?: throw BadRequestException(Message.EMAIL_ALREADY_VERIFIED)

if (emailVerification == null || emailVerification.code != code) {
throw NotFoundException()
if (emailVerification.code != code) {
throw BadRequestException(Message.EMAIL_VERIFICATION_CODE_NOT_VALID)
}

val newEmail = user.emailVerification?.newEmail
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@ class SignUpService(
}

emailVerificationService.createForUser(user, dto.callbackUrl)

return null
return JwtAuthenticationResponse(jwtService.emitToken(user.id, true))
}

fun signUp(
Expand Down
Loading

0 comments on commit 7017824

Please sign in to comment.