diff --git a/backend/api/src/main/kotlin/io/tolgee/controllers/AuthenticationTag.kt b/backend/api/src/main/kotlin/io/tolgee/controllers/AuthenticationTag.kt
new file mode 100644
index 0000000000..3254bc3c79
--- /dev/null
+++ b/backend/api/src/main/kotlin/io/tolgee/controllers/AuthenticationTag.kt
@@ -0,0 +1,7 @@
+package io.tolgee.controllers
+
+import io.swagger.v3.oas.annotations.tags.Tag
+
+/** This tag is used to mark controllers handling authentication */
+@Tag(name = "Authentication")
+annotation class AuthenticationTag
diff --git a/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt b/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt
index a00bcdbf68..8f818fa04b 100644
--- a/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt
+++ b/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt
@@ -2,14 +2,9 @@ package io.tolgee.controllers
import com.fasterxml.jackson.databind.node.TextNode
import io.swagger.v3.oas.annotations.Operation
-import io.swagger.v3.oas.annotations.tags.Tag
-import io.tolgee.component.email.TolgeeEmailSender
import io.tolgee.configuration.tolgee.AuthenticationProperties
import io.tolgee.configuration.tolgee.TolgeeProperties
import io.tolgee.constants.Message
-import io.tolgee.dtos.misc.EmailParams
-import io.tolgee.dtos.request.auth.ResetPassword
-import io.tolgee.dtos.request.auth.ResetPasswordRequest
import io.tolgee.dtos.request.auth.SignUpDto
import io.tolgee.dtos.security.LoginRequest
import io.tolgee.exceptions.AuthenticationException
@@ -35,7 +30,6 @@ import io.tolgee.service.security.UserCredentialsService
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
@@ -49,12 +43,11 @@ import java.util.*
@RestController
@RequestMapping("/api/public")
-@Tag(name = "Authentication")
+@AuthenticationTag
class PublicController(
private val jwtService: JwtService,
private val properties: TolgeeProperties,
private val userAccountService: UserAccountService,
- private val tolgeeEmailSender: TolgeeEmailSender,
private val emailVerificationService: EmailVerificationService,
private val reCaptchaValidationService: ReCaptchaValidationService,
private val signUpService: SignUpService,
@@ -84,105 +77,6 @@ class PublicController(
return JwtAuthenticationResponse(jwt)
}
- @Operation(summary = "Request password reset")
- @PostMapping("/reset_password_request")
- @OpenApiHideFromPublicDocs
- @RateLimited(limit = 2, refillDurationInMs = 300000)
- fun resetPasswordRequest(
- @RequestBody @Valid
- request: ResetPasswordRequest,
- ) {
- if (!authProperties.nativeEnabled) {
- throw DisabledFunctionalityException(Message.NATIVE_AUTHENTICATION_DISABLED)
- }
-
- val userAccount = userAccountService.findActive(request.email!!) ?: return
-
- if (!emailVerificationService.isVerified(userAccount)) {
- throw BadRequestException(Message.EMAIL_NOT_VERIFIED)
- }
-
- if (userAccount.accountType === UserAccount.AccountType.MANAGED) {
- val params =
- EmailParams(
- to = request.email!!,
- subject = "Password reset - SSO managed account",
- text =
- """
- Hello! 👋
- We received a request to reset the password for your account. However, your account is managed by your organization and uses a single sign-on (SSO) service to log in.
- To access your account, please use the "SSO Login" button on the Tolgee login page. No password reset is needed.
- If you did not make this request, you may safely ignore this email.
-
- Regards,
- Tolgee
- """.trimIndent(),
- )
-
- tolgeeEmailSender.sendEmail(params)
- return
- }
-
- val code = RandomStringUtils.randomAlphabetic(50)
- userAccountService.setResetPasswordCode(userAccount, code)
-
- val callbackString = code + "," + request.email
- val url = request.callbackUrl + "/" + Base64.getEncoder().encodeToString(callbackString.toByteArray())
- val isInitial = userAccount.accountType == UserAccount.AccountType.THIRD_PARTY
-
- val params =
- EmailParams(
- to = request.email!!,
- subject = if (isInitial) "Initial password configuration" else "Password reset",
- text =
- """
- Hello! 👋
- ${if (isInitial) "To set a password for your account, follow this link:
" else "To reset your password, follow this link:
"}
- $url
- If you have not requested this e-mail, please ignore it.
-
- Regards,
- Tolgee
- """.trimIndent(),
- )
-
- tolgeeEmailSender.sendEmail(params)
- }
-
- @GetMapping("/reset_password_validate/{email}/{code}")
- @Operation(summary = "Validate password-resetting key")
- @OpenApiHideFromPublicDocs
- fun resetPasswordValidate(
- @PathVariable("code") code: String,
- @PathVariable("email") email: String,
- ) {
- if (!authProperties.nativeEnabled) {
- throw DisabledFunctionalityException(Message.NATIVE_AUTHENTICATION_DISABLED)
- }
- validateEmailCode(code, email)
- }
-
- @PostMapping("/reset_password_set")
- @Operation(summary = "Set a new password", description = "Checks the password reset code from e-mail")
- @OpenApiHideFromPublicDocs
- fun resetPasswordSet(
- @RequestBody @Valid
- request: ResetPassword,
- ) {
- if (!authProperties.nativeEnabled) {
- throw DisabledFunctionalityException(Message.NATIVE_AUTHENTICATION_DISABLED)
- }
- val userAccount = validateEmailCode(request.code!!, request.email!!)
- if (userAccount.accountType === UserAccount.AccountType.MANAGED) {
- throw AuthenticationException(Message.OPERATION_UNAVAILABLE_FOR_ACCOUNT_TYPE)
- }
- if (userAccount.accountType === UserAccount.AccountType.THIRD_PARTY) {
- userAccountService.setAccountType(userAccount, UserAccount.AccountType.LOCAL)
- }
- userAccountService.setUserPassword(userAccount, request.password)
- userAccountService.removeResetCode(userAccount)
- }
-
@PostMapping("/sign_up")
@Transactional
@OpenApiHideFromPublicDocs
@@ -272,16 +166,4 @@ class PublicController(
}
return user
}
-
- private fun validateEmailCode(
- code: String,
- email: String,
- ): UserAccount {
- val userAccount = userAccountService.findActive(email) ?: throw BadRequestException(Message.BAD_CREDENTIALS)
- val resetCodeValid = userAccountService.isResetCodeValid(userAccount, code)
- if (!resetCodeValid) {
- throw BadRequestException(Message.BAD_CREDENTIALS)
- }
- return userAccount
- }
}
diff --git a/backend/api/src/main/kotlin/io/tolgee/controllers/resetPassword/PasswordResetController.kt b/backend/api/src/main/kotlin/io/tolgee/controllers/resetPassword/PasswordResetController.kt
new file mode 100644
index 0000000000..1091592d00
--- /dev/null
+++ b/backend/api/src/main/kotlin/io/tolgee/controllers/resetPassword/PasswordResetController.kt
@@ -0,0 +1,89 @@
+package io.tolgee.controllers.resetPassword
+
+import io.swagger.v3.oas.annotations.Operation
+import io.tolgee.configuration.tolgee.AuthenticationProperties
+import io.tolgee.constants.Message
+import io.tolgee.controllers.AuthenticationTag
+import io.tolgee.dtos.request.auth.ResetPassword
+import io.tolgee.dtos.request.auth.ResetPasswordRequest
+import io.tolgee.exceptions.AuthenticationException
+import io.tolgee.exceptions.BadRequestException
+import io.tolgee.exceptions.DisabledFunctionalityException
+import io.tolgee.model.UserAccount
+import io.tolgee.openApiDocs.OpenApiHideFromPublicDocs
+import io.tolgee.security.ratelimit.RateLimited
+import io.tolgee.service.security.UserAccountService
+import jakarta.validation.Valid
+import org.springframework.context.ApplicationContext
+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
+
+@RestController
+@RequestMapping("/api/public")
+@AuthenticationTag
+class PasswordResetController(
+ private val userAccountService: UserAccountService,
+ private val applicationContext: ApplicationContext,
+ private val authProperties: AuthenticationProperties,
+) {
+ @Operation(summary = "Request password reset")
+ @PostMapping("/reset_password_request")
+ @OpenApiHideFromPublicDocs
+ @RateLimited(limit = 2, refillDurationInMs = 300000)
+ fun resetPasswordRequest(
+ @RequestBody @Valid
+ request: ResetPasswordRequest,
+ ) {
+ ResetPasswordRequestHandler(applicationContext, request).handle()
+ }
+
+ @GetMapping("/reset_password_validate/{email}/{code}")
+ @Operation(summary = "Validate password-resetting key")
+ @OpenApiHideFromPublicDocs
+ fun resetPasswordValidate(
+ @PathVariable("code") code: String,
+ @PathVariable("email") email: String,
+ ) {
+ if (!authProperties.nativeEnabled) {
+ throw DisabledFunctionalityException(Message.NATIVE_AUTHENTICATION_DISABLED)
+ }
+ validateEmailCode(code, email)
+ }
+
+ @PostMapping("/reset_password_set")
+ @Operation(summary = "Set a new password", description = "Checks the password reset code from e-mail")
+ @OpenApiHideFromPublicDocs
+ fun resetPasswordSet(
+ @RequestBody @Valid
+ request: ResetPassword,
+ ) {
+ if (!authProperties.nativeEnabled) {
+ throw DisabledFunctionalityException(Message.NATIVE_AUTHENTICATION_DISABLED)
+ }
+ val userAccount = validateEmailCode(request.code!!, request.email!!)
+ if (userAccount.accountType === UserAccount.AccountType.MANAGED) {
+ throw AuthenticationException(Message.OPERATION_UNAVAILABLE_FOR_ACCOUNT_TYPE)
+ }
+ if (userAccount.accountType === UserAccount.AccountType.THIRD_PARTY) {
+ userAccountService.setAccountType(userAccount, UserAccount.AccountType.LOCAL)
+ }
+ userAccountService.setUserPassword(userAccount, request.password)
+ userAccountService.removeResetCode(userAccount)
+ }
+
+ private fun validateEmailCode(
+ code: String,
+ email: String,
+ ): UserAccount {
+ val userAccount = userAccountService.findActive(email) ?: throw BadRequestException(Message.BAD_CREDENTIALS)
+ val resetCodeValid = userAccountService.isResetCodeValid(userAccount, code)
+ if (!resetCodeValid) {
+ throw BadRequestException(Message.BAD_CREDENTIALS)
+ }
+ return userAccount
+ }
+}
diff --git a/backend/api/src/main/kotlin/io/tolgee/controllers/resetPassword/ResetPasswordRequestHandler.kt b/backend/api/src/main/kotlin/io/tolgee/controllers/resetPassword/ResetPasswordRequestHandler.kt
new file mode 100644
index 0000000000..d283adb9ee
--- /dev/null
+++ b/backend/api/src/main/kotlin/io/tolgee/controllers/resetPassword/ResetPasswordRequestHandler.kt
@@ -0,0 +1,149 @@
+package io.tolgee.controllers.resetPassword
+
+import io.tolgee.component.email.TolgeeEmailSender
+import io.tolgee.configuration.tolgee.AuthenticationProperties
+import io.tolgee.configuration.tolgee.TolgeeProperties
+import io.tolgee.constants.Message
+import io.tolgee.dtos.misc.EmailParams
+import io.tolgee.dtos.request.auth.ResetPasswordRequest
+import io.tolgee.exceptions.BadRequestException
+import io.tolgee.exceptions.DisabledFunctionalityException
+import io.tolgee.fixtures.removeSlashSuffix
+import io.tolgee.model.UserAccount
+import io.tolgee.service.EmailVerificationService
+import io.tolgee.service.security.UserAccountService
+import io.tolgee.util.Logging
+import io.tolgee.util.logger
+import org.apache.commons.lang3.RandomStringUtils
+import org.springframework.context.ApplicationContext
+import java.util.*
+
+class ResetPasswordRequestHandler(
+ private val applicationContext: ApplicationContext,
+ private val request: ResetPasswordRequest,
+) : Logging {
+ fun handle() {
+ if (!authProperties.nativeEnabled) {
+ throw DisabledFunctionalityException(Message.NATIVE_AUTHENTICATION_DISABLED)
+ }
+
+ val userAccount = userAccount ?: return
+
+ checkUserHasVerifiedEmail(userAccount)
+
+ if (userAccount.accountType === UserAccount.AccountType.MANAGED) {
+ sendErrorEmailForNonNativeAuthUser()
+ return
+ }
+
+ val url = saveSecretCodeAndGetCallbackUrl()
+ sendPasswordResetEmail(url)
+ }
+
+ private fun sendPasswordResetEmail(url: String) {
+ val isInitial = userAccount!!.accountType == UserAccount.AccountType.THIRD_PARTY
+
+ val params =
+ EmailParams(
+ to = request.email,
+ subject = if (isInitial) "Initial password configuration" else "Password reset",
+ text =
+ """
+ Hello! 👋
+ ${if (isInitial) "To set a password for your account, follow this link:
" else "To reset your password, follow this link:
"}
+ $url
+ If you have not requested this e-mail, please ignore it.
+
+ Regards,
+ Tolgee
+ """.trimIndent(),
+ )
+
+ tolgeeEmailSender.sendEmail(params)
+ }
+
+ private fun sendErrorEmailForNonNativeAuthUser() {
+ val params =
+ EmailParams(
+ to = request.email,
+ subject = "Password reset - SSO managed account",
+ text =
+ """
+ Hello! 👋
+ We received a request to reset the password for your account. However, your account is managed by your organization and uses a single sign-on (SSO) service to log in.
+ To access your account, please use the "SSO Login" button on the Tolgee login page. No password reset is needed.
+ If you did not make this request, you may safely ignore this email.
+
+ Regards,
+ Tolgee
+ """.trimIndent(),
+ )
+
+ tolgeeEmailSender.sendEmail(params)
+ }
+
+ private fun checkUserHasVerifiedEmail(userAccount: UserAccount) {
+ if (!emailVerificationService.isVerified(userAccount)) {
+ throw BadRequestException(Message.EMAIL_NOT_VERIFIED)
+ }
+ }
+
+ private fun saveSecretCodeAndGetCallbackUrl(): String {
+ val code = generateCode()
+ saveCode(code)
+ return getCallbackUrlBase()?.removeSlashSuffix() + "/" + getEncodedCodeString(code)
+ }
+
+ private fun getCallbackUrlBase(): String? {
+ if (frontEndUrlFromProperties == null) {
+ logger.warn(
+ "Frontend URL is not set in properties. Using frontend URL from the request can lead " +
+ "to one-click account takeover attacks. Please set the frontend URL in the properties. " +
+ "(tolgee.front-end-url)" +
+ "\n\n" +
+ "For more information about the configuration, consult the documentation: " +
+ "https://docs.tolgee.io/platform/self_hosting/configuration",
+ )
+ return request.callbackUrl
+ }
+
+ return frontEndUrlFromProperties!!.removeSlashSuffix() + "/reset_password"
+ }
+
+ private fun saveCode(code: String?) {
+ userAccountService.setResetPasswordCode(userAccount!!, code)
+ }
+
+ private fun generateCode(): String = RandomStringUtils.randomAlphabetic(50)
+
+ private fun getEncodedCodeString(code: String): String {
+ val callbackString = code + "," + request.email
+ return Base64.getEncoder().encodeToString(callbackString.toByteArray())
+ }
+
+ private val frontEndUrlFromProperties by lazy { tolgeeProperties.frontEndUrl }
+
+ private val userAccount by lazy {
+ userAccountService.findActive(request.email)
+ }
+
+ private val tolgeeProperties by lazy {
+ applicationContext.getBean(TolgeeProperties::class.java)
+ }
+
+ private val authProperties: AuthenticationProperties by lazy {
+ applicationContext.getBean(AuthenticationProperties::class.java)
+ }
+
+ private val userAccountService: UserAccountService by lazy {
+ applicationContext.getBean(UserAccountService::class.java)
+ }
+
+ private val emailVerificationService: EmailVerificationService by lazy {
+ applicationContext.getBean(EmailVerificationService::class.java)
+ }
+
+ private val tolgeeEmailSender: TolgeeEmailSender by lazy {
+ applicationContext.getBean(TolgeeEmailSender::class.java)
+ }
+}
diff --git a/backend/app/src/test/kotlin/io/tolgee/controllers/resetPassword/ResetPasswordControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/controllers/resetPassword/ResetPasswordControllerTest.kt
new file mode 100644
index 0000000000..817f14ff5e
--- /dev/null
+++ b/backend/app/src/test/kotlin/io/tolgee/controllers/resetPassword/ResetPasswordControllerTest.kt
@@ -0,0 +1,74 @@
+package io.tolgee.controllers.resetPassword
+
+import com.posthog.java.PostHog
+import io.tolgee.development.testDataBuilder.data.BaseTestData
+import io.tolgee.dtos.request.auth.ResetPasswordRequest
+import io.tolgee.fixtures.*
+import io.tolgee.testing.AbstractControllerTest
+import io.tolgee.testing.assert
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.mockito.kotlin.times
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
+import org.springframework.boot.test.mock.mockito.MockBean
+
+@AutoConfigureMockMvc
+class ResetPasswordControllerTest :
+ AbstractControllerTest() {
+ private var frontEndUrlBefore: String? = null
+
+ @BeforeEach
+ fun setup() {
+ emailTestUtil.initMocks()
+ frontEndUrlBefore = tolgeeProperties.frontEndUrl
+ testData = BaseTestData()
+ saveTestData()
+ }
+
+ @AfterEach
+ fun tearDown() {
+ tolgeeProperties.frontEndUrl = frontEndUrlBefore
+ }
+
+ private lateinit var testData: BaseTestData
+
+ @Autowired
+ private lateinit var emailTestUtil: EmailTestUtil
+
+ @MockBean
+ @Autowired
+ lateinit var postHog: PostHog
+
+ @Test
+ fun `email contains correct callback url with frontend url provided`() {
+ tolgeeProperties.frontEndUrl = "http://dummy-url.com/"
+ executePasswordChangeRequest()
+ emailTestUtil.firstMessageContent.assert.contains("http://dummy-url.com/reset_password/")
+ // We don't want double slashes
+ emailTestUtil.firstMessageContent.assert.doesNotContain("reset_password//")
+ }
+
+ @Test
+ fun `email contains correct callback url without frontend url provided`() {
+ executePasswordChangeRequest()
+ emailTestUtil.firstMessageContent.assert.contains("https://hello.com/aa/")
+ // We don't want double slashes
+ emailTestUtil.firstMessageContent.assert.doesNotContain("aa//")
+ }
+
+ private fun executePasswordChangeRequest() {
+ val dto =
+ ResetPasswordRequest(
+ email = testData.user.username,
+ callbackUrl = "https://hello.com/aa/",
+ )
+ performPost("/api/public/reset_password_request", dto).andIsOk
+ }
+
+ private fun saveTestData() {
+ testData.user.username = "example@example.com"
+ testDataService.saveTestData(testData.root)
+ }
+}
diff --git a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/TolgeeProperties.kt b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/TolgeeProperties.kt
index 99860d7d95..cec72ce027 100644
--- a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/TolgeeProperties.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/TolgeeProperties.kt
@@ -92,7 +92,11 @@ open class TolgeeProperties(
@DocProperty(
description =
"Public URL where Tolgee is accessible. " +
- "Used to generate links to Tolgee (e.g. email confirmation link).",
+ "Used to generate links to Tolgee (e.g. email confirmation link)." +
+ "\n\n" +
+ "**Warning:** Not providing this property leads to security issues." +
+ "Providing this property is highly " +
+ "recommended especially if you are managing publicly accessible Tolgee instance. ",
)
var frontEndUrl: String? = null,
var websocket: WebsocketProperties = WebsocketProperties(),
diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/auth/ResetPasswordRequest.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/auth/ResetPasswordRequest.kt
index 668f7f004f..713196f3d7 100644
--- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/auth/ResetPasswordRequest.kt
+++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/auth/ResetPasswordRequest.kt
@@ -8,5 +8,5 @@ data class ResetPasswordRequest(
var callbackUrl: String? = null,
@field:Email
@field:NotBlank
- var email: String? = null,
+ var email: String,
)
diff --git a/backend/misc/src/main/kotlin/io/tolgee/fixtures/removeSlashSuffix.kt b/backend/misc/src/main/kotlin/io/tolgee/fixtures/removeSlashSuffix.kt
new file mode 100644
index 0000000000..91322d7ecb
--- /dev/null
+++ b/backend/misc/src/main/kotlin/io/tolgee/fixtures/removeSlashSuffix.kt
@@ -0,0 +1,9 @@
+package io.tolgee.fixtures
+
+fun String.removeSlashSuffix(): String {
+ return REGEX.replace(this, "")
+}
+
+private val REGEX by lazy {
+ Regex("/+$")
+}