diff --git a/backend/api/src/main/kotlin/io/tolgee/api/publicConfiguration/PublicConfigurationAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/api/publicConfiguration/PublicConfigurationAssembler.kt index 2acf82b87b..3b6bbdf056 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/publicConfiguration/PublicConfigurationAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/publicConfiguration/PublicConfigurationAssembler.kt @@ -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 @@ -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? { diff --git a/backend/api/src/main/kotlin/io/tolgee/api/publicConfiguration/PublicConfigurationDTO.kt b/backend/api/src/main/kotlin/io/tolgee/api/publicConfiguration/PublicConfigurationDTO.kt index 9b387e8cad..e94c99717e 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/publicConfiguration/PublicConfigurationDTO.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/publicConfiguration/PublicConfigurationDTO.kt @@ -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, @@ -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?) { @@ -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, diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationController.kt index 8363b38d56..f2e3cb3f25 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationController.kt @@ -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 @@ -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)), 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 dc31891bef..dcd3dc89c8 100644 --- a/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt @@ -4,6 +4,7 @@ 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 @@ -11,7 +12,9 @@ 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 @@ -19,18 +22,26 @@ 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 @@ -38,9 +49,6 @@ import java.util.* @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, @@ -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, ) { @Operation(summary = "Generate JWT token") @PostMapping("/generatetoken") @@ -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) @@ -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! 👋

+ 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) @@ -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) } @@ -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) } @@ -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) } @@ -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 { diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/SsoTenantModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/SsoTenantModel.kt new file mode 100644 index 0000000000..c596875a98 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/SsoTenantModel.kt @@ -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(), + Serializable { + override val global: Boolean + get() = false +} diff --git a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/GithubOAuthDelegate.kt b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/GithubOAuthDelegate.kt index 0bc883c9aa..796ebd5aa3 100644 --- a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/GithubOAuthDelegate.kt +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/GithubOAuthDelegate.kt @@ -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 @@ -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() body["client_id"] = githubConfigurationProperties.clientId @@ -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) } @@ -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) { diff --git a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/GoogleOAuthDelegate.kt b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/GoogleOAuthDelegate.kt index ab58952f72..297050823b 100644 --- a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/GoogleOAuthDelegate.kt +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/GoogleOAuthDelegate.kt @@ -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 @@ -24,13 +26,17 @@ class GoogleOAuthDelegate( private val restTemplate: RestTemplate, properties: TolgeeProperties, private val signUpService: SignUpService, -) { +) : ThirdPartyAuthDelegate { private val googleConfigurationProperties: GoogleAuthenticationProperties = properties.authentication.google - fun getTokenResponse( + override val name: String + get() = "google" + + override fun getTokenResponse( receivedCode: String?, invitationCode: String?, redirectUri: String?, + domain: String?, ): JwtAuthenticationResponse { try { val body = HashMap() @@ -57,7 +63,7 @@ class GoogleOAuthDelegate( if (exchange.statusCode != HttpStatus.OK || exchange.body == null) { throw AuthenticationException(Message.THIRD_PARTY_UNAUTHORIZED) } - val userResponse = exchange.body + val userResponse = exchange.body ?: throw AuthenticationException(Message.THIRD_PARTY_AUTH_UNKNOWN_ERROR) // check google email verified if (userResponse.email_verified == false) { @@ -65,7 +71,9 @@ class GoogleOAuthDelegate( } // Split the comma-separated list of domains into a List - val allowedDomains = googleConfigurationProperties.workspaceDomain?.split(",")?.map { it.trim() } ?: listOf() + val allowedDomains = + googleConfigurationProperties.workspaceDomain?.split(",")?.map { it.trim() } + ?: listOf() // ensure that only Google Workspace users from allowed domains can log in if (allowedDomains.isNotEmpty()) { @@ -76,9 +84,8 @@ class GoogleOAuthDelegate( val googleEmail = userResponse.email ?: throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL) - val userAccountOptional = userAccountService.findByThirdParty("google", userResponse!!.sub!!) - val user = - userAccountOptional.orElseGet { + val userAccount = + userAccountService.findByThirdParty(ThirdPartyAuthType.GOOGLE, userResponse.sub!!) ?: let { userAccountService.findActive(googleEmail)?.let { throw AuthenticationException(Message.USERNAME_ALREADY_EXISTS) } @@ -88,13 +95,13 @@ class GoogleOAuthDelegate( ?: throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL) newUserAccount.name = userResponse.name ?: (userResponse.given_name + " " + userResponse.family_name) newUserAccount.thirdPartyAuthId = userResponse.sub - newUserAccount.thirdPartyAuthType = "google" + newUserAccount.thirdPartyAuthType = ThirdPartyAuthType.GOOGLE 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) { diff --git a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuth2Delegate.kt b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuth2Delegate.kt index 11fd179c85..31b2ae2f47 100644 --- a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuth2Delegate.kt +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuth2Delegate.kt @@ -5,10 +5,11 @@ 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.service.security.SignUpService -import io.tolgee.service.security.UserAccountService +import io.tolgee.security.service.thirdParty.ThirdPartyAuthDelegate +import io.tolgee.security.thirdParty.data.OAuthUserDetails import org.slf4j.LoggerFactory import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders @@ -24,18 +25,21 @@ import org.springframework.web.client.RestTemplate @Component class OAuth2Delegate( private val jwtService: JwtService, - private val userAccountService: UserAccountService, private val restTemplate: RestTemplate, properties: TolgeeProperties, - private val signUpService: SignUpService, -) { + private val oAuthUserHandler: OAuthUserHandler, +) : ThirdPartyAuthDelegate { private val oauth2ConfigurationProperties: OAuth2AuthenticationProperties = properties.authentication.oauth2 private val logger = LoggerFactory.getLogger(this::class.java) - fun getTokenResponse( + override val name: String + get() = "oauth2" + + override fun getTokenResponse( receivedCode: String?, invitationCode: String?, redirectUri: String?, + domain: String?, ): JwtAuthenticationResponse { try { val body: MultiValueMap = LinkedMultiValueMap() @@ -90,33 +94,22 @@ class OAuth2Delegate( logger.info("Third party user email is null. Missing scope email?") throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL) } - - val userAccountOptional = userAccountService.findByThirdParty("oauth2", userResponse.sub!!) + val userData = + OAuthUserDetails( + sub = userResponse.sub!!, + name = userResponse.name, + givenName = userResponse.given_name, + familyName = userResponse.family_name, + email = email, + ) val user = - userAccountOptional.orElseGet { - userAccountService.findActive(email)?.let { - throw AuthenticationException(Message.USERNAME_ALREADY_EXISTS) - } - - val newUserAccount = UserAccount() - newUserAccount.username = - userResponse.email ?: throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL) - - // build name for userAccount based on available fields by third party - var name = userResponse.email!!.split("@")[0] - if (userResponse.name != null) { - name = userResponse.name!! - } else if (userResponse.given_name != null && userResponse.family_name != null) { - name = "${userResponse.given_name} ${userResponse.family_name}" - } - newUserAccount.name = name - newUserAccount.thirdPartyAuthId = userResponse.sub - newUserAccount.thirdPartyAuthType = "oauth2" - newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY - signUpService.signUp(newUserAccount, invitationCode, null) + oAuthUserHandler.findOrCreateUser( + userData, + invitationCode, + ThirdPartyAuthType.OAUTH2, + UserAccount.AccountType.THIRD_PARTY, + ) - newUserAccount - } val jwt = jwtService.emitToken(user.id) return JwtAuthenticationResponse(jwt) } diff --git a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt new file mode 100644 index 0000000000..1151db7113 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt @@ -0,0 +1,93 @@ +package io.tolgee.security.thirdParty + +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.thirdParty.data.OAuthUserDetails +import io.tolgee.service.organization.OrganizationRoleService +import io.tolgee.service.security.SignUpService +import io.tolgee.service.security.UserAccountService +import org.springframework.stereotype.Component + +@Component +class OAuthUserHandler( + private val signUpService: SignUpService, + private val organizationRoleService: OrganizationRoleService, + private val userAccountService: UserAccountService, +) { + fun findOrCreateUser( + userResponse: OAuthUserDetails, + invitationCode: String?, + thirdPartyAuthType: ThirdPartyAuthType, + accountType: UserAccount.AccountType, + ): UserAccount { + val userAccount = + getUserAccount(thirdPartyAuthType, userResponse) + + if (userAccount != null) { + if (thirdPartyAuthType in arrayOf(ThirdPartyAuthType.SSO, ThirdPartyAuthType.SSO_GLOBAL)) { + userAccountService.updateSsoSession(userAccount, userResponse.refreshToken) + } + return userAccount + } + + return createUser(userResponse, invitationCode, thirdPartyAuthType, accountType) + } + + private fun getUserAccount( + thirdPartyAuthType: ThirdPartyAuthType, + userResponse: OAuthUserDetails, + ): UserAccount? { + if (thirdPartyAuthType == ThirdPartyAuthType.SSO) { + if (userResponse.tenant == null) { + // This should never happen + throw AuthenticationException(Message.THIRD_PARTY_AUTH_UNKNOWN_ERROR) + } + return userAccountService.findEnabledBySsoDomain(userResponse.tenant.domain, userResponse.sub!!) + } + if (thirdPartyAuthType !in arrayOf(ThirdPartyAuthType.SSO_GLOBAL, ThirdPartyAuthType.OAUTH2)) { + // This should never happen + throw AuthenticationException(Message.THIRD_PARTY_AUTH_UNKNOWN_ERROR) + } + return userAccountService.findByThirdParty(thirdPartyAuthType, userResponse.sub!!) + } + + private fun createUser( + userResponse: OAuthUserDetails, + invitationCode: String?, + thirdPartyAuthType: ThirdPartyAuthType, + accountType: UserAccount.AccountType, + ): UserAccount { + if (userAccountService.findActive(userResponse.email) != null) { + throw AuthenticationException(Message.USERNAME_ALREADY_EXISTS) + } + + val newUserAccount = UserAccount() + newUserAccount.username = userResponse.email + + val name = + userResponse.name ?: run { + if (userResponse.givenName != null && userResponse.familyName != null) { + "${userResponse.givenName} ${userResponse.familyName}" + } else { + userResponse.email.split("@")[0] + } + } + newUserAccount.name = name + newUserAccount.thirdPartyAuthId = userResponse.sub + newUserAccount.thirdPartyAuthType = thirdPartyAuthType + newUserAccount.ssoRefreshToken = userResponse.refreshToken + newUserAccount.accountType = accountType + newUserAccount.ssoSessionExpiry = userAccountService.getCurrentSsoExpiration(thirdPartyAuthType) + + val organization = userResponse.tenant?.organization + signUpService.signUp(newUserAccount, invitationCode, null, organizationForced = organization) + + if (organization != null) { + organizationRoleService.setManaged(newUserAccount, organization, true) + } + + return newUserAccount + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/SsoDelegateOssStub.kt b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/SsoDelegateOssStub.kt new file mode 100644 index 0000000000..e277c5b33c --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/SsoDelegateOssStub.kt @@ -0,0 +1,27 @@ +package io.tolgee.security.thirdParty + +import io.tolgee.dtos.cacheable.UserAccountDto +import io.tolgee.security.payload.JwtAuthenticationResponse +import io.tolgee.security.service.thirdParty.SsoDelegate +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import org.springframework.stereotype.Component + +@Component +@Order(Ordered.LOWEST_PRECEDENCE) +class SsoDelegateOssStub : SsoDelegate { + override fun verifyUserSsoAccountAvailable(user: UserAccountDto): Boolean { + // no-op + return true + } + + override fun getTokenResponse( + receivedCode: String?, + invitationCode: String?, + redirectUri: String?, + domain: String?, + ): JwtAuthenticationResponse { + // no-op + throw UnsupportedOperationException("Not included in OSS") + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/data/OAuthUserDetails.kt b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/data/OAuthUserDetails.kt new file mode 100644 index 0000000000..6d69919899 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/data/OAuthUserDetails.kt @@ -0,0 +1,13 @@ +package io.tolgee.security.thirdParty.data + +import io.tolgee.dtos.sso.SsoTenantConfig + +data class OAuthUserDetails( + var sub: String? = null, + var name: String? = null, + var givenName: String? = null, + var familyName: String? = null, + var email: String = "", + val refreshToken: String? = null, + val tenant: SsoTenantConfig? = null, +) diff --git a/backend/app/src/main/resources/application-e2e.yaml b/backend/app/src/main/resources/application-e2e.yaml index e3e52f6457..f8948b2aea 100644 --- a/backend/app/src/main/resources/application-e2e.yaml +++ b/backend/app/src/main/resources/application-e2e.yaml @@ -23,6 +23,15 @@ tolgee: authorization-url: "https://dummy-url.com/authorize" token-url: "https://dummy-url.com/oauth/token" user-url: "https://dummy-url.com/userinfo" + sso-organizations: + enabled: false + sso-global: + enabled: false + authorization-uri: "https://dummy-url.com" + client-id: "dummy_client_id" + client-secret: "clientSecret" + domain: "domain.com" + token-uri: "http://tokenUri" create-demo-for-initial-user: false rate-limits: # tests go brrrr but then get 429'd and no longer go brrrr global-limits: false diff --git a/backend/data/src/main/kotlin/io/tolgee/api/ISsoTenant.kt b/backend/data/src/main/kotlin/io/tolgee/api/ISsoTenant.kt new file mode 100644 index 0000000000..e371e43b18 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/api/ISsoTenant.kt @@ -0,0 +1,10 @@ +package io.tolgee.api + +interface ISsoTenant { + val clientId: String + val clientSecret: String + val authorizationUri: String + val domain: String + val tokenUri: String + val global: Boolean +} diff --git a/backend/data/src/main/kotlin/io/tolgee/api/IUserAccount.kt b/backend/data/src/main/kotlin/io/tolgee/api/IUserAccount.kt index a26a45ea81..5526f17613 100644 --- a/backend/data/src/main/kotlin/io/tolgee/api/IUserAccount.kt +++ b/backend/data/src/main/kotlin/io/tolgee/api/IUserAccount.kt @@ -11,7 +11,7 @@ interface IUserAccount { get() = this.totpKey?.isNotEmpty() ?: false val needsSuperJwt: Boolean - get() = this.accountType != UserAccount.AccountType.THIRD_PARTY || isMfaEnabled + get() = this.accountType == UserAccount.AccountType.LOCAL || isMfaEnabled val totpKey: ByteArray? diff --git a/backend/data/src/main/kotlin/io/tolgee/component/ThirdPartyAuthTypeConverter.kt b/backend/data/src/main/kotlin/io/tolgee/component/ThirdPartyAuthTypeConverter.kt new file mode 100644 index 0000000000..0647144931 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/component/ThirdPartyAuthTypeConverter.kt @@ -0,0 +1,16 @@ +package io.tolgee.component + +import io.tolgee.model.enums.ThirdPartyAuthType +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter + +@Converter() +class ThirdPartyAuthTypeConverter : AttributeConverter { + override fun convertToDatabaseColumn(attribute: ThirdPartyAuthType?): String? { + return attribute?.code() + } + + override fun convertToEntityAttribute(dbData: String?): ThirdPartyAuthType? { + return dbData?.uppercase()?.let { ThirdPartyAuthType.valueOf(it) } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/AuthenticationProperties.kt b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/AuthenticationProperties.kt index 93ce6f46bf..7480f755de 100644 --- a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/AuthenticationProperties.kt +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/AuthenticationProperties.kt @@ -81,7 +81,9 @@ class AuthenticationProperties( description = "Whether users are allowed to register on Tolgee.\n" + "When set to `false`, existing users must send invites " + - "to projects to new users for them to be able to register.", + "to projects to new users for them to be able to register.\n" + + "When SSO is enabled, users can still register via SSO, " + + "even if this setting is set to `false`.", ) var registrationsAllowed: Boolean = false, @E2eRuntimeMutable @@ -137,12 +139,16 @@ class AuthenticationProperties( "When `false`, only administrators can create organizations.\n" + "By default, when the user has no organization, one is created for them; " + "this doesn't apply when this setting is set to `false`. " + - "In that case, the user without organization has no permissions on the server.", + "In that case, the user without organization has no permissions on the server.\n\n" + + "When SSO authentication is enabled, users created by SSO don't have their " + + "own organization automatically created no matter the value of this setting.", ) var userCanCreateOrganizations: Boolean = true, var github: GithubAuthenticationProperties = GithubAuthenticationProperties(), var google: GoogleAuthenticationProperties = GoogleAuthenticationProperties(), var oauth2: OAuth2AuthenticationProperties = OAuth2AuthenticationProperties(), + var ssoGlobal: SsoGlobalProperties = SsoGlobalProperties(), + var ssoOrganizations: SsoOrganizationsProperties = SsoOrganizationsProperties(), ) { fun checkAllowedRegistrations() { if (!this.registrationsAllowed) { diff --git a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoGlobalProperties.kt b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoGlobalProperties.kt new file mode 100644 index 0000000000..46b872de4d --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoGlobalProperties.kt @@ -0,0 +1,84 @@ +package io.tolgee.configuration.tolgee + +import io.tolgee.api.ISsoTenant +import io.tolgee.configuration.annotations.DocProperty +import jakarta.annotation.PostConstruct +import org.springframework.boot.context.properties.ConfigurationProperties +import kotlin.reflect.KProperty0 + +@ConfigurationProperties(prefix = "tolgee.authentication.sso-global") +@DocProperty( + description = + "Single sign-on (SSO) is an authentication process that allows a user to" + + " access multiple applications with one set of login credentials. To use SSO" + + " in Tolgee, can either configure global SSO settings in this section or" + + " in refer to `sso-organizations` section for enabling the per Organization mode.\n\n" + + "There is a significant difference between global and per organization SSO:" + + " Global SSO can handle authentication for all server users no matter which organizations they belong to," + + " while per organization SSO can handle authentication only for users of the organization and" + + " such users cannot be members of any other organization. SSO users associated with per organization SSO have" + + " no rights to create or manage organizations. Global SSO users should be invited to organizations they need to" + + " have access to. Per organization SSO users are automatically added to the organization they belong to.", + displayName = "Server wide Single Sign-On", +) +class SsoGlobalProperties : ISsoTenant { + @E2eRuntimeMutable + @DocProperty(description = "Enables SSO authentication on global level - as a login method for the whole server") + var enabled: Boolean = false + + @DocProperty(description = "Unique identifier for an application") + override var clientId: String = "" + + @DocProperty(description = "Key used to authenticate the application") + override var clientSecret: String = "" + + @DocProperty(description = "URL to redirect users for authentication") + override var authorizationUri: String = "" + + @DocProperty(description = "URL for exchanging authorization code for tokens") + override var tokenUri: String = "" + + @DocProperty(description = "Used to identify the organization on login page") + override var domain: String = "" + + @DocProperty( + description = + "Minutes after which the server will recheck the user's with the SSO provider to" + + " ensure the user account is still valid. This is to prevent the user from being" + + " able to access the server after the account has been disabled or deleted in the SSO provider.", + ) + var sessionExpirationMinutes: Int = 10 + + @DocProperty( + description = + "Custom logo URL to be displayed on the login screen. Can be set only when `nativeEnabled` is `false`.", + ) + var customLogoUrl: String? = null + + @DocProperty( + description = "Custom text for the SSO login page.", + ) + var customLoginText: String? = null + + @DocProperty(hidden = true) + override val global: Boolean + get() = true + + @PostConstruct + fun validate() { + if (enabled) { + listOf( + ::clientId, + ::clientSecret, + ::authorizationUri, + ::domain, + ::tokenUri, + ).forEach { + it.validateIsNotBlank() + } + } + } + + private fun KProperty0.validateIsNotBlank() = + require(!this.get().isNullOrBlank()) { "Property ${this.name} must be set when SSO is enabled" } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoOrganizationsProperties.kt b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoOrganizationsProperties.kt new file mode 100644 index 0000000000..f13d2533b6 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoOrganizationsProperties.kt @@ -0,0 +1,34 @@ +package io.tolgee.configuration.tolgee + +import io.tolgee.configuration.annotations.DocProperty +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "tolgee.authentication.sso-organizations") +@DocProperty( + description = + "Single sign-on (SSO) is an authentication process that allows a user to" + + " access multiple applications with one set of login credentials. To use SSO" + + " in Tolgee, can either configure global SSO settings in `sso-global` section or" + + " in the per Organization mode by setting the `enable` to `true` in this section and configuring" + + " it separately for each organization in the organization settings.\n\n" + + "There is a significant difference between global and per organization SSO:" + + " Global SSO can handle authentication for all server users no matter which organizations they belong to," + + " while per organization SSO can handle authentication only for users of the organization and" + + " such users cannot be members of any other organization. SSO users associated with per organization SSO have" + + " no rights to create or manage organizations. Global SSO users should be invited to organizations they need to" + + " have access to. Per organization SSO users are automatically added to the organization they belong to.", + displayName = "Per-Organization Single Sign-On", +) +class SsoOrganizationsProperties { + @E2eRuntimeMutable + @DocProperty(description = "Enables SSO authentication") + var enabled: Boolean = false + + @DocProperty( + description = + "Minutes after which the server will recheck the user's with the SSO provider to" + + " ensure the user account is still valid. This is to prevent the user from being" + + " able to access the server after the account has been disabled or deleted in the SSO provider.", + ) + var sessionExpirationMinutes: Int = 10 +} diff --git a/backend/data/src/main/kotlin/io/tolgee/constants/Feature.kt b/backend/data/src/main/kotlin/io/tolgee/constants/Feature.kt index b014083e12..a67d20ecea 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Feature.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Feature.kt @@ -19,4 +19,5 @@ enum class Feature { AI_PROMPT_CUSTOMIZATION, SLACK_INTEGRATION, TASKS, + SSO, } diff --git a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt index a6319ced19..d7cb042edd 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -243,6 +243,17 @@ enum class Message { TASK_NOT_FINISHED, TASK_NOT_OPEN, THIS_FEATURE_IS_NOT_IMPLEMENTED_IN_OSS, + SSO_TOKEN_EXCHANGE_FAILED, + SSO_USER_INFO_RETRIEVAL_FAILED, + SSO_ID_TOKEN_EXPIRED, + SSO_USER_CANNOT_CREATE_ORGANIZATION, + SSO_CANT_VERIFY_USER, + SSO_AUTH_MISSING_DOMAIN, + SSO_DOMAIN_NOT_FOUND_OR_DISABLED, + NATIVE_AUTHENTICATION_DISABLED, + INVITATION_ORGANIZATION_MISMATCH, + USER_IS_MANAGED_BY_ORGANIZATION, + CANNOT_SET_SSO_PROVIDER_MISSING_FIELDS, ; val code: String diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt index 5c9399cce7..4699501ec9 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt @@ -12,6 +12,7 @@ import io.tolgee.development.testDataBuilder.builders.TranslationBuilder import io.tolgee.development.testDataBuilder.builders.UserAccountBuilder import io.tolgee.development.testDataBuilder.builders.UserPreferencesBuilder import io.tolgee.development.testDataBuilder.builders.slack.SlackUserConnectionBuilder +import io.tolgee.service.TenantService import io.tolgee.service.automations.AutomationService import io.tolgee.service.bigMeta.BigMetaService import io.tolgee.service.contentDelivery.ContentDeliveryConfigService @@ -62,6 +63,7 @@ class TestDataService( private val screenshotService: ScreenshotService, private val translationCommentService: TranslationCommentService, private val tagService: TagService, + private val tenantService: TenantService, private val organizationService: OrganizationService, private val organizationRoleService: OrganizationRoleService, private val apiKeyService: ApiKeyService, @@ -172,6 +174,7 @@ class TestDataService( saveOrganizationAvatars(builder) saveAllMtCreditBuckets(builder) saveSlackWorkspaces(builder) + saveOrganizationTenants(builder) } private fun saveSlackWorkspaces(builder: TestDataBuilder) { @@ -194,6 +197,10 @@ class TestDataService( organizationRoleService.saveAll(builder.data.organizations.flatMap { it.data.roles.map { it.self } }) } + private fun saveOrganizationTenants(builder: TestDataBuilder) { + tenantService.saveAll(builder.data.organizations.mapNotNull { it.data.tenant?.self }) + } + private fun finalize() { entityManager.flush() clearEntityManager() diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/OrganizationBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/OrganizationBuilder.kt index 69993ff5ae..3706e5133d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/OrganizationBuilder.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/OrganizationBuilder.kt @@ -5,6 +5,7 @@ import io.tolgee.model.MtCreditBucket import io.tolgee.model.Organization import io.tolgee.model.OrganizationRole import io.tolgee.model.Permission +import io.tolgee.model.SsoTenant import io.tolgee.model.UserAccount import io.tolgee.model.enums.ProjectPermissionType.VIEW import io.tolgee.model.slackIntegration.OrganizationSlackWorkspace @@ -17,6 +18,7 @@ class OrganizationBuilder( var roles: MutableList = mutableListOf() var avatarFile: ClassPathResource? = null var slackWorkspaces: MutableList = mutableListOf() + var tenant: SsoTenantBuilder? = null } var defaultOrganizationOfUser: UserAccount? = null @@ -47,4 +49,11 @@ class OrganizationBuilder( fun setAvatar(filePath: String) { data.avatarFile = ClassPathResource(filePath, this.javaClass.classLoader) } + + fun setTenant(ft: FT): SsoTenantBuilder { + val builder = SsoTenantBuilder(this) + ft(builder.self) + data.tenant = builder + return builder + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/SsoTenantBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/SsoTenantBuilder.kt new file mode 100644 index 0000000000..b89f1fda10 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/SsoTenantBuilder.kt @@ -0,0 +1,14 @@ +package io.tolgee.development.testDataBuilder.builders + +import io.tolgee.development.testDataBuilder.EntityDataBuilder +import io.tolgee.model.SsoTenant + +class SsoTenantBuilder( + val organizationBuilder: OrganizationBuilder, +) : EntityDataBuilder { + override var self: SsoTenant = + SsoTenant().apply { + organization = organizationBuilder.self + organizationBuilder.self.ssoTenant = this + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SsoOrganizationsLoginTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SsoOrganizationsLoginTestData.kt new file mode 100644 index 0000000000..4c479b1e75 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SsoOrganizationsLoginTestData.kt @@ -0,0 +1,44 @@ +package io.tolgee.development.testDataBuilder.data + +import io.tolgee.development.testDataBuilder.builders.UserAccountBuilder +import io.tolgee.model.enums.OrganizationRoleType + +class SsoOrganizationsLoginTestData : BaseTestData("ssoOrgLoginTestUser", "Empty project") { + var orgAdmin: UserAccountBuilder +// var orgMember: UserAccountBuilder + + init { +// orgMember = +// root.addUserAccount { +// username = "organization.member@test.com" +// name = "Organization member" +// } + + orgAdmin = + root.addUserAccount { + username = "organization.owner@test.com" + name = "Organization owner" + } + + userAccountBuilder.defaultOrganizationBuilder.apply { +// addRole { +// user = orgMember.self +// type = OrganizationRoleType.MEMBER +// } + + addRole { + user = orgAdmin.self + type = OrganizationRoleType.OWNER + } + + setTenant { + enabled = true + authorizationUri = "https://dummy-url.com" + clientId = "dummy_client_id" + clientSecret = "clientSecret" + domain = "domain.com" + tokenUri = "http://tokenUri" + } + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SsoTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SsoTestData.kt new file mode 100644 index 0000000000..5ae2b05687 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SsoTestData.kt @@ -0,0 +1,36 @@ +package io.tolgee.development.testDataBuilder.data + +import io.tolgee.model.SsoTenant +import io.tolgee.model.UserAccount +import io.tolgee.model.enums.OrganizationRoleType + +class SsoTestData() : BaseTestData() { + val organization = this.projectBuilder.self.organizationOwner + + var userNotOwner: UserAccount + lateinit var tenant: SsoTenant + + init { + userNotOwner = + root.addUserAccount { + username = "userNotOwner" + }.self + userAccountBuilder.defaultOrganizationBuilder.apply { + addRole { + user = userNotOwner + type = OrganizationRoleType.MEMBER + } + } + } + + fun addTenant() { + tenant = + userAccountBuilder.defaultOrganizationBuilder.setTenant { + domain = "registrationId" + clientId = "dummy_client_id" + clientSecret = "clientSecret" + authorizationUri = "https://dummy-url.com" + tokenUri = "http://tokenUri" + }.self + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/UserAccountDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/UserAccountDto.kt index b929cff48d..6c22ec4fa9 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/UserAccountDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/UserAccountDto.kt @@ -1,6 +1,7 @@ package io.tolgee.dtos.cacheable import io.tolgee.model.UserAccount +import io.tolgee.model.enums.ThirdPartyAuthType import java.io.Serializable import java.util.* @@ -14,6 +15,9 @@ data class UserAccountDto( val deleted: Boolean, val tokensValidNotBefore: Date?, val emailVerified: Boolean, + val thirdPartyAuth: ThirdPartyAuthType?, + val ssoRefreshToken: String?, + val ssoSessionExpiry: Date?, ) : Serializable { companion object { fun fromEntity(entity: UserAccount) = @@ -27,6 +31,9 @@ data class UserAccountDto( deleted = entity.deletedAt != null, tokensValidNotBefore = entity.tokensValidNotBefore, emailVerified = entity.emailVerification == null, + thirdPartyAuth = entity.thirdPartyAuthType, + ssoRefreshToken = entity.ssoRefreshToken, + ssoSessionExpiry = entity.ssoSessionExpiry, ) } diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/sso/SsoTenantConfig.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/sso/SsoTenantConfig.kt new file mode 100644 index 0000000000..bfcbb9bf06 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/sso/SsoTenantConfig.kt @@ -0,0 +1,24 @@ +package io.tolgee.dtos.sso + +import io.tolgee.api.ISsoTenant +import io.tolgee.model.Organization + +data class SsoTenantConfig( + override val clientId: String, + override val clientSecret: String, + override val authorizationUri: String, + override val domain: String, + override val tokenUri: String, + override val global: Boolean, + val organization: Organization? = null, +) : ISsoTenant { + constructor(other: ISsoTenant, organization: Organization?) : this( + clientId = other.clientId, + clientSecret = other.clientSecret, + authorizationUri = other.authorizationUri, + domain = other.domain, + tokenUri = other.tokenUri, + global = other.global, + organization = organization, + ) +} diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/sso/SsoTenantDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/sso/SsoTenantDto.kt new file mode 100644 index 0000000000..d1eb949c60 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/sso/SsoTenantDto.kt @@ -0,0 +1,26 @@ +package io.tolgee.dtos.sso + +import io.tolgee.api.ISsoTenant +import io.tolgee.model.SsoTenant + +data class SsoTenantDto( + val enabled: Boolean, + override val authorizationUri: String, + override val clientId: String, + override val clientSecret: String, + override val tokenUri: String, + override val domain: String, +) : ISsoTenant { + override val global: Boolean + get() = false +} + +fun SsoTenant.toDto(): SsoTenantDto = + SsoTenantDto( + authorizationUri = this.authorizationUri, + clientId = this.clientId, + clientSecret = this.clientSecret, + tokenUri = this.tokenUri, + enabled = this.enabled, + domain = this.domain, + ) diff --git a/backend/data/src/main/kotlin/io/tolgee/exceptions/AuthExpiredException.kt b/backend/data/src/main/kotlin/io/tolgee/exceptions/AuthExpiredException.kt new file mode 100644 index 0000000000..f57b4a1ea3 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/exceptions/AuthExpiredException.kt @@ -0,0 +1,5 @@ +package io.tolgee.exceptions + +import io.tolgee.constants.Message + +class AuthExpiredException(message: Message) : AuthenticationException(message) diff --git a/backend/data/src/main/kotlin/io/tolgee/exceptions/AuthenticationException.kt b/backend/data/src/main/kotlin/io/tolgee/exceptions/AuthenticationException.kt index 43b35601f7..98a13f80f8 100644 --- a/backend/data/src/main/kotlin/io/tolgee/exceptions/AuthenticationException.kt +++ b/backend/data/src/main/kotlin/io/tolgee/exceptions/AuthenticationException.kt @@ -1,10 +1,20 @@ package io.tolgee.exceptions +import io.tolgee.constants.Message import org.springframework.http.HttpStatus -import org.springframework.web.bind.annotation.ResponseStatus +import java.io.Serializable + +open class AuthenticationException : ErrorException { + constructor(message: Message) : super(message) + + constructor(message: Message, params: List? = null, cause: Exception? = null) : super( + message, + params, + cause, + ) + + constructor(code: String, params: List? = null, cause: Exception? = null) : super(code, params, cause) -@ResponseStatus(HttpStatus.UNAUTHORIZED) -class AuthenticationException(message: io.tolgee.constants.Message) : ErrorException(message) { override val httpStatus: HttpStatus get() = HttpStatus.UNAUTHORIZED } diff --git a/backend/data/src/main/kotlin/io/tolgee/exceptions/DisabledFunctionalityException.kt b/backend/data/src/main/kotlin/io/tolgee/exceptions/DisabledFunctionalityException.kt new file mode 100644 index 0000000000..12f0cb13be --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/exceptions/DisabledFunctionalityException.kt @@ -0,0 +1,11 @@ +package io.tolgee.exceptions + +import io.tolgee.constants.Message +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.ResponseStatus + +@ResponseStatus(HttpStatus.CONFLICT) +class DisabledFunctionalityException(message: Message) : ErrorException(message) { + override val httpStatus: HttpStatus + get() = HttpStatus.CONFLICT +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/Organization.kt b/backend/data/src/main/kotlin/io/tolgee/model/Organization.kt index 0c27a0e510..283169f8d0 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/Organization.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/Organization.kt @@ -62,6 +62,9 @@ class Organization( override var deletedAt: Date? = null + @OneToOne(mappedBy = "organization", fetch = FetchType.LAZY) + var ssoTenant: SsoTenant? = null + @OneToMany(mappedBy = "organization", fetch = FetchType.LAZY, orphanRemoval = true) var organizationSlackWorkspace: MutableList = mutableListOf() } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/OrganizationRole.kt b/backend/data/src/main/kotlin/io/tolgee/model/OrganizationRole.kt index 775ce166a7..a4682d6065 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/OrganizationRole.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/OrganizationRole.kt @@ -38,6 +38,10 @@ class OrganizationRole( @ManyToOne var user: UserAccount? = null + // Unique constraint manually created in the schema: + // - Only one role where managed is true per user + var managed: Boolean = false + @ManyToOne @NotNull var organization: Organization? = null diff --git a/backend/data/src/main/kotlin/io/tolgee/model/SsoTenant.kt b/backend/data/src/main/kotlin/io/tolgee/model/SsoTenant.kt new file mode 100644 index 0000000000..d0c90c0882 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/SsoTenant.kt @@ -0,0 +1,33 @@ +package io.tolgee.model + +import io.tolgee.api.ISsoTenant +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.OneToOne +import jakarta.persistence.Table +import jakarta.validation.constraints.NotNull +import org.hibernate.annotations.ColumnDefault + +@Entity +@Table(name = "tenant") +class SsoTenant : ISsoTenant, StandardAuditModel() { + /** + * The domain column uses unique constraint. + * When the tenant is enabled the domain must not be empty and must be unique across all enabled tenants. + */ + override var domain: String = "" + override var clientId: String = "" + override var clientSecret: String = "" + override var authorizationUri: String = "" + override var tokenUri: String = "" + + @NotNull + @OneToOne(fetch = FetchType.LAZY) + lateinit var organization: Organization + + override val global: Boolean + get() = false + + @ColumnDefault("true") + var enabled: Boolean = true +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt index d2dfc91d9a..732875fdb7 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt @@ -3,11 +3,14 @@ package io.tolgee.model import io.hypersistence.utils.hibernate.type.array.ListArrayType import io.tolgee.activity.annotation.ActivityLoggedEntity import io.tolgee.api.IUserAccount +import io.tolgee.component.ThirdPartyAuthTypeConverter +import io.tolgee.model.enums.ThirdPartyAuthType import io.tolgee.model.slackIntegration.SlackConfig import io.tolgee.model.slackIntegration.SlackUserConnection import io.tolgee.model.task.Task import jakarta.persistence.CascadeType import jakarta.persistence.Column +import jakarta.persistence.Convert import jakarta.persistence.Entity import jakarta.persistence.EnumType import jakarta.persistence.Enumerated @@ -57,7 +60,14 @@ data class UserAccount( var emailVerification: EmailVerification? = null @Column(name = "third_party_auth_type") - var thirdPartyAuthType: String? = null + @Convert(converter = ThirdPartyAuthTypeConverter::class) + var thirdPartyAuthType: ThirdPartyAuthType? = null + + @Column(name = "sso_refresh_token", columnDefinition = "TEXT") + var ssoRefreshToken: String? = null + + @Column(name = "sso_session_expiry") + var ssoSessionExpiry: Date? = null @Column(name = "third_party_auth_id") var thirdPartyAuthId: String? = null @@ -117,7 +127,7 @@ data class UserAccount( permissions: MutableSet, role: Role = Role.USER, accountType: AccountType = AccountType.LOCAL, - thirdPartyAuthType: String?, + thirdPartyAuthType: ThirdPartyAuthType?, thirdPartyAuthId: String?, resetPasswordCode: String?, ) : this(id = 0L, username = "", password, name = "") { diff --git a/backend/data/src/main/kotlin/io/tolgee/model/enums/ThirdPartyAuthType.kt b/backend/data/src/main/kotlin/io/tolgee/model/enums/ThirdPartyAuthType.kt new file mode 100644 index 0000000000..cf4aea8787 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/enums/ThirdPartyAuthType.kt @@ -0,0 +1,12 @@ +package io.tolgee.model.enums + +enum class ThirdPartyAuthType { + GOOGLE, + GITHUB, + OAUTH2, + SSO, + SSO_GLOBAL, + ; + + fun code(): String = name.lowercase() +} diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRoleRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRoleRepository.kt index bca49eee78..eb2146e186 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRoleRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRoleRepository.kt @@ -15,6 +15,8 @@ interface OrganizationRoleRepository : JpaRepository { organizationId: Long, ): OrganizationRole? + fun findOneByUserIdAndManagedIsTrue(userId: Long): OrganizationRole? + fun countAllByOrganizationIdAndTypeAndUserIdNot( id: Long, owner: OrganizationRoleType, diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt index ec6435c76b..bd6d1f4ecd 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt @@ -3,6 +3,7 @@ package io.tolgee.repository import io.tolgee.dtos.queryResults.UserAccountView import io.tolgee.dtos.request.task.UserAccountFilters import io.tolgee.model.UserAccount +import io.tolgee.model.enums.ThirdPartyAuthType import io.tolgee.model.views.UserAccountInProjectView import io.tolgee.model.views.UserAccountWithOrganizationRoleView import org.springframework.context.annotation.Lazy @@ -159,8 +160,27 @@ interface UserAccountRepository : JpaRepository { ) fun findThirdByThirdParty( thirdPartyAuthId: String, - thirdPartyAuthType: String, - ): Optional + thirdPartyAuthType: ThirdPartyAuthType, + ): UserAccount? + + @Query( + """ + from UserAccount ua + join OrganizationRole orl on orl.user = ua + join Organization o on orl.organization = o + where ua.thirdPartyAuthId = :thirdPartyAuthId + and orl.managed = true + and o.ssoTenant.domain = :domain + and o.ssoTenant.enabled = true + and o.deletedAt is null + and ua.deletedAt is null + and ua.disabledAt is null + """, + ) + fun findEnabledBySsoDomain( + thirdPartyAuthId: String, + domain: String, + ): UserAccount? @Query( """ select ua.id as id, ua.name as name, ua.username as username, mr.type as organizationRole, diff --git a/backend/data/src/main/kotlin/io/tolgee/security/authentication/JwtService.kt b/backend/data/src/main/kotlin/io/tolgee/security/authentication/JwtService.kt index 7563edb179..a1904f4603 100644 --- a/backend/data/src/main/kotlin/io/tolgee/security/authentication/JwtService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/security/authentication/JwtService.kt @@ -28,6 +28,7 @@ import io.tolgee.component.CurrentDateProvider import io.tolgee.configuration.tolgee.AuthenticationProperties import io.tolgee.constants.Message import io.tolgee.dtos.cacheable.UserAccountDto +import io.tolgee.exceptions.AuthExpiredException import io.tolgee.exceptions.AuthenticationException import io.tolgee.service.security.UserAccountService import org.springframework.beans.factory.annotation.Qualifier @@ -129,7 +130,7 @@ class JwtService( val account = validateJwt(jws.body) if (account.tokensValidNotBefore != null && jws.body.issuedAt.before(account.tokensValidNotBefore)) { - throw AuthenticationException(Message.EXPIRED_JWT_TOKEN) + throw AuthExpiredException(Message.EXPIRED_JWT_TOKEN) } val steClaim = jws.body[SUPER_JWT_TOKEN_EXPIRATION_CLAIM] as? Long diff --git a/backend/data/src/main/kotlin/io/tolgee/service/TenantService.kt b/backend/data/src/main/kotlin/io/tolgee/service/TenantService.kt new file mode 100644 index 0000000000..eddbebfe02 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/service/TenantService.kt @@ -0,0 +1,25 @@ +package io.tolgee.service + +import io.tolgee.dtos.sso.SsoTenantConfig +import io.tolgee.dtos.sso.SsoTenantDto +import io.tolgee.model.Organization +import io.tolgee.model.SsoTenant + +interface TenantService { + fun getEnabledConfigByDomain(domain: String): SsoTenantConfig + + fun save(tenant: SsoTenant): SsoTenant + + fun saveAll(tenants: Iterable): List + + fun findAll(): List + + fun findTenant(organizationId: Long): SsoTenant? + + fun getTenant(organizationId: Long): SsoTenant + + fun createOrUpdate( + request: SsoTenantDto, + organization: Organization, + ): SsoTenant +} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/TenantServiceOssStub.kt b/backend/data/src/main/kotlin/io/tolgee/service/TenantServiceOssStub.kt new file mode 100644 index 0000000000..17afaedf63 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/service/TenantServiceOssStub.kt @@ -0,0 +1,53 @@ +package io.tolgee.service + +import io.tolgee.constants.Message +import io.tolgee.dtos.sso.SsoTenantConfig +import io.tolgee.dtos.sso.SsoTenantDto +import io.tolgee.exceptions.NotFoundException +import io.tolgee.model.Organization +import io.tolgee.model.SsoTenant +import org.springframework.stereotype.Service + +@Service +class TenantServiceOssStub : TenantService { + override fun getEnabledConfigByDomain(domain: String): SsoTenantConfig { + throw NotFoundException(Message.SSO_DOMAIN_NOT_FOUND_OR_DISABLED) + } + + override fun save(tenant: SsoTenant): SsoTenant { + // no-op + throw UnsupportedOperationException("Not included in OSS") + } + + override fun saveAll(tenants: Iterable): List { + // no-op + if (tenants.any { true }) { + // isn't empty + throw UnsupportedOperationException("Not included in OSS") + } + return emptyList() + } + + override fun findAll(): List { + // no-op + return emptyList() + } + + override fun findTenant(organizationId: Long): SsoTenant? { + // no-op + return null + } + + override fun getTenant(organizationId: Long): SsoTenant { + // no-op + throw UnsupportedOperationException("Not included in OSS") + } + + override fun createOrUpdate( + request: SsoTenantDto, + organization: Organization, + ): SsoTenant { + // no-op + throw UnsupportedOperationException("Not included in OSS") + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationRoleService.kt b/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationRoleService.kt index 3282bde189..332779c9d9 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationRoleService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationRoleService.kt @@ -192,12 +192,33 @@ class OrganizationRoleService( return UserOrganizationRoleDto.fromEntity(userId, entity) } + fun getManagedBy(userId: Long): Organization? { + return organizationRoleRepository.findOneByUserIdAndManagedIsTrue(userId)?.organization + } + + @CacheEvict(Caches.ORGANIZATION_ROLES, key = "{#organization.id, #user.id}") + fun setManaged( + user: UserAccount, + organization: Organization, + managed: Boolean, + ) { + val role = + organizationRoleRepository.findOneByUserIdAndOrganizationId(user.id, organization.id) + ?: throw NotFoundException(Message.USER_IS_NOT_MEMBER_OF_ORGANIZATION) + role.managed = managed + organizationRoleRepository.save(role) + } + @CacheEvict(Caches.ORGANIZATION_ROLES, key = "{#organization.id, #user.id}") fun grantRoleToUser( user: UserAccount, organization: Organization, organizationRoleType: OrganizationRoleType, ) { + val managedBy = getManagedBy(user.id) + if (managedBy != null && managedBy.id != organization.id) { + throw ValidationException(Message.USER_IS_MANAGED_BY_ORGANIZATION) + } OrganizationRole(user = user, organization = organization, type = organizationRoleType) .let { organization.memberRoles.add(it) @@ -214,6 +235,10 @@ class OrganizationRoleService( organizationId: Long, userId: Long, ) { + val managedBy = getManagedBy(userId) + if (managedBy != null && managedBy.id == organizationId) { + throw ValidationException(Message.USER_IS_MANAGED_BY_ORGANIZATION) + } val role = organizationRoleRepository.findOneByUserIdAndOrganizationId(userId, organizationId)?.let { organizationRoleRepository.delete(it) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationService.kt b/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationService.kt index fa21001f57..4c9a2a81be 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationService.kt @@ -16,10 +16,12 @@ import io.tolgee.model.Permission 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.repository.OrganizationRepository import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.service.AvatarService import io.tolgee.service.QuickStartService +import io.tolgee.service.TenantService import io.tolgee.service.invitation.InvitationService import io.tolgee.service.project.ProjectService import io.tolgee.service.security.PermissionService @@ -46,6 +48,7 @@ import io.tolgee.dtos.cacheable.OrganizationDto as CachedOrganizationDto @Transactional class OrganizationService( private val organizationRepository: OrganizationRepository, + private val tenantService: TenantService?, private val authenticationFacade: AuthenticationFacade, private val slugGenerator: SlugGenerator, private val organizationRoleService: OrganizationRoleService, @@ -145,7 +148,11 @@ class OrganizationService( exceptOrganizationId: Long = 0, ): Organization? { return findPreferred(userAccount.id, exceptOrganizationId) ?: let { - if (tolgeeProperties.authentication.userCanCreateOrganizations || userAccount.role == UserAccount.Role.ADMIN) { + val canCreateOrganizations = + tolgeeProperties.authentication.userCanCreateOrganizations && + userAccount.thirdPartyAuthType !== ThirdPartyAuthType.SSO + + if (canCreateOrganizations || userAccount.role == UserAccount.Role.ADMIN) { return@let createPreferred(userAccount) } null @@ -237,6 +244,11 @@ class OrganizationService( ], ) fun delete(organization: Organization) { + val tenant = organization.ssoTenant + if (tenant != null && tenant.enabled) { + tenant.enabled = false + tenantService?.save(tenant) + } organization.deletedAt = currentDateProvider.date save(organization) eventPublisher.publishEvent(BeforeOrganizationDeleteEvent(organization)) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/SignUpService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/SignUpService.kt index 38f7f623b2..6cb4ded6de 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/SignUpService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/SignUpService.kt @@ -5,13 +5,16 @@ import io.tolgee.constants.Message import io.tolgee.dtos.request.auth.SignUpDto import io.tolgee.exceptions.AuthenticationException import io.tolgee.exceptions.BadRequestException -import io.tolgee.model.Invitation +import io.tolgee.model.Organization import io.tolgee.model.UserAccount +import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.model.enums.ThirdPartyAuthType import io.tolgee.security.authentication.JwtService import io.tolgee.security.payload.JwtAuthenticationResponse import io.tolgee.service.EmailVerificationService import io.tolgee.service.QuickStartService import io.tolgee.service.invitation.InvitationService +import io.tolgee.service.organization.OrganizationRoleService import io.tolgee.service.organization.OrganizationService import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service @@ -25,6 +28,7 @@ class SignUpService( private val jwtService: JwtService, private val emailVerificationService: EmailVerificationService, private val organizationService: OrganizationService, + private val organizationRoleService: OrganizationRoleService, private val quickStartService: QuickStartService, private val passwordEncoder: PasswordEncoder, ) { @@ -45,20 +49,42 @@ class SignUpService( return JwtAuthenticationResponse(jwtService.emitToken(user.id, true)) } + @Transactional fun signUp( entity: UserAccount, invitationCode: String?, organizationName: String?, userSource: String? = null, + organizationForced: Organization? = null, ): UserAccount { - val invitation = findAndCheckInvitationOnRegistration(invitationCode) + if (invitationCode == null && + entity.accountType != UserAccount.AccountType.MANAGED && + !tolgeeProperties.authentication.registrationsAllowed + ) { + throw AuthenticationException(Message.REGISTRATIONS_NOT_ALLOWED) + } + + val invitation = invitationCode?.let(invitationService::getInvitation) val user = userAccountService.createUser(entity, userSource) if (invitation != null) { + if (organizationForced != null && invitation.organizationRole?.organization != organizationForced) { + // Invitations are allowed only for specific organization + throw BadRequestException(Message.INVITATION_ORGANIZATION_MISMATCH) + } invitationService.accept(invitation.code, user) + } else if (organizationForced != null) { + organizationRoleService.grantRoleToUser( + user, + organizationForced, + OrganizationRoleType.MEMBER, + ) } - val canCreateOrganization = tolgeeProperties.authentication.userCanCreateOrganizations - if (canCreateOrganization && (invitation == null || !organizationName.isNullOrBlank())) { + if ( + user.thirdPartyAuthType != ThirdPartyAuthType.SSO && + tolgeeProperties.authentication.userCanCreateOrganizations && + (invitation == null || !organizationName.isNullOrBlank()) + ) { val name = if (organizationName.isNullOrBlank()) user.name else organizationName val organization = organizationService.createPreferred(user, name) quickStartService.create(user, organization) @@ -70,15 +96,4 @@ class SignUpService( val encodedPassword = passwordEncoder.encode(request.password!!) return UserAccount(name = request.name, username = request.email, password = encodedPassword) } - - @Transactional - fun findAndCheckInvitationOnRegistration(invitationCode: String?): Invitation? { - if (invitationCode == null) { - if (!tolgeeProperties.authentication.registrationsAllowed) { - throw AuthenticationException(Message.REGISTRATIONS_NOT_ALLOWED) - } - return null - } - return invitationService.getInvitation(invitationCode) - } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt index 7a988efe06..994f90bdd7 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt @@ -20,6 +20,7 @@ import io.tolgee.exceptions.BadRequestException import io.tolgee.exceptions.NotFoundException import io.tolgee.exceptions.PermissionException import io.tolgee.model.UserAccount +import io.tolgee.model.enums.ThirdPartyAuthType import io.tolgee.model.views.ExtendedUserAccountInProject import io.tolgee.model.views.UserAccountInProjectView import io.tolgee.model.views.UserAccountWithOrganizationRoleView @@ -28,6 +29,7 @@ import io.tolgee.service.AvatarService import io.tolgee.service.EmailVerificationService import io.tolgee.service.organization.OrganizationService import io.tolgee.util.Logging +import io.tolgee.util.addMinutes import jakarta.persistence.EntityManager import jakarta.servlet.http.HttpServletRequest import org.apache.commons.lang3.time.DateUtils @@ -91,6 +93,7 @@ class UserAccountService( return userAccountRepository.findInitialUser() } + @Transactional fun get(id: Long): UserAccount { return this.findActive(id) ?: throw NotFoundException(Message.USER_NOT_FOUND) } @@ -223,12 +226,17 @@ class UserAccountService( } fun findByThirdParty( - type: String, + type: ThirdPartyAuthType, id: String, - ): Optional { + ): UserAccount? { return userAccountRepository.findThirdByThirdParty(id, type) } + fun findEnabledBySsoDomain( + type: String, + idSub: String, + ): UserAccount? = userAccountRepository.findEnabledBySsoDomain(idSub, type) + @Transactional @CacheEvict(cacheNames = [Caches.USER_ACCOUNTS], key = "#result.id") fun setAccountType( @@ -320,6 +328,27 @@ class UserAccountService( return userAccountRepository.save(userAccount) } + @Transactional + @CacheEvict(cacheNames = [Caches.USER_ACCOUNTS], key = "#userAccount.id") + fun updateSsoSession( + userAccount: UserAccount, + refreshToken: String?, + ): UserAccount { + userAccount.ssoRefreshToken = refreshToken + userAccount.ssoSessionExpiry = getCurrentSsoExpiration(userAccount.thirdPartyAuthType) + return userAccountRepository.save(userAccount) + } + + fun getCurrentSsoExpiration(type: ThirdPartyAuthType?): Date? { + return currentDateProvider.date.addMinutes( + when (type) { + ThirdPartyAuthType.SSO -> tolgeeProperties.authentication.ssoOrganizations.sessionExpirationMinutes + ThirdPartyAuthType.SSO_GLOBAL -> tolgeeProperties.authentication.ssoGlobal.sessionExpirationMinutes + else -> return null + }, + ) + } + @Transactional @CacheEvict(cacheNames = [Caches.USER_ACCOUNTS], key = "#userAccount.id") fun removeAvatar(userAccount: UserAccount) { diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index 6f57803241..9df08a4be0 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -3878,4 +3878,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + create unique index on public.tenant (domain) where enabled = true; + + + alter table tenant add constraint tenant_domain_not_blank check (enabled = false or (domain is not null and domain != '')); + + + + + + + + create unique index organization_member_role_only_one_managed on public.organization_role (user_id) where managed = true; + + + + + diff --git a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/SsoOrganizationsLoginE2eDataController.kt b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/SsoOrganizationsLoginE2eDataController.kt new file mode 100644 index 0000000000..b06b05ae54 --- /dev/null +++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/SsoOrganizationsLoginE2eDataController.kt @@ -0,0 +1,26 @@ +package io.tolgee.controllers.internal.e2eData + +import io.swagger.v3.oas.annotations.Hidden +import io.tolgee.development.testDataBuilder.builders.TestDataBuilder +import io.tolgee.development.testDataBuilder.data.SsoOrganizationsLoginTestData +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.CrossOrigin +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@CrossOrigin(origins = ["*"]) +@Hidden +@RequestMapping(value = ["internal/e2e-data/sso-organizations-login"]) +@Transactional +class SsoOrganizationsLoginE2eDataController : AbstractE2eDataController() { + @GetMapping(value = ["/generate"]) + @Transactional + fun generateBasicTestData() { + testDataService.saveTestData(testData) + } + + override val testData: TestDataBuilder + get() = SsoOrganizationsLoginTestData().root +} diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFilter.kt b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFilter.kt index 96c54562a3..3035680c4c 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFilter.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthenticationFilter.kt @@ -20,9 +20,11 @@ import io.tolgee.component.CurrentDateProvider import io.tolgee.configuration.tolgee.AuthenticationProperties import io.tolgee.constants.Message import io.tolgee.dtos.cacheable.UserAccountDto +import io.tolgee.exceptions.AuthExpiredException import io.tolgee.exceptions.AuthenticationException import io.tolgee.security.PAT_PREFIX import io.tolgee.security.ratelimit.RateLimitService +import io.tolgee.security.service.thirdParty.SsoDelegate import io.tolgee.service.security.ApiKeyService import io.tolgee.service.security.PatService import io.tolgee.service.security.UserAccountService @@ -50,6 +52,8 @@ class AuthenticationFilter( private val apiKeyService: ApiKeyService, @Lazy private val patService: PatService, + @Lazy + private val ssoDelegate: SsoDelegate, ) : OncePerRequestFilter() { override fun doFilterInternal( request: HttpServletRequest, @@ -79,6 +83,8 @@ class AuthenticationFilter( if (authorization != null) { if (authorization.startsWith("Bearer ")) { val auth = jwtService.validateToken(authorization.substring(7)) + checkIfSsoUserStillValid(auth.principal) + SecurityContextHolder.getContext().authentication = auth return } @@ -111,6 +117,12 @@ class AuthenticationFilter( } } + private fun checkIfSsoUserStillValid(userDto: UserAccountDto) { + if (!ssoDelegate.verifyUserSsoAccountAvailable(userDto)) { + throw AuthExpiredException(Message.SSO_CANT_VERIFY_USER) + } + } + private fun pakAuth(key: String) { val parsed = apiKeyService.parseApiKey(key) @@ -129,6 +141,8 @@ class AuthenticationFilter( userAccountService.findDto(pak.userAccountId) ?: throw AuthenticationException(Message.USER_NOT_FOUND) + checkIfSsoUserStillValid(userAccount) + apiKeyService.updateLastUsedAsync(pak.id) SecurityContextHolder.getContext().authentication = TolgeeAuthentication( @@ -152,6 +166,8 @@ class AuthenticationFilter( userAccountService.findDto(pat.userAccountId) ?: throw AuthenticationException(Message.USER_NOT_FOUND) + checkIfSsoUserStillValid(userAccount) + patService.updateLastUsedAsync(pat.id) SecurityContextHolder.getContext().authentication = TolgeeAuthentication( diff --git a/backend/security/src/main/kotlin/io/tolgee/security/service/thirdParty/SsoDelegate.kt b/backend/security/src/main/kotlin/io/tolgee/security/service/thirdParty/SsoDelegate.kt new file mode 100644 index 0000000000..2299d36215 --- /dev/null +++ b/backend/security/src/main/kotlin/io/tolgee/security/service/thirdParty/SsoDelegate.kt @@ -0,0 +1,10 @@ +package io.tolgee.security.service.thirdParty + +import io.tolgee.dtos.cacheable.UserAccountDto + +interface SsoDelegate : ThirdPartyAuthDelegate { + override val name: String + get() = "sso" + + fun verifyUserSsoAccountAvailable(user: UserAccountDto): Boolean +} diff --git a/backend/security/src/main/kotlin/io/tolgee/security/service/thirdParty/ThirdPartyAuthDelegate.kt b/backend/security/src/main/kotlin/io/tolgee/security/service/thirdParty/ThirdPartyAuthDelegate.kt new file mode 100644 index 0000000000..38e9098845 --- /dev/null +++ b/backend/security/src/main/kotlin/io/tolgee/security/service/thirdParty/ThirdPartyAuthDelegate.kt @@ -0,0 +1,14 @@ +package io.tolgee.security.service.thirdParty + +import io.tolgee.security.payload.JwtAuthenticationResponse + +interface ThirdPartyAuthDelegate { + val name: String + + fun getTokenResponse( + receivedCode: String?, + invitationCode: String?, + redirectUri: String?, + domain: String?, + ): JwtAuthenticationResponse +} diff --git a/backend/security/src/test/kotlin/io/tolgee/security/authentication/AuthenticationDisabledFilterTest.kt b/backend/security/src/test/kotlin/io/tolgee/security/authentication/AuthenticationDisabledFilterTest.kt index dbcbf1773e..c1c912eff7 100644 --- a/backend/security/src/test/kotlin/io/tolgee/security/authentication/AuthenticationDisabledFilterTest.kt +++ b/backend/security/src/test/kotlin/io/tolgee/security/authentication/AuthenticationDisabledFilterTest.kt @@ -45,7 +45,7 @@ class AuthenticationDisabledFilterTest { private val userAccount = mock(UserAccount::class.java) private val authenticationDisabledFilter = - AuthenticationFilter(authProperties, mock(), mock(), mock(), userAccountService, mock(), mock()) + AuthenticationFilter(authProperties, mock(), mock(), mock(), userAccountService, mock(), mock(), mock()) @BeforeEach fun setupMocksAndSecurityCtx() { diff --git a/backend/security/src/test/kotlin/io/tolgee/security/authentication/AuthenticationFilterTest.kt b/backend/security/src/test/kotlin/io/tolgee/security/authentication/AuthenticationFilterTest.kt index bf11fea501..746ef24655 100644 --- a/backend/security/src/test/kotlin/io/tolgee/security/authentication/AuthenticationFilterTest.kt +++ b/backend/security/src/test/kotlin/io/tolgee/security/authentication/AuthenticationFilterTest.kt @@ -27,6 +27,7 @@ import io.tolgee.model.UserAccount import io.tolgee.security.ratelimit.RateLimitPolicy import io.tolgee.security.ratelimit.RateLimitService import io.tolgee.security.ratelimit.RateLimitedException +import io.tolgee.security.service.thirdParty.SsoDelegate import io.tolgee.service.security.ApiKeyService import io.tolgee.service.security.PatService import io.tolgee.service.security.UserAccountService @@ -80,6 +81,8 @@ class AuthenticationFilterTest { private val userAccount = Mockito.mock(UserAccount::class.java, Mockito.RETURNS_DEFAULTS) + private val ssoDelegate = Mockito.mock(SsoDelegate::class.java) + private val authenticationFilter = AuthenticationFilter( authProperties, @@ -89,6 +92,7 @@ class AuthenticationFilterTest { userAccountService, pakService, patService, + ssoDelegate, ) private val authenticationFacade = @@ -155,6 +159,8 @@ class AuthenticationFilterTest { Mockito.`when`(userAccount.needsSuperJwt).thenReturn(false) Mockito.`when`(userAccountDto.id).thenReturn(TEST_USER_ID) + Mockito.`when`(ssoDelegate.verifyUserSsoAccountAvailable(userAccountDto)).thenReturn(true) + SecurityContextHolder.getContext().authentication = null } diff --git a/e2e/cypress/common/apiCalls/common.ts b/e2e/cypress/common/apiCalls/common.ts index 3524b5eb9c..e1e6989e5d 100644 --- a/e2e/cypress/common/apiCalls/common.ts +++ b/e2e/cypress/common/apiCalls/common.ts @@ -195,6 +195,18 @@ export const setTranslations = ( method: 'POST', }); +export const enableOrganizationsSsoProvider = () => + setProperty('authentication.ssoOrganizations.enabled', true); + +export const disableOrganizationsSsoProvider = () => + setProperty('authentication.ssoOrganizations.enabled', false); + +export const enableGlobalSsoProvider = () => + setProperty('authentication.ssoGlobal.enabled', true); + +export const disableGlobalSsoProvider = () => + setProperty('authentication.ssoGlobal.enabled', false); + export const deleteProject = (id: number) => { return v2apiFetch(`projects/${id}`, { method: 'DELETE' }); }; diff --git a/e2e/cypress/common/apiCalls/testData/testData.ts b/e2e/cypress/common/apiCalls/testData/testData.ts index 27061a87ff..4fa3280d49 100644 --- a/e2e/cypress/common/apiCalls/testData/testData.ts +++ b/e2e/cypress/common/apiCalls/testData/testData.ts @@ -6,6 +6,10 @@ import { components } from '../../../../../webapp/src/service/apiSchema.generate export type PermissionModelScopes = components['schemas']['PermissionModel']['scopes']; +export const ssoOrganizationsLoginTestData = generateTestDataObject( + 'sso-organizations-login' +); + export const organizationTestData = generateTestDataObject('organizations'); export const organizationNewTestData = diff --git a/e2e/cypress/common/login.ts b/e2e/cypress/common/login.ts index 555bdb0e7c..1952f9b5d1 100644 --- a/e2e/cypress/common/login.ts +++ b/e2e/cypress/common/login.ts @@ -22,26 +22,30 @@ export const loginWithFakeGithub = () => { cy.contains('Projects').should('be.visible'); }; -export const loginWithFakeOAuth2 = () => { +const loginWithFakeOAuth2Generic = ( + service: string, + loginButtonLabel: string, + scope: string +) => { cy.intercept('https://dummy-url.com/**', { statusCode: 200, - body: 'Fake OAuht2', + body: 'Fake OAuth2', }).as('oauth2'); - cy.contains('OAuth2 login').click(); + cy.contains(loginButtonLabel).click(); cy.wait('@oauth2').then((interception) => { const params = new URL(interception.request.url).searchParams; expect(params.get('client_id')).to.eq('dummy_client_id'); expect(params.get('response_type')).to.eq('code'); - expect(params.get('scope')).to.eq('openid email profile'); + expect(params.get('scope')).to.eq(scope); expect(params.get('state')).to.matches( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i ); // should be the uuid generated by crypto - cy.contains('Fake OAuht2').should('be.visible'); + cy.contains('Fake OAuth2').should('be.visible'); cy.visit( HOST + - `/login/auth_callback/oauth2?code=this_is_dummy_code&redirect_uri=https%3A%2F%2Fdummy-url.com%2Fcallback&state=${params.get( + `/login/auth_callback/${service}?code=this_is_dummy_code&redirect_uri=https%3A%2F%2Fdummy-url.com%2Fcallback&state=${params.get( 'state' )}` ); @@ -49,6 +53,18 @@ export const loginWithFakeOAuth2 = () => { }); }; +export const loginWithFakeOAuth2 = () => { + loginWithFakeOAuth2Generic('oauth2', 'OAuth2 login', 'openid email profile'); +}; + +export const loginWithFakeSso = () => { + loginWithFakeOAuth2Generic( + 'sso', + 'SSO login', + 'openid profile email offline_access' + ); +}; + export const loginViaForm = (username = USERNAME, password = PASSWORD) => { cy.xpath('//input[@name="username"]') .type(username) diff --git a/e2e/cypress/e2e/security/login.cy.ts b/e2e/cypress/e2e/security/login.cy.ts index f02f621924..10bd7511e5 100644 --- a/e2e/cypress/e2e/security/login.cy.ts +++ b/e2e/cypress/e2e/security/login.cy.ts @@ -1,6 +1,7 @@ /// import * as totp from 'totp-generator'; import { HOST, PASSWORD, USERNAME } from '../../common/constants'; +import { ssoOrganizationsLoginTestData } from '../../common/apiCalls/testData/testData'; import { getAnyContainingText } from '../../common/xPath'; import { createUser, @@ -8,6 +9,11 @@ import { disableEmailVerification, getParsedResetPasswordEmail, login, + logout, + enableOrganizationsSsoProvider, + disableOrganizationsSsoProvider, + enableGlobalSsoProvider, + disableGlobalSsoProvider, userDisableMfa, userEnableMfa, } from '../../common/apiCalls/common'; @@ -19,6 +25,7 @@ import { loginViaForm, loginWithFakeGithub, loginWithFakeOAuth2, + loginWithFakeSso, } from '../../common/login'; import { waitForGlobalLoading } from '../../common/loading'; @@ -55,21 +62,6 @@ context('Login', () => { cy.contains('Invalid credentials').should('be.visible'); }); - it('login with github', () => { - disableEmailVerification(); - checkAnonymousIdSet(); - - loginWithFakeGithub(); - - checkAnonymousIdUnset(); - checkAnonymousUserIdentified(); - }); - it('login with oauth2', { retries: { runMode: 5 } }, () => { - disableEmailVerification(); - - loginWithFakeOAuth2(); - }); - it('logout', () => { login(); cy.reload(); @@ -151,3 +143,66 @@ context('Login', () => { }); }); }); + +context('Login third party', () => { + beforeEach(() => { + disableEmailVerification(); + cy.visit(HOST); + }); + + it('login with github', () => { + checkAnonymousIdSet(); + + loginWithFakeGithub(); + + checkAnonymousIdUnset(); + checkAnonymousUserIdentified(); + }); + it('login with oauth2', { retries: { runMode: 5 } }, () => { + loginWithFakeOAuth2(); + }); +}); + +context('Login SSO', () => { + beforeEach(() => { + disableEmailVerification(); + }); + + context('SSO Organizations Login', () => { + beforeEach(() => { + ssoOrganizationsLoginTestData.clean(); + ssoOrganizationsLoginTestData.generate(); + enableOrganizationsSsoProvider(); + + cy.visit(HOST); + }); + + it('login with global sso', { retries: { runMode: 5 } }, () => { + cy.contains('SSO login').click(); + cy.xpath("//*[@name='domain']").type('domain.com'); + loginWithFakeSso(); + }); + + afterEach(() => { + logout(); + disableOrganizationsSsoProvider(); + ssoOrganizationsLoginTestData.clean(); + }); + }); + + context('SSO Global Login', () => { + beforeEach(() => { + enableGlobalSsoProvider(); + cy.visit(HOST); + }); + + it('login with global sso', { retries: { runMode: 5 } }, () => { + loginWithFakeSso(); + }); + + afterEach(() => { + logout(); + disableGlobalSsoProvider(); + }); + }); +}); diff --git a/e2e/cypress/support/e2e.ts b/e2e/cypress/support/e2e.ts index 8775aa37cb..631c3b3362 100644 --- a/e2e/cypress/support/e2e.ts +++ b/e2e/cypress/support/e2e.ts @@ -49,4 +49,5 @@ before(() => { setFeature('MULTIPLE_CONTENT_DELIVERY_CONFIGS', true); setFeature('AI_PROMPT_CUSTOMIZATION', true); setFeature('TASKS', true); + setFeature('SSO', true); }); diff --git a/ee/backend/app/build.gradle b/ee/backend/app/build.gradle index db2aaaec96..80a0fae18a 100644 --- a/ee/backend/app/build.gradle +++ b/ee/backend/app/build.gradle @@ -47,6 +47,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter") implementation("org.springframework.boot:spring-boot-starter-web") implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation(project(":data")) implementation(project(":security")) implementation(project(":api")) @@ -64,6 +65,9 @@ dependencies { /** * MISC */ + implementation libs.jjwtApi + implementation libs.jjwtImpl + implementation libs.jjwtJackson implementation libs.hibernateTypes implementation libs.jacksonKotlin implementation libs.commonsCodec diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoAuthController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoAuthController.kt new file mode 100644 index 0000000000..03db0e8aa2 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoAuthController.kt @@ -0,0 +1,46 @@ +package io.tolgee.ee.api.v2.controllers + +import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.component.FrontendUrlProvider +import io.tolgee.component.enabledFeaturesProvider.EnabledFeaturesProvider +import io.tolgee.constants.Feature +import io.tolgee.dtos.sso.SsoTenantConfig +import io.tolgee.ee.data.DomainRequest +import io.tolgee.ee.data.SsoUrlResponse +import io.tolgee.service.TenantService +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/public") +@Tag(name = "Authentication") +class SsoAuthController( + private val tenantService: TenantService, + private val frontendUrlProvider: FrontendUrlProvider, + private val enabledFeaturesProvider: EnabledFeaturesProvider, +) { + @PostMapping("/authorize_oauth/sso/authentication-url") + fun getAuthenticationUrl( + @RequestBody request: DomainRequest, + ): SsoUrlResponse { + val registrationId = request.domain + val tenant = tenantService.getEnabledConfigByDomain(registrationId) + enabledFeaturesProvider.checkFeatureEnabled( + organizationId = tenant.organization?.id, + Feature.SSO, + ) + val redirectUrl = buildAuthUrl(tenant, state = request.state) + + return SsoUrlResponse(redirectUrl) + } + + private fun buildAuthUrl( + tenant: SsoTenantConfig, + state: String, + ): String = + "${tenant.authorizationUri}?" + + "client_id=${tenant.clientId}&" + + "redirect_uri=${frontendUrlProvider.url + "/login/auth_callback/sso"}&" + + "response_type=code&" + + "scope=openid profile email offline_access&" + + "state=$state" +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoProviderController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoProviderController.kt new file mode 100644 index 0000000000..0efe8784e5 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoProviderController.kt @@ -0,0 +1,96 @@ +package io.tolgee.ee.api.v2.controllers + +import io.tolgee.component.enabledFeaturesProvider.EnabledFeaturesProvider +import io.tolgee.constants.Feature +import io.tolgee.constants.Message +import io.tolgee.dtos.sso.SsoTenantDto +import io.tolgee.dtos.sso.toDto +import io.tolgee.ee.api.v2.hateoas.assemblers.SsoTenantAssembler +import io.tolgee.ee.data.CreateProviderRequest +import io.tolgee.exceptions.BadRequestException +import io.tolgee.exceptions.NotFoundException +import io.tolgee.hateoas.ee.SsoTenantModel +import io.tolgee.model.SsoTenant +import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.security.authentication.RequiresSuperAuthentication +import io.tolgee.security.authorization.RequiresOrganizationRole +import io.tolgee.service.TenantService +import io.tolgee.service.organization.OrganizationService +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.* + +@RestController +@CrossOrigin(origins = ["*"]) +@RequestMapping(value = ["/v2/organizations/{organizationId:[0-9]+}/sso"]) +class SsoProviderController( + private val tenantService: TenantService, + private val ssoTenantAssembler: SsoTenantAssembler, + private val enabledFeaturesProvider: EnabledFeaturesProvider, + private val organizationService: OrganizationService, +) { + @RequiresOrganizationRole(role = OrganizationRoleType.OWNER) + @PutMapping("") + @RequiresSuperAuthentication + fun setProvider( + @RequestBody @Valid request: CreateProviderRequest, + @PathVariable organizationId: Long, + ): SsoTenantModel { + validateProvider(request) + + enabledFeaturesProvider.checkFeatureEnabled( + organizationId = organizationId, + Feature.SSO, + ) + + val organization = organizationService.get(organizationId) + return ssoTenantAssembler.toModel(tenantService.createOrUpdate(request.toDto(), organization).toDto()) + } + + @RequiresOrganizationRole(role = OrganizationRoleType.OWNER) + @GetMapping("") + @RequiresSuperAuthentication + fun findProvider( + @PathVariable organizationId: Long, + ): SsoTenantModel? { + enabledFeaturesProvider.checkFeatureEnabled( + organizationId = organizationId, + Feature.SSO, + ) + val tenant: SsoTenant + try { + tenant = tenantService.getTenant(organizationId) + } catch (_: NotFoundException) { + return null + } + return ssoTenantAssembler.toModel(tenant.toDto()) + } + + private fun validateProvider(req: CreateProviderRequest) { + if (!req.enabled) { + return + } + + listOf( + req::clientId, + req::clientSecret, + req::authorizationUri, + req::domain, + req::tokenUri, + ).forEach { + if (it.get().isBlank()) { + throw BadRequestException(Message.CANNOT_SET_SSO_PROVIDER_MISSING_FIELDS, listOf(it.name)) + } + } + } + + private fun CreateProviderRequest.toDto(): SsoTenantDto { + return SsoTenantDto( + authorizationUri = this.authorizationUri, + clientId = this.clientId, + clientSecret = this.clientSecret, + tokenUri = this.tokenUri, + enabled = this.enabled, + domain = this.domain, + ) + } +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/SsoTenantAssembler.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/SsoTenantAssembler.kt new file mode 100644 index 0000000000..a068f3f255 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/SsoTenantAssembler.kt @@ -0,0 +1,24 @@ +package io.tolgee.ee.api.v2.hateoas.assemblers + +import io.tolgee.dtos.sso.SsoTenantDto +import io.tolgee.ee.api.v2.controllers.SsoProviderController +import io.tolgee.hateoas.ee.SsoTenantModel +import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport +import org.springframework.stereotype.Component + +@Component +class SsoTenantAssembler : + RepresentationModelAssemblerSupport( + SsoProviderController::class.java, + SsoTenantModel::class.java, + ) { + override fun toModel(entity: SsoTenantDto): SsoTenantModel = + SsoTenantModel( + authorizationUri = entity.authorizationUri, + clientId = entity.clientId, + clientSecret = entity.clientSecret, + tokenUri = entity.tokenUri, + enabled = entity.enabled, + domain = entity.domain, + ) +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/CreateProviderRequest.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/CreateProviderRequest.kt new file mode 100644 index 0000000000..3bb7491ee0 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/CreateProviderRequest.kt @@ -0,0 +1,31 @@ +package io.tolgee.ee.data + +import io.swagger.v3.oas.annotations.media.Schema +import io.tolgee.api.ISsoTenant +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import org.springframework.validation.annotation.Validated + +@Validated +data class CreateProviderRequest( + val enabled: Boolean, + @field:NotNull + @field:Size(max = 255) + override val clientId: String, + @field:NotNull + @field:Size(max = 255) + override val clientSecret: String, + @field:NotNull + @field:Size(max = 255) + override val authorizationUri: String, + @field:NotNull + @field:Size(max = 255) + override val tokenUri: String, + @field:NotNull + @field:Size(max = 255) + override val domain: String, +) : ISsoTenant { + @get:Schema(hidden = true) + override val global: Boolean + get() = false +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/DomainRequest.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/DomainRequest.kt new file mode 100644 index 0000000000..506e7dae0d --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/DomainRequest.kt @@ -0,0 +1,6 @@ +package io.tolgee.ee.data + +data class DomainRequest( + val domain: String, + val state: String, +) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/GenericUserResponse.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/GenericUserResponse.kt new file mode 100644 index 0000000000..8ada02eee4 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/GenericUserResponse.kt @@ -0,0 +1,12 @@ +package io.tolgee.ee.data + +@Suppress("PropertyName") +class GenericUserResponse { + var sub: String? = null + var name: String? = null + + var given_name: String? = null + + var family_name: String? = null + var email: String? = null +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/OAuth2TokenResponse.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/OAuth2TokenResponse.kt new file mode 100644 index 0000000000..405f17c083 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/OAuth2TokenResponse.kt @@ -0,0 +1,8 @@ +package io.tolgee.ee.data + +@Suppress("PropertyName") +class OAuth2TokenResponse( + val id_token: String, + val scope: String, + val refresh_token: String, +) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/SsoUrlResponse.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/SsoUrlResponse.kt new file mode 100644 index 0000000000..08ae78fd41 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/SsoUrlResponse.kt @@ -0,0 +1,5 @@ +package io.tolgee.ee.data + +data class SsoUrlResponse( + val redirectUrl: String, +) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/exceptions/SsoAuthorizationException.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/exceptions/SsoAuthorizationException.kt new file mode 100644 index 0000000000..d5ecff53be --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/exceptions/SsoAuthorizationException.kt @@ -0,0 +1,17 @@ +package io.tolgee.ee.exceptions + +import io.tolgee.constants.Message +import io.tolgee.exceptions.AuthenticationException +import java.io.Serializable + +class SsoAuthorizationException : AuthenticationException { + constructor(message: Message) : super(message) + + constructor(message: Message, params: List? = null, cause: Exception? = null) : super( + message, + params, + cause, + ) + + constructor(code: String, params: List? = null, cause: Exception? = null) : super(code, params, cause) +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt new file mode 100644 index 0000000000..766bce7c5c --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt @@ -0,0 +1,18 @@ +package io.tolgee.ee.repository + +import io.tolgee.model.SsoTenant +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository + +@Repository +interface TenantRepository : JpaRepository { + fun findByDomain(domain: String): SsoTenant? + + fun findByOrganizationId(id: Long): SsoTenant? + + @Query( + "select t from SsoTenant t where t.enabled = true and t.domain = :domain and t.organization.deletedAt is null", + ) + fun findEnabledByDomain(domain: String): SsoTenant? +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/security/thirdParty/SsoDelegateEe.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/security/thirdParty/SsoDelegateEe.kt new file mode 100644 index 0000000000..f1fbe66649 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/security/thirdParty/SsoDelegateEe.kt @@ -0,0 +1,259 @@ +package io.tolgee.ee.security.thirdParty + +import io.jsonwebtoken.JwtParser +import io.jsonwebtoken.Jwts +import io.tolgee.component.CurrentDateProvider +import io.tolgee.component.enabledFeaturesProvider.EnabledFeaturesProvider +import io.tolgee.configuration.tolgee.TolgeeProperties +import io.tolgee.constants.Feature +import io.tolgee.constants.Message +import io.tolgee.dtos.cacheable.UserAccountDto +import io.tolgee.dtos.sso.SsoTenantConfig +import io.tolgee.ee.data.GenericUserResponse +import io.tolgee.ee.data.OAuth2TokenResponse +import io.tolgee.ee.exceptions.SsoAuthorizationException +import io.tolgee.exceptions.AuthenticationException +import io.tolgee.exceptions.BadRequestException +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.SsoDelegate +import io.tolgee.security.thirdParty.OAuthUserHandler +import io.tolgee.security.thirdParty.data.OAuthUserDetails +import io.tolgee.service.TenantService +import io.tolgee.service.organization.OrganizationRoleService +import io.tolgee.service.security.UserAccountService +import io.tolgee.util.Logging +import io.tolgee.util.logger +import org.springframework.context.annotation.Primary +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Component +import org.springframework.util.LinkedMultiValueMap +import org.springframework.util.MultiValueMap +import org.springframework.web.client.RestClientException +import org.springframework.web.client.RestTemplate +import java.util.* + +@Primary +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +class SsoDelegateEe( + private val jwtService: JwtService, + private val restTemplate: RestTemplate, + private val tolgeeProperties: TolgeeProperties, + private val organizationRoleService: OrganizationRoleService, + private val tenantService: TenantService, + private val oAuthUserHandler: OAuthUserHandler, + private val userAccountService: UserAccountService, + private val currentDateProvider: CurrentDateProvider, + private val enabledFeaturesProvider: EnabledFeaturesProvider, +) : SsoDelegate, Logging { + private val jwtParser: JwtParser = + Jwts.parserBuilder() + .setClock { currentDateProvider.date } + .build() + + override fun getTokenResponse( + code: String?, + invitationCode: String?, + redirectUri: String?, + domain: String?, + ): JwtAuthenticationResponse { + if (domain.isNullOrEmpty()) { + throw BadRequestException(Message.SSO_AUTH_MISSING_DOMAIN) + } + + val tenant = tenantService.getEnabledConfigByDomain(domain) + enabledFeaturesProvider.checkFeatureEnabled( + organizationId = tenant.organization?.id, + Feature.SSO, + ) + + val token = + fetchToken(tenant, code, redirectUri) + ?: throw SsoAuthorizationException(Message.SSO_TOKEN_EXCHANGE_FAILED) + + val userInfo = decodeIdTokenUnsafe(token.id_token) + return getTokenResponseForUser(userInfo, tenant, invitationCode, token.refresh_token) + } + + private fun fetchToken( + tenant: SsoTenantConfig, + code: String?, + redirectUrl: String?, + ): OAuth2TokenResponse? { + val headers = + HttpHeaders().apply { + contentType = MediaType.APPLICATION_FORM_URLENCODED + } + + val body: MultiValueMap = LinkedMultiValueMap() + body.add("grant_type", "authorization_code") + body.add("code", code) + body.add("redirect_uri", redirectUrl) + body.add("client_id", tenant.clientId) + body.add("client_secret", tenant.clientSecret) + body.add("scope", "openid") + + val request = HttpEntity(body, headers) + return try { + val response: ResponseEntity = + restTemplate.exchange( + tenant.tokenUri, + HttpMethod.POST, + request, + OAuth2TokenResponse::class.java, + ) + response.body + } catch (e: RestClientException) { + logger.info("Failed to exchange code for token: ${e.message}") + null + } + } + + private fun decodeIdTokenUnsafe(idToken: String): GenericUserResponse { + // We assume the token was received directly from the SSO provider and is safe - no need to verify the signature. + try { + val jwt = idToken.substring(0, idToken.lastIndexOf('.') + 1) + val claims = jwtParser.parseClaimsJwt(jwt).body + + val expirationTime: Date? = claims.expiration + if (expirationTime?.before(currentDateProvider.date) == true) { + throw SsoAuthorizationException(Message.SSO_ID_TOKEN_EXPIRED) + } + + return GenericUserResponse().apply { + sub = claims.subject + name = claims.get("name", String::class.java) + given_name = claims.get("given_name", String::class.java) + family_name = claims.get("family_name", String::class.java) + email = claims.get("email", String::class.java) + } + } catch (e: Exception) { + logger.warn(e.stackTraceToString()) + throw SsoAuthorizationException(Message.SSO_USER_INFO_RETRIEVAL_FAILED, cause = e) + } + } + + private fun getTokenResponseForUser( + userResponse: GenericUserResponse, + tenant: SsoTenantConfig, + invitationCode: String?, + refreshToken: String, + ): JwtAuthenticationResponse { + val email = + userResponse.email ?: let { + logger.info("Third party user email is null. Missing scope email?") + throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL) + } + val userData = + OAuthUserDetails( + sub = userResponse.sub!!, + name = userResponse.name, + givenName = userResponse.given_name, + familyName = userResponse.family_name, + email = email, + refreshToken = refreshToken, + tenant = tenant, + ) + val user = + oAuthUserHandler.findOrCreateUser( + userData, + invitationCode, + if (tenant.global) ThirdPartyAuthType.SSO_GLOBAL else ThirdPartyAuthType.SSO, + UserAccount.AccountType.MANAGED, + ) + val jwt = jwtService.emitToken(user.id) + return JwtAuthenticationResponse(jwt) + } + + fun fetchOrganizationsSsoDomainFor(userId: Long): String? { + val organization = organizationRoleService.getManagedBy(userId) ?: return null + val tenant = tenantService.findTenant(organization.id) + return tenant?.domain + } + + override fun verifyUserSsoAccountAvailable(user: UserAccountDto): Boolean { + val isSsoUser = user.thirdPartyAuth in arrayOf(ThirdPartyAuthType.SSO, ThirdPartyAuthType.SSO_GLOBAL) + val isSessionExpired = user.ssoSessionExpiry?.after(currentDateProvider.date) != true + if (!isSsoUser || !isSessionExpired) { + return true + } + + val domain = + when (user.thirdPartyAuth) { + ThirdPartyAuthType.SSO -> fetchOrganizationsSsoDomainFor(user.id) + ThirdPartyAuthType.SSO_GLOBAL -> tolgeeProperties.authentication.ssoGlobal.domain + else -> null + } + val refreshToken = user.ssoRefreshToken + + if (domain == null || refreshToken == null) { + return false + } + + return updateRefreshToken(user, domain, refreshToken) + } + + private fun updateRefreshToken( + user: UserAccountDto, + domain: String, + refreshToken: String, + ): Boolean { + val tenant = tenantService.getEnabledConfigByDomain(domain) + enabledFeaturesProvider.checkFeatureEnabled( + organizationId = tenant.organization?.id, + Feature.SSO, + ) + + val response = fetchRefreshToken(tenant, refreshToken) + if (response?.refresh_token == null) { + return false + } + + val userAccount = userAccountService.get(user.id) + userAccountService.updateSsoSession(userAccount, response.refresh_token) + return true + } + + private fun fetchRefreshToken( + tenant: SsoTenantConfig, + refreshToken: String, + ): OAuth2TokenResponse? { + val headers = + HttpHeaders().apply { + contentType = MediaType.APPLICATION_FORM_URLENCODED + } + + val body: MultiValueMap = LinkedMultiValueMap() + body.apply { + add("grant_type", "refresh_token") + add("client_id", tenant.clientId) + add("client_secret", tenant.clientSecret) + add("scope", "offline_access openid") + add("refresh_token", refreshToken) + } + + val request = HttpEntity(body, headers) + try { + val response: ResponseEntity = + restTemplate.exchange( + tenant.tokenUri, + HttpMethod.POST, + request, + OAuth2TokenResponse::class.java, + ) + return response.body + } catch (e: RestClientException) { + logger.info("Failed to refresh token: ${e.message}") + } + return null + } +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/sso/TenantServiceImpl.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/sso/TenantServiceImpl.kt new file mode 100644 index 0000000000..5e661eb9bd --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/sso/TenantServiceImpl.kt @@ -0,0 +1,67 @@ +package io.tolgee.ee.service.sso + +import io.tolgee.configuration.tolgee.TolgeeProperties +import io.tolgee.constants.Message +import io.tolgee.dtos.sso.SsoTenantConfig +import io.tolgee.dtos.sso.SsoTenantDto +import io.tolgee.ee.repository.TenantRepository +import io.tolgee.exceptions.NotFoundException +import io.tolgee.model.Organization +import io.tolgee.model.SsoTenant +import io.tolgee.service.TenantService +import org.springframework.context.annotation.Primary +import org.springframework.stereotype.Service + +@Primary +@Service +class TenantServiceImpl( + private val tenantRepository: TenantRepository, + private val properties: TolgeeProperties, +) : TenantService { + override fun getEnabledConfigByDomain(domain: String): SsoTenantConfig { + return properties.authentication.ssoGlobal + .takeIf { it.enabled && domain == it.domain } + ?.let { ssoTenantProperties -> SsoTenantConfig(ssoTenantProperties, null) } + ?: domain + .takeIf { properties.authentication.ssoOrganizations.enabled } + ?.let { + tenantRepository.findEnabledByDomain(it)?.let { ssoTenantEntity -> + SsoTenantConfig(ssoTenantEntity, ssoTenantEntity.organization) + } + } + ?: throw NotFoundException(Message.SSO_DOMAIN_NOT_FOUND_OR_DISABLED) + } + + override fun save(tenant: SsoTenant): SsoTenant = tenantRepository.save(tenant) + + override fun saveAll(tenants: Iterable): List = tenantRepository.saveAll(tenants) + + override fun findAll(): List = tenantRepository.findAll() + + override fun findTenant(organizationId: Long): SsoTenant? = tenantRepository.findByOrganizationId(organizationId) + + override fun getTenant(organizationId: Long): SsoTenant = findTenant(organizationId) ?: throw NotFoundException() + + override fun createOrUpdate( + request: SsoTenantDto, + organization: Organization, + ): SsoTenant { + val tenant = findTenant(organization.id) ?: SsoTenant() + setTenantsFields(tenant, request, organization) + return save(tenant) + } + + private fun setTenantsFields( + tenant: SsoTenant, + dto: SsoTenantDto, + organization: Organization, + ) { + tenant.organization = organization + tenant.domain = dto.domain + tenant.clientId = dto.clientId + tenant.clientSecret = dto.clientSecret + tenant.authorizationUri = dto.authorizationUri + tenant.tokenUri = dto.tokenUri + tenant.enabled = dto.enabled + } +} diff --git a/ee/backend/tests/build.gradle b/ee/backend/tests/build.gradle index 01e15401d1..0e6f21fc51 100644 --- a/ee/backend/tests/build.gradle +++ b/ee/backend/tests/build.gradle @@ -42,9 +42,13 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation "org.springframework.boot:spring-boot-starter-hateoas" + implementation libs.jjwtApi + implementation libs.jjwtImpl + implementation libs.jjwtJackson testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation(project(":testing")) + testImplementation(project(":security")) testImplementation(project(":ee-app")) testImplementation(project(":server-app")) testImplementation(project(":api")) diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/SsoGlobalTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/SsoGlobalTest.kt new file mode 100644 index 0000000000..8fc426741e --- /dev/null +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/SsoGlobalTest.kt @@ -0,0 +1,207 @@ +package io.tolgee.ee + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.tolgee.constants.Feature +import io.tolgee.constants.Message +import io.tolgee.development.testDataBuilder.data.SsoTestData +import io.tolgee.dtos.request.organization.OrganizationDto +import io.tolgee.ee.component.PublicEnabledFeaturesProvider +import io.tolgee.ee.data.OAuth2TokenResponse +import io.tolgee.ee.security.thirdParty.SsoDelegateEe +import io.tolgee.ee.utils.SsoMultiTenantsMocks +import io.tolgee.exceptions.NotFoundException +import io.tolgee.service.TenantService +import io.tolgee.testing.AuthorizedControllerTest +import io.tolgee.testing.assert +import io.tolgee.testing.assertions.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.ArgumentMatchers.startsWith +import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.only +import org.mockito.kotlin.verify +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.HttpEntity +import org.springframework.http.HttpMethod +import org.springframework.http.ResponseEntity +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.MvcResult +import org.springframework.web.client.RestTemplate +import java.util.* + +@SpringBootTest( + properties = [ + "tolgee.cache.enabled=true", + ], +) +class SsoGlobalTest : AuthorizedControllerTest() { + private lateinit var testData: SsoTestData + + @MockBean + @Autowired + private val restTemplate: RestTemplate? = null + + @Autowired + private var authMvc: MockMvc? = null + + @Autowired + private lateinit var ssoDelegate: SsoDelegateEe + + @Autowired + private lateinit var tenantService: TenantService + + @Autowired + private lateinit var enabledFeaturesProvider: PublicEnabledFeaturesProvider + + private val ssoMultiTenantsMocks: SsoMultiTenantsMocks by lazy { + SsoMultiTenantsMocks(authMvc, restTemplate, tenantService) + } + + @BeforeEach + fun setup() { + enabledFeaturesProvider.forceEnabled = setOf(Feature.SSO) + currentDateProvider.forcedDate = currentDateProvider.date + tolgeeProperties.authentication.ssoGlobal.apply { + enabled = true + domain = "registrationId" + clientId = "dummy_client_id" + clientSecret = "clientSecret" + authorizationUri = "https://dummy-url.com" + tokenUri = "http://tokenUri" + } + testData = SsoTestData() + testDataService.saveTestData(testData.root) + } + + @AfterEach + fun tearDown() { + testDataService.cleanTestData(testData.root) + tolgeeProperties.authentication.ssoGlobal.apply { + enabled = false + domain = "" + clientId = "" + clientSecret = "" + authorizationUri = "" + tokenUri = "" + } + currentDateProvider.forcedDate = null + enabledFeaturesProvider.forceEnabled = null + } + + @Test + fun `creates new user account and return access token on sso log in`() { + val response = loginAsSsoUser() + assertThat(response.response.status).isEqualTo(200) + val result = jacksonObjectMapper().readValue(response.response.contentAsString, HashMap::class.java) + result["accessToken"].assert.isNotNull + result["tokenType"].assert.isEqualTo("Bearer") + val userName = SsoMultiTenantsMocks.jwtClaimsSet.get("email") as String + assertThat(userAccountService.findActive(userName)).isNotNull + } + + @Test + fun `does not return auth link when tenant is disabled`() { + tolgeeProperties.authentication.ssoGlobal.enabled = false + val response = ssoMultiTenantsMocks.getAuthLink("registrationId").response + assertThat(response.status).isEqualTo(404) + assertThat(response.contentAsString).contains(Message.SSO_DOMAIN_NOT_FOUND_OR_DISABLED.code) + } + + @Test + fun `does not auth user when tenant is disabled`() { + tolgeeProperties.authentication.ssoGlobal.enabled = false + val response = + ssoMultiTenantsMocks.authorize( + "registrationId", + tokenUri = tolgeeProperties.authentication.ssoGlobal.tokenUri, + ) + assertThat(response.response.status).isEqualTo(404) + assertThat(response.response.contentAsString).contains(Message.SSO_DOMAIN_NOT_FOUND_OR_DISABLED.code) + } + + @Test + fun `doesn't authorize user when token exchange fails`() { + val response = + ssoMultiTenantsMocks.authorize( + "registrationId", + ResponseEntity(null, null, 401), + ) + assertThat(response.response.status).isEqualTo(401) + assertThat(response.response.contentAsString).contains(Message.SSO_TOKEN_EXCHANGE_FAILED.code) + val userName = SsoMultiTenantsMocks.jwtClaimsSet.get("email") as String + assertThrows { userAccountService.get(userName) } + } + + @Test + fun `sso auth saves refresh token`() { + loginAsSsoUser() + val userName = SsoMultiTenantsMocks.jwtClaimsSet.get("email") as String + val user = userAccountService.get(userName) + assertThat(user.ssoRefreshToken).isNotNull + assertThat(user.thirdPartyAuthType?.code()).isEqualTo("sso_global") + } + + @Test + fun `user account available validation works`() { + loginAsSsoUser() + val userName = SsoMultiTenantsMocks.jwtClaimsSet.get("email") as String + val user = userAccountService.get(userName) + val userDto = userAccountService.getDto(user.id) + assertThat( + ssoDelegate.verifyUserSsoAccountAvailable( + userDto, + ), + ).isTrue + } + + @Test + fun `after timeout should call token endpoint `() { + loginAsSsoUser() + clearInvocations(restTemplate) + val userName = SsoMultiTenantsMocks.jwtClaimsSet.get("email") as String + val user = userAccountService.get(userName) + val userDto = userAccountService.getDto(user.id) + currentDateProvider.forcedDate = Date(currentDateProvider.date.time + 600_000) + + assertThat( + ssoDelegate.verifyUserSsoAccountAvailable( + userDto, + ), + ).isTrue + + verify(restTemplate, only())?.exchange( + startsWith("http://tokenUri"), + eq(HttpMethod.POST), + any(HttpEntity::class.java), + eq(OAuth2TokenResponse::class.java), + ) + } + + @Test + fun `sso auth works via global config`() { + val response = ssoMultiTenantsMocks.authorize("registrationId") + + val result = jacksonObjectMapper().readValue(response.response.contentAsString, HashMap::class.java) + result["accessToken"].assert.isNotNull + result["tokenType"].assert.isEqualTo("Bearer") + } + + fun organizationDto() = + OrganizationDto( + "Test org", + "This is description", + "test-org", + ) + + fun loginAsSsoUser( + tokenResponse: ResponseEntity? = SsoMultiTenantsMocks.defaultTokenResponse, + ): MvcResult { + return ssoMultiTenantsMocks.authorize("registrationId", tokenResponse = tokenResponse) + } +} diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/SsoOrganizationsTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/SsoOrganizationsTest.kt new file mode 100644 index 0000000000..3e3819a736 --- /dev/null +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/SsoOrganizationsTest.kt @@ -0,0 +1,223 @@ +package io.tolgee.ee + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.tolgee.constants.Feature +import io.tolgee.constants.Message +import io.tolgee.development.testDataBuilder.data.SsoTestData +import io.tolgee.dtos.request.organization.OrganizationDto +import io.tolgee.ee.component.PublicEnabledFeaturesProvider +import io.tolgee.ee.data.OAuth2TokenResponse +import io.tolgee.ee.security.thirdParty.SsoDelegateEe +import io.tolgee.ee.utils.SsoMultiTenantsMocks +import io.tolgee.exceptions.NotFoundException +import io.tolgee.fixtures.andIsForbidden +import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.service.TenantService +import io.tolgee.testing.AuthorizedControllerTest +import io.tolgee.testing.assert +import io.tolgee.testing.assertions.Assertions.assertThat +import jakarta.transaction.Transactional +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.ArgumentMatchers.startsWith +import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.only +import org.mockito.kotlin.verify +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.HttpEntity +import org.springframework.http.HttpMethod +import org.springframework.http.ResponseEntity +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.MvcResult +import org.springframework.web.client.RestTemplate +import java.util.* + +@SpringBootTest( + properties = [ + "tolgee.cache.enabled=true", + ], +) +class SsoOrganizationsTest : AuthorizedControllerTest() { + private lateinit var testData: SsoTestData + + @MockBean + @Autowired + private val restTemplate: RestTemplate? = null + + @Autowired + private var authMvc: MockMvc? = null + + @Autowired + private lateinit var ssoDelegate: SsoDelegateEe + + @Autowired + private lateinit var tenantService: TenantService + + @Autowired + private lateinit var enabledFeaturesProvider: PublicEnabledFeaturesProvider + + private val ssoMultiTenantsMocks: SsoMultiTenantsMocks by lazy { + SsoMultiTenantsMocks(authMvc, restTemplate, tenantService) + } + + @BeforeEach + fun setup() { + enabledFeaturesProvider.forceEnabled = setOf(Feature.SSO) + currentDateProvider.forcedDate = currentDateProvider.date + tolgeeProperties.authentication.ssoOrganizations.enabled = true + testData = SsoTestData() + testData.addTenant() + testDataService.saveTestData(testData.root) + } + + @AfterEach + fun tearDown() { + testDataService.cleanTestData(testData.root) + tolgeeProperties.authentication.ssoOrganizations.enabled = false + currentDateProvider.forcedDate = null + enabledFeaturesProvider.forceEnabled = null + } + + @Test + fun `creates new user account and return access token on sso log in`() { + val response = loginAsSsoUser() + assertThat(response.response.status).isEqualTo(200) + val result = jacksonObjectMapper().readValue(response.response.contentAsString, HashMap::class.java) + result["accessToken"].assert.isNotNull + result["tokenType"].assert.isEqualTo("Bearer") + val userName = SsoMultiTenantsMocks.jwtClaimsSet.get("email") as String + assertThat(userAccountService.findActive(userName)).isNotNull + } + + @Test + fun `does not return auth link when tenant is disabled`() { + testData.tenant.enabled = false + tenantService.save(testData.tenant) + val response = ssoMultiTenantsMocks.getAuthLink("registrationId").response + assertThat(response.status).isEqualTo(404) + assertThat(response.contentAsString).contains(Message.SSO_DOMAIN_NOT_FOUND_OR_DISABLED.code) + } + + @Test + fun `does not auth user when tenant is disabled`() { + testData.tenant.enabled = false + tenantService.save(testData.tenant) + val response = ssoMultiTenantsMocks.authorize("registrationId", tokenUri = testData.tenant.tokenUri) + assertThat(response.response.status).isEqualTo(404) + assertThat(response.response.contentAsString).contains(Message.SSO_DOMAIN_NOT_FOUND_OR_DISABLED.code) + } + + @Test + fun `new user belongs to organization associated with the sso issuer`() { + loginAsSsoUser() + val userName = SsoMultiTenantsMocks.jwtClaimsSet.get("email") as String + val user = userAccountService.get(userName) + assertThat(organizationRoleService.isUserOfRole(user.id, testData.organization.id, OrganizationRoleType.MEMBER)) + .isEqualTo(true) + } + + @Test + fun `doesn't authorize user when token exchange fails`() { + val response = + ssoMultiTenantsMocks.authorize( + "registrationId", + ResponseEntity(null, null, 401), + ) + assertThat(response.response.status).isEqualTo(401) + assertThat(response.response.contentAsString).contains(Message.SSO_TOKEN_EXCHANGE_FAILED.code) + val userName = SsoMultiTenantsMocks.jwtClaimsSet.get("email") as String + assertThrows { userAccountService.get(userName) } + } + + @Transactional + @Test + fun `sso auth doesn't create demo project and user organization`() { + loginAsSsoUser( + tokenResponse = SsoMultiTenantsMocks.defaultTokenResponse2, + ) + val userName = SsoMultiTenantsMocks.jwtClaimsSet2.get("email") as String + val user = userAccountService.get(userName) + assertThat(user.organizationRoles.size).isEqualTo(1) + assertThat(user.organizationRoles[0].organization?.id).isEqualTo(testData.organization.id) + assertThat(user.organizationRoles[0].managed).isTrue + } + + @Transactional + @Test + fun `sso user can't create organization`() { + loginAsSsoUser() + val userName = SsoMultiTenantsMocks.jwtClaimsSet.get("email") as String + val user = userAccountService.get(userName) + loginAsUser(user) + performAuthPost( + "/v2/organizations", + organizationDto(), + ).andIsForbidden + } + + @Test + fun `sso auth saves refresh token`() { + loginAsSsoUser() + val userName = SsoMultiTenantsMocks.jwtClaimsSet.get("email") as String + val user = userAccountService.get(userName) + assertThat(user.ssoRefreshToken).isNotNull + val managedBy = organizationRoleService.getManagedBy(user.id) + assertThat(managedBy).isNotNull + assertThat(user.thirdPartyAuthType?.code()).isEqualTo("sso") + } + + @Test + fun `user account available validation works`() { + loginAsSsoUser() + val userName = SsoMultiTenantsMocks.jwtClaimsSet.get("email") as String + val user = userAccountService.get(userName) + val userDto = userAccountService.getDto(user.id) + assertThat( + ssoDelegate.verifyUserSsoAccountAvailable( + userDto, + ), + ).isTrue + } + + @Test + fun `after timeout should call token endpoint `() { + loginAsSsoUser() + clearInvocations(restTemplate) + val userName = SsoMultiTenantsMocks.jwtClaimsSet.get("email") as String + val user = userAccountService.get(userName) + val userDto = userAccountService.getDto(user.id) + currentDateProvider.forcedDate = Date(currentDateProvider.date.time + 600_000) + + assertThat( + ssoDelegate.verifyUserSsoAccountAvailable( + userDto, + ), + ).isTrue + + verify(restTemplate, only())?.exchange( + startsWith("http://tokenUri"), + eq(HttpMethod.POST), + any(HttpEntity::class.java), + eq(OAuth2TokenResponse::class.java), + ) + } + + fun organizationDto() = + OrganizationDto( + "Test org", + "This is description", + "test-org", + ) + + fun loginAsSsoUser( + tokenResponse: ResponseEntity? = SsoMultiTenantsMocks.defaultTokenResponse, + ): MvcResult { + return ssoMultiTenantsMocks.authorize("registrationId", tokenResponse = tokenResponse) + } +} diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/SsoProviderControllerTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/SsoProviderControllerTest.kt new file mode 100644 index 0000000000..dd4f1cb8b2 --- /dev/null +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/SsoProviderControllerTest.kt @@ -0,0 +1,168 @@ +package io.tolgee.ee.api.v2.controllers + +import io.tolgee.constants.Feature +import io.tolgee.development.testDataBuilder.data.SsoTestData +import io.tolgee.ee.component.PublicEnabledFeaturesProvider +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsBadRequest +import io.tolgee.fixtures.andIsForbidden +import io.tolgee.fixtures.andIsOk +import io.tolgee.testing.AuthorizedControllerTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired + +class SsoProviderControllerTest : AuthorizedControllerTest() { + private lateinit var testData: SsoTestData + + @Autowired + private lateinit var enabledFeaturesProvider: PublicEnabledFeaturesProvider + + @BeforeEach + fun setup() { + testData = SsoTestData() + testDataService.saveTestData(testData.root) + this.userAccount = testData.user + enabledFeaturesProvider.forceEnabled = setOf(Feature.SSO) + } + + @Test + fun `creates and returns sso provider`() { + performAuthPut( + "/v2/organizations/${testData.organization.id}/sso", + requestTenant(), + ).andIsOk + + performAuthGet("/v2/organizations/${testData.organization.id}/sso") + .andIsOk + .andAssertThatJson { + node("domain").isEqualTo("google") + node("clientId").isEqualTo("dummy_client_id") + node("clientSecret").isEqualTo("clientSecret") + node("authorizationUri").isEqualTo("https://dummy-url.com") + node("tokenUri").isEqualTo("tokenUri") + node("enabled").isEqualTo(true) + } + } + + @Test + fun `always creates sso provider when disabled`() { + performAuthPut( + "/v2/organizations/${testData.organization.id}/sso", + requestTenant2(), + ).andIsOk + + performAuthGet("/v2/organizations/${testData.organization.id}/sso") + .andIsOk + .andAssertThatJson { + node("domain").isEqualTo("") + node("clientId").isEqualTo("") + node("clientSecret").isEqualTo("") + node("authorizationUri").isEqualTo("") + node("tokenUri").isEqualTo("") + node("enabled").isEqualTo(false) + } + } + + @Test + fun `does not allow to save invalid sso provider #1`() { + performAuthPut( + "/v2/organizations/${testData.organization.id}/sso", + requestTenantInvalid1(), + ).andIsBadRequest + } + + @Test + fun `does not allow to save invalid sso provider #2`() { + performAuthPut( + "/v2/organizations/${testData.organization.id}/sso", + requestTenantInvalid2(), + ).andIsBadRequest + } + + @Test + fun `does not allow to save invalid sso provider #3`() { + performAuthPut( + "/v2/organizations/${testData.organization.id}/sso", + requestTenantInvalid3(), + ).andIsBadRequest + } + + @Test + fun `does not allow to save invalid sso provider #4`() { + performAuthPut( + "/v2/organizations/${testData.organization.id}/sso", + requestTenantInvalid4(), + ).andIsBadRequest + } + + @Test + fun `fails if user is not owner of organization`() { + this.userAccount = testData.userNotOwner + loginAsUser(testData.userNotOwner.username) + performAuthPut( + "/v2/organizations/${testData.organization.id}/sso", + requestTenant(), + ).andIsForbidden + } + + fun requestTenant() = + mapOf( + "domain" to "google", + "clientId" to "dummy_client_id", + "clientSecret" to "clientSecret", + "authorizationUri" to "https://dummy-url.com", + "redirectUri" to "redirectUri", + "tokenUri" to "tokenUri", + "enabled" to true, + ) + + fun requestTenant2() = + mapOf( + "domain" to "", + "clientId" to "", + "clientSecret" to "", + "authorizationUri" to "", + "redirectUri" to "", + "tokenUri" to "", + "enabled" to false, + ) + + fun requestTenantInvalid1() = + mapOf( + "domain" to "", + "clientId" to "dummy_client_id", + "clientSecret" to "clientSecret", + "authorizationUri" to "https://dummy-url.com", + "redirectUri" to "redirectUri", + "tokenUri" to "tokenUri", + "enabled" to true, + ) + + fun requestTenantInvalid2() = + mapOf( + "domain" to "", + "clientId" to "", + "clientSecret" to "", + "authorizationUri" to "", + "redirectUri" to "", + "tokenUri" to "", + "enabled" to true, + ) + + fun requestTenantInvalid3() = + mapOf( + "enabled" to true, + ) + + fun requestTenantInvalid4() = + mapOf( + "domain" to "asdfasdf00".repeat(26), + "clientId" to "dummy_client_id", + "clientSecret" to "clientSecret", + "authorizationUri" to "https://dummy-url.com", + "redirectUri" to "redirectUri", + "tokenUri" to "tokenUri", + "enabled" to true, + ) +} diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/utils/SsoMultiTenantsMocks.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/utils/SsoMultiTenantsMocks.kt new file mode 100644 index 0000000000..b6887f128e --- /dev/null +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/utils/SsoMultiTenantsMocks.kt @@ -0,0 +1,129 @@ +package io.tolgee.ee.utils + +import io.jsonwebtoken.Claims +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm +import io.jsonwebtoken.security.Keys +import io.tolgee.ee.data.OAuth2TokenResponse +import io.tolgee.service.TenantService +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.whenever +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.MvcResult +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.web.client.RestTemplate +import java.util.* + +class SsoMultiTenantsMocks( + private var authMvc: MockMvc? = null, + private val restTemplate: RestTemplate? = null, + private val tenantService: TenantService? = null, +) { + companion object { + val defaultToken = + OAuth2TokenResponse( + id_token = generateTestJwt(jwtClaimsSet), + scope = "scope", + refresh_token = "refresh_token", + ) + val defaultToken2 = + OAuth2TokenResponse( + id_token = generateTestJwt(jwtClaimsSet2), + scope = "scope", + refresh_token = "refresh_token", + ) + + val defaultTokenResponse = + ResponseEntity( + defaultToken, + HttpStatus.OK, + ) + val defaultTokenResponse2 = + ResponseEntity( + defaultToken2, + HttpStatus.OK, + ) + + val jwtClaimsSet: Claims + get() { + return Jwts.claims().apply { + subject = "testSubject" + issuer = "https://test-oauth-provider.com" + expiration = Date(System.currentTimeMillis() + 3600 * 1000) + put("name", "Test User") + put("given_name", "Test") + put("given_name", "Test") + put("family_name", "User") + put("email", "mail@mail.com") + } + } + + val jwtClaimsSet2: Claims + get() { + return Jwts.claims().apply { + subject = "testSubject" + issuer = "https://test-oauth-provider.com" + expiration = Date(System.currentTimeMillis() + 3600 * 1000) + put("name", "Test User2") + put("given_name", "Test2") + put("given_name", "Test2") + put("family_name", "User2") + put("email", "mai2@mail.com") + } + } + + private fun generateTestJwt(claims: Claims): String { + val testSecret = "test-256-bit-secretAAAAAAAAAAAAAAA" + val key = Keys.hmacShaKeyFor(testSecret.toByteArray()) + return Jwts.builder() + .setClaims(claims) + .signWith(key, SignatureAlgorithm.HS256) + .compact() + } + } + + fun authorize( + domain: String, + tokenResponse: ResponseEntity? = defaultTokenResponse, + tokenUri: String = tenantService?.getEnabledConfigByDomain(domain)?.tokenUri!!, + ): MvcResult { + val receivedCode = "fake_access_token" + // mock token exchange + whenever( + restTemplate?.exchange( + eq(tokenUri), + eq(HttpMethod.POST), + any(), + eq(OAuth2TokenResponse::class.java), + ), + ).thenReturn(tokenResponse) + + return authMvc!! + .perform( + MockMvcRequestBuilders.get( + "/api/public/authorize_oauth/sso?domain=$domain&code=$receivedCode&redirect_uri=redirect_uri", + ), + ).andReturn() + } + + fun getAuthLink(domain: String): MvcResult = + authMvc!! + .perform( + MockMvcRequestBuilders + .post("/api/public/authorize_oauth/sso/authentication-url") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "domain": "$domain", + "state": "state" + } + """.trimIndent(), + ), + ).andReturn() +} diff --git a/webapp/src/component/RootRouter.tsx b/webapp/src/component/RootRouter.tsx index bb38150bed..952bb5aa8f 100644 --- a/webapp/src/component/RootRouter.tsx +++ b/webapp/src/component/RootRouter.tsx @@ -1,12 +1,10 @@ -import React, { FC } from 'react'; +import React from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; -import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3'; import { LINKS } from 'tg.constants/links'; import { ProjectsRouter } from 'tg.views/projects/ProjectsRouter'; import { UserSettingsRouter } from 'tg.views/userSettings/UserSettingsRouter'; import { OrganizationsRouter } from 'tg.views/organizations/OrganizationsRouter'; -import { useConfig } from 'tg.globalContext/helpers'; import { AdministrationView } from 'tg.views/administration/AdministrationView'; import { RootView } from 'tg.views/RootView'; import { routes } from 'tg.ee'; @@ -17,6 +15,7 @@ import { RequirePreferredOrganization } from '../RequirePreferredOrganization'; import { HelpMenu } from './HelpMenu'; import { PublicOnlyRoute } from './common/PublicOnlyRoute'; import { PreferredOrganizationRedirect } from './security/PreferredOrganizationRedirect'; +import { RecaptchaProvider } from 'tg.component/common/RecaptchaProvider'; const LoginRouter = React.lazy( () => import(/* webpackChunkName: "login" */ './security/Login/LoginRouter') @@ -62,22 +61,6 @@ const AcceptInvitationHandler = React.lazy( ) ); -const RecaptchaProvider: FC = (props) => { - const config = useConfig(); - if (!config.recaptchaSiteKey) { - return <>{props.children}; - } - - return ( - - {props.children} - - ); -}; - export const RootRouter = () => { return ( <> diff --git a/webapp/src/component/common/RecaptchaProvider.tsx b/webapp/src/component/common/RecaptchaProvider.tsx new file mode 100644 index 0000000000..bbab512c54 --- /dev/null +++ b/webapp/src/component/common/RecaptchaProvider.tsx @@ -0,0 +1,19 @@ +import React, { FC } from 'react'; +import { useConfig } from 'tg.globalContext/helpers'; +import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3'; + +export const RecaptchaProvider: FC = (props) => { + const config = useConfig(); + if (!config.recaptchaSiteKey) { + return <>{props.children}; + } + + return ( + + {props.children} + + ); +}; diff --git a/webapp/src/component/security/Login/LoginCredentialsForm.tsx b/webapp/src/component/security/Login/LoginCredentialsForm.tsx index 539a6ef02e..51a55f2674 100644 --- a/webapp/src/component/security/Login/LoginCredentialsForm.tsx +++ b/webapp/src/component/security/Login/LoginCredentialsForm.tsx @@ -1,8 +1,15 @@ import React, { RefObject } from 'react'; -import { Button, Link as MuiLink, Typography, styled } from '@mui/material'; +import { + Button, + Link as MuiLink, + Typography, + styled, + Alert, +} from '@mui/material'; import Box from '@mui/material/Box'; import { T } from '@tolgee/react'; import { Link } from 'react-router-dom'; +import { LogIn01 } from '@untitled-ui/icons-react'; import { LINKS } from 'tg.constants/links'; import { useConfig } from 'tg.globalContext/helpers'; @@ -31,41 +38,126 @@ type LoginViewCredentialsProps = { export function LoginCredentialsForm(props: LoginViewCredentialsProps) { const remoteConfig = useConfig(); - const { login } = useGlobalActions(); - const isLoading = useGlobalContext((c) => c.auth.loginLoadable.isLoading); + const { login, loginRedirectSso } = useGlobalActions(); + const isLoading = useGlobalContext( + (c) => + c.auth.loginLoadable.isLoading || c.auth.redirectSsoUrlLoadable.isLoading + ); const oAuthServices = useOAuthServices(); + const nativeEnabled = remoteConfig.nativeEnabled; + const organizationsSsoEnabled = + remoteConfig.authMethods?.ssoOrganizations.enabled ?? false; + const globalSsoEnabled = remoteConfig.authMethods?.ssoGlobal.enabled ?? false; + const hasNonNativeAuthMethods = + oAuthServices.length > 0 || organizationsSsoEnabled || globalSsoEnabled; + const noLoginMethods = !nativeEnabled && !hasNonNativeAuthMethods; + + const customLogoUrl = remoteConfig.authMethods?.ssoGlobal.customLogoUrl; + const customLoginText = remoteConfig.authMethods?.ssoGlobal.customLoginText; + const loginText = customLoginText ? ( + {customLoginText} + ) : ( + + ); + + function globalSsoLogin() { + loginRedirectSso(remoteConfig.authMethods?.ssoGlobal.domain as string); + } + return ( - - - + {noLoginMethods && ( + + {/* Did you mess up your configuration? */} + + + )} - - {remoteConfig.passwordResettable && ( - + - - - - - )} - + + + + + + + + + + + )} + + {!nativeEnabled && globalSsoEnabled && customLogoUrl && ( + + Custom Logo + + )} + + {nativeEnabled && hasNonNativeAuthMethods && ( + + )} + + {(organizationsSsoEnabled || globalSsoEnabled) && ( + + {organizationsSsoEnabled && ( + + )} + {!organizationsSsoEnabled && ( + } + variant="outlined" + style={{ marginBottom: '0.5rem' }} + color="inherit" + onClick={globalSsoLogin} + > + {loginText} + + )} + + )} - {oAuthServices.length > 0 && } {oAuthServices.map((provider) => (