Skip to content

Commit

Permalink
feat: SSO (Single Sign-On) support (#2693)
Browse files Browse the repository at this point in the history
Fixes #2273

---------

Co-authored-by: Ivan Manzhosov <[email protected]>
Co-authored-by: Jan Cizmar <[email protected]>
  • Loading branch information
3 people authored Dec 10, 2024
1 parent 32ca0ec commit d78e421
Show file tree
Hide file tree
Showing 96 changed files with 3,508 additions and 262 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ package io.tolgee.api.publicConfiguration
import io.tolgee.api.publicConfiguration.PublicConfigurationDTO.AuthMethodsDTO
import io.tolgee.api.publicConfiguration.PublicConfigurationDTO.OAuthPublicConfigDTO
import io.tolgee.api.publicConfiguration.PublicConfigurationDTO.OAuthPublicExtendsConfigDTO
import io.tolgee.api.publicConfiguration.PublicConfigurationDTO.SsoGlobalPublicConfigDTO
import io.tolgee.api.publicConfiguration.PublicConfigurationDTO.SsoOrganizationsPublicConfigDTO
import io.tolgee.component.contentDelivery.ContentDeliveryFileStorageProvider
import io.tolgee.component.publicBillingConfProvider.PublicBillingConfProvider
import io.tolgee.configuration.PlausibleDto
import io.tolgee.configuration.tolgee.AuthenticationProperties
import io.tolgee.configuration.tolgee.TolgeeProperties
import io.tolgee.constants.FileStoragePath
import io.tolgee.constants.MtServiceType
Expand Down Expand Up @@ -62,27 +65,39 @@ class PublicConfigurationAssembler(
),
connected = properties.slack.token != null,
),
nativeEnabled = properties.authentication.nativeEnabled,
passwordResettable = properties.authentication.nativeEnabled,
allowRegistrations = properties.authentication.registrationsAllowed,
authMethods = getAuthMethods(),
authMethods = properties.authentication.asAuthMethodsDTO(),
)
}

private fun getAuthMethods(): AuthMethodsDTO? {
if (properties.authentication.enabled) {
return AuthMethodsDTO(
OAuthPublicConfigDTO(
properties.authentication.github.clientId,
),
OAuthPublicConfigDTO(properties.authentication.google.clientId),
OAuthPublicExtendsConfigDTO(
properties.authentication.oauth2.clientId,
properties.authentication.oauth2.authorizationUrl,
properties.authentication.oauth2.scopes,
),
)
private fun AuthenticationProperties.asAuthMethodsDTO(): AuthMethodsDTO? {
if (!enabled) {
return null
}
return null

return AuthMethodsDTO(
OAuthPublicConfigDTO(
github.clientId,
),
OAuthPublicConfigDTO(google.clientId),
OAuthPublicExtendsConfigDTO(
oauth2.clientId,
oauth2.authorizationUrl,
oauth2.scopes,
),
SsoGlobalPublicConfigDTO(
ssoGlobal.enabled,
ssoGlobal.clientId,
ssoGlobal.domain,
ssoGlobal.customLogoUrl,
ssoGlobal.customLoginText,
),
SsoOrganizationsPublicConfigDTO(
ssoOrganizations.enabled,
),
)
}

private fun getPrimaryMtService(): MtServiceType? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ class PublicConfigurationDTO(
val billing: PublicBillingConfigurationDTO,
val version: String,
val authentication: Boolean,
var authMethods: AuthMethodsDTO?,
val authMethods: AuthMethodsDTO?,
val nativeEnabled: Boolean,
@Deprecated("Use nativeEnabled instead", ReplaceWith("nativeEnabled"))
val passwordResettable: Boolean,
val allowRegistrations: Boolean,
val screenshotsUrl: String,
Expand All @@ -37,6 +39,8 @@ class PublicConfigurationDTO(
val github: OAuthPublicConfigDTO,
val google: OAuthPublicConfigDTO,
val oauth2: OAuthPublicExtendsConfigDTO,
val ssoGlobal: SsoGlobalPublicConfigDTO,
val ssoOrganizations: SsoOrganizationsPublicConfigDTO,
)

data class OAuthPublicConfigDTO(val clientId: String?) {
Expand All @@ -51,6 +55,18 @@ class PublicConfigurationDTO(
val enabled: Boolean = !clientId.isNullOrEmpty()
}

data class SsoGlobalPublicConfigDTO(
val enabled: Boolean,
val clientId: String?,
val domain: String?,
val customLogoUrl: String?,
val customLoginText: String?,
)

data class SsoOrganizationsPublicConfigDTO(
val enabled: Boolean,
)

data class MtServicesDTO(
val defaultPrimaryService: MtServiceType?,
val services: Map<MtServiceType, MtServiceDTO>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import io.tolgee.model.Project
import io.tolgee.model.UserAccount
import io.tolgee.model.enums.OrganizationRoleType
import io.tolgee.model.enums.ProjectPermissionType
import io.tolgee.model.enums.ThirdPartyAuthType
import io.tolgee.model.views.UserAccountWithOrganizationRoleView
import io.tolgee.openApiDocs.OpenApiOrderExtension
import io.tolgee.security.authentication.AllowApiAccess
Expand Down Expand Up @@ -108,6 +109,11 @@ class OrganizationController(
) {
throw PermissionException()
}
if (authenticationFacade.authenticatedUserEntity.thirdPartyAuthType === ThirdPartyAuthType.SSO &&
authenticationFacade.authenticatedUser.role != UserAccount.Role.ADMIN
) {
throw PermissionException(Message.SSO_USER_CANNOT_CREATE_ORGANIZATION)
}
this.organizationService.create(dto).let {
return ResponseEntity(
organizationModelAssembler.toModel(OrganizationView.of(it, OrganizationRoleType.OWNER)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,51 @@ 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
import io.tolgee.exceptions.BadRequestException
import io.tolgee.exceptions.DisabledFunctionalityException
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.security.service.thirdParty.ThirdPartyAuthDelegate
import io.tolgee.service.EmailVerificationService
import io.tolgee.service.security.*
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 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.*
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 java.util.*

@RestController
@RequestMapping("/api/public")
@Tag(name = "Authentication")
class PublicController(
private val jwtService: JwtService,
private val githubOAuthDelegate: GithubOAuthDelegate,
private val googleOAuthDelegate: GoogleOAuthDelegate,
private val oauth2Delegate: OAuth2Delegate,
private val properties: TolgeeProperties,
private val userAccountService: UserAccountService,
private val tolgeeEmailSender: TolgeeEmailSender,
Expand All @@ -49,6 +57,8 @@ class PublicController(
private val signUpService: SignUpService,
private val mfaService: MfaService,
private val userCredentialsService: UserCredentialsService,
private val authProperties: AuthenticationProperties,
private val thirdPartyAuthDelegates: List<ThirdPartyAuthDelegate>,
) {
@Operation(summary = "Generate JWT token")
@PostMapping("/generatetoken")
Expand All @@ -57,6 +67,10 @@ class PublicController(
@RequestBody @Valid
loginRequest: LoginRequest,
): JwtAuthenticationResponse {
if (!authProperties.nativeEnabled) {
throw AuthenticationException(Message.NATIVE_AUTHENTICATION_DISABLED)
}

val userAccount = userCredentialsService.checkUserCredentials(loginRequest.username, loginRequest.password)
mfaService.checkMfa(userAccount, loginRequest.otp)

Expand All @@ -73,12 +87,37 @@ class PublicController(
@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! 👋<br/><br/>
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.<br/><br/>
To access your account, please use the "SSO Login" button on the Tolgee login page. No password reset is needed.<br/><br/>
If you did not make this request, you may safely ignore this email.<br/><br/>
Regards,<br/>
Tolgee
""".trimIndent(),
)

tolgeeEmailSender.sendEmail(params)
return
}

val code = RandomStringUtils.randomAlphabetic(50)
userAccountService.setResetPasswordCode(userAccount, code)

Expand Down Expand Up @@ -112,6 +151,9 @@ class PublicController(
@PathVariable("code") code: String,
@PathVariable("email") email: String,
) {
if (!authProperties.nativeEnabled) {
throw DisabledFunctionalityException(Message.NATIVE_AUTHENTICATION_DISABLED)
}
validateEmailCode(code, email)
}

Expand All @@ -122,7 +164,13 @@ class PublicController(
@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)
}
Expand All @@ -144,6 +192,9 @@ class PublicController(
if (!reCaptchaValidationService.validate(dto.recaptchaToken, "")) {
throw BadRequestException(Message.INVALID_RECAPTCHA_TOKEN)
}
if (!authProperties.nativeEnabled) {
throw DisabledFunctionalityException(Message.NATIVE_AUTHENTICATION_DISABLED)
}
return signUpService.signUp(dto)
}

Expand Down Expand Up @@ -182,28 +233,16 @@ class PublicController(
@RequestParam(value = "code", required = true) code: String?,
@RequestParam(value = "redirect_uri", required = true) redirectUri: String?,
@RequestParam(value = "invitationCode", required = false) invitationCode: String?,
@RequestParam(value = "domain", required = false) domain: String?,
): JwtAuthenticationResponse {
if (properties.internal.fakeGithubLogin && code == "this_is_dummy_code") {
val user = getFakeGithubUser()
return JwtAuthenticationResponse(jwtService.emitToken(user.id))
}
return when (serviceType) {
"github" -> {
githubOAuthDelegate.getTokenResponse(code, invitationCode)
}

"google" -> {
googleOAuthDelegate.getTokenResponse(code, invitationCode, redirectUri)
}

"oauth2" -> {
oauth2Delegate.getTokenResponse(code, invitationCode, redirectUri)
}

else -> {
throw NotFoundException(Message.SERVICE_NOT_FOUND)
}
}
return thirdPartyAuthDelegates.find { it.name == serviceType }
?.getTokenResponse(code, invitationCode, redirectUri, domain)
?: throw NotFoundException(Message.SERVICE_NOT_FOUND)
}

private fun getFakeGithubUser(): UserAccount {
Expand Down
22 changes: 22 additions & 0 deletions backend/api/src/main/kotlin/io/tolgee/hateoas/ee/SsoTenantModel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package io.tolgee.hateoas.ee

import io.tolgee.api.ISsoTenant
import org.springframework.hateoas.RepresentationModel
import org.springframework.hateoas.server.core.Relation
import java.io.Serializable

@Suppress("unused")
@Relation(collectionRelation = "ssoTenants", itemRelation = "ssoTenant")
class SsoTenantModel(
val enabled: Boolean,
override val authorizationUri: String,
override val clientId: String,
override val clientSecret: String,
override val tokenUri: String,
override val domain: String,
) : ISsoTenant,
RepresentationModel<SsoTenantModel>(),
Serializable {
override val global: Boolean
get() = false
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import io.tolgee.configuration.tolgee.TolgeeProperties
import io.tolgee.constants.Message
import io.tolgee.exceptions.AuthenticationException
import io.tolgee.model.UserAccount
import io.tolgee.model.enums.ThirdPartyAuthType
import io.tolgee.security.authentication.JwtService
import io.tolgee.security.payload.JwtAuthenticationResponse
import io.tolgee.security.service.thirdParty.ThirdPartyAuthDelegate
import io.tolgee.service.security.SignUpService
import io.tolgee.service.security.UserAccountService
import org.springframework.http.HttpEntity
Expand All @@ -25,12 +27,16 @@ class GithubOAuthDelegate(
private val restTemplate: RestTemplate,
properties: TolgeeProperties,
private val signUpService: SignUpService,
) {
) : ThirdPartyAuthDelegate {
private val githubConfigurationProperties: GithubAuthenticationProperties = properties.authentication.github

fun getTokenResponse(
override val name: String = "github"

override fun getTokenResponse(
receivedCode: String?,
invitationCode: String?,
redirectUri: String?,
domain: String?,
): JwtAuthenticationResponse {
val body = HashMap<String, String?>()
body["client_id"] = githubConfigurationProperties.clientId
Expand Down Expand Up @@ -74,9 +80,8 @@ class GithubOAuthDelegate(
)?.email
?: throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL)

val userAccountOptional = userAccountService.findByThirdParty("github", userResponse!!.id!!)
val user =
userAccountOptional.orElseGet {
val userAccount =
userAccountService.findByThirdParty(ThirdPartyAuthType.GITHUB, userResponse!!.id!!) ?: let {
userAccountService.findActive(githubEmail)?.let {
throw AuthenticationException(Message.USERNAME_ALREADY_EXISTS)
}
Expand All @@ -85,14 +90,15 @@ class GithubOAuthDelegate(
newUserAccount.username = githubEmail
newUserAccount.name = userResponse.name ?: userResponse.login
newUserAccount.thirdPartyAuthId = userResponse.id
newUserAccount.thirdPartyAuthType = "github"
newUserAccount.thirdPartyAuthType = ThirdPartyAuthType.GITHUB
newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY

signUpService.signUp(newUserAccount, invitationCode, null)

newUserAccount
}
val jwt = jwtService.emitToken(user.id)

val jwt = jwtService.emitToken(userAccount.id)
return JwtAuthenticationResponse(jwt)
}
if (response == null) {
Expand Down
Loading

0 comments on commit d78e421

Please sign in to comment.