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("/+$") +}