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/configuration/PublicConfigurationDTO.kt b/backend/api/src/main/kotlin/io/tolgee/configuration/PublicConfigurationDTO.kt index bf137db308..772148011d 100644 --- a/backend/api/src/main/kotlin/io/tolgee/configuration/PublicConfigurationDTO.kt +++ b/backend/api/src/main/kotlin/io/tolgee/configuration/PublicConfigurationDTO.kt @@ -1,6 +1,7 @@ package io.tolgee.configuration import io.swagger.v3.oas.annotations.media.Schema +import io.tolgee.configuration.tolgee.AuthenticationProperties import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.constants.FileStoragePath import io.tolgee.constants.MtServiceType @@ -14,9 +15,11 @@ class PublicConfigurationDTO( val version: String, ) { val authentication: Boolean = properties.authentication.enabled - var authMethods: AuthMethodsDTO? = null - val passwordResettable: Boolean - val allowRegistrations: Boolean + val authMethods: AuthMethodsDTO? = properties.authentication.asAuthMethodsDTO() + + @Deprecated("Use nativeEnabled instead", ReplaceWith("nativeEnabled")) + val passwordResettable: Boolean = properties.authentication.nativeEnabled + val allowRegistrations: Boolean = properties.authentication.registrationsAllowed val screenshotsUrl = properties.fileStorageUrl + "/" + FileStoragePath.SCREENSHOTS val maxUploadFileSize = properties.maxUploadFileSize val clientSentryDsn = properties.sentry.clientDsn @@ -28,6 +31,7 @@ class PublicConfigurationDTO( val maxTranslationTextLength: Long = properties.maxTranslationTextLength val recaptchaSiteKey = properties.recaptcha.siteKey val chatwootToken = properties.chatwootToken + val nativeEnabled = properties.authentication.nativeEnabled val capterraTracker = properties.capterraTracker val ga4Tag = properties.ga4Tag val postHogApiKey: String? = properties.postHog.apiKey @@ -50,10 +54,40 @@ class PublicConfigurationDTO( connected = properties.slack.token != null, ) + companion object { + private fun AuthenticationProperties.asAuthMethodsDTO(): AuthMethodsDTO? { + if (!enabled) { + 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, + ), + ) + } + } + class AuthMethodsDTO( val github: OAuthPublicConfigDTO, val google: OAuthPublicConfigDTO, val oauth2: OAuthPublicExtendsConfigDTO, + val ssoGlobal: SsoGlobalPublicConfigDTO, + val ssoOrganizations: SsoOrganizationsPublicConfigDTO, ) data class OAuthPublicConfigDTO(val clientId: String?) { @@ -68,6 +102,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, @@ -82,23 +128,4 @@ class PublicConfigurationDTO( val enabled: Boolean, val connected: Boolean, ) - - init { - if (authentication) { - authMethods = - AuthMethodsDTO( - OAuthPublicConfigDTO( - properties.authentication.github.clientId, - ), - OAuthPublicConfigDTO(properties.authentication.google.clientId), - OAuthPublicExtendsConfigDTO( - properties.authentication.oauth2.clientId, - properties.authentication.oauth2.authorizationUrl, - properties.authentication.oauth2.scopes, - ), - ) - } - passwordResettable = properties.authentication.nativeEnabled - allowRegistrations = properties.authentication.registrationsAllowed - } } 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 2956671d9e..79586e0579 100644 --- a/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt @@ -4,14 +4,19 @@ import com.fasterxml.jackson.databind.node.TextNode import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import io.tolgee.component.email.TolgeeEmailSender +import io.tolgee.configuration.tolgee.AuthenticationProperties import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.constants.Message import io.tolgee.dtos.misc.EmailParams +import io.tolgee.dtos.request.AuthProviderChangeRequestDto 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.response.AuthProviderChangeResponseDto 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,15 +24,18 @@ 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.service.thirdParty.SsoDelegate import io.tolgee.security.thirdParty.GithubOAuthDelegate import io.tolgee.security.thirdParty.GoogleOAuthDelegate import io.tolgee.security.thirdParty.OAuth2Delegate +import io.tolgee.service.AuthProviderChangeRequestService import io.tolgee.service.EmailVerificationService import io.tolgee.service.security.* import jakarta.validation.Valid import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull import org.apache.commons.lang3.RandomStringUtils +import org.springframework.context.annotation.Lazy import org.springframework.http.MediaType import org.springframework.transaction.annotation.Transactional import org.springframework.web.bind.annotation.* @@ -41,6 +49,7 @@ class PublicController( private val githubOAuthDelegate: GithubOAuthDelegate, private val googleOAuthDelegate: GoogleOAuthDelegate, private val oauth2Delegate: OAuth2Delegate, + private val ssoDelegate: SsoDelegate, private val properties: TolgeeProperties, private val userAccountService: UserAccountService, private val tolgeeEmailSender: TolgeeEmailSender, @@ -49,6 +58,9 @@ class PublicController( private val signUpService: SignUpService, private val mfaService: MfaService, private val userCredentialsService: UserCredentialsService, + private val authProperties: AuthenticationProperties, + @Lazy + private val authProviderChangeRequestService: AuthProviderChangeRequestService, ) { @Operation(summary = "Generate JWT token") @PostMapping("/generatetoken") @@ -57,11 +69,17 @@ 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) // two factor passed, so we can generate super token val jwt = jwtService.emitToken(userAccount.id, true) + + authProviderChangeRequestService.resolveChangeRequestIfExist(userAccount) return JwtAuthenticationResponse(jwt) } @@ -72,7 +90,32 @@ class PublicController( @RequestBody @Valid request: ResetPasswordRequest, ) { + if (!authProperties.nativeEnabled) { + throw DisabledFunctionalityException(Message.NATIVE_AUTHENTICATION_DISABLED) + } + val userAccount = userAccountService.findActive(request.email!!) ?: return + if (userAccount.accountType === UserAccount.AccountType.MANAGED) { + val params = + EmailParams( + to = request.email!!, + subject = "Password Reset Request - 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 for login.

+ 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) @@ -106,6 +149,9 @@ class PublicController( @PathVariable("code") code: String, @PathVariable("email") email: String, ) { + if (!authProperties.nativeEnabled) { + throw DisabledFunctionalityException(Message.NATIVE_AUTHENTICATION_DISABLED) + } validateEmailCode(code, email) } @@ -116,7 +162,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) } @@ -138,6 +190,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) } @@ -176,6 +231,7 @@ 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() @@ -194,12 +250,29 @@ class PublicController( oauth2Delegate.getTokenResponse(code, invitationCode, redirectUri) } + "sso" -> { + ssoDelegate.getTokenResponse(code, invitationCode, redirectUri, domain) + } + else -> { throw NotFoundException(Message.SERVICE_NOT_FOUND) } } } + @PostMapping("/auth-provider/request-change") + fun submitOrCancelAuthProviderChangeRequest( + @RequestBody authProviderChangeRequestDto: AuthProviderChangeRequestDto, + ) { + authProviderChangeRequestService.confirmOrCancel(authProviderChangeRequestDto) + } + + @GetMapping("/auth-provider/get-request") + fun getAuthProviderChangeRequest( + @RequestParam("requestId") id: Long, + ): AuthProviderChangeResponseDto = + AuthProviderChangeResponseDto.fromEntity(authProviderChangeRequestService.getById(id)) + private fun getFakeGithubUser(): UserAccount { val username = "johndoe@doe.com" val user = 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..f2ad755394 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/SsoTenantModel.kt @@ -0,0 +1,18 @@ +package io.tolgee.hateoas.ee + +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 authorizationUri: String, + val clientId: String, + val clientSecret: String, + val tokenUri: String, + val isEnabled: Boolean, + val jwkSetUri: String, + val domainName: String, +) : RepresentationModel(), + Serializable 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..80a3679b58 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,10 +5,12 @@ 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 org.springframework.context.annotation.Lazy import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod @@ -25,6 +27,8 @@ class GithubOAuthDelegate( private val restTemplate: RestTemplate, properties: TolgeeProperties, private val signUpService: SignUpService, + @Lazy + private val userConflictManager: UserConflictManager, ) { private val githubConfigurationProperties: GithubAuthenticationProperties = properties.authentication.github @@ -74,18 +78,19 @@ class GithubOAuthDelegate( )?.email ?: throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL) - val userAccountOptional = userAccountService.findByThirdParty("github", userResponse!!.id!!) + val userAccountOptional = userAccountService.findByThirdParty(ThirdPartyAuthType.GITHUB, userResponse!!.id!!) + userConflictManager.resolveRequestIfExist(userAccountOptional) val user = userAccountOptional.orElseGet { userAccountService.findActive(githubEmail)?.let { - throw AuthenticationException(Message.USERNAME_ALREADY_EXISTS) + manageUserNameConflict(it) } val newUserAccount = UserAccount() 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) @@ -106,6 +111,14 @@ class GithubOAuthDelegate( throw AuthenticationException(Message.THIRD_PARTY_AUTH_UNKNOWN_ERROR) } + private fun manageUserNameConflict(user: UserAccount) { + userConflictManager.manageUserNameConflict( + user, + ThirdPartyAuthType.GITHUB, + newAccountType = UserAccount.AccountType.THIRD_PARTY, + ) + } + class GithubEmailResponse { var email: String? = null var primary = false 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..522708afd3 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,10 +5,12 @@ 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 org.springframework.context.annotation.Lazy import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod @@ -24,6 +26,8 @@ class GoogleOAuthDelegate( private val restTemplate: RestTemplate, properties: TolgeeProperties, private val signUpService: SignUpService, + @Lazy + private val userConflictManager: UserConflictManager, ) { private val googleConfigurationProperties: GoogleAuthenticationProperties = properties.authentication.google @@ -76,11 +80,12 @@ class GoogleOAuthDelegate( val googleEmail = userResponse.email ?: throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL) - val userAccountOptional = userAccountService.findByThirdParty("google", userResponse!!.sub!!) + val userAccountOptional = userAccountService.findByThirdParty(ThirdPartyAuthType.GOOGLE, userResponse!!.sub!!) + userConflictManager.resolveRequestIfExist(userAccountOptional) val user = userAccountOptional.orElseGet { userAccountService.findActive(googleEmail)?.let { - throw AuthenticationException(Message.USERNAME_ALREADY_EXISTS) + manageUserNameConflict(it) } val newUserAccount = UserAccount() @@ -88,7 +93,7 @@ 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) @@ -110,6 +115,14 @@ class GoogleOAuthDelegate( } } + private fun manageUserNameConflict(user: UserAccount) { + userConflictManager.manageUserNameConflict( + user, + ThirdPartyAuthType.GOOGLE, + newAccountType = UserAccount.AccountType.THIRD_PARTY, + ) + } + @Suppress("PropertyName") class GoogleUserResponse { var sub: String? = 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..5f37a6a34b 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,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.service.security.SignUpService -import io.tolgee.service.security.UserAccountService +import io.tolgee.security.thirdParty.data.OAuthUserDetails import org.slf4j.LoggerFactory import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders @@ -24,10 +24,9 @@ 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, ) { private val oauth2ConfigurationProperties: OAuth2AuthenticationProperties = properties.authentication.oauth2 private val logger = LoggerFactory.getLogger(this::class.java) @@ -90,33 +89,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..18d41533e4 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt @@ -0,0 +1,155 @@ +package io.tolgee.security.thirdParty + +import io.tolgee.component.CurrentDateProvider +import io.tolgee.configuration.tolgee.SsoGlobalProperties +import io.tolgee.configuration.tolgee.SsoLocalProperties +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 io.tolgee.util.addMinutes +import org.springframework.stereotype.Component +import java.util.* + +@Component +class OAuthUserHandler( + private val signUpService: SignUpService, + private val organizationRoleService: OrganizationRoleService, + private val ssoLocalProperties: SsoLocalProperties, + private val ssoGlobalProperties: SsoGlobalProperties, + private val userAccountService: UserAccountService, + private val currentDateProvider: CurrentDateProvider, + private val userConflictManager: UserConflictManager, +) { + fun findOrCreateUser( + userResponse: OAuthUserDetails, + invitationCode: String?, + thirdPartyAuthType: ThirdPartyAuthType, + accountType: UserAccount.AccountType, + ): UserAccount { + val userAccountOptional = + if (thirdPartyAuthType == ThirdPartyAuthType.SSO) { + if (userResponse.tenant == null) { + // This should never happen + throw AuthenticationException(Message.THIRD_PARTY_AUTH_UNKNOWN_ERROR) + } + userAccountService.findBySsoDomain(userResponse.tenant.domain, userResponse.sub!!) + } else { + // SSO_GLOBAL or OAUTH2 + userAccountService.findByThirdParty(thirdPartyAuthType, userResponse.sub!!) + } + + userAccountOptional.ifPresent { + if ( + thirdPartyAuthType == ThirdPartyAuthType.SSO || + thirdPartyAuthType == ThirdPartyAuthType.SSO_GLOBAL + ) { + updateRefreshToken(it, userResponse.refreshToken) + resetSsoSessionExpiry(it) + } + } + userConflictManager.resolveRequestIfExist(userAccountOptional) + return userAccountOptional.orElseGet { + userAccountService.findActive(userResponse.email)?.let { + manageUserNameConflict(it, userResponse, thirdPartyAuthType) + } + createUser(userResponse, invitationCode, thirdPartyAuthType, accountType) + } + } + + private fun createUser( + userResponse: OAuthUserDetails, + invitationCode: String?, + thirdPartyAuthType: ThirdPartyAuthType, + accountType: UserAccount.AccountType, + ): UserAccount { + userAccountService.findActive(userResponse.email)?.let { + 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 = ssoCurrentExpiration(thirdPartyAuthType) + + val organization = userResponse.tenant?.organization + signUpService.signUp(newUserAccount, invitationCode, null, organizationForced = organization) + + if (organization != null) { + organizationRoleService.setManaged(newUserAccount, organization, true) + } + + return newUserAccount + } + + private fun manageUserNameConflict( + user: UserAccount, + userResponse: OAuthUserDetails, + thirdPartyAuthType: ThirdPartyAuthType, + ) { + userConflictManager.manageUserNameConflict( + user, + thirdPartyAuthType, + UserAccount.AccountType.THIRD_PARTY, + userResponse.tenant?.domain, + userResponse.sub, + userResponse.refreshToken, + ssoCurrentExpiration(thirdPartyAuthType), + ) + } + + fun updateRefreshToken( + userAccount: UserAccount, + refreshToken: String?, + ) { + if (userAccount.ssoRefreshToken != refreshToken) { + userAccount.ssoRefreshToken = refreshToken + userAccountService.save(userAccount) + } + } + + fun updateRefreshToken( + userAccountId: Long, + refreshToken: String?, + ) { + val userAccount = userAccountService.get(userAccountId) + updateRefreshToken(userAccount, refreshToken) + } + + fun resetSsoSessionExpiry(user: UserAccount) { + user.ssoSessionExpiry = ssoCurrentExpiration(user.thirdPartyAuthType) + userAccountService.save(user) + } + + fun resetSsoSessionExpiry(userAccountId: Long) { + val user = userAccountService.get(userAccountId) + resetSsoSessionExpiry(user) + } + + private fun ssoCurrentExpiration(type: ThirdPartyAuthType?): Date? { + return currentDateProvider.date.addMinutes( + when (type) { + ThirdPartyAuthType.SSO -> ssoLocalProperties.sessionExpirationMinutes + ThirdPartyAuthType.SSO_GLOBAL -> ssoGlobalProperties.sessionExpirationMinutes + else -> return null + }, + ) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/SsoTenantConfig.kt b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/SsoTenantConfig.kt new file mode 100644 index 0000000000..2dfc438a55 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/SsoTenantConfig.kt @@ -0,0 +1,63 @@ +package io.tolgee.security.thirdParty + +import io.tolgee.configuration.tolgee.SsoGlobalProperties +import io.tolgee.constants.Message +import io.tolgee.exceptions.BadRequestException +import io.tolgee.model.Organization +import io.tolgee.model.SsoTenant +import kotlin.reflect.KProperty0 + +data class SsoTenantConfig( + val name: String, + val clientId: String, + val clientSecret: String, + val authorizationUri: String, + val domain: String, + val jwkSetUri: String, + val tokenUri: String, + val global: Boolean, + val organization: Organization? = null, +) { + companion object { + fun SsoTenant.toConfig(): SsoTenantConfig? { + if (!enabled) { + return null + } + + return SsoTenantConfig( + name = name, + clientId = clientId, + clientSecret = clientSecret, + authorizationUri = authorizationUri, + domain = domain, + jwkSetUri = jwkSetUri, + tokenUri = tokenUri, + global = false, + organization = organization, + ) + } + + fun SsoGlobalProperties.toConfig(): SsoTenantConfig? { + if (!enabled) { + return null + } + + return SsoTenantConfig( + name = "Global SSO Provider", + clientId = ::clientId.validate(), + clientSecret = ::clientSecret.validate(), + authorizationUri = ::authorizationUri.validate(), + domain = ::domain.validate(), + jwkSetUri = ::jwkSetUri.validate(), + tokenUri = ::tokenUri.validate(), + global = true, + ) + } + + private fun KProperty0.validate(): T = + this.get() ?: throw BadRequestException( + Message.SSO_GLOBAL_CONFIG_MISSING_PROPERTIES, + listOf(name), + ) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/UserConflictManager.kt b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/UserConflictManager.kt new file mode 100644 index 0000000000..26ddd6521c --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/UserConflictManager.kt @@ -0,0 +1,48 @@ +package io.tolgee.security.thirdParty + +import io.tolgee.constants.Message +import io.tolgee.dtos.AuthProviderChangeRequestData +import io.tolgee.exceptions.AuthenticationException +import io.tolgee.model.UserAccount +import io.tolgee.model.enums.ThirdPartyAuthType +import io.tolgee.service.AuthProviderChangeRequestService +import org.springframework.context.annotation.Lazy +import org.springframework.stereotype.Component +import java.util.* + +@Component +class UserConflictManager( + @Lazy + private val authProviderChangeRequestService: AuthProviderChangeRequestService, +) { + fun manageUserNameConflict( + user: UserAccount, + newAuthProvider: ThirdPartyAuthType, + newAccountType: UserAccount.AccountType, + ssoDomain: String? = null, + sub: String? = null, + refreshToken: String? = null, + calculateExpirationDate: Date? = null, + ) { + val requestData = + AuthProviderChangeRequestData( + userAccount = user, + newAuthProvider = newAuthProvider, + oldAuthProvider = user.thirdPartyAuthType, + newAccountType = newAccountType, + oldAccountType = user.accountType, + ssoDomain = ssoDomain, + sub = sub, + refreshToken = refreshToken, + calculateExpirationDate = calculateExpirationDate, + ) + val request = authProviderChangeRequestService.create(requestData) + throw AuthenticationException(Message.USERNAME_ALREADY_EXISTS, params = listOf(request?.id)) + } + + fun resolveRequestIfExist(user: Optional) { + if (user.isPresent) { + authProviderChangeRequestService.resolveChangeRequestIfExist(user.get()) + } + } +} 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..746e890920 --- /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.security.thirdParty.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/build.gradle b/backend/app/build.gradle index 3fe559db16..eb36e4c2ac 100644 --- a/backend/app/build.gradle +++ b/backend/app/build.gradle @@ -77,6 +77,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-cache' implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.security:spring-security-oauth2-client") implementation "org.springframework.boot:spring-boot-starter-validation" implementation "org.springframework.boot:spring-boot-starter-hateoas" implementation "org.springframework.boot:spring-boot-configuration-processor" diff --git a/backend/app/src/test/kotlin/io/tolgee/AuthTest.kt b/backend/app/src/test/kotlin/io/tolgee/AuthTest.kt index bb0b12b217..aec0e7ac01 100644 --- a/backend/app/src/test/kotlin/io/tolgee/AuthTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/AuthTest.kt @@ -10,6 +10,7 @@ import io.tolgee.fixtures.andIsUnauthorized import io.tolgee.fixtures.generateUniqueString import io.tolgee.fixtures.mapResponseTo import io.tolgee.model.Project +import io.tolgee.model.enums.ThirdPartyAuthType import io.tolgee.security.authentication.JwtService import io.tolgee.security.thirdParty.GithubOAuthDelegate.GithubEmailResponse import io.tolgee.testing.AbstractControllerTest @@ -271,6 +272,14 @@ class AuthTest : AbstractControllerTest() { assertExpired(token) } + @Test + fun `doesn't auth sso user`() { + val user = userAccountService.get(initialUsername) + user.thirdPartyAuthType = ThirdPartyAuthType.SSO + userAccountService.save(user) + doAuthentication(initialUsername, initialPassword).andIsUnauthorized + } + private fun assertExpired(token: String) { mvc.perform( MockMvcRequestBuilders.put("/v2/projects/${project.id}/users/${project.id}/revoke-access") diff --git a/backend/app/src/test/kotlin/io/tolgee/ChangeAuthTypeTest.kt b/backend/app/src/test/kotlin/io/tolgee/ChangeAuthTypeTest.kt new file mode 100644 index 0000000000..10f8ea0c5d --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/ChangeAuthTypeTest.kt @@ -0,0 +1,250 @@ +package io.tolgee + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.nimbusds.jose.proc.SecurityContext +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor +import io.tolgee.configuration.tolgee.SsoGlobalProperties +import io.tolgee.constants.Feature +import io.tolgee.constants.Message +import io.tolgee.development.testDataBuilder.data.ChangeAuthTypeTestData +import io.tolgee.dtos.request.AuthProviderChangeRequestDto +import io.tolgee.ee.component.PublicEnabledFeaturesProvider +import io.tolgee.ee.service.sso.TenantService +import io.tolgee.model.UserAccount +import io.tolgee.model.enums.ThirdPartyAuthType +import io.tolgee.service.AuthProviderChangeRequestService +import io.tolgee.testing.AbstractControllerTest +import io.tolgee.testing.assertions.Assertions.assertThat +import io.tolgee.util.GitHubAuthUtil +import io.tolgee.util.GoogleAuthUtil +import io.tolgee.util.SsoAuthUtil +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.test.web.servlet.MockMvc +import org.springframework.web.client.RestTemplate + +class ChangeAuthTypeTest : AbstractControllerTest() { + @MockBean + @Autowired + private val restTemplate: RestTemplate? = null + + @Autowired + private var authMvc: MockMvc? = null + + lateinit var changeAuthTypeTestData: ChangeAuthTypeTestData + + val objectMapper = jacksonObjectMapper() + + @Autowired + private lateinit var authProviderChangeRequestService: AuthProviderChangeRequestService + + @Autowired + lateinit var ssoGlobalProperties: SsoGlobalProperties + + @Autowired + private lateinit var tenantService: TenantService + + @MockBean + @Autowired + private val jwtProcessor: ConfigurableJWTProcessor? = null + + private val gitHubAuthUtil: GitHubAuthUtil by lazy { GitHubAuthUtil(tolgeeProperties, authMvc, restTemplate) } + private val googleAuthUtil: GoogleAuthUtil by lazy { GoogleAuthUtil(tolgeeProperties, authMvc, restTemplate) } + private val ssoUtil: SsoAuthUtil by lazy { SsoAuthUtil(authMvc, restTemplate, tenantService, jwtProcessor) } + + @Autowired + private lateinit var enabledFeaturesProvider: PublicEnabledFeaturesProvider + + @BeforeAll + fun init() { + changeAuthTypeTestData = ChangeAuthTypeTestData() + testDataService.saveTestData(changeAuthTypeTestData.root) + enabledFeaturesProvider.forceEnabled = setOf(Feature.SSO) + } + + @Test + fun `throws and creates new request to change provider`() { + gitHubAuthUtil.authorizeGithubUser() + val googleResponse = + googleAuthUtil + .authorizeGoogleUser( + userResponse = + ResponseEntity( + googleAuthUtil.userResponseWithExisingEmail, + HttpStatus.OK, + ), + ).response + Assertions.assertThat(googleResponse.status).isEqualTo(401) + Assertions.assertThat(googleResponse.contentAsString).contains(Message.USERNAME_ALREADY_EXISTS.code) + val errorResponse: ErrorResponse = objectMapper.readValue(googleResponse.contentAsString) + errorResponse.params[0].let { + val request = authProviderChangeRequestService.findById(it).get() + Assertions.assertThat(request).isNotNull + } + } + + @Test + fun `change provider from GitHub to Google if request exists and confirmed by user`() { + gitHubAuthUtil.authorizeGithubUser() + var user = userAccountService.get(googleAuthUtil.userResponseWithExisingEmail.email!!) + assertThat(user.thirdPartyAuthType).isEqualTo(ThirdPartyAuthType.GITHUB) + + val googleResponse = + googleAuthUtil + .authorizeGoogleUser( + userResponse = + ResponseEntity( + googleAuthUtil.userResponseWithExisingEmail, + HttpStatus.OK, + ), + ).response + val errorResponse: ErrorResponse = objectMapper.readValue(googleResponse.contentAsString) + val requestId = errorResponse.params[0] + authProviderChangeRequestService.confirmOrCancel( + AuthProviderChangeRequestDto( + changeRequestId = requestId, + isConfirmed = true, + ), + ) + assertThat(authProviderChangeRequestService.getById(requestId).isConfirmed).isTrue + + val successResponse = gitHubAuthUtil.authorizeGithubUser().response + assertThat(successResponse.status).isEqualTo(200) + + user = userAccountService.get(googleAuthUtil.userResponseWithExisingEmail.email!!) + assertThat(user.thirdPartyAuthType).isEqualTo(ThirdPartyAuthType.GOOGLE) + assertThat(user.accountType).isEqualTo(UserAccount.AccountType.THIRD_PARTY) + } + + @Test + fun `change provider from Google to GitHub if request exists and confirmed by user`() { + googleAuthUtil.authorizeGoogleUser( + userResponse = + ResponseEntity( + googleAuthUtil.userResponseWithExisingEmail, + HttpStatus.OK, + ), + ) + var user = userAccountService.get(googleAuthUtil.userResponseWithExisingEmail.email!!) + assertThat(user.thirdPartyAuthType).isEqualTo(ThirdPartyAuthType.GOOGLE) + + val githubResponse = gitHubAuthUtil.authorizeGithubUser().response + val errorResponse: ErrorResponse = objectMapper.readValue(githubResponse.contentAsString) + val requestId = errorResponse.params[0] + authProviderChangeRequestService.confirmOrCancel( + AuthProviderChangeRequestDto( + changeRequestId = requestId, + isConfirmed = true, + ), + ) + assertThat(authProviderChangeRequestService.getById(requestId).isConfirmed).isTrue + + val successResponse = + googleAuthUtil + .authorizeGoogleUser( + userResponse = + ResponseEntity( + googleAuthUtil.userResponseWithExisingEmail, + HttpStatus.OK, + ), + ).response + assertThat(successResponse.status).isEqualTo(200) + + user = userAccountService.get(googleAuthUtil.userResponseWithExisingEmail.email!!) + assertThat(user.thirdPartyAuthType).isEqualTo(ThirdPartyAuthType.GITHUB) + assertThat(user.accountType).isEqualTo(UserAccount.AccountType.THIRD_PARTY) + } + + @Test + fun `change provider from Google to Sso if request exists and confirmed by user`() { + val domain = "sso.com" + setSso(domain) + googleAuthUtil.authorizeGoogleUser( + userResponse = + ResponseEntity( + googleAuthUtil.userResponseWithExisingEmail, + HttpStatus.OK, + ), + ) + var user = userAccountService.get(googleAuthUtil.userResponseWithExisingEmail.email!!) + assertThat(user.thirdPartyAuthType).isEqualTo(ThirdPartyAuthType.GOOGLE) + + val ssoResponse = ssoUtil.authorize(domain, jwtClaims = SsoAuthUtil.jwtClaimsWithExistingUserSet).response + val errorResponse: ErrorResponse = objectMapper.readValue(ssoResponse.contentAsString) + val requestId = errorResponse.params[0] + authProviderChangeRequestService.confirmOrCancel( + AuthProviderChangeRequestDto( + changeRequestId = requestId, + isConfirmed = true, + ), + ) + assertThat(authProviderChangeRequestService.getById(requestId).isConfirmed).isTrue + + val successResponse = + googleAuthUtil + .authorizeGoogleUser( + userResponse = + ResponseEntity( + googleAuthUtil.userResponseWithExisingEmail, + HttpStatus.OK, + ), + ).response + assertThat(successResponse.status).isEqualTo(200) + + user = userAccountService.get(googleAuthUtil.userResponseWithExisingEmail.email!!) + assertThat(user.thirdPartyAuthType).isEqualTo(ThirdPartyAuthType.SSO_GLOBAL) + assertThat(user.accountType).isEqualTo(UserAccount.AccountType.THIRD_PARTY) + //assertThat(user.ssoTenant?.domain).isEqualTo(domain) + assertThat(user.ssoSessionExpiry).isNotNull() + assertThat(user.ssoRefreshToken).isNotNull() + } + + @Test + fun `change provider from native to Sso if request exists and confirmed by user`() { + val domain = "sso.com" + setSso(domain) + + testDataService.saveTestData(changeAuthTypeTestData.createUserExisting) + doAuthentication(changeAuthTypeTestData.userExisting.username, "admin") + val ssoResponse = ssoUtil.authorize(domain, jwtClaims = SsoAuthUtil.jwtClaimsWithExistingUserSet).response + val errorResponse: ErrorResponse = objectMapper.readValue(ssoResponse.contentAsString) + val requestId = errorResponse.params[0] + authProviderChangeRequestService.confirmOrCancel( + AuthProviderChangeRequestDto( + changeRequestId = requestId, + isConfirmed = true, + ), + ) + assertThat(authProviderChangeRequestService.getById(requestId).isConfirmed).isTrue + + doAuthentication(changeAuthTypeTestData.userExisting.username, "admin") + + val user = userAccountService.get(changeAuthTypeTestData.userExisting.username) + assertThat(user.thirdPartyAuthType).isEqualTo(ThirdPartyAuthType.SSO_GLOBAL) + assertThat(user.accountType).isEqualTo(UserAccount.AccountType.THIRD_PARTY) + //assertThat(user.ssoTenant?.domain).isEqualTo(domain) + assertThat(user.ssoSessionExpiry).isNotNull() + assertThat(user.ssoRefreshToken).isNotNull() + } + + private fun setSso(domain: String) { + ssoGlobalProperties.enabled = true + ssoGlobalProperties.domain = domain + ssoGlobalProperties.clientId = "clientId" + ssoGlobalProperties.clientSecret = "clientSecret" + ssoGlobalProperties.authorizationUri = "authorizationUri" + ssoGlobalProperties.tokenUri = "http://tokenUri" + ssoGlobalProperties.jwkSetUri = "http://jwkSetUri" + } + + data class ErrorResponse( + val code: String, + val params: List, + ) +} diff --git a/backend/app/src/test/kotlin/io/tolgee/util/GoogleAuthUtil.kt b/backend/app/src/test/kotlin/io/tolgee/util/GoogleAuthUtil.kt index 26257e72a3..412be43800 100644 --- a/backend/app/src/test/kotlin/io/tolgee/util/GoogleAuthUtil.kt +++ b/backend/app/src/test/kotlin/io/tolgee/util/GoogleAuthUtil.kt @@ -31,6 +31,18 @@ class GoogleAuthUtil( return fakeGoogleUser } + val userResponseWithExisingEmail: GoogleOAuthDelegate.GoogleUserResponse + get() { + val fakeGoogleUser = GoogleOAuthDelegate.GoogleUserResponse() + fakeGoogleUser.sub = "fakeId" + fakeGoogleUser.name = "fakeName" + fakeGoogleUser.given_name = "fakeGiveName" + fakeGoogleUser.family_name = "fakeGivenFamilyName" + fakeGoogleUser.email = "fake_email@email.com" + fakeGoogleUser.email_verified = true + return fakeGoogleUser + } + private val defaultTokenResponse: Map get() { val accessToken = "fake_access_token" diff --git a/backend/app/src/test/kotlin/io/tolgee/util/SsoAuthUtil.kt b/backend/app/src/test/kotlin/io/tolgee/util/SsoAuthUtil.kt new file mode 100644 index 0000000000..4cbc443d3f --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/util/SsoAuthUtil.kt @@ -0,0 +1,156 @@ +package io.tolgee.util + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.crypto.MACSigner +import com.nimbusds.jose.proc.SecurityContext +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor +import io.tolgee.ee.data.OAuth2TokenResponse +import io.tolgee.ee.service.sso.TenantService +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +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 SsoAuthUtil( + private var authMvc: MockMvc? = null, + private val restTemplate: RestTemplate? = null, + private val tenantService: TenantService? = null, + private val jwtProcessor: ConfigurableJWTProcessor?, +) { + companion object { + val defaultToken = + OAuth2TokenResponse(id_token = generateTestJwt(), scope = "scope", refresh_token = "refresh_token") + + val defaultTokenResponse = + ResponseEntity( + defaultToken, + HttpStatus.OK, + ) + + val jwtClaimsSet: JWTClaimsSet + get() { + val claimsSet = + JWTClaimsSet + .Builder() + .subject("testSubject") + .issuer("https://test-oauth-provider.com") + .expirationTime(Date(System.currentTimeMillis() + 3600 * 1000)) + .claim("name", "Test User") + .claim("given_name", "Test") + .claim("given_name", "Test") + .claim("family_name", "User") + .claim("email", "mail@mail.com") + .build() + return claimsSet + } + + val jwtClaimsWithExistingUserSet: JWTClaimsSet + get() { + val claimsSet = + JWTClaimsSet + .Builder() + .subject("testSubject") + .issuer("https://test-oauth-provider.com") + .expirationTime(Date(System.currentTimeMillis() + 3600 * 1000)) + .claim("name", "Test User2") + .claim("given_name", "Test2") + .claim("given_name", "Test2") + .claim("family_name", "User2") + .claim("email", "fake_email@email.com") + .build() + return claimsSet + } + + private fun generateTestJwt(): String { + val header = JWSHeader(JWSAlgorithm.HS256) + + val signedJwt = SignedJWT(header, jwtClaimsSet) + + val testSecret = "test-256-bit-secretAAAAAAAAAAAAAAA" + val signer = MACSigner(testSecret.toByteArray()) + + signedJwt.sign(signer) + + return signedJwt.serialize() + } + } + + fun authorize( + registrationId: String, + tokenResponse: ResponseEntity? = defaultTokenResponse, + jwtClaims: JWTClaimsSet = jwtClaimsSet, + ): MvcResult { + val receivedCode = "fake_access_token" + val tenant = tenantService?.getEnabledConfigByDomain(registrationId)!! + // mock token exchange + whenever( + restTemplate?.exchange( + eq(tenant.tokenUri), + eq(HttpMethod.POST), + any(), + eq(OAuth2TokenResponse::class.java), + ), + ).thenReturn(tokenResponse) + + // mock parsing of jwt + mockJwt(jwtClaims) + + return authMvc!! + .perform( + MockMvcRequestBuilders.get( + "/api/public/authorize_oauth/sso?code=$receivedCode&redirect_uri=redirect_uri&domain=$registrationId", + ), + ).andReturn() + } + + fun getAuthLink(registrationId: String): MvcResult = + authMvc!! + .perform( + MockMvcRequestBuilders + .post("/v2/public/oauth2/callback/get-authentication-url") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "domain": "$registrationId", + "state": "state" + } + """.trimIndent(), + ), + ).andReturn() + + private fun mockJwt(jwtClaims: JWTClaimsSet) { + whenever( + jwtProcessor?.process( + any(), + isNull(), + ), + ).thenReturn(jwtClaims) + } + + fun mockTokenExchange( + tokenUri: String, + tokenResponse: ResponseEntity? = defaultTokenResponse, + ) { + whenever( + restTemplate?.exchange( + eq(tokenUri), + eq(HttpMethod.POST), + any(), + eq(OAuth2TokenResponse::class.java), + ), + ).thenReturn(tokenResponse) + } +} 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..3766fa7aa1 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: SsoLocalProperties = SsoLocalProperties(), ) { 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..bbbf4c69b0 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoGlobalProperties.kt @@ -0,0 +1,62 @@ +package io.tolgee.configuration.tolgee + +import io.tolgee.configuration.annotations.DocProperty +import org.springframework.boot.context.properties.ConfigurationProperties + +@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 = "Single Sign-On", +) +class SsoGlobalProperties { + @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") + var clientId: String? = null + + @DocProperty(description = "Key used to authenticate the application") + var clientSecret: String? = null + + @DocProperty(description = "URL to redirect users for authentication") + var authorizationUri: String? = null + + @DocProperty(description = "URL for exchanging authorization code for tokens") + var tokenUri: String? = null + + @DocProperty(description = "Used to identify the organization on login page") + var domain: String? = null + + @DocProperty(description = "URL to retrieve the JSON Web Key Set (JWKS)") + var jwkSetUri: String? = null + + @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`. " + + "You may need that when you want to enable login via your custom SSO.", + ) + var customLogoUrl: String? = null + + @DocProperty( + description = "Custom text for the SSO login page.", + ) + var customLoginText: String? = null +} diff --git a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoLocalProperties.kt b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoLocalProperties.kt new file mode 100644 index 0000000000..f73e82ba39 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoLocalProperties.kt @@ -0,0 +1,33 @@ +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 = "Single Sign-On per Organization", +) +class SsoLocalProperties { + @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 f3e5e1691c..79d1e64fd9 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -242,6 +242,17 @@ enum class Message { TASK_NOT_FOUND, TASK_NOT_FINISHED, TASK_NOT_OPEN, + SSO_TOKEN_EXCHANGE_FAILED, + SSO_USER_INFO_RETRIEVAL_FAILED, + SSO_ID_TOKEN_EXPIRED, + SSO_USER_CANNOT_CREATE_ORGANIZATION, + SSO_CANT_VERIFY_USER, + SSO_GLOBAL_CONFIG_MISSING_PROPERTIES, + SSO_AUTH_MISSING_DOMAIN, + SSO_DOMAIN_NOT_FOUND_OR_DISABLED, + NATIVE_AUTHENTICATION_DISABLED, + INVITATION_ORGANIZATION_MISMATCH, + USER_IS_MANAGED_BY_ORGANIZATION, ; val code: String diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ChangeAuthTypeTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ChangeAuthTypeTestData.kt new file mode 100644 index 0000000000..1b166e3ddb --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ChangeAuthTypeTestData.kt @@ -0,0 +1,17 @@ +package io.tolgee.development.testDataBuilder.data + +import io.tolgee.development.testDataBuilder.builders.TestDataBuilder +import io.tolgee.model.UserAccount + +class ChangeAuthTypeTestData : BaseTestData() { + var userExisting: UserAccount + + val createUserExisting: TestDataBuilder = + TestDataBuilder().apply { + userExisting = + addUserAccount { + name = "fake_email@email.com" + username = "fake_email@email.com" + }.self + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/OAuthTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/OAuthTestData.kt new file mode 100644 index 0000000000..bbd2c9ff98 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/OAuthTestData.kt @@ -0,0 +1,29 @@ +package io.tolgee.development.testDataBuilder.data + +import io.tolgee.development.testDataBuilder.builders.TestDataBuilder +import io.tolgee.model.Organization +import io.tolgee.model.UserAccount +import io.tolgee.model.enums.OrganizationRoleType + +class OAuthTestData : BaseTestData() { + val organization = this.projectBuilder.self.organizationOwner + + var userNotOwner: UserAccount + var userNotOwnerOrganization: Organization + val createUserNotOwner: TestDataBuilder = + TestDataBuilder().apply { + userNotOwner = + addUserAccount userBuilder@{ + username = "userNotOwner" + }.self + userNotOwnerOrganization = + addOrganization { + name = "organization" + }.build { + addRole { + user = userNotOwner + type = OrganizationRoleType.MEMBER + } + }.self + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/AuthProviderChangeRequestData.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/AuthProviderChangeRequestData.kt new file mode 100644 index 0000000000..86537d24e1 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/AuthProviderChangeRequestData.kt @@ -0,0 +1,17 @@ +package io.tolgee.dtos + +import io.tolgee.model.UserAccount +import io.tolgee.model.enums.ThirdPartyAuthType +import java.util.* + +data class AuthProviderChangeRequestData( + val userAccount: UserAccount, + val newAuthProvider: ThirdPartyAuthType?, + val oldAuthProvider: ThirdPartyAuthType?, + val newAccountType: UserAccount.AccountType, + val oldAccountType: UserAccount.AccountType?, + val ssoDomain: String? = null, + val sub: String? = null, + val refreshToken: String? = null, + val calculateExpirationDate: Date? = null, +) 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/request/AuthProviderChangeRequestDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/AuthProviderChangeRequestDto.kt new file mode 100644 index 0000000000..2afb7d4987 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/AuthProviderChangeRequestDto.kt @@ -0,0 +1,6 @@ +package io.tolgee.dtos.request + +data class AuthProviderChangeRequestDto( + val isConfirmed: Boolean, + val changeRequestId: Long, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/response/AuthProviderChangeResponseDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/response/AuthProviderChangeResponseDto.kt new file mode 100644 index 0000000000..aeca10cec4 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/response/AuthProviderChangeResponseDto.kt @@ -0,0 +1,22 @@ +package io.tolgee.dtos.response + +import io.tolgee.model.AuthProviderChangeRequest + +data class AuthProviderChangeResponseDto( + var newAuthType: String?, + var oldAuthType: String?, + var newAccountType: String?, + var userId: Long?, + var oldAccountType: String?, +) { + companion object { + fun fromEntity(entity: AuthProviderChangeRequest): AuthProviderChangeResponseDto = + AuthProviderChangeResponseDto( + newAuthType = entity.newAuthProvider?.code(), + oldAuthType = entity.oldAuthProvider?.code(), + newAccountType = entity.newAccountType?.name, + userId = entity.userAccount?.id, + oldAccountType = entity.userAccount?.accountType?.name, + ) + } +} 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..f750a46248 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/exceptions/AuthExpiredException.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.UNAUTHORIZED) // TODO: there doesn't seem to be a specific status for this +class AuthExpiredException(message: Message) : ErrorException(message) { + override val httpStatus: HttpStatus + get() = HttpStatus.UNAUTHORIZED +} 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..9ff5d7fef4 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,15 @@ 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 @ResponseStatus(HttpStatus.UNAUTHORIZED) -class AuthenticationException(message: io.tolgee.constants.Message) : ErrorException(message) { +open class AuthenticationException( + message: Message, + params: List? = null, +) : ErrorException(message, params) { 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/AuthProviderChangeRequest.kt b/backend/data/src/main/kotlin/io/tolgee/model/AuthProviderChangeRequest.kt new file mode 100644 index 0000000000..4e3b3792d1 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/AuthProviderChangeRequest.kt @@ -0,0 +1,36 @@ +package io.tolgee.model + +import io.tolgee.component.ThirdPartyAuthTypeConverter +import io.tolgee.model.enums.ThirdPartyAuthType +import jakarta.persistence.Column +import jakarta.persistence.Convert +import jakarta.persistence.Entity +import jakarta.persistence.OneToOne +import java.util.* + +@Entity +class AuthProviderChangeRequest : StandardAuditModel() { + @Convert(converter = ThirdPartyAuthTypeConverter::class) + var newAuthProvider: ThirdPartyAuthType? = null + + @Convert(converter = ThirdPartyAuthTypeConverter::class) + var oldAuthProvider: ThirdPartyAuthType? = null + + var newAccountType: UserAccount.AccountType? = null + + var oldAccountType: UserAccount.AccountType? = null + + @OneToOne + var userAccount: UserAccount? = null + + var newSsoDomain: String? = null + + var newSub: String? = null + + @Column(columnDefinition = "TEXT") + var ssoRefreshToken: String? = null + + var ssoExpiration: Date? = null + + var isConfirmed: Boolean = false +} 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..0b1f146b7c 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/OrganizationRole.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/OrganizationRole.kt @@ -17,6 +17,10 @@ import jakarta.validation.constraints.NotNull columnNames = ["user_id", "organization_id"], name = "organization_member_role_user_organization_unique", ), + UniqueConstraint( + columnNames = ["user_id", "managed"], + name = "organization_member_role_only_one_managed", + ), ], ) class OrganizationRole( @@ -38,6 +42,8 @@ class OrganizationRole( @ManyToOne var user: UserAccount? = null + 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..dbc08bbf34 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/SsoTenant.kt @@ -0,0 +1,28 @@ +package io.tolgee.model + +import jakarta.persistence.* +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import org.hibernate.annotations.ColumnDefault + +@Entity +@Table(name = "tenant") +class SsoTenant : StandardAuditModel() { + var name: String = "" + var clientId: String = "" + var clientSecret: String = "" + var authorizationUri: String = "" + + @Column(unique = true, nullable = false) + @NotBlank + var domain: String = "" + var jwkSetUri: String = "" + var tokenUri: String = "" + + @NotNull + @OneToOne(fetch = FetchType.LAZY) + lateinit var organization: Organization + + @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..56e570b9a1 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt @@ -3,26 +3,17 @@ 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.Entity -import jakarta.persistence.EnumType -import jakarta.persistence.Enumerated -import jakarta.persistence.FetchType -import jakarta.persistence.GeneratedValue -import jakarta.persistence.GenerationType -import jakarta.persistence.Id -import jakarta.persistence.ManyToMany -import jakarta.persistence.OneToMany -import jakarta.persistence.OneToOne -import jakarta.persistence.OrderBy +import jakarta.persistence.* import jakarta.validation.constraints.NotBlank import org.hibernate.annotations.ColumnDefault import org.hibernate.annotations.Type import java.util.* +import kotlin.jvm.Transient @Entity @ActivityLoggedEntity @@ -57,7 +48,17 @@ 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 + + @OneToOne(mappedBy = "userAccount") + var authProviderChangeRequest: AuthProviderChangeRequest? = 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 +118,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/AuthProviderChangeRequestRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/AuthProviderChangeRequestRepository.kt new file mode 100644 index 0000000000..b32c357137 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/repository/AuthProviderChangeRequestRepository.kt @@ -0,0 +1,21 @@ +package io.tolgee.repository + +import io.tolgee.model.AuthProviderChangeRequest +import io.tolgee.model.UserAccount +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository +import java.util.* + +@Repository +interface AuthProviderChangeRequestRepository : JpaRepository { + fun findByUserAccountId(id: Long): Optional + + @Query( + "SELECT a FROM AuthProviderChangeRequest a WHERE a.userAccount = :userAccount AND a.isConfirmed = :isConfirmed", + ) + fun findByUserAccountAndIsConfirmed( + userAccount: UserAccount, + isConfirmed: Boolean, + ): Optional +} 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/TenantRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/TenantRepository.kt new file mode 100644 index 0000000000..fa866292e9 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/repository/TenantRepository.kt @@ -0,0 +1,18 @@ +package io.tolgee.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", + ) + fun findEnabledByDomain(domain: String): SsoTenant? +} 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..b925d89bca 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,7 +160,41 @@ interface UserAccountRepository : JpaRepository { ) fun findThirdByThirdParty( thirdPartyAuthId: String, - thirdPartyAuthType: String, + thirdPartyAuthType: ThirdPartyAuthType, + ): Optional + + @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 ua.deletedAt is null + and ua.disabledAt is null + """, + ) + fun findBySsoDomain( + thirdPartyAuthId: String, + domain: String, + ): Optional + + @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 ((:ssoTenantId is null and o.ssoTenant is null) or o.ssoTenant.id = :ssoTenantId) + and ua.deletedAt is null + and ua.disabledAt is null + """, + ) + fun findBySsoTenantId( + thirdPartyAuthId: String, + ssoTenantId: Long?, ): Optional @Query( 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/AuthProviderChangeRequestService.kt b/backend/data/src/main/kotlin/io/tolgee/service/AuthProviderChangeRequestService.kt new file mode 100644 index 0000000000..5d10811a2f --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/service/AuthProviderChangeRequestService.kt @@ -0,0 +1,84 @@ +package io.tolgee.service + +import io.tolgee.dtos.AuthProviderChangeRequestData +import io.tolgee.dtos.request.AuthProviderChangeRequestDto +import io.tolgee.exceptions.BadRequestException +import io.tolgee.exceptions.NotFoundException +import io.tolgee.model.AuthProviderChangeRequest +import io.tolgee.model.UserAccount +import io.tolgee.repository.AuthProviderChangeRequestRepository +import io.tolgee.service.security.UserAccountService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import java.util.* + +@Service +class AuthProviderChangeRequestService( + private val authProviderChangeRequestRepository: AuthProviderChangeRequestRepository, + private val userAccountService: UserAccountService, +) { + fun getById(id: Long): AuthProviderChangeRequest = findById(id).orElseGet { throw NotFoundException() } + + fun findById(id: Long): Optional = authProviderChangeRequestRepository.findById(id) + + fun findByUserAccount(userAccountId: Long): Optional = + authProviderChangeRequestRepository.findByUserAccountId(userAccountId) + + fun getByUserAccountId(userAccountId: Long): AuthProviderChangeRequest = + findByUserAccount(userAccountId).orElseGet { throw NotFoundException() } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun create(data: AuthProviderChangeRequestData): AuthProviderChangeRequest? { + findByUserAccount(data.userAccount.id).ifPresent { + throw BadRequestException("User already has a pending auth provider change request") + } + val authProviderChangeRequest = + AuthProviderChangeRequest().apply { + this.userAccount = data.userAccount + this.newAuthProvider = data.newAuthProvider + this.oldAuthProvider = data.oldAuthProvider + this.newAccountType = data.newAccountType + this.oldAccountType = data.oldAccountType + this.newSsoDomain = data.ssoDomain + this.newSub = data.sub + this.ssoRefreshToken = data.refreshToken + this.ssoExpiration = data.calculateExpirationDate + } + + val saved = authProviderChangeRequestRepository.save(authProviderChangeRequest) + data.userAccount.authProviderChangeRequest = saved + userAccountService.save(data.userAccount) + + return saved + } + + fun confirmOrCancel(authProviderChangeRequestDto: AuthProviderChangeRequestDto) { + val entity = getById(authProviderChangeRequestDto.changeRequestId) + if (!authProviderChangeRequestDto.isConfirmed) { + authProviderChangeRequestRepository.delete(entity) + return + } + entity.isConfirmed = true + authProviderChangeRequestRepository.save(entity) + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun resolveChangeRequestIfExist(userAccount: UserAccount): Boolean { + var wasResolved = false + + authProviderChangeRequestRepository.findByUserAccountAndIsConfirmed(userAccount, true).ifPresent { + userAccount.accountType = it.newAccountType + userAccount.thirdPartyAuthType = it.newAuthProvider + userAccount.thirdPartyAuthId = it.newSub + userAccount.ssoRefreshToken = it.ssoRefreshToken + userAccount.ssoSessionExpiry = it.ssoExpiration + userAccount.authProviderChangeRequest = null + userAccountService.save(userAccount) + authProviderChangeRequestRepository.delete(it) + wasResolved = true + } + + return wasResolved + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/EeSsoTenantService.kt b/backend/data/src/main/kotlin/io/tolgee/service/EeSsoTenantService.kt new file mode 100644 index 0000000000..99b112e821 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/service/EeSsoTenantService.kt @@ -0,0 +1,7 @@ +package io.tolgee.service + +import io.tolgee.model.SsoTenant + +interface EeSsoTenantService { + fun getByDomain(domain: String): SsoTenant +} 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 0f5dbd0eb0..a84bfb1852 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,6 +16,7 @@ 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 @@ -145,7 +146,13 @@ class OrganizationService( exceptOrganizationId: Long = 0, ): Organization? { return findPreferred(userAccount.id, exceptOrganizationId) ?: let { - if (tolgeeProperties.authentication.userCanCreateOrganizations || userAccount.role == UserAccount.Role.ADMIN) { + if ( + ( + tolgeeProperties.authentication.userCanCreateOrganizations && + userAccount.thirdPartyAuthType !== ThirdPartyAuthType.SSO + ) || + userAccount.role == UserAccount.Role.ADMIN + ) { return@let createPreferred(userAccount) } null 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 ed022a7b9b..291f85bf3b 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.InvitationService import io.tolgee.service.QuickStartService +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 one 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..a37d1d36b9 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 @@ -223,12 +224,22 @@ class UserAccountService( } fun findByThirdParty( - type: String, + type: ThirdPartyAuthType, id: String, ): Optional { return userAccountRepository.findThirdByThirdParty(id, type) } + fun findBySsoDomain( + type: String, + idSub: String, + ): Optional = userAccountRepository.findBySsoDomain(idSub, type) + + fun findBySsoTenantId( + tenantId: Long?, + idSub: String, + ): Optional = userAccountRepository.findBySsoTenantId(idSub, tenantId) + @Transactional @CacheEvict(cacheNames = [Caches.USER_ACCOUNTS], key = "#result.id") fun setAccountType( diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index 6f57803241..f6bd1eba81 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -3878,4 +3878,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..a29fb39a74 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)) + checkIfSsoUserStillExists(auth.principal) + SecurityContextHolder.getContext().authentication = auth return } @@ -111,6 +117,12 @@ class AuthenticationFilter( } } + private fun checkIfSsoUserStillExists(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) + checkIfSsoUserStillExists(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) + checkIfSsoUserStillExists(userAccount) + patService.updateLastUsedAsync(pat.id) SecurityContextHolder.getContext().authentication = TolgeeAuthentication( diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthenticationInterceptor.kt b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthenticationInterceptor.kt index 3bb595fdce..29d8173384 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthenticationInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authentication/AuthenticationInterceptor.kt @@ -72,6 +72,9 @@ class AuthenticationInterceptor( authenticationProperties.enabled && authenticationFacade.authenticatedUser.needsSuperJwt && !authenticationFacade.isUserSuperAuthenticated + // TODO: && authentication.nativeEnabled || authenticationFacade.authenticatedUser.isMfaEnabled + // similar check is already in the needsSuperJwt bit it doesn't account for the nativeEnabled config option + // should we just add the isMfaEnabled to the user dto? ) { throw PermissionException(Message.EXPIRED_SUPER_JWT_TOKEN) } 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..5ae0c68a6b --- /dev/null +++ b/backend/security/src/main/kotlin/io/tolgee/security/service/thirdParty/SsoDelegate.kt @@ -0,0 +1,17 @@ +package io.tolgee.security.service.thirdParty + +import io.tolgee.dtos.cacheable.UserAccountDto +import io.tolgee.security.payload.JwtAuthenticationResponse +import org.springframework.stereotype.Component + +@Component +interface SsoDelegate { + fun getTokenResponse( + receivedCode: String?, + invitationCode: String?, + redirectUri: String?, + domain: String?, + ): JwtAuthenticationResponse + + fun verifyUserSsoAccountAvailable(user: UserAccountDto): Boolean +} 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..50f8b74054 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 = diff --git a/e2e/cypress/common/apiCalls/common.ts b/e2e/cypress/common/apiCalls/common.ts index 3524b5eb9c..a11a2343b1 100644 --- a/e2e/cypress/common/apiCalls/common.ts +++ b/e2e/cypress/common/apiCalls/common.ts @@ -195,6 +195,26 @@ export const setTranslations = ( method: 'POST', }); +export const setSsoProvider = () => { + const sql = `insert into public.tenant (id, organization_id, domain, client_id, client_secret, authorization_uri, + jwk_set_uri, token_uri, enabled, + name, sso_provider, created_at, updated_at) + values ('1', 1, 'domain.com', 'clientId', 'clientSecret', 'http://authorizationUri', + 'http://jwkSetUri', 'http://tokenUri', true, 'name', 'sso', CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP)`; + internalFetch(`sql/execute`, { method: 'POST', body: sql }); +}; + +export const deleteSso = () => { + const sql = ` + delete + from public.tenant + where organization_id = 1 + `; + + return internalFetch(`sql/execute`, { method: 'POST', body: sql }); +}; + export const deleteProject = (id: number) => { return v2apiFetch(`projects/${id}`, { method: 'DELETE' }); }; diff --git a/e2e/cypress/common/login.ts b/e2e/cypress/common/login.ts index 555bdb0e7c..fefc082b8c 100644 --- a/e2e/cypress/common/login.ts +++ b/e2e/cypress/common/login.ts @@ -49,6 +49,34 @@ export const loginWithFakeOAuth2 = () => { }); }; +export const loginWithFakeSso = () => { + cy.intercept('http://authorizationuri/**', { + statusCode: 200, + body: 'Fake Sso', + }).as('sso'); + + cy.contains('Log in via SSO').click(); + + cy.wait('@sso').then((interception) => { + const params = new URL(interception.request.url).searchParams; + expect(params.get('client_id')).to.eq('clientId'); + expect(params.get('response_type')).to.eq('code'); + expect(params.get('scope')).to.eq('openid profile email'); + 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.visit( + HOST + + `/login/open-id/auth-callback?code=this_is_dummy_code&redirect_uri=https%3A%2F%2Fdummy-url.com%2Fcallback&state=${params.get( + 'state' + )}` + ); + + cy.contains('Projects').should('be.visible'); + }); +}; + 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..a91bb3177b 100644 --- a/e2e/cypress/e2e/security/login.cy.ts +++ b/e2e/cypress/e2e/security/login.cy.ts @@ -5,9 +5,12 @@ import { getAnyContainingText } from '../../common/xPath'; import { createUser, deleteAllEmails, + deleteSso, disableEmailVerification, getParsedResetPasswordEmail, login, + logout, + setSsoProvider, userDisableMfa, userEnableMfa, } from '../../common/apiCalls/common'; @@ -19,6 +22,7 @@ import { loginViaForm, loginWithFakeGithub, loginWithFakeOAuth2, + loginWithFakeSso, } from '../../common/login'; import { waitForGlobalLoading } from '../../common/loading'; @@ -31,6 +35,22 @@ context('Login', () => { cy.gcy('global-language-menu').should('be.visible'); }); + context('SSO Login', () => { + it('login with sso', { retries: { runMode: 5 } }, () => { + disableEmailVerification(); + setSsoProvider(); + cy.visit(HOST); + cy.contains('Log in with SSO').click(); + cy.xpath("//*[@name='domain']").type('domain.com'); + loginWithFakeSso(); + }); + + afterEach(() => { + deleteSso(); + logout(); + }); + }); + it('login', () => { checkAnonymousIdSet(); @@ -66,7 +86,6 @@ context('Login', () => { }); it('login with oauth2', { retries: { runMode: 5 } }, () => { disableEmailVerification(); - loginWithFakeOAuth2(); }); diff --git a/ee/backend/app/build.gradle b/ee/backend/app/build.gradle index db2aaaec96..f9fce37ddc 100644 --- a/ee/backend/app/build.gradle +++ b/ee/backend/app/build.gradle @@ -47,6 +47,8 @@ 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("org.springframework.boot:spring-boot-starter-oauth2-client") + implementation(project(":data")) implementation(project(":security")) implementation(project(":api")) 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..c559519b65 --- /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.ee.data.DomainRequest +import io.tolgee.ee.data.SsoUrlResponse +import io.tolgee.ee.service.sso.TenantService +import io.tolgee.security.thirdParty.SsoTenantConfig +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/open-id/auth-callback"}&" + + "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..75a4010b9a --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoProviderController.kt @@ -0,0 +1,59 @@ +package io.tolgee.ee.api.v2.controllers + +import io.tolgee.component.enabledFeaturesProvider.EnabledFeaturesProvider +import io.tolgee.constants.Feature +import io.tolgee.ee.api.v2.hateoas.assemblers.SsoTenantAssembler +import io.tolgee.ee.data.CreateProviderRequest +import io.tolgee.ee.data.toDto +import io.tolgee.ee.service.sso.TenantService +import io.tolgee.exceptions.NotFoundException +import io.tolgee.hateoas.ee.SsoTenantModel +import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.security.authentication.RequiresSuperAuthentication +import io.tolgee.security.authorization.RequiresOrganizationRole +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 { + enabledFeaturesProvider.checkFeatureEnabled( + organizationId = organizationId, + Feature.SSO, + ) + + val organization = organizationService.get(organizationId) + return ssoTenantAssembler.toModel(tenantService.saveOrUpdate(request, organization).toDto()) + } + + @RequiresOrganizationRole(role = OrganizationRoleType.OWNER) + @GetMapping("") + @RequiresSuperAuthentication + fun findProvider( + @PathVariable organizationId: Long, + ): SsoTenantModel? = + try { + enabledFeaturesProvider.checkFeatureEnabled( + organizationId = organizationId, + Feature.SSO, + ) + + ssoTenantAssembler.toModel(tenantService.getTenant(organizationId).toDto()) + } catch (e: NotFoundException) { + null + } +} 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..2edf088b8d --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/SsoTenantAssembler.kt @@ -0,0 +1,25 @@ +package io.tolgee.ee.api.v2.hateoas.assemblers + +import io.tolgee.ee.api.v2.controllers.SsoProviderController +import io.tolgee.ee.data.SsoTenantDto +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, + isEnabled = entity.isEnabled, + jwkSetUri = entity.jwkSetUri, + domainName = entity.domainName, + ) +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/configuration/JwtProcessorConfiguration.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/configuration/JwtProcessorConfiguration.kt new file mode 100644 index 0000000000..5b732a7624 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/configuration/JwtProcessorConfiguration.kt @@ -0,0 +1,13 @@ +package io.tolgee.ee.configuration + +import com.nimbusds.jose.proc.SecurityContext +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor +import com.nimbusds.jwt.proc.DefaultJWTProcessor +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class JwtProcessorConfiguration { + @Bean + fun jwtProcessor(): ConfigurableJWTProcessor = DefaultJWTProcessor() +} 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..e5e72630ab --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/CreateProviderRequest.kt @@ -0,0 +1,22 @@ +package io.tolgee.ee.data + +import jakarta.validation.constraints.NotNull +import org.springframework.validation.annotation.Validated + +@Validated +data class CreateProviderRequest( + val name: String?, + @field:NotNull + val clientId: String, + @field:NotNull + val clientSecret: String, + @field:NotNull + val authorizationUri: String, + @field:NotNull + val tokenUri: String, + @field:NotNull + val jwkSetUri: String, + val isEnabled: Boolean, + @field:NotNull + val domainName: String, +) 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/SsoTenantDto.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/SsoTenantDto.kt new file mode 100644 index 0000000000..833b61d720 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/SsoTenantDto.kt @@ -0,0 +1,24 @@ +package io.tolgee.ee.data + +import io.tolgee.model.SsoTenant + +data class SsoTenantDto( + val authorizationUri: String, + val clientId: String, + val clientSecret: String, + val tokenUri: String, + val isEnabled: Boolean, + val jwkSetUri: String, + val domainName: String, +) + +fun SsoTenant.toDto(): SsoTenantDto = + SsoTenantDto( + authorizationUri = this.authorizationUri, + clientId = this.clientId, + clientSecret = this.clientSecret, + tokenUri = this.tokenUri, + isEnabled = this.enabled, + jwkSetUri = this.jwkSetUri, + domainName = this.domain, + ) 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..972b364b11 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/exceptions/SsoAuthorizationException.kt @@ -0,0 +1,6 @@ +package io.tolgee.ee.exceptions + +import io.tolgee.constants.Message +import io.tolgee.exceptions.AuthenticationException + +class SsoAuthorizationException(msg: Message) : AuthenticationException(msg) 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..6494c64964 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/security/thirdParty/SsoDelegateEe.kt @@ -0,0 +1,240 @@ +package io.tolgee.ee.security.thirdParty + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.jwk.source.JWKSource +import com.nimbusds.jose.jwk.source.RemoteJWKSet +import com.nimbusds.jose.proc.JWSAlgorithmFamilyJWSKeySelector +import com.nimbusds.jose.proc.SecurityContext +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor +import io.tolgee.component.CurrentDateProvider +import io.tolgee.component.enabledFeaturesProvider.EnabledFeaturesProvider +import io.tolgee.configuration.tolgee.SsoGlobalProperties +import io.tolgee.constants.Feature +import io.tolgee.constants.Message +import io.tolgee.dtos.cacheable.UserAccountDto +import io.tolgee.ee.data.GenericUserResponse +import io.tolgee.ee.data.OAuth2TokenResponse +import io.tolgee.ee.exceptions.SsoAuthorizationException +import io.tolgee.ee.service.sso.TenantService +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.SsoTenantConfig +import io.tolgee.security.thirdParty.data.OAuthUserDetails +import io.tolgee.service.organization.OrganizationRoleService +import io.tolgee.util.Logging +import io.tolgee.util.logger +import org.springframework.http.* +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.net.URL +import java.util.* + +@Component +class SsoDelegateEe( + private val jwtService: JwtService, + private val restTemplate: RestTemplate, + private val jwtProcessor: ConfigurableJWTProcessor, + private val ssoGlobalProperties: SsoGlobalProperties, + private val organizationRoleService: OrganizationRoleService, + private val tenantService: TenantService, + private val oAuthUserHandler: OAuthUserHandler, + private val currentDateProvider: CurrentDateProvider, + private val enabledFeaturesProvider: EnabledFeaturesProvider, +) : SsoDelegate, Logging { + 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 = decodeIdToken(token.id_token, tenant.jwkSetUri) + 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 decodeIdToken( + idToken: String, + jwkSetUri: String, + ): GenericUserResponse { + try { + val signedJWT = SignedJWT.parse(idToken) + + // TODO: Why is this deprecated? + val jwkSource: JWKSource = RemoteJWKSet(URL(jwkSetUri)) + + val keySelector = JWSAlgorithmFamilyJWSKeySelector(JWSAlgorithm.Family.RSA, jwkSource) + jwtProcessor.jwsKeySelector = keySelector + + val jwtClaimsSet: JWTClaimsSet = jwtProcessor.process(signedJWT, null) + + val expirationTime: Date = jwtClaimsSet.expirationTime + if (expirationTime.before(Date())) { + throw SsoAuthorizationException(Message.SSO_ID_TOKEN_EXPIRED) + } + + return GenericUserResponse().apply { + sub = jwtClaimsSet.subject + name = jwtClaimsSet.getStringClaim("name") + given_name = jwtClaimsSet.getStringClaim("given_name") + family_name = jwtClaimsSet.getStringClaim("family_name") + email = jwtClaimsSet.getStringClaim("email") + } + } catch (e: Exception) { + logger.info(e.stackTraceToString()) + throw SsoAuthorizationException(Message.SSO_USER_INFO_RETRIEVAL_FAILED) + } + } + + 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 fetchLocalSsoDomainFor(userId: Long): String? { + // TODO: Cache this? + val organization = organizationRoleService.getManagedBy(userId) ?: return null + val tenant = tenantService.findTenant(organization.id) + return tenant?.domain + } + + override fun verifyUserSsoAccountAvailable(user: UserAccountDto): Boolean { + var domain = + if (user.thirdPartyAuth == ThirdPartyAuthType.SSO) { + fetchLocalSsoDomainFor(user.id) + } else if (user.thirdPartyAuth == ThirdPartyAuthType.SSO_GLOBAL) { + ssoGlobalProperties.domain + } else { + // Not SSO user + return true + } + + if (domain == null || user.ssoRefreshToken == null) { + throw AuthenticationException(Message.SSO_CANT_VERIFY_USER) + } + if (user.ssoSessionExpiry?.after(currentDateProvider.date) == true) { + return true + } + + val tenant = tenantService.getEnabledConfigByDomain(domain) + enabledFeaturesProvider.checkFeatureEnabled( + organizationId = tenant.organization?.id, + Feature.SSO, + ) + val headers = + HttpHeaders().apply { + contentType = MediaType.APPLICATION_FORM_URLENCODED + } + + val body: MultiValueMap = LinkedMultiValueMap() + body.add("grant_type", "refresh_token") + body.add("client_id", tenant.clientId) + body.add("client_secret", tenant.clientSecret) + body.add("scope", "offline_access openid") + body.add("refresh_token", user.ssoRefreshToken) + + val request = HttpEntity(body, headers) + return try { + val response: ResponseEntity = + restTemplate.exchange( + tenant.tokenUri, + HttpMethod.POST, + request, + OAuth2TokenResponse::class.java, + ) + if (response.body?.refresh_token != null) { + oAuthUserHandler.resetSsoSessionExpiry(user.id) + oAuthUserHandler.updateRefreshToken(user.id, response.body?.refresh_token) + return true + } + false + } catch (e: RestClientException) { + logger.info("Failed to refresh token: ${e.message}") + false + } + } +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TenantService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TenantService.kt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/sso/TenantService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/sso/TenantService.kt new file mode 100644 index 0000000000..e21fab3953 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/sso/TenantService.kt @@ -0,0 +1,70 @@ +package io.tolgee.ee.service.sso + +import io.tolgee.configuration.tolgee.SsoGlobalProperties +import io.tolgee.configuration.tolgee.SsoLocalProperties +import io.tolgee.constants.Message +import io.tolgee.ee.data.CreateProviderRequest +import io.tolgee.exceptions.NotFoundException +import io.tolgee.model.Organization +import io.tolgee.model.SsoTenant +import io.tolgee.repository.TenantRepository +import io.tolgee.security.thirdParty.SsoTenantConfig +import io.tolgee.security.thirdParty.SsoTenantConfig.Companion.toConfig +import io.tolgee.service.EeSsoTenantService +import org.springframework.stereotype.Service + +@Service +class TenantService( + private val tenantRepository: TenantRepository, + private val ssoGlobalProperties: SsoGlobalProperties, + private val ssoLocalProperties: SsoLocalProperties, +) : EeSsoTenantService{ + fun getById(id: Long): SsoTenant = tenantRepository.findById(id).orElseThrow { NotFoundException() } + + override fun getByDomain(domain: String): SsoTenant { + return tenantRepository.findByDomain(domain) ?: throw NotFoundException() + } + + fun getEnabledConfigByDomain(domain: String): SsoTenantConfig { + return ssoGlobalProperties + .takeIf { it.enabled && domain == it.domain } + ?.toConfig() + ?: domain + .takeIf { ssoLocalProperties.enabled } + ?.let { tenantRepository.findEnabledByDomain(it)?.toConfig() } + ?: throw NotFoundException(Message.SSO_DOMAIN_NOT_FOUND_OR_DISABLED) + } + + fun save(tenant: SsoTenant): SsoTenant = tenantRepository.save(tenant) + + fun findAll(): List = tenantRepository.findAll() + + fun findTenant(organizationId: Long): SsoTenant? = tenantRepository.findByOrganizationId(organizationId) + + fun getTenant(organizationId: Long): SsoTenant = findTenant(organizationId) ?: throw NotFoundException() + + fun saveOrUpdate( + request: CreateProviderRequest, + organization: Organization, + ): SsoTenant { + val tenant = findTenant(organization.id) ?: SsoTenant() + return setAndSaveTenantsFields(tenant, request, organization) + } + + private fun setAndSaveTenantsFields( + tenant: SsoTenant, + dto: CreateProviderRequest, + organization: Organization, + ): SsoTenant { + tenant.name = dto.name ?: "" + tenant.organization = organization + tenant.domain = dto.domainName + tenant.clientId = dto.clientId + tenant.clientSecret = dto.clientSecret + tenant.authorizationUri = dto.authorizationUri + tenant.tokenUri = dto.tokenUri + tenant.jwkSetUri = dto.jwkSetUri + tenant.enabled = dto.isEnabled + return save(tenant) + } +} diff --git a/ee/backend/tests/build.gradle b/ee/backend/tests/build.gradle index 01e15401d1..5c85f5e8a5 100644 --- a/ee/backend/tests/build.gradle +++ b/ee/backend/tests/build.gradle @@ -43,8 +43,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation "org.springframework.boot:spring-boot-starter-hateoas" + testImplementation("org.springframework.boot:spring-boot-starter-oauth2-client") 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/OAuthTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt new file mode 100644 index 0000000000..8c7ccdebe3 --- /dev/null +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -0,0 +1,247 @@ +package io.tolgee.ee + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.nimbusds.jose.proc.SecurityContext +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor +import io.tolgee.configuration.tolgee.SsoGlobalProperties +import io.tolgee.constants.Message +import io.tolgee.development.testDataBuilder.data.OAuthTestData +import io.tolgee.dtos.cacheable.UserAccountDto +import io.tolgee.dtos.request.organization.OrganizationDto +import io.tolgee.ee.data.OAuth2TokenResponse +import io.tolgee.ee.security.thirdParty.SsoDelegateEe +import io.tolgee.ee.service.sso.TenantService +import io.tolgee.ee.utils.OAuthMultiTenantsMocks +import io.tolgee.ee.utils.OAuthMultiTenantsMocks.Companion.jwtClaimsSet +import io.tolgee.exceptions.NotFoundException +import io.tolgee.fixtures.andIsForbidden +import io.tolgee.model.SsoTenant +import io.tolgee.model.enums.OrganizationRoleType +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.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.ArgumentMatchers.* +import org.mockito.Mockito.times +import org.mockito.kotlin.clearInvocations +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 OAuthTest : AuthorizedControllerTest() { + private lateinit var testData: OAuthTestData + + @MockBean + @Autowired + private val restTemplate: RestTemplate? = null + + @Autowired + private var authMvc: MockMvc? = null + + @MockBean + @Autowired + private val jwtProcessor: ConfigurableJWTProcessor? = null + + @Autowired + private lateinit var ssoDelegate: SsoDelegateEe + + @Autowired + private lateinit var tenantService: TenantService + + @Autowired + lateinit var ssoGlobalProperties: SsoGlobalProperties + + private val oAuthMultiTenantsMocks: OAuthMultiTenantsMocks by lazy { + OAuthMultiTenantsMocks(authMvc, restTemplate, tenantService, jwtProcessor) + } + + @BeforeEach + fun setup() { + currentDateProvider.forcedDate = currentDateProvider.date + ssoGlobalProperties.enabled = false + testData = OAuthTestData() + testDataService.saveTestData(testData.root) + } + + private fun addTenant(): SsoTenant = + tenantService.findTenant(testData.organization.id) + ?: SsoTenant() + .apply { + name = "tenant1" + domain = "registrationId" + clientId = "clientId" + clientSecret = "clientSecret" + authorizationUri = "authorizationUri" + jwkSetUri = "http://jwkSetUri" + tokenUri = "http://tokenUri" + organization = testData.organization + }.let { tenantService.save(it) } + + @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 = jwtClaimsSet.getStringClaim("email") + assertThat(userAccountService.get(userName)).isNotNull + } + + @Test + fun `does not return auth link when tenant is disabled`() { + val tenant = addTenant() + tenant.enabled = false + tenantService.save(tenant) + val response = oAuthMultiTenantsMocks.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`() { + val tenant = addTenant() + tenant.enabled = false + tenantService.save(tenant) + val response = oAuthMultiTenantsMocks.authorize("registrationId") + 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 = jwtClaimsSet.getStringClaim("email") + 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`() { + addTenant() + val response = + oAuthMultiTenantsMocks.authorize( + "registrationId", + ResponseEntity(null, null, 400), + ) + assertThat(response.response.status).isEqualTo(400) + assertThat(response.response.contentAsString).contains(Message.SSO_TOKEN_EXCHANGE_FAILED.code) + val userName = jwtClaimsSet.getStringClaim("email") + assertThrows { userAccountService.get(userName) } + } + + @Transactional + @Test + fun `sso auth doesn't create demo project and user organization`() { + loginAsSsoUser(jwtClaims = OAuthMultiTenantsMocks.jwtClaimsSet2) + val user = userAccountService.get("mai2@mail.com") + assertThat(user.organizationRoles.size).isEqualTo(1) + assertThat(user.organizationRoles[0].organization?.id).isEqualTo(testData.organization.id) + } + + @Transactional + @Test + fun `sso user can't create organization`() { + loginAsSsoUser() + val userName = jwtClaimsSet.getStringClaim("email") + val user = userAccountService.get(userName) + loginAsUser(user) + performAuthPost( + "/v2/organizations", + organizationDto(), + ).andIsForbidden + } + + @Test + fun `sso auth saves refresh token`() { + loginAsSsoUser() + val userName = jwtClaimsSet.getStringClaim("email") + 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 = jwtClaimsSet.getStringClaim("email") + val user = userAccountService.get(userName) + assertThat( + ssoDelegate.verifyUserSsoAccountAvailable( + UserAccountDto.fromEntity(user), + ), + ).isTrue + } + + @Test + fun `after timeout should call token endpoint `() { + clearInvocations(restTemplate) + loginAsSsoUser() + val userName = jwtClaimsSet.getStringClaim("email") + val user = userAccountService.get(userName) + currentDateProvider.forcedDate = Date(currentDateProvider.date.time + 600_000) + + oAuthMultiTenantsMocks.mockTokenExchange("http://tokenUri") + assertThat( + ssoDelegate.verifyUserSsoAccountAvailable( + UserAccountDto.fromEntity(user), + ), + ).isTrue + + // first call is in loginAsSsoUser + verify(restTemplate, times(2))?.exchange( + anyString(), + eq(HttpMethod.POST), + any(HttpEntity::class.java), + eq(OAuth2TokenResponse::class.java), + ) + } + + @Test + fun `sso auth works via global config`() { + ssoGlobalProperties.enabled = true + ssoGlobalProperties.domain = "registrationId" + ssoGlobalProperties.clientId = "clientId" + ssoGlobalProperties.clientSecret = "clientSecret" + ssoGlobalProperties.authorizationUri = "authorizationUri" + ssoGlobalProperties.tokenUri = "http://tokenUri" + ssoGlobalProperties.jwkSetUri = "http://jwkSetUri" + val response = oAuthMultiTenantsMocks.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(jwtClaims: JWTClaimsSet = jwtClaimsSet): MvcResult { + addTenant() + return oAuthMultiTenantsMocks.authorize("registrationId", jwtClaims = jwtClaims) + } +} 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..daabac3258 --- /dev/null +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/SsoProviderControllerTest.kt @@ -0,0 +1,71 @@ +package io.tolgee.ee.api.v2.controllers + +import io.tolgee.constants.Feature +import io.tolgee.development.testDataBuilder.data.OAuthTestData +import io.tolgee.ee.component.PublicEnabledFeaturesProvider +import io.tolgee.fixtures.andAssertThatJson +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: OAuthTestData + + @Autowired + private lateinit var enabledFeaturesProvider: PublicEnabledFeaturesProvider + + @BeforeEach + fun setup() { + testData = OAuthTestData() + testDataService.saveTestData(testData.root) + this.userAccount = testData.user + loginAsUser(testData.user.username) + 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("domainName").isEqualTo("google") + node("clientId").isEqualTo("clientId") + node("clientSecret").isEqualTo("clientSecret") + node("authorizationUri").isEqualTo("authorization") + node("tokenUri").isEqualTo("tokenUri") + node("jwkSetUri").isEqualTo("jwkSetUri") + node("isEnabled").isEqualTo(true) + } + } + + @Test + fun `fails if user is not owner of organization`() { + testDataService.saveTestData(testData.createUserNotOwner) + this.userAccount = testData.userNotOwner + loginAsUser(testData.userNotOwner.username) + performAuthPut( + "/v2/organizations/${testData.userNotOwnerOrganization.id}/sso", + requestTenant(), + ).andIsForbidden + } + + fun requestTenant() = + mapOf( + "domainName" to "google", + "clientId" to "clientId", + "clientSecret" to "clientSecret", + "authorizationUri" to "authorization", + "redirectUri" to "redirectUri", + "tokenUri" to "tokenUri", + "jwkSetUri" to "jwkSetUri", + "isEnabled" to true, + ) +} diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/utils/OAuthMultiTenantsMocks.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/utils/OAuthMultiTenantsMocks.kt new file mode 100644 index 0000000000..b235f7d532 --- /dev/null +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/utils/OAuthMultiTenantsMocks.kt @@ -0,0 +1,156 @@ +package io.tolgee.ee.utils + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.crypto.MACSigner +import com.nimbusds.jose.proc.SecurityContext +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor +import io.tolgee.ee.data.OAuth2TokenResponse +import io.tolgee.ee.service.sso.TenantService +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +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 OAuthMultiTenantsMocks( + private var authMvc: MockMvc? = null, + private val restTemplate: RestTemplate? = null, + private val tenantService: TenantService? = null, + private val jwtProcessor: ConfigurableJWTProcessor?, +) { + companion object { + val defaultToken = + OAuth2TokenResponse(id_token = generateTestJwt(), scope = "scope", refresh_token = "refresh_token") + + val defaultTokenResponse = + ResponseEntity( + defaultToken, + HttpStatus.OK, + ) + + val jwtClaimsSet: JWTClaimsSet + get() { + val claimsSet = + JWTClaimsSet + .Builder() + .subject("testSubject") + .issuer("https://test-oauth-provider.com") + .expirationTime(Date(System.currentTimeMillis() + 3600 * 1000)) + .claim("name", "Test User") + .claim("given_name", "Test") + .claim("given_name", "Test") + .claim("family_name", "User") + .claim("email", "mail@mail.com") + .build() + return claimsSet + } + + val jwtClaimsSet2: JWTClaimsSet + get() { + val claimsSet = + JWTClaimsSet + .Builder() + .subject("testSubject") + .issuer("https://test-oauth-provider.com") + .expirationTime(Date(System.currentTimeMillis() + 3600 * 1000)) + .claim("name", "Test User2") + .claim("given_name", "Test2") + .claim("given_name", "Test2") + .claim("family_name", "User2") + .claim("email", "mai2@mail.com") + .build() + return claimsSet + } + + private fun generateTestJwt(): String { + val header = JWSHeader(JWSAlgorithm.HS256) + + val signedJwt = SignedJWT(header, jwtClaimsSet) + + val testSecret = "test-256-bit-secretAAAAAAAAAAAAAAA" + val signer = MACSigner(testSecret.toByteArray()) + + signedJwt.sign(signer) + + return signedJwt.serialize() + } + } + + fun authorize( + registrationId: String, + tokenResponse: ResponseEntity? = defaultTokenResponse, + jwtClaims: JWTClaimsSet = jwtClaimsSet, + ): MvcResult { + val receivedCode = "fake_access_token" + val tenant = tenantService?.getEnabledConfigByDomain(registrationId)!! + // mock token exchange + whenever( + restTemplate?.exchange( + eq(tenant.tokenUri), + eq(HttpMethod.POST), + any(), + eq(OAuth2TokenResponse::class.java), + ), + ).thenReturn(tokenResponse) + + // mock parsing of jwt + mockJwt(jwtClaims) + + return authMvc!! + .perform( + MockMvcRequestBuilders.get( + "/api/public/authorize_oauth/sso?domain=$registrationId&code=$receivedCode&redirect_uri=redirect_uri", + ), + ).andReturn() + } + + fun getAuthLink(registrationId: String): MvcResult = + authMvc!! + .perform( + MockMvcRequestBuilders + .post("/api/public/authorize_oauth/sso/authentication-url") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "domain": "$registrationId", + "state": "state" + } + """.trimIndent(), + ), + ).andReturn() + + private fun mockJwt(jwtClaims: JWTClaimsSet) { + whenever( + jwtProcessor?.process( + any(), + isNull(), + ), + ).thenReturn(jwtClaims) + } + + fun mockTokenExchange( + tokenUri: String, + tokenResponse: ResponseEntity? = defaultTokenResponse, + ) { + whenever( + restTemplate?.exchange( + eq(tokenUri), + eq(HttpMethod.POST), + any(), + eq(OAuth2TokenResponse::class.java), + ), + ).thenReturn(tokenResponse) + } +} diff --git a/webapp/src/component/RootRouter.tsx b/webapp/src/component/RootRouter.tsx index 7b3a0ced91..4147f66658 100644 --- a/webapp/src/component/RootRouter.tsx +++ b/webapp/src/component/RootRouter.tsx @@ -17,6 +17,7 @@ import { RequirePreferredOrganization } from '../RequirePreferredOrganization'; import { HelpMenu } from './HelpMenu'; import { PublicOnlyRoute } from './common/PublicOnlyRoute'; import { PreferredOrganizationRedirect } from './security/PreferredOrganizationRedirect'; +import { SsoLoginView } from 'tg.component/security/Sso/SsoLoginView'; const LoginRouter = React.lazy( () => import(/* webpackChunkName: "login" */ './security/Login/LoginRouter') @@ -92,6 +93,11 @@ export const RootRouter = () => ( + + + + + diff --git a/webapp/src/component/security/ChangeAuthProviderView.tsx b/webapp/src/component/security/ChangeAuthProviderView.tsx new file mode 100644 index 0000000000..70bc697c1f --- /dev/null +++ b/webapp/src/component/security/ChangeAuthProviderView.tsx @@ -0,0 +1,157 @@ +import { DashboardPage } from 'tg.component/layout/DashboardPage'; +import { CompactView } from 'tg.component/layout/CompactView'; +import { Box, styled } from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; +import { LINKS } from 'tg.constants/links'; +import LoadingButton from 'tg.component/common/form/LoadingButton'; +import { globalContext } from 'tg.globalContext/globalActions'; +import { useApiMutation, useApiQuery } from 'tg.service/http/useQueryApi'; +import React from 'react'; +import { Alert } from 'tg.component/common/Alert'; +import { TranslatedError } from 'tg.translationTools/TranslatedError'; + +const StyledContainer = styled('div')` + padding: 28px 4px 0px 4px; + display: grid; + justify-items: center; +`; + +const StyledTitle = styled('h2')` + color: ${({ theme }) => theme.palette.text.primary} + font-size: 24px; + font-style: normal; + font-weight: 400; + padding-top: 40px; + margin-top: 0px; + text-align: center; +`; + +const StyledDescription = styled('div')` + color: ${({ theme }) => theme.palette.text.primary} + font-size: 15px; + padding-bottom: 60px; + max-width: 550px; + text-align: center; +`; + +const LOCAL_STORAGE_CHANGE_PROVIDER = 'change_provider'; + +function getProviderName(accountType?: string, authType?: string) { + if (accountType === 'LOCAL') { + return 'username and password'; + } else if (accountType === 'THIRD_PARTY') { + return authType || 'Third-Party Provider'; + } + return 'Unknown Provider'; +} + +export function ChangeAuthProviderView() { + const { t } = useTranslate(); + const requestIdString = localStorage.getItem(LOCAL_STORAGE_CHANGE_PROVIDER); + const requestId = requestIdString ? Number(requestIdString) : 0; + + const { data } = useApiQuery({ + url: '/api/public/auth-provider/get-request', + method: 'get', + query: { + requestId, + }, + }); + + const { error, isSuccess, isLoading, mutate } = useApiMutation({ + url: '/api/public/auth-provider/request-change', + method: 'post', + }); + + const currentProvider = getProviderName( + data?.oldAccountType, + data?.oldAuthType + ); + const newProvider = getProviderName(data?.newAccountType, data?.newAuthType); + + const confirmChange = () => { + mutate({ + content: { + 'application/json': { + changeRequestId: requestId, + isConfirmed: true, + }, + }, + }); + + if (isSuccess) { + globalContext.actions?.redirectTo(LINKS.LOGIN.build()); + } + }; + + const discardChange = () => { + mutate({ + content: { + 'application/json': { + changeRequestId: requestId, + isConfirmed: false, + }, + }, + }); + + if (isSuccess) { + globalContext.actions?.redirectTo(LINKS.LOGIN.build()); + } + }; + + return ( + + + + + ) + } + maxWidth={800} + windowTitle={t('slack_connect_title')} + primaryContent={ + + + + + + + + , + }} + /> + + + + + + + + + + + + } + /> + + ); +} diff --git a/webapp/src/component/security/Login/LoginCredentialsForm.tsx b/webapp/src/component/security/Login/LoginCredentialsForm.tsx index 615c85ce0b..86c7c8d134 100644 --- a/webapp/src/component/security/Login/LoginCredentialsForm.tsx +++ b/webapp/src/component/security/Login/LoginCredentialsForm.tsx @@ -3,6 +3,7 @@ import { Button, Link as MuiLink, Typography, styled } 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 +32,121 @@ 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 localSsoEnabled = + remoteConfig.authMethods?.ssoOrganizations.enabled ?? false; + const globalSsoEnabled = remoteConfig.authMethods?.ssoGlobal.enabled ?? false; + const hasNonNativeAuthMethods = + oAuthServices.length > 0 || localSsoEnabled || globalSsoEnabled; + const noLoginMethods = !nativeEnabled && !hasNonNativeAuthMethods; + + const customLogoUrl = remoteConfig.authMethods?.ssoGlobal.customLogoUrl; + // TODO: the custom logo is just displayed on button, is that expected? + const logoIcon = customLogoUrl ? ( + Custom Logo + ) : ( + + ); + 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 && hasNonNativeAuthMethods && ( + + )} + + {(localSsoEnabled || globalSsoEnabled) && ( + + {localSsoEnabled && ( + + )} + {!localSsoEnabled && ( + + {loginText} + + )} + + )} - {oAuthServices.length > 0 && } {oAuthServices.map((provider) => (