From a52e18ee0453e7ce973c98a2189b570448d0ca6b Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 2 Sep 2024 15:06:58 +0200 Subject: [PATCH 001/162] feat: create dynamic client registration --- backend/app/build.gradle | 1 + ee/backend/app/build.gradle | 2 ++ .../io/tolgee/ee/data/DynamicOAuth2ClientRegistration.kt | 8 ++++++++ 3 files changed, 11 insertions(+) create mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/data/DynamicOAuth2ClientRegistration.kt diff --git a/backend/app/build.gradle b/backend/app/build.gradle index 1575e7cafc..3c0179d06e 100644 --- a/backend/app/build.gradle +++ b/backend/app/build.gradle @@ -74,6 +74,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/ee/backend/app/build.gradle b/ee/backend/app/build.gradle index 2306801ae3..ae4c20955e 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/data/DynamicOAuth2ClientRegistration.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/DynamicOAuth2ClientRegistration.kt new file mode 100644 index 0000000000..a6f46d439b --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/DynamicOAuth2ClientRegistration.kt @@ -0,0 +1,8 @@ +package io.tolgee.ee.data + +import org.springframework.security.oauth2.client.registration.ClientRegistration + +class DynamicOAuth2ClientRegistration( + var tenantId: String, + var clientRegistration: ClientRegistration, +) From 278aa624d881e27954025e8a845c0459fce2aca9 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 2 Sep 2024 15:08:58 +0200 Subject: [PATCH 002/162] feat: Add a tenant to configure the dynamic configuration of providers. --- .../main/kotlin/io/tolgee/ee/model/Tenant.kt | 19 ++++++ ...namicOAuth2ClientRegistrationRepository.kt | 59 +++++++++++++++++++ .../tolgee/ee/repository/TenantRepository.kt | 10 ++++ .../io/tolgee/ee/service/TenantService.kt | 42 +++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/model/Tenant.kt create mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/DynamicOAuth2ClientRegistrationRepository.kt create mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt create mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TenantService.kt diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/model/Tenant.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/model/Tenant.kt new file mode 100644 index 0000000000..285a3e1a4c --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/model/Tenant.kt @@ -0,0 +1,19 @@ +package io.tolgee.ee.model + +import io.tolgee.model.StandardAuditModel +import jakarta.persistence.Entity +import jakarta.persistence.Table + +@Entity +@Table(schema = "ee") +class Tenant : StandardAuditModel() { + var name: String = "" + var ssoProvider: String = "" + var clientId: String = "" + var clientSecret: String = "" + var authorizationUri: String = "" + var domain: String = "" + var jwkSetUri: String = "" + var tokenUri: String = "" + var redirectUriBase: String = "" // base Tolgee frontend url can be different for different users so need to store it +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/DynamicOAuth2ClientRegistrationRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/DynamicOAuth2ClientRegistrationRepository.kt new file mode 100644 index 0000000000..59fef6b572 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/DynamicOAuth2ClientRegistrationRepository.kt @@ -0,0 +1,59 @@ +package io.tolgee.ee.repository + +import io.tolgee.ee.data.DynamicOAuth2ClientRegistration +import io.tolgee.ee.model.Tenant +import io.tolgee.ee.service.TenantService +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationContext +import org.springframework.security.oauth2.client.registration.ClientRegistration +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository +import org.springframework.security.oauth2.core.AuthorizationGrantType + +class DynamicOAuth2ClientRegistrationRepository(applicationContext: ApplicationContext) : + ClientRegistrationRepository { + private val dynamicClientRegistrations: MutableMap = mutableMapOf() + + @Autowired + private val tenantService: TenantService = applicationContext.getBean(TenantService::class.java) + + override fun findByRegistrationId(registrationId: String): ClientRegistration { + dynamicClientRegistrations[registrationId]?.let { + return it.clientRegistration + } + + val tenant: Tenant = tenantService.getByDomain(registrationId) + val dynamicRegistration = createDynamicClientRegistration(tenant) + return dynamicRegistration.clientRegistration + } + + private fun createDynamicClientRegistration(tenant: Tenant): DynamicOAuth2ClientRegistration { + val clientRegistration = + ClientRegistration.withRegistrationId(tenant.domain) + .clientId(tenant.clientId) + .clientSecret(tenant.clientSecret) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationUri(tenant.authorizationUri) + .tokenUri(tenant.tokenUri) + .jwkSetUri(tenant.jwkSetUri) + .redirectUri(tenant.redirectUriBase + "/openId/auth_callback/" + tenant.domain) + .scope("openid", "profile", "email") + .build() + + val dynamicRegistration = + DynamicOAuth2ClientRegistration( + tenantId = tenant.id.toString(), + clientRegistration = clientRegistration, + ) + + dynamicClientRegistrations[tenant.domain] = dynamicRegistration + return dynamicRegistration + } + + fun String.toLongOrThrow(): Long { + return try { + this.toLong() + } catch (e: NumberFormatException) { + throw IllegalArgumentException("Invalid tenant ID: $this") + } + } +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt new file mode 100644 index 0000000000..d70d553820 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt @@ -0,0 +1,10 @@ +package io.tolgee.ee.repository + +import io.tolgee.ee.model.Tenant +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface TenantRepository : JpaRepository { + fun findByDomain(domain: String): Tenant? +} 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..78055ef25f --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TenantService.kt @@ -0,0 +1,42 @@ +package io.tolgee.ee.service + +import io.tolgee.ee.data.CreateProviderRequest +import io.tolgee.ee.model.Tenant +import io.tolgee.ee.repository.TenantRepository +import io.tolgee.exceptions.NotFoundException +import org.springframework.stereotype.Service + +@Service +class TenantService( + private val tenantRepository: TenantRepository, +) { + fun getById(id: Long): Tenant { + return tenantRepository.findById(id).orElseThrow { NotFoundException() } + } + + fun getByDomain(domain: String): Tenant { + return tenantRepository.findByDomain(domain) ?: throw NotFoundException() + } + + fun save(tenant: Tenant): Tenant { + return tenantRepository.save(tenant) + } + + fun findAll(): List { + return tenantRepository.findAll() + } + + fun save(dto: CreateProviderRequest): Tenant { + val tenant = Tenant() + tenant.name = dto.name + tenant.ssoProvider = dto.ssoProvider + tenant.clientId = dto.clientId + tenant.clientSecret = dto.clientSecret + tenant.authorizationUri = dto.authorizationUri + tenant.tokenUri = dto.tokenUri + tenant.jwkSetUri = dto.jwkSetUri + tenant.domain = dto.domain + tenant.redirectUriBase = dto.redirectUri + return save(tenant) + } +} From 140c6dfb368ff7daa14c0828f035c6c6b4d8636a Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 2 Sep 2024 15:10:35 +0200 Subject: [PATCH 003/162] feat: Add a controller to fetch callbacks from the provider and return a JWT token for login. --- .../controllers/OAuth2CallbackController.kt | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt new file mode 100644 index 0000000000..4cf7cdb4f1 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -0,0 +1,207 @@ +package io.tolgee.ee.api.v2.controllers + +import com.posthog.java.shaded.org.json.JSONObject +import io.tolgee.constants.Message +import io.tolgee.ee.repository.DynamicOAuth2ClientRegistrationRepository +import io.tolgee.exceptions.AuthenticationException +import io.tolgee.model.UserAccount +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 jakarta.servlet.http.HttpServletResponse +import org.springframework.context.ApplicationContext +import org.springframework.http.* +import org.springframework.security.oauth2.client.registration.ClientRegistration +import org.springframework.util.LinkedMultiValueMap +import org.springframework.util.MultiValueMap +import org.springframework.web.bind.annotation.* +import org.springframework.web.client.HttpClientErrorException +import org.springframework.web.client.RestTemplate +import java.util.* + +@RestController +@RequestMapping("v2/oauth2/callback/") +class OAuth2CallbackController( + private val jwtService: JwtService, + private val restTemplate: RestTemplate, + private val userAccountService: UserAccountService, + private val signUpService: SignUpService, + private val applicationContext: ApplicationContext, +) { + @PostMapping("/get-authentication-url") + fun getAuthenticationUrl( + @RequestBody request: DomainRequest, + ): SsoUrlResponse { + val dynamicOAuth2ClientRegistrationRepository = DynamicOAuth2ClientRegistrationRepository(applicationContext) + + val registrationId = request.domain + val clientRegistration = dynamicOAuth2ClientRegistrationRepository.findByRegistrationId(registrationId) + val redirectUrl = buildAuthUrl(clientRegistration, state = request.state) + + return SsoUrlResponse(redirectUrl) + } + + private fun buildAuthUrl( + clientRegistration: ClientRegistration, + state: String, + ): String { + return "${clientRegistration.providerDetails.authorizationUri}?" + + "client_id=${clientRegistration.clientId}&" + + "redirect_uri=${clientRegistration.redirectUri}&" + + "response_type=code&" + + "scope=${clientRegistration.scopes.joinToString(" ")}&" + + "state=$state" + } + + @GetMapping("/{registrationId}") + fun handleCallback( + @RequestParam(value = "code", required = true) code: String, + @RequestParam state: String, + @RequestParam(value = "redirect_uri", required = true) redirectUrl: String, + @RequestParam(defaultValue = "") error: String, + @RequestParam(defaultValue = "") error_description: String, + response: HttpServletResponse, + @PathVariable registrationId: String, + ): JwtAuthenticationResponse? { + if (error.isNotBlank()) { + println(error) + println(error_description) + throw Exception() + } + val dynamicOAuth2ClientRegistrationRepository = DynamicOAuth2ClientRegistrationRepository(applicationContext) + + val clientRegistration = dynamicOAuth2ClientRegistrationRepository.findByRegistrationId(registrationId) + + val tokenResponse = exchangeCodeForToken(clientRegistration, code, redirectUrl) + + if (tokenResponse == null) { + println("Failed to obtain access token") + throw Exception() + } + + val userInfo = getUserInfo(tokenResponse.id_token) + + if (userInfo == null) { + println("Failed to get user info from OAuth2 provider") + throw Exception() + } + + return registration(userInfo, clientRegistration.registrationId) + } + + private fun registration( + userResponse: GenericUserResponse, + registrationId: String, + ): JwtAuthenticationResponse? { + val email = + userResponse.email ?: let { + println("Third party user email is null. Missing scope email?") + throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL) + } + + val userAccountOptional = userAccountService.findByThirdParty(registrationId, userResponse.sub!!) + 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 = registrationId + newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY + signUpService.signUp(newUserAccount, null, null) + + newUserAccount + } + val jwt = jwtService.emitToken(user.id) + return JwtAuthenticationResponse(jwt) + } + + private fun getUserInfo(idToken: String): GenericUserResponse { + val jwt = decodeJwt(idToken) + val response = + GenericUserResponse().apply { + sub = jwt.optString("sub") + name = jwt.optString("name") + given_name = jwt.optString("given_name") + family_name = jwt.optString("family_name") + email = jwt.optString("email") + } + + return response + } + + fun decodeJwt(jwt: String): JSONObject { + val parts = jwt.split(".") + if (parts.size != 3) throw IllegalArgumentException("JWT does not have 3 parts") + + val payload = parts[1] + val decodedPayload = String(Base64.getUrlDecoder().decode(payload)) + + return JSONObject(decodedPayload) + } + + private fun exchangeCodeForToken( + clientRegistration: ClientRegistration, + 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", clientRegistration.clientId) + body.add("client_secret", clientRegistration.clientSecret) + body.add("scope", "openid") + + val request = HttpEntity(body, headers) + return try { + val response: ResponseEntity = + restTemplate.exchange( + clientRegistration.providerDetails.tokenUri, + HttpMethod.POST, + request, + OAuth2TokenResponse::class.java, + ) + response.body + } catch (e: HttpClientErrorException) { + println(e) + null + } + } + + class GenericUserResponse { + var sub: String? = null + var name: String? = null + var given_name: String? = null + var family_name: String? = null + var email: String? = null + } + + class OAuth2TokenResponse( + val id_token: String, + val scope: String, + ) + + data class DomainRequest(val domain: String, val state: String) + + data class SsoUrlResponse(val redirectUrl: String) +} From b5e75b99e15b9733f1028b4d63ac1ae4912ad860 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 2 Sep 2024 15:11:07 +0200 Subject: [PATCH 004/162] feat: Add a controller to dynamically configure providers --- .../v2/controllers/SsoProviderController.kt | 20 +++++++++++++++++++ .../tolgee/ee/data/CreateProviderRequest.kt | 13 ++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoProviderController.kt create mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/data/CreateProviderRequest.kt 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..c681e0b528 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoProviderController.kt @@ -0,0 +1,20 @@ +package io.tolgee.ee.api.v2.controllers + +import io.tolgee.ee.data.CreateProviderRequest +import io.tolgee.ee.model.Tenant +import io.tolgee.ee.service.TenantService +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/providers") +class SsoProviderController(private val tenantService: TenantService) { + @PostMapping("") + fun addProvider( + @RequestBody request: CreateProviderRequest, + ): Tenant { + return tenantService.save(request) + } +} 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..ac942ace98 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/CreateProviderRequest.kt @@ -0,0 +1,13 @@ +package io.tolgee.ee.data + +data class CreateProviderRequest( + val name: String, + val ssoProvider: String, + val clientId: String, + val clientSecret: String, + val authorizationUri: String, + val tokenUri: String, + val jwkSetUri: String, + val domain: String, + val redirectUri: String, +) From f7b8c0aab4abd6225227c62a8811f0ff1845d840 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 2 Sep 2024 15:13:13 +0200 Subject: [PATCH 005/162] feat: add sso login to FE --- webapp/src/component/RootRouter.tsx | 10 +++ .../security/Login/LoginCredentialsForm.tsx | 10 ++- .../component/security/Sso/LoginSsoForm.tsx | 79 +++++++++++++++++++ .../component/security/Sso/SsoLoginView.tsx | 45 +++++++++++ .../security/Sso/SsoRedirectionHandler.tsx | 52 ++++++++++++ webapp/src/constants/links.tsx | 6 ++ webapp/src/globalContext/useAuthService.tsx | 47 +++++++++++ 7 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 webapp/src/component/security/Sso/LoginSsoForm.tsx create mode 100644 webapp/src/component/security/Sso/SsoLoginView.tsx create mode 100644 webapp/src/component/security/Sso/SsoRedirectionHandler.tsx diff --git a/webapp/src/component/RootRouter.tsx b/webapp/src/component/RootRouter.tsx index 3cbe673c60..dc42fbd0c4 100644 --- a/webapp/src/component/RootRouter.tsx +++ b/webapp/src/component/RootRouter.tsx @@ -16,6 +16,8 @@ import { HelpMenu } from './HelpMenu'; import { PublicOnlyRoute } from './common/PublicOnlyRoute'; import { PreferredOrganizationRedirect } from './security/PreferredOrganizationRedirect'; import { RootView } from 'tg.views/RootView'; +import { SsoLoginView } from 'tg.component/security/Sso/SsoLoginView'; +import { SsoRedirectionHandler } from 'tg.component/security/Sso/SsoRedirectionHandler'; const LoginRouter = React.lazy( () => import(/* webpackChunkName: "login" */ './security/Login/LoginRouter') @@ -91,6 +93,14 @@ export const RootRouter = () => ( + + + + + + + + diff --git a/webapp/src/component/security/Login/LoginCredentialsForm.tsx b/webapp/src/component/security/Login/LoginCredentialsForm.tsx index 615c85ce0b..713726611f 100644 --- a/webapp/src/component/security/Login/LoginCredentialsForm.tsx +++ b/webapp/src/component/security/Login/LoginCredentialsForm.tsx @@ -1,5 +1,5 @@ import React, { RefObject } from 'react'; -import { Button, Link as MuiLink, Typography, styled } from '@mui/material'; +import { Button, Link as MuiLink, styled, Typography } from '@mui/material'; import Box from '@mui/material/Box'; import { T } from '@tolgee/react'; import { Link } from 'react-router-dom'; @@ -65,6 +65,14 @@ export function LoginCredentialsForm(props: LoginViewCredentialsProps) { )} + + + + + + + + {oAuthServices.length > 0 && } {oAuthServices.map((provider) => ( diff --git a/webapp/src/component/security/Sso/LoginSsoForm.tsx b/webapp/src/component/security/Sso/LoginSsoForm.tsx new file mode 100644 index 0000000000..9a6e9202f7 --- /dev/null +++ b/webapp/src/component/security/Sso/LoginSsoForm.tsx @@ -0,0 +1,79 @@ +import React, { RefObject } from 'react'; +import { Link as MuiLink, styled, Typography } from '@mui/material'; +import Box from '@mui/material/Box'; +import { T } from '@tolgee/react'; +import { Link } from 'react-router-dom'; + +import { LINKS } from 'tg.constants/links'; +import LoadingButton from 'tg.component/common/form/LoadingButton'; +import { StandardForm } from 'tg.component/common/form/StandardForm'; +import { TextField } from 'tg.component/common/form/fields/TextField'; +import { + useGlobalActions, + useGlobalContext, +} from 'tg.globalContext/GlobalContext'; +import { v4 as uuidv4 } from 'uuid'; + +const StyledInputFields = styled('div')` + display: grid; + align-items: start; + gap: 16px; + padding-bottom: 32px; +`; + +type Credentials = { domain: string }; +type LoginViewCredentialsProps = { + credentialsRef: RefObject; +}; +const LOCAL_STORAGE_STATE_KEY = 'oauth2State'; + + +export function LoginSsoForm(props: LoginViewCredentialsProps) { + const { getSsoAuthLinkByDomain } = useGlobalActions(); + const isLoading = useGlobalContext((c) => c.auth.loginLoadable.isLoading); + + return ( + + + + + + + + + + + + + + + + } + onSubmit={async (data) => { + const state = uuidv4(); + localStorage.setItem(LOCAL_STORAGE_STATE_KEY, state); + const response = await getSsoAuthLinkByDomain(data.domain, state); + //console.log(response) + window.location.href = response.redirectUrl; + + }} + > + + } + minHeight={false} + /> + + + ); +} diff --git a/webapp/src/component/security/Sso/SsoLoginView.tsx b/webapp/src/component/security/Sso/SsoLoginView.tsx new file mode 100644 index 0000000000..f99f104393 --- /dev/null +++ b/webapp/src/component/security/Sso/SsoLoginView.tsx @@ -0,0 +1,45 @@ +import { FunctionComponent, useRef, useState } from 'react'; +import { useTranslate } from '@tolgee/react'; +import { Alert, useMediaQuery } from '@mui/material'; + +import { DashboardPage } from 'tg.component/layout/DashboardPage'; +import { TranslatedError } from 'tg.translationTools/TranslatedError'; +import { + CompactView, + SPLIT_CONTENT_BREAK_POINT, +} from 'tg.component/layout/CompactView'; +import { useGlobalContext } from 'tg.globalContext/GlobalContext'; +import { LoginMoreInfo } from 'tg.component/security/Login/LoginMoreInfo'; +import { LoginSsoForm } from 'tg.component/security/Sso/LoginSsoForm'; + +export const SsoLoginView: FunctionComponent = () => { + const { t } = useTranslate(); + const credentialsRef = useRef({ domain: '' }); + const [mfaRequired, setMfaRequired] = useState(false); + + const error = useGlobalContext((c) => c.auth.loginLoadable.error); + const isLoading = useGlobalContext((c) => c.auth.loginLoadable.isLoading); + + const isSmall = useMediaQuery(SPLIT_CONTENT_BREAK_POINT); + + return ( + + + + + ) + } + primaryContent={} + secondaryContent={} + /> + + ); +}; diff --git a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx new file mode 100644 index 0000000000..baa4fa87ca --- /dev/null +++ b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx @@ -0,0 +1,52 @@ +import { FunctionComponent, useEffect } from 'react'; +import { Redirect, useHistory, useRouteMatch } from 'react-router-dom'; + +import { LINKS, PARAMS } from 'tg.constants/links'; + +import { + useGlobalActions, + useGlobalContext, +} from 'tg.globalContext/GlobalContext'; +import { FullPageLoading } from 'tg.component/common/FullPageLoading'; + +interface SsoRedirectionHandlerProps {} +const LOCAL_STORAGE_STATE_KEY = 'oauth2State'; + +export const SsoRedirectionHandler: FunctionComponent< + SsoRedirectionHandlerProps +> = () => { + const allowPrivate = useGlobalContext((c) => c.auth.allowPrivate); + const loginLoadable = useGlobalContext((c) => c.auth.authorizeOAuthLoadable); + + const { loginWithOAuthCodeOpenId } = useGlobalActions(); + const match = useRouteMatch(); + const history = useHistory(); + + useEffect(() => { + const url = new URLSearchParams(window.location.search); + const code = url.get('code'); + + const state = url.get('state'); + const storedState = localStorage.getItem(LOCAL_STORAGE_STATE_KEY); + if (storedState !== state) { + history.replace(LINKS.LOGIN.build()); + return; + } else { + localStorage.removeItem(LOCAL_STORAGE_STATE_KEY); + } + + if (code && !allowPrivate) { + loginWithOAuthCodeOpenId(match.params[PARAMS.SERVICE_TYPE], code); + } + }, [allowPrivate]); + + if (loginLoadable.error) { + return ( + + + + ); + } + + return ; +}; diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index 603c77e589..8f9cba415c 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -77,6 +77,12 @@ export class LINKS { 'auth_callback/' + p(PARAMS.SERVICE_TYPE) ); + static OPENID_RESPONSE = Link.ofRoot( + 'openId/auth_callback/' + p(PARAMS.SERVICE_TYPE) + ); + + static SSO_LOGIN = Link.ofRoot('sso'); + static EMAIL_VERIFICATION = Link.ofParent( LINKS.LOGIN, 'verify_email/' + p(PARAMS.USER_ID) + '/' + p(PARAMS.VERIFICATION_CODE) diff --git a/webapp/src/globalContext/useAuthService.tsx b/webapp/src/globalContext/useAuthService.tsx index 4956850053..8d3b88092c 100644 --- a/webapp/src/globalContext/useAuthService.tsx +++ b/webapp/src/globalContext/useAuthService.tsx @@ -54,6 +54,16 @@ export const useAuthService = ( method: 'get', }); + const authorizeOpenIdLoadable = useApiMutation({ + url: '/v2/oauth2/callback/{registrationId}', + method: 'get', + }); + + const openIdAuthUrlLoadable = useApiMutation({ + url: '/v2/oauth2/callback/get-authentication-url', + method: 'post', + }); + const acceptInvitationLoadable = useApiMutation({ url: '/v2/invitations/{code}/accept', method: 'get', @@ -186,6 +196,43 @@ export const useAuthService = ( setInvitationCode(undefined); await handleAfterLogin(response!); }, + + async loginWithOAuthCodeOpenId(registrationId: string, code: string) { + const redirectUri = LINKS.OPENID_RESPONSE.buildWithOrigin({ + [PARAMS.SERVICE_TYPE]: registrationId, + }); + const response = await authorizeOpenIdLoadable.mutateAsync( + { + path: { registrationId: registrationId }, + query: { + code, + redirect_uri: redirectUri, + state: "random_state", + }, + }, + { + onError: (error) => { + console.log(error); + messageService.error(); + }, + } + ); + await handleAfterLogin(response!); + }, + + async getSsoAuthLinkByDomain(domain: string, state: string) { + return await openIdAuthUrlLoadable.mutateAsync( + { + content: { 'application/json': { domain, state } }, + }, + { + onError: (error) => { + messageService.error(); + }, + } + ); + }, + async signUp(data: Omit) { signupLoadable.mutate( { From 4a14242398fb025405ebeebe1a5a7ba31ac45158 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 6 Sep 2024 19:32:19 +0200 Subject: [PATCH 006/162] fix: move auth logic from controller to service --- .../controllers/OAuth2CallbackController.kt | 167 +---------------- .../io/tolgee/ee/service/OAuthService.kt | 174 ++++++++++++++++++ 2 files changed, 184 insertions(+), 157 deletions(-) create mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index 4cf7cdb4f1..9934d90e79 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -1,40 +1,22 @@ package io.tolgee.ee.api.v2.controllers -import com.posthog.java.shaded.org.json.JSONObject -import io.tolgee.constants.Message import io.tolgee.ee.repository.DynamicOAuth2ClientRegistrationRepository -import io.tolgee.exceptions.AuthenticationException -import io.tolgee.model.UserAccount -import io.tolgee.security.authentication.JwtService +import io.tolgee.ee.service.OAuthService import io.tolgee.security.payload.JwtAuthenticationResponse -import io.tolgee.service.security.SignUpService -import io.tolgee.service.security.UserAccountService import jakarta.servlet.http.HttpServletResponse -import org.springframework.context.ApplicationContext -import org.springframework.http.* import org.springframework.security.oauth2.client.registration.ClientRegistration -import org.springframework.util.LinkedMultiValueMap -import org.springframework.util.MultiValueMap import org.springframework.web.bind.annotation.* -import org.springframework.web.client.HttpClientErrorException -import org.springframework.web.client.RestTemplate -import java.util.* @RestController @RequestMapping("v2/oauth2/callback/") class OAuth2CallbackController( - private val jwtService: JwtService, - private val restTemplate: RestTemplate, - private val userAccountService: UserAccountService, - private val signUpService: SignUpService, - private val applicationContext: ApplicationContext, + private val dynamicOAuth2ClientRegistrationRepository: DynamicOAuth2ClientRegistrationRepository, + private val oauthService: OAuthService, ) { @PostMapping("/get-authentication-url") fun getAuthenticationUrl( @RequestBody request: DomainRequest, ): SsoUrlResponse { - val dynamicOAuth2ClientRegistrationRepository = DynamicOAuth2ClientRegistrationRepository(applicationContext) - val registrationId = request.domain val clientRegistration = dynamicOAuth2ClientRegistrationRepository.findByRegistrationId(registrationId) val redirectUrl = buildAuthUrl(clientRegistration, state = request.state) @@ -57,150 +39,21 @@ class OAuth2CallbackController( @GetMapping("/{registrationId}") fun handleCallback( @RequestParam(value = "code", required = true) code: String, - @RequestParam state: String, @RequestParam(value = "redirect_uri", required = true) redirectUrl: String, @RequestParam(defaultValue = "") error: String, @RequestParam(defaultValue = "") error_description: String, response: HttpServletResponse, @PathVariable registrationId: String, ): JwtAuthenticationResponse? { - if (error.isNotBlank()) { - println(error) - println(error_description) - throw Exception() - } - val dynamicOAuth2ClientRegistrationRepository = DynamicOAuth2ClientRegistrationRepository(applicationContext) - - val clientRegistration = dynamicOAuth2ClientRegistrationRepository.findByRegistrationId(registrationId) - - val tokenResponse = exchangeCodeForToken(clientRegistration, code, redirectUrl) - - if (tokenResponse == null) { - println("Failed to obtain access token") - throw Exception() - } - - val userInfo = getUserInfo(tokenResponse.id_token) - - if (userInfo == null) { - println("Failed to get user info from OAuth2 provider") - throw Exception() - } - - return registration(userInfo, clientRegistration.registrationId) - } - - private fun registration( - userResponse: GenericUserResponse, - registrationId: String, - ): JwtAuthenticationResponse? { - val email = - userResponse.email ?: let { - println("Third party user email is null. Missing scope email?") - throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL) - } - - val userAccountOptional = userAccountService.findByThirdParty(registrationId, userResponse.sub!!) - 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 = registrationId - newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY - signUpService.signUp(newUserAccount, null, null) - - newUserAccount - } - val jwt = jwtService.emitToken(user.id) - return JwtAuthenticationResponse(jwt) + return oauthService.handleOAuthCallback( + registrationId = registrationId, + code = code, + redirectUrl = redirectUrl, + error = error, + errorDescription = error_description, + ) } - private fun getUserInfo(idToken: String): GenericUserResponse { - val jwt = decodeJwt(idToken) - val response = - GenericUserResponse().apply { - sub = jwt.optString("sub") - name = jwt.optString("name") - given_name = jwt.optString("given_name") - family_name = jwt.optString("family_name") - email = jwt.optString("email") - } - - return response - } - - fun decodeJwt(jwt: String): JSONObject { - val parts = jwt.split(".") - if (parts.size != 3) throw IllegalArgumentException("JWT does not have 3 parts") - - val payload = parts[1] - val decodedPayload = String(Base64.getUrlDecoder().decode(payload)) - - return JSONObject(decodedPayload) - } - - private fun exchangeCodeForToken( - clientRegistration: ClientRegistration, - 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", clientRegistration.clientId) - body.add("client_secret", clientRegistration.clientSecret) - body.add("scope", "openid") - - val request = HttpEntity(body, headers) - return try { - val response: ResponseEntity = - restTemplate.exchange( - clientRegistration.providerDetails.tokenUri, - HttpMethod.POST, - request, - OAuth2TokenResponse::class.java, - ) - response.body - } catch (e: HttpClientErrorException) { - println(e) - null - } - } - - class GenericUserResponse { - var sub: String? = null - var name: String? = null - var given_name: String? = null - var family_name: String? = null - var email: String? = null - } - - class OAuth2TokenResponse( - val id_token: String, - val scope: String, - ) - data class DomainRequest(val domain: String, val state: String) data class SsoUrlResponse(val redirectUrl: String) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt new file mode 100644 index 0000000000..433fb46719 --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -0,0 +1,174 @@ +package io.tolgee.ee.service + +import com.posthog.java.shaded.org.json.JSONObject +import io.tolgee.constants.Message +import io.tolgee.ee.exceptions.OAuthAuthorizationException +import io.tolgee.ee.repository.DynamicOAuth2ClientRegistrationRepository +import io.tolgee.exceptions.AuthenticationException +import io.tolgee.model.UserAccount +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.util.Logging +import io.tolgee.util.logger +import org.springframework.http.* +import org.springframework.security.oauth2.client.registration.ClientRegistration +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import org.springframework.util.MultiValueMap +import org.springframework.web.client.HttpClientErrorException +import org.springframework.web.client.RestTemplate +import java.util.* + +@Service +class OAuthService( + private val dynamicOAuth2ClientRegistrationRepository: DynamicOAuth2ClientRegistrationRepository, + private val jwtService: JwtService, + private val userAccountService: UserAccountService, + private val signUpService: SignUpService, + private val restTemplate: RestTemplate, +) : Logging { + fun handleOAuthCallback( + registrationId: String, + code: String, + redirectUrl: String, + error: String, + errorDescription: String, + ): JwtAuthenticationResponse? { + if (error.isNotBlank()) { + logger.info("Third party auth failed: $errorDescription $error") + throw OAuthAuthorizationException( + Message.THIRD_PARTY_AUTH_FAILED, + "$errorDescription $error", + ) + } + + val clientRegistration = + dynamicOAuth2ClientRegistrationRepository + .findByRegistrationId(registrationId) + + val tokenResponse = + exchangeCodeForToken(clientRegistration, code, redirectUrl) + ?: throw OAuthAuthorizationException( + Message.TOKEN_EXCHANGE_FAILED, + null, + ) + + val userInfo = + getUserInfo(tokenResponse.id_token) + + return register(userInfo, clientRegistration.registrationId) + } + + private fun exchangeCodeForToken( + clientRegistration: ClientRegistration, + 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", clientRegistration.clientId) + body.add("client_secret", clientRegistration.clientSecret) + body.add("scope", "openid") + + val request = HttpEntity(body, headers) + return try { + val response: ResponseEntity = + restTemplate.exchange( + clientRegistration.providerDetails.tokenUri, + HttpMethod.POST, + request, + OAuth2TokenResponse::class.java, + ) + response.body + } catch (e: HttpClientErrorException) { + logger.info("Failed to exchange code for token: ${e.message}") + null + } + } + + private fun getUserInfo(idToken: String): GenericUserResponse { + val jwt = decodeJwt(idToken) + val response = + GenericUserResponse().apply { + sub = jwt.optString("sub") + name = jwt.optString("name") + given_name = jwt.optString("given_name") + family_name = jwt.optString("family_name") + email = jwt.optString("email") + } + + return response + } + + fun decodeJwt(jwt: String): JSONObject { + val parts = jwt.split(".") + if (parts.size != 3) throw IllegalArgumentException("JWT does not have 3 parts") + + val payload = parts[1] + val decodedPayload = String(Base64.getUrlDecoder().decode(payload)) + + return JSONObject(decodedPayload) + } + + private fun register( + userResponse: GenericUserResponse, + registrationId: 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 userAccountOptional = userAccountService.findByThirdParty(registrationId, userResponse.sub!!) + 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 = registrationId + newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY + signUpService.signUp(newUserAccount, null, null) + + newUserAccount + } + val jwt = jwtService.emitToken(user.id) + return JwtAuthenticationResponse(jwt) + } + + class OAuth2TokenResponse( + val id_token: String, + val scope: String, + ) + + class GenericUserResponse { + var sub: String? = null + var name: String? = null + var given_name: String? = null + var family_name: String? = null + var email: String? = null + } +} From 8302d3375fcdd1a18748a5c3641678d65e62954b Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 6 Sep 2024 19:32:46 +0200 Subject: [PATCH 007/162] feat: add custom exceptions --- .../data/src/main/kotlin/io/tolgee/constants/Message.kt | 3 +++ .../tolgee/ee/exceptions/OAuthAuthorizationException.kt | 9 +++++++++ 2 files changed, 12 insertions(+) create mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/exceptions/OAuthAuthorizationException.kt 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 0e05d76d7f..1aa8c45baa 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -235,6 +235,9 @@ enum class Message { SLACK_WORKSPACE_ALREADY_CONNECTED, SLACK_CONNECTION_ERROR, EMAIL_VERIFICATION_CODE_NOT_VALID, + THIRD_PARTY_AUTH_FAILED, + TOKEN_EXCHANGE_FAILED, + USER_INFO_RETRIEVAL_FAILED, ; val code: String diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/exceptions/OAuthAuthorizationException.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/exceptions/OAuthAuthorizationException.kt new file mode 100644 index 0000000000..2536fe0fea --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/exceptions/OAuthAuthorizationException.kt @@ -0,0 +1,9 @@ +package io.tolgee.ee.exceptions + +import io.tolgee.constants.Message +import io.tolgee.exceptions.ExpectedException + +data class OAuthAuthorizationException( + val msg: Message, + val details: String? = null, +) : RuntimeException("${msg.code}: $details"), ExpectedException From 10a70b75478a493db4b054ef732a326bb40f2df9 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 6 Sep 2024 19:33:32 +0200 Subject: [PATCH 008/162] fix: add component annotation --- .../io/tolgee/configuration/WebSecurityConfig.kt | 7 +++++-- .../DynamicOAuth2ClientRegistrationRepository.kt | 12 +++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt b/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt index eda562309d..ef7512cda9 100644 --- a/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt +++ b/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt @@ -27,6 +27,7 @@ import io.tolgee.security.ratelimit.GlobalUserRateLimitFilter import io.tolgee.security.ratelimit.RateLimitInterceptor import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.context.ApplicationContext import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.core.Ordered @@ -54,6 +55,7 @@ class WebSecurityConfig( private val organizationAuthorizationInterceptor: OrganizationAuthorizationInterceptor, private val projectAuthorizationInterceptor: ProjectAuthorizationInterceptor, private val exceptionHandlerFilter: ExceptionHandlerFilter, + private val applicationContext: ApplicationContext, ) : WebMvcConfigurer { @Bean fun securityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain { @@ -76,7 +78,7 @@ class WebSecurityConfig( } }, ) - it.requestMatchers("/api/public/**", "/v2/public/**").permitAll() + it.requestMatchers("/api/public/**", "/v2/public/**", "v2/oauth2/callback/**").permitAll() it.requestMatchers("/v2/administration/**", "/v2/ee-license/**").hasRole("ADMIN") it.requestMatchers("/api/**", "/v2/**").authenticated() it.anyRequest().permitAll() @@ -89,7 +91,8 @@ class WebSecurityConfig( headers.referrerPolicy { it.policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN) } - }.build() + } + .build() } @Bean diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/DynamicOAuth2ClientRegistrationRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/DynamicOAuth2ClientRegistrationRepository.kt index 59fef6b572..110a67a4f3 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/DynamicOAuth2ClientRegistrationRepository.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/DynamicOAuth2ClientRegistrationRepository.kt @@ -3,19 +3,18 @@ package io.tolgee.ee.repository import io.tolgee.ee.data.DynamicOAuth2ClientRegistration import io.tolgee.ee.model.Tenant import io.tolgee.ee.service.TenantService -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.context.ApplicationContext import org.springframework.security.oauth2.client.registration.ClientRegistration import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository import org.springframework.security.oauth2.core.AuthorizationGrantType +import org.springframework.stereotype.Component -class DynamicOAuth2ClientRegistrationRepository(applicationContext: ApplicationContext) : +@Component +class DynamicOAuth2ClientRegistrationRepository( + private val tenantService: TenantService, +) : ClientRegistrationRepository { private val dynamicClientRegistrations: MutableMap = mutableMapOf() - @Autowired - private val tenantService: TenantService = applicationContext.getBean(TenantService::class.java) - override fun findByRegistrationId(registrationId: String): ClientRegistration { dynamicClientRegistrations[registrationId]?.let { return it.clientRegistration @@ -34,7 +33,6 @@ class DynamicOAuth2ClientRegistrationRepository(applicationContext: ApplicationC .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationUri(tenant.authorizationUri) .tokenUri(tenant.tokenUri) - .jwkSetUri(tenant.jwkSetUri) .redirectUri(tenant.redirectUriBase + "/openId/auth_callback/" + tenant.domain) .scope("openid", "profile", "email") .build() From ac291dbba034b9fea2189a964e83db80544e32bd Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 6 Sep 2024 19:34:07 +0200 Subject: [PATCH 009/162] feat: save domain with port if presents --- .../io/tolgee/ee/service/TenantService.kt | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) 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 index 78055ef25f..00d59c1a45 100644 --- 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 @@ -3,8 +3,11 @@ package io.tolgee.ee.service import io.tolgee.ee.data.CreateProviderRequest import io.tolgee.ee.model.Tenant import io.tolgee.ee.repository.TenantRepository +import io.tolgee.exceptions.BadRequestException import io.tolgee.exceptions.NotFoundException import org.springframework.stereotype.Service +import java.net.URI +import java.net.URISyntaxException @Service class TenantService( @@ -28,15 +31,36 @@ class TenantService( fun save(dto: CreateProviderRequest): Tenant { val tenant = Tenant() - tenant.name = dto.name - tenant.ssoProvider = dto.ssoProvider + tenant.name = dto.name ?: "" + tenant.domain = extractDomain(dto.authorizationUri) tenant.clientId = dto.clientId tenant.clientSecret = dto.clientSecret tenant.authorizationUri = dto.authorizationUri tenant.tokenUri = dto.tokenUri - tenant.jwkSetUri = dto.jwkSetUri - tenant.domain = dto.domain - tenant.redirectUriBase = dto.redirectUri + tenant.redirectUriBase = dto.redirectUri.removeSuffix("/") return save(tenant) } + + private fun extractDomain(authorizationUri: String): String { + return try { + val uri = URI(authorizationUri) + val domain = uri.host + val port = uri.port + + val domainWithPort = + if (port != -1) { + "$domain:$port" + } else { + domain + } + + if (domainWithPort.startsWith("www.")) { + domainWithPort.substring(4) + } else { + domainWithPort + } + } catch (e: URISyntaxException) { + throw BadRequestException("Invalid authorization uri") + } + } } From 1143485ccca7bb5d35a403f1ec36abe359f36c8b Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 6 Sep 2024 19:34:46 +0200 Subject: [PATCH 010/162] fix: change url --- .../io/tolgee/ee/api/v2/controllers/SsoProviderController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index c681e0b528..5f3ca0595c 100644 --- 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 @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @RestController -@RequestMapping("/providers") +@RequestMapping("/v2/sso/providers") class SsoProviderController(private val tenantService: TenantService) { @PostMapping("") fun addProvider( From d8dfdb86cb98e65482b7b6a127674fd29a9ebe89 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 6 Sep 2024 19:35:11 +0200 Subject: [PATCH 011/162] fix: add more properties CreateProviderRequest --- .../main/kotlin/io/tolgee/ee/data/CreateProviderRequest.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 index ac942ace98..7ebac51089 100644 --- 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 @@ -1,13 +1,10 @@ package io.tolgee.ee.data data class CreateProviderRequest( - val name: String, - val ssoProvider: String, + val name: String?, val clientId: String, val clientSecret: String, val authorizationUri: String, - val tokenUri: String, - val jwkSetUri: String, - val domain: String, val redirectUri: String, + val tokenUri: String, ) From e6a8cd37cce9f55c0e65dec3a66802e28bd1e692 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sun, 8 Sep 2024 11:53:06 +0200 Subject: [PATCH 012/162] fix: The function wasn't marked as a transaction, so it caused a data integrity exception. --- .../src/main/kotlin/io/tolgee/service/security/SignUpService.kt | 1 + 1 file changed, 1 insertion(+) 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..cf2bb713e4 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 @@ -45,6 +45,7 @@ class SignUpService( return JwtAuthenticationResponse(jwtService.emitToken(user.id, true)) } + @Transactional fun signUp( entity: UserAccount, invitationCode: String?, From fccbe74359b3803f5a0a3f5fbd8948cd5b840274 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Wed, 11 Sep 2024 19:32:03 +0200 Subject: [PATCH 013/162] feat: add an ability to accept invitation code to backend part --- .../ee/api/v2/controllers/OAuth2CallbackController.kt | 2 ++ .../src/main/kotlin/io/tolgee/ee/service/OAuthService.kt | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index 9934d90e79..e723d61e67 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -42,6 +42,7 @@ class OAuth2CallbackController( @RequestParam(value = "redirect_uri", required = true) redirectUrl: String, @RequestParam(defaultValue = "") error: String, @RequestParam(defaultValue = "") error_description: String, + @RequestParam(value = "invitationCode", required = false) invitationCode: String?, response: HttpServletResponse, @PathVariable registrationId: String, ): JwtAuthenticationResponse? { @@ -51,6 +52,7 @@ class OAuth2CallbackController( redirectUrl = redirectUrl, error = error, errorDescription = error_description, + invitationCode = invitationCode, ) } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index 433fb46719..46cdfd95db 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -35,6 +35,7 @@ class OAuthService( redirectUrl: String, error: String, errorDescription: String, + invitationCode: String?, ): JwtAuthenticationResponse? { if (error.isNotBlank()) { logger.info("Third party auth failed: $errorDescription $error") @@ -58,7 +59,7 @@ class OAuthService( val userInfo = getUserInfo(tokenResponse.id_token) - return register(userInfo, clientRegistration.registrationId) + return register(userInfo, clientRegistration.registrationId, invitationCode) } private fun exchangeCodeForToken( @@ -122,6 +123,7 @@ class OAuthService( private fun register( userResponse: GenericUserResponse, registrationId: String, + invitationCode: String?, ): JwtAuthenticationResponse? { val email = userResponse.email ?: let { @@ -151,7 +153,7 @@ class OAuthService( newUserAccount.thirdPartyAuthId = userResponse.sub newUserAccount.thirdPartyAuthType = registrationId newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY - signUpService.signUp(newUserAccount, null, null) + signUpService.signUp(newUserAccount, invitationCode, null) newUserAccount } From de3cd3cda1fad66f94e2de77047dc15494584e75 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Wed, 11 Sep 2024 19:46:16 +0200 Subject: [PATCH 014/162] feat: add properties to enable custom logo & button text --- .../configuration/tolgee/AuthenticationProperties.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) 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..89f5c13982 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 @@ -143,6 +143,17 @@ class AuthenticationProperties( var github: GithubAuthenticationProperties = GithubAuthenticationProperties(), var google: GoogleAuthenticationProperties = GoogleAuthenticationProperties(), var oauth2: OAuth2AuthenticationProperties = OAuth2AuthenticationProperties(), + @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 log in via third-party SSO options", + ) + var customLogoUrl: String? = null, + @DocProperty( + description = "Custom text for the login button.", + defaultExplanation = "Defaults to 'Login' if not set.", + ) + var customButtonText: String = "SsoLogin", ) { fun checkAllowedRegistrations() { if (!this.registrationsAllowed) { From 8d285fafa650e415920b2797b67d1b22bf1cd574 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Thu, 12 Sep 2024 11:39:21 +0200 Subject: [PATCH 015/162] feat: add create sso provider route --- .../organizations/OrganizationsRouter.tsx | 5 + .../BaseOrganizationSettingsView.tsx | 6 ++ .../sso/CreateProviderSsoForm.tsx | 96 +++++++++++++++++++ .../organizations/sso/OrganizationSsoView.tsx | 41 ++++++++ 4 files changed, 148 insertions(+) create mode 100644 webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx create mode 100644 webapp/src/views/organizations/sso/OrganizationSsoView.tsx diff --git a/webapp/src/views/organizations/OrganizationsRouter.tsx b/webapp/src/views/organizations/OrganizationsRouter.tsx index e16b684a8f..89879bd80d 100644 --- a/webapp/src/views/organizations/OrganizationsRouter.tsx +++ b/webapp/src/views/organizations/OrganizationsRouter.tsx @@ -17,6 +17,7 @@ import { OrganizationInvoicesView } from './billing/Invoices/OrganizationInvoice import { OrganizationSubscriptionsView } from './billing/Subscriptions/OrganizationSubscriptionsView'; import { OrganizationBillingTestClockHelperView } from './billing/OrganizationBillingTestClockHelperView'; import { OrganizationAppsView } from './apps/OrganizationAppsView'; +import { OrganizationSsoView } from 'tg.views/organizations/sso/OrganizationSsoView'; const SpecificOrganizationRouter = () => { const organization = useOrganization(); @@ -67,6 +68,10 @@ const SpecificOrganizationRouter = () => { + + + + ) : ( = ({ }), label: t('organization_menu_apps'), }); + menuItems.push({ + link: LINKS.ORGANIZATION_SSO.build({ + [PARAMS.ORGANIZATION_SLUG]: organizationSlug, + }), + label: t('organization_menu_sso_login'), + }); if (config.billing.enabled) { menuItems.push({ link: LINKS.ORGANIZATION_SUBSCRIPTIONS.build({ diff --git a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx new file mode 100644 index 0000000000..469383d99f --- /dev/null +++ b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx @@ -0,0 +1,96 @@ +import React, { RefObject } from 'react'; +import { styled } from '@mui/material'; +import { T } from '@tolgee/react'; +import { StandardForm } from 'tg.component/common/form/StandardForm'; +import { TextField } from 'tg.component/common/form/fields/TextField'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { messageService } from 'tg.service/MessageService'; + +const StyledInputFields = styled('div')` + display: grid; + align-items: start; + gap: 16px; + padding-bottom: 32px; +`; + +type Provider = { + authorizationUri: string; + clientId: string; + clientSecret: string; + redirectUri: string; + tokenUri: string; +}; +type ProviderProps = { + credentialsRef: RefObject; +}; + +export function CreateProviderSsoForm(props: ProviderProps) { + + const providersLoadable = useApiMutation({ + url: `/v2/sso/providers`, + method: 'post', + }); + + + return ( + { + providersLoadable.mutate( + { + content: {'application/json': {...data}}, + }, + { + onSuccess(data) { + messageService.success(); + } + } + , + ) + ; + }} + > + + } + minHeight={false} + /> + + + } + minHeight={false} + /> + + + } + minHeight={false} + /> + + + } + minHeight={false} + /> + + + } + minHeight={false} + /> + + + ); +} diff --git a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx new file mode 100644 index 0000000000..adddba70c0 --- /dev/null +++ b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx @@ -0,0 +1,41 @@ +import React, { FunctionComponent, useRef } from 'react'; +import { useTranslate } from '@tolgee/react'; +import { BaseOrganizationSettingsView } from '../components/BaseOrganizationSettingsView'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { useOrganization } from '../useOrganization'; +import { CreateProviderSsoForm } from 'tg.views/organizations/sso/CreateProviderSsoForm'; + +export const OrganizationSsoView: FunctionComponent = () => { + const organization = useOrganization(); + const { t } = useTranslate(); + if (!organization) { + return null; + } + const credentialsRef = useRef({ + authorizationUri: '', + clientId: '', + clientSecret: '', + redirectUri: '', + tokenUri: '', + }); + + return ( + + + + ); +}; From e866ebb81d1e579c6af97d59c9c98713792d469b Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Thu, 12 Sep 2024 11:40:14 +0200 Subject: [PATCH 016/162] feat: add an ability to set custom login logo to FE --- .../configuration/PublicConfigurationDTO.kt | 3 ++ .../tolgee/AuthenticationProperties.kt | 2 +- .../security/Login/LoginCredentialsForm.tsx | 29 +++++++++++++++++-- .../component/security/Login/LoginView.tsx | 8 +++-- 4 files changed, 36 insertions(+), 6 deletions(-) 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 3baa1f7f7b..6e31e909b3 100644 --- a/backend/api/src/main/kotlin/io/tolgee/configuration/PublicConfigurationDTO.kt +++ b/backend/api/src/main/kotlin/io/tolgee/configuration/PublicConfigurationDTO.kt @@ -28,6 +28,9 @@ class PublicConfigurationDTO( val maxTranslationTextLength: Long = properties.maxTranslationTextLength val recaptchaSiteKey = properties.recaptcha.siteKey val chatwootToken = properties.chatwootToken + val nativeEnabled = properties.authentication.nativeEnabled + val customLoginLogo = properties.authentication.customLogoUrl + val customLoginText = properties.authentication.customButtonText val capterraTracker = properties.capterraTracker val ga4Tag = properties.ga4Tag val postHogApiKey: String? = properties.postHog.apiKey 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 89f5c13982..b57766a802 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 @@ -148,7 +148,7 @@ class AuthenticationProperties( "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 log in via third-party SSO options", ) - var customLogoUrl: String? = null, + var customLogoUrl: String? = "https://user-images.githubusercontent.com/18496315/188628892-33fcc282-26f1-4035-8105-95952bd93de9.svg", @DocProperty( description = "Custom text for the login button.", defaultExplanation = "Defaults to 'Login' if not set.", diff --git a/webapp/src/component/security/Login/LoginCredentialsForm.tsx b/webapp/src/component/security/Login/LoginCredentialsForm.tsx index 713726611f..a1d92e5354 100644 --- a/webapp/src/component/security/Login/LoginCredentialsForm.tsx +++ b/webapp/src/component/security/Login/LoginCredentialsForm.tsx @@ -33,7 +33,6 @@ export function LoginCredentialsForm(props: LoginViewCredentialsProps) { const remoteConfig = useConfig(); const { login } = useGlobalActions(); const isLoading = useGlobalContext((c) => c.auth.loginLoadable.isLoading); - const oAuthServices = useOAuthServices(); return ( @@ -42,7 +41,7 @@ export function LoginCredentialsForm(props: LoginViewCredentialsProps) { submitButtons={ - + )} {remoteConfig.passwordResettable && ( @@ -66,13 +66,35 @@ export function LoginCredentialsForm(props: LoginViewCredentialsProps) { + {remoteConfig.nativeEnabled && ( + )} + {!remoteConfig.nativeEnabled && ( + + )} + {oAuthServices.length > 0 && } {oAuthServices.map((provider) => ( @@ -104,7 +126,7 @@ export function LoginCredentialsForm(props: LoginViewCredentialsProps) { } }} > - + {remoteConfig.nativeEnabled && ( } @@ -117,6 +139,7 @@ export function LoginCredentialsForm(props: LoginViewCredentialsProps) { minHeight={false} /> + )} ); } diff --git a/webapp/src/component/security/Login/LoginView.tsx b/webapp/src/component/security/Login/LoginView.tsx index 2777c1d353..ef6778eb24 100644 --- a/webapp/src/component/security/Login/LoginView.tsx +++ b/webapp/src/component/security/Login/LoginView.tsx @@ -1,6 +1,6 @@ import { FunctionComponent, useRef, useState } from 'react'; import { T, useTranslate } from '@tolgee/react'; -import { Alert, useMediaQuery, Link as MuiLink } from '@mui/material'; +import { Alert, Link as MuiLink, useMediaQuery } from '@mui/material'; import { Link } from 'react-router-dom'; import { LINKS } from 'tg.constants/links'; @@ -33,6 +33,10 @@ export const LoginView: FunctionComponent = () => { c.auth.allowRegistration ); + const nativeEnabled = useGlobalContext( + (c) => c.initialData.serverConfiguration.nativeEnabled + ); + useReportOnce('LOGIN_PAGE_OPENED'); if (mfaRequired) { @@ -54,7 +58,7 @@ export const LoginView: FunctionComponent = () => { windowTitle={t('login_title')} title={t('login_title')} subtitle={ - registrationAllowed ? ( + registrationAllowed && nativeEnabled ? ( Date: Thu, 12 Sep 2024 11:40:33 +0200 Subject: [PATCH 017/162] feat: add create sso provider links --- webapp/src/constants/links.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index 8f9cba415c..c6cb127472 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -250,6 +250,8 @@ export class LINKS { static ORGANIZATION_INVOICES = Link.ofParent(LINKS.ORGANIZATION, 'invoices'); + static ORGANIZATION_SSO = Link.ofParent(LINKS.ORGANIZATION, 'sso'); + static ORGANIZATION_BILLING_TEST_CLOCK_HELPER = Link.ofParent( LINKS.ORGANIZATION, 'billing-test-clock-helper' From c468f2cf2ecfffe0ee8ba1f5f8d44b5664bb0472 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Thu, 12 Sep 2024 11:40:57 +0200 Subject: [PATCH 018/162] feat: pass invitationCode to BE --- webapp/src/globalContext/useAuthService.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/webapp/src/globalContext/useAuthService.tsx b/webapp/src/globalContext/useAuthService.tsx index 8d3b88092c..c2d62acbd0 100644 --- a/webapp/src/globalContext/useAuthService.tsx +++ b/webapp/src/globalContext/useAuthService.tsx @@ -207,12 +207,14 @@ export const useAuthService = ( query: { code, redirect_uri: redirectUri, - state: "random_state", + invitationCode: invitationCode, }, }, { onError: (error) => { - console.log(error); + if (error.code === 'invitation_code_does_not_exist_or_expired') { + setInvitationCode(undefined); + } messageService.error(); }, } From bf602936f36f94c38a36ca1c77c65bd25ddfc01e Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 16 Sep 2024 23:25:57 +0200 Subject: [PATCH 019/162] feat: display saved provider on FE --- .../v2/controllers/SsoProviderController.kt | 19 ++++++++++----- .../kotlin/io/tolgee/ee/data/TenantDto.kt | 21 ++++++++++++++++ .../main/kotlin/io/tolgee/ee/model/Tenant.kt | 1 + .../tolgee/ee/repository/TenantRepository.kt | 2 ++ .../io/tolgee/ee/service/TenantService.kt | 10 +++++++- .../sso/CreateProviderSsoForm.tsx | 19 ++++++++++----- .../organizations/sso/OrganizationSsoView.tsx | 24 ++++++++++++++++++- 7 files changed, 82 insertions(+), 14 deletions(-) create mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/data/TenantDto.kt 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 index 5f3ca0595c..d7d738e886 100644 --- 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 @@ -1,20 +1,27 @@ package io.tolgee.ee.api.v2.controllers import io.tolgee.ee.data.CreateProviderRequest +import io.tolgee.ee.data.TenantDto +import io.tolgee.ee.data.toDto import io.tolgee.ee.model.Tenant import io.tolgee.ee.service.TenantService -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* @RestController -@RequestMapping("/v2/sso/providers") +@RequestMapping(value = ["/v2/{organizationId:[0-9]+}/sso/providers"]) class SsoProviderController(private val tenantService: TenantService) { @PostMapping("") fun addProvider( @RequestBody request: CreateProviderRequest, + @PathVariable organizationId: Long, ): Tenant { - return tenantService.save(request) + return tenantService.save(request, organizationId) + } + + @GetMapping("") + fun getProvider( + @PathVariable organizationId: Long, + ): TenantDto? { + return tenantService.findTenant(organizationId)?.toDto() } } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/TenantDto.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/TenantDto.kt new file mode 100644 index 0000000000..29ac29ee7c --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/TenantDto.kt @@ -0,0 +1,21 @@ +package io.tolgee.ee.data + +import io.tolgee.ee.model.Tenant + +data class TenantDto( + val authorizationUri: String, + val clientId: String, + val clientSecret: String, + val redirectUri: String, + val tokenUri: String, +) + +fun Tenant.toDto(): TenantDto { + return TenantDto( + authorizationUri = this.authorizationUri, + clientId = this.clientId, + clientSecret = this.clientSecret, + redirectUri = this.redirectUriBase, + tokenUri = this.tokenUri, + ) +} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/model/Tenant.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/model/Tenant.kt index 285a3e1a4c..26c6463045 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/model/Tenant.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/model/Tenant.kt @@ -16,4 +16,5 @@ class Tenant : StandardAuditModel() { var jwkSetUri: String = "" var tokenUri: String = "" var redirectUriBase: String = "" // base Tolgee frontend url can be different for different users so need to store it + var organizationId: Long = 0L } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt index d70d553820..e1bce8b67b 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt @@ -7,4 +7,6 @@ import org.springframework.stereotype.Repository @Repository interface TenantRepository : JpaRepository { fun findByDomain(domain: String): Tenant? + + fun findByOrganizationId(id: Long): Tenant? } 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 index 00d59c1a45..d13fc6693a 100644 --- 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 @@ -29,9 +29,13 @@ class TenantService( return tenantRepository.findAll() } - fun save(dto: CreateProviderRequest): Tenant { + fun save( + dto: CreateProviderRequest, + organizationId: Long, + ): Tenant { val tenant = Tenant() tenant.name = dto.name ?: "" + tenant.organizationId = organizationId tenant.domain = extractDomain(dto.authorizationUri) tenant.clientId = dto.clientId tenant.clientSecret = dto.clientSecret @@ -63,4 +67,8 @@ class TenantService( throw BadRequestException("Invalid authorization uri") } } + + fun findTenant(organizationId: Long): Tenant? { + return tenantRepository.findByOrganizationId(organizationId) + } } diff --git a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx index 469383d99f..99252ec2fb 100644 --- a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx +++ b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx @@ -1,10 +1,11 @@ -import React, { RefObject } from 'react'; +import React from 'react'; import { styled } from '@mui/material'; import { T } from '@tolgee/react'; import { StandardForm } from 'tg.component/common/form/StandardForm'; import { TextField } from 'tg.component/common/form/fields/TextField'; import { useApiMutation } from 'tg.service/http/useQueryApi'; import { messageService } from 'tg.service/MessageService'; +import { useOrganization } from 'tg.views/organizations/useOrganization'; const StyledInputFields = styled('div')` display: grid; @@ -21,24 +22,30 @@ type Provider = { tokenUri: string; }; type ProviderProps = { - credentialsRef: RefObject; + credentialsRef: React.RefObject; }; export function CreateProviderSsoForm(props: ProviderProps) { - const providersLoadable = useApiMutation({ - url: `/v2/sso/providers`, + const organization = useOrganization(); + if (!organization) { + return null; + } + + const providersCreate = useApiMutation({ + url: `/v2/{organizationId}/sso/providers`, method: 'post', + invalidatePrefix: '/v2/organizations', }); - return ( { - providersLoadable.mutate( + providersCreate.mutate( { + path: {organizationId: organization.id}, content: {'application/json': {...data}}, }, { diff --git a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx index adddba70c0..bf0c17a93b 100644 --- a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx +++ b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx @@ -1,9 +1,10 @@ -import React, { FunctionComponent, useRef } from 'react'; +import React, { FunctionComponent, useEffect, useRef } from 'react'; import { useTranslate } from '@tolgee/react'; import { BaseOrganizationSettingsView } from '../components/BaseOrganizationSettingsView'; import { LINKS, PARAMS } from 'tg.constants/links'; import { useOrganization } from '../useOrganization'; import { CreateProviderSsoForm } from 'tg.views/organizations/sso/CreateProviderSsoForm'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; export const OrganizationSsoView: FunctionComponent = () => { const organization = useOrganization(); @@ -11,6 +12,15 @@ export const OrganizationSsoView: FunctionComponent = () => { if (!organization) { return null; } + + const providersLoadable = useApiQuery({ + url: `/v2/{organizationId}/sso/providers`, + method: 'get', + path: { + organizationId: organization.id, + }, + }); + const credentialsRef = useRef({ authorizationUri: '', clientId: '', @@ -19,6 +29,18 @@ export const OrganizationSsoView: FunctionComponent = () => { tokenUri: '', }); + useEffect(() => { + if (providersLoadable.data) { + credentialsRef.current = { + authorizationUri: providersLoadable.data.authorizationUri || '', + clientId: providersLoadable.data.clientId || '', + clientSecret: providersLoadable.data.clientSecret || '', + redirectUri: providersLoadable.data.redirectUri || '', + tokenUri: providersLoadable.data.tokenUri || '', + }; + } + }, [providersLoadable.data]); + return ( Date: Tue, 17 Sep 2024 13:30:03 +0200 Subject: [PATCH 020/162] feat: verify id token to improve security --- .../kotlin/io/tolgee/constants/Message.kt | 1 + .../io/tolgee/ee/service/OAuthService.kt | 54 ++++++++++++++----- 2 files changed, 41 insertions(+), 14 deletions(-) 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 1aa8c45baa..339396b4c4 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -238,6 +238,7 @@ enum class Message { THIRD_PARTY_AUTH_FAILED, TOKEN_EXCHANGE_FAILED, USER_INFO_RETRIEVAL_FAILED, + ID_TOKEN_EXPIRED, ; val code: String diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index 46cdfd95db..29465a0a4c 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -1,5 +1,13 @@ package io.tolgee.ee.service +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 com.nimbusds.jwt.proc.DefaultJWTProcessor import com.posthog.java.shaded.org.json.JSONObject import io.tolgee.constants.Message import io.tolgee.ee.exceptions.OAuthAuthorizationException @@ -19,6 +27,7 @@ import org.springframework.util.LinkedMultiValueMap import org.springframework.util.MultiValueMap import org.springframework.web.client.HttpClientErrorException import org.springframework.web.client.RestTemplate +import java.net.URL import java.util.* @Service @@ -56,9 +65,7 @@ class OAuthService( null, ) - val userInfo = - getUserInfo(tokenResponse.id_token) - + val userInfo = verifyAndDecodeIdToken(tokenResponse.id_token, clientRegistration.providerDetails.jwkSetUri) return register(userInfo, clientRegistration.registrationId, invitationCode) } @@ -96,23 +103,42 @@ class OAuthService( } } - private fun getUserInfo(idToken: String): GenericUserResponse { - val jwt = decodeJwt(idToken) - val response = - GenericUserResponse().apply { - sub = jwt.optString("sub") - name = jwt.optString("name") - given_name = jwt.optString("given_name") - family_name = jwt.optString("family_name") - email = jwt.optString("email") + fun verifyAndDecodeIdToken( + idToken: String, + jwkSetUri: String, + ): GenericUserResponse { + try { + val signedJWT = SignedJWT.parse(idToken) + + val jwkSource: JWKSource = RemoteJWKSet(URL(jwkSetUri)) + + val jwtProcessor: ConfigurableJWTProcessor = DefaultJWTProcessor() + val keySelector = JWSAlgorithmFamilyJWSKeySelector.fromJWKSource(jwkSource) + jwtProcessor.jwsKeySelector = keySelector + + val jwtClaimsSet: JWTClaimsSet = jwtProcessor.process(signedJWT, null) + + val expirationTime: Date = jwtClaimsSet.expirationTime + if (expirationTime.before(Date())) { + throw OAuthAuthorizationException(Message.ID_TOKEN_EXPIRED, null) } - return response + 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 OAuthAuthorizationException(Message.USER_INFO_RETRIEVAL_FAILED, null) + } } fun decodeJwt(jwt: String): JSONObject { val parts = jwt.split(".") - if (parts.size != 3) throw IllegalArgumentException("JWT does not have 3 parts") + if (parts.size != 3) throw IllegalArgumentException("JWT does not have 3 parts") // todo change exception type val payload = parts[1] val decodedPayload = String(Base64.getUrlDecoder().decode(payload)) From 45acdfe71c0ee77a9bff4e2e39ca8d2ab86e4ae6 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Tue, 24 Sep 2024 22:08:19 +0200 Subject: [PATCH 021/162] feat: disable/enable sso provider FE --- .../sso/CreateProviderSsoForm.tsx | 12 +++++++---- .../organizations/sso/OrganizationSsoView.tsx | 21 +++++++++++++++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx index 99252ec2fb..266469df47 100644 --- a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx +++ b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx @@ -25,7 +25,7 @@ type ProviderProps = { credentialsRef: React.RefObject; }; -export function CreateProviderSsoForm(props: ProviderProps) { +export function CreateProviderSsoForm({ credentialsRef, disabled }) { const organization = useOrganization(); if (!organization) { @@ -36,12 +36,11 @@ export function CreateProviderSsoForm(props: ProviderProps) { url: `/v2/{organizationId}/sso/providers`, method: 'post', invalidatePrefix: '/v2/organizations', - }); + }) return ( { providersCreate.mutate( { @@ -60,6 +59,7 @@ export function CreateProviderSsoForm(props: ProviderProps) { > } @@ -68,6 +68,7 @@ export function CreateProviderSsoForm(props: ProviderProps) { } @@ -76,6 +77,7 @@ export function CreateProviderSsoForm(props: ProviderProps) { } @@ -84,6 +86,7 @@ export function CreateProviderSsoForm(props: ProviderProps) { } @@ -92,6 +95,7 @@ export function CreateProviderSsoForm(props: ProviderProps) { } diff --git a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx index bf0c17a93b..756afb1ecd 100644 --- a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx +++ b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx @@ -1,10 +1,12 @@ -import React, { FunctionComponent, useEffect, useRef } from 'react'; +import React, { FunctionComponent, useEffect, useRef, useState } from 'react'; import { useTranslate } from '@tolgee/react'; import { BaseOrganizationSettingsView } from '../components/BaseOrganizationSettingsView'; import { LINKS, PARAMS } from 'tg.constants/links'; import { useOrganization } from '../useOrganization'; import { CreateProviderSsoForm } from 'tg.views/organizations/sso/CreateProviderSsoForm'; import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { FormControlLabel, Switch } from '@mui/material'; +import Box from '@mui/material/Box'; export const OrganizationSsoView: FunctionComponent = () => { const organization = useOrganization(); @@ -28,6 +30,7 @@ export const OrganizationSsoView: FunctionComponent = () => { redirectUri: '', tokenUri: '', }); + const [showForm, setShowForm] = useState(false); useEffect(() => { if (providersLoadable.data) { @@ -38,8 +41,13 @@ export const OrganizationSsoView: FunctionComponent = () => { redirectUri: providersLoadable.data.redirectUri || '', tokenUri: providersLoadable.data.tokenUri || '', }; + + setShowForm(providersLoadable.data.isEnabled); } }, [providersLoadable.data]); + const handleSwitchChange = (event) => { + setShowForm(event.target.checked); + }; return ( { hideChildrenOnLoading={false} maxWidth="normal" > - + } + label={t('organization_sso_switch')} + /> + + + ); }; From f1e4999a9ef941311baee7ca5bf9ff72aabf6bc1 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Tue, 24 Sep 2024 22:08:57 +0200 Subject: [PATCH 022/162] feat: also set jwk uri in ClientRegistration --- .../repository/DynamicOAuth2ClientRegistrationRepository.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/DynamicOAuth2ClientRegistrationRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/DynamicOAuth2ClientRegistrationRepository.kt index 110a67a4f3..1d021d4e1f 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/DynamicOAuth2ClientRegistrationRepository.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/DynamicOAuth2ClientRegistrationRepository.kt @@ -16,10 +16,6 @@ class DynamicOAuth2ClientRegistrationRepository( private val dynamicClientRegistrations: MutableMap = mutableMapOf() override fun findByRegistrationId(registrationId: String): ClientRegistration { - dynamicClientRegistrations[registrationId]?.let { - return it.clientRegistration - } - val tenant: Tenant = tenantService.getByDomain(registrationId) val dynamicRegistration = createDynamicClientRegistration(tenant) return dynamicRegistration.clientRegistration @@ -33,6 +29,7 @@ class DynamicOAuth2ClientRegistrationRepository( .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationUri(tenant.authorizationUri) .tokenUri(tenant.tokenUri) + .jwkSetUri(tenant.jwkSetUri) .redirectUri(tenant.redirectUriBase + "/openId/auth_callback/" + tenant.domain) .scope("openid", "profile", "email") .build() From 0477f25c5a7a498a3c87e8e6a702ee36ae519634 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sun, 6 Oct 2024 13:18:55 +0200 Subject: [PATCH 023/162] feat: now a new user logged in via sso is a member of the organization that issued the sso provider --- .../controllers/OAuth2CallbackController.kt | 4 +-- .../data/DynamicOAuth2ClientRegistration.kt | 3 +- .../io/tolgee/ee/service/OAuthService.kt | 29 ++++++++++++------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index e723d61e67..6aebfb8016 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -18,8 +18,8 @@ class OAuth2CallbackController( @RequestBody request: DomainRequest, ): SsoUrlResponse { val registrationId = request.domain - val clientRegistration = dynamicOAuth2ClientRegistrationRepository.findByRegistrationId(registrationId) - val redirectUrl = buildAuthUrl(clientRegistration, state = request.state) + val dynamicOAuth2ClientRegistration = dynamicOAuth2ClientRegistrationRepository.findByRegistrationId(registrationId) + val redirectUrl = buildAuthUrl(dynamicOAuth2ClientRegistration.clientRegistration, state = request.state) return SsoUrlResponse(redirectUrl) } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/DynamicOAuth2ClientRegistration.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/DynamicOAuth2ClientRegistration.kt index a6f46d439b..f1fdc1f15e 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/DynamicOAuth2ClientRegistration.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/DynamicOAuth2ClientRegistration.kt @@ -1,8 +1,9 @@ package io.tolgee.ee.data +import io.tolgee.ee.model.Tenant import org.springframework.security.oauth2.client.registration.ClientRegistration class DynamicOAuth2ClientRegistration( - var tenantId: String, + var tenant: Tenant, var clientRegistration: ClientRegistration, ) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index 29465a0a4c..635ba4a7cc 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -7,15 +7,17 @@ import com.nimbusds.jose.proc.SecurityContext import com.nimbusds.jwt.JWTClaimsSet import com.nimbusds.jwt.SignedJWT import com.nimbusds.jwt.proc.ConfigurableJWTProcessor -import com.nimbusds.jwt.proc.DefaultJWTProcessor import com.posthog.java.shaded.org.json.JSONObject import io.tolgee.constants.Message +import io.tolgee.ee.data.DynamicOAuth2ClientRegistration import io.tolgee.ee.exceptions.OAuthAuthorizationException import io.tolgee.ee.repository.DynamicOAuth2ClientRegistrationRepository import io.tolgee.exceptions.AuthenticationException import io.tolgee.model.UserAccount +import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.security.authentication.JwtService import io.tolgee.security.payload.JwtAuthenticationResponse +import io.tolgee.service.organization.OrganizationRoleService import io.tolgee.service.security.SignUpService import io.tolgee.service.security.UserAccountService import io.tolgee.util.Logging @@ -37,6 +39,8 @@ class OAuthService( private val userAccountService: UserAccountService, private val signUpService: SignUpService, private val restTemplate: RestTemplate, + private val jwtProcessor: ConfigurableJWTProcessor, + private val organizationRoleService: OrganizationRoleService, ) : Logging { fun handleOAuthCallback( registrationId: String, @@ -54,10 +58,12 @@ class OAuthService( ) } - val clientRegistration = + val dynamicOAuth2ClientRegistration = dynamicOAuth2ClientRegistrationRepository .findByRegistrationId(registrationId) + val clientRegistration = dynamicOAuth2ClientRegistration.clientRegistration + val tokenResponse = exchangeCodeForToken(clientRegistration, code, redirectUrl) ?: throw OAuthAuthorizationException( @@ -66,10 +72,10 @@ class OAuthService( ) val userInfo = verifyAndDecodeIdToken(tokenResponse.id_token, clientRegistration.providerDetails.jwkSetUri) - return register(userInfo, clientRegistration.registrationId, invitationCode) + return register(userInfo, dynamicOAuth2ClientRegistration, invitationCode) } - private fun exchangeCodeForToken( + fun exchangeCodeForToken( clientRegistration: ClientRegistration, code: String, redirectUrl: String, @@ -99,7 +105,7 @@ class OAuthService( response.body } catch (e: HttpClientErrorException) { logger.info("Failed to exchange code for token: ${e.message}") - null + null // todo throw exception } } @@ -112,7 +118,6 @@ class OAuthService( val jwkSource: JWKSource = RemoteJWKSet(URL(jwkSetUri)) - val jwtProcessor: ConfigurableJWTProcessor = DefaultJWTProcessor() val keySelector = JWSAlgorithmFamilyJWSKeySelector.fromJWKSource(jwkSource) jwtProcessor.jwsKeySelector = keySelector @@ -123,6 +128,7 @@ class OAuthService( throw OAuthAuthorizationException(Message.ID_TOKEN_EXPIRED, null) } + return GenericUserResponse().apply { sub = jwtClaimsSet.subject name = jwtClaimsSet.getStringClaim("name") @@ -148,16 +154,17 @@ class OAuthService( private fun register( userResponse: GenericUserResponse, - registrationId: String, + dynamicOAuth2ClientRegistration: DynamicOAuth2ClientRegistration, invitationCode: String?, - ): JwtAuthenticationResponse? { + ): JwtAuthenticationResponse { + val clientRegistration = dynamicOAuth2ClientRegistration.clientRegistration 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 userAccountOptional = userAccountService.findByThirdParty(registrationId, userResponse.sub!!) + val userAccountOptional = userAccountService.findByThirdParty(clientRegistration.registrationId, userResponse.sub!!) val user = userAccountOptional.orElseGet { userAccountService.findActive(email)?.let { @@ -177,10 +184,10 @@ class OAuthService( } newUserAccount.name = name newUserAccount.thirdPartyAuthId = userResponse.sub - newUserAccount.thirdPartyAuthType = registrationId + newUserAccount.thirdPartyAuthType = clientRegistration.registrationId newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY signUpService.signUp(newUserAccount, invitationCode, null) - + organizationRoleService.grantRoleToUser(newUserAccount, dynamicOAuth2ClientRegistration.tenant.organizationId, OrganizationRoleType.MEMBER) newUserAccount } val jwt = jwtService.emitToken(user.id) From 8a83e52c53a760d1b10dafd29d5936b4ccb22c1b Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sun, 6 Oct 2024 13:28:48 +0200 Subject: [PATCH 024/162] feat: refactor login form FE --- .../security/Login/LoginCredentialsForm.tsx | 118 +++++++++--------- .../component/security/Sso/LoginSsoForm.tsx | 22 ++-- 2 files changed, 70 insertions(+), 70 deletions(-) diff --git a/webapp/src/component/security/Login/LoginCredentialsForm.tsx b/webapp/src/component/security/Login/LoginCredentialsForm.tsx index a1d92e5354..a951551aee 100644 --- a/webapp/src/component/security/Login/LoginCredentialsForm.tsx +++ b/webapp/src/component/security/Login/LoginCredentialsForm.tsx @@ -1,20 +1,18 @@ -import React, { RefObject } from 'react'; -import { Button, Link as MuiLink, styled, Typography } from '@mui/material'; +import React, {RefObject} from 'react'; +import {Button, Link as MuiLink, styled, Typography} from '@mui/material'; import Box from '@mui/material/Box'; -import { T } from '@tolgee/react'; -import { Link } from 'react-router-dom'; +import {T} from '@tolgee/react'; +import {Link} from 'react-router-dom'; +import LoginIcon from '@mui/icons-material/Login'; -import { LINKS } from 'tg.constants/links'; -import { useConfig } from 'tg.globalContext/helpers'; +import {LINKS} from 'tg.constants/links'; +import {useConfig} from 'tg.globalContext/helpers'; import LoadingButton from 'tg.component/common/form/LoadingButton'; -import { StandardForm } from 'tg.component/common/form/StandardForm'; -import { TextField } from 'tg.component/common/form/fields/TextField'; -import { useOAuthServices } from 'tg.hooks/useOAuthServices'; -import { - useGlobalActions, - useGlobalContext, -} from 'tg.globalContext/GlobalContext'; -import { ApiError } from 'tg.service/http/ApiError'; +import {StandardForm} from 'tg.component/common/form/StandardForm'; +import {TextField} from 'tg.component/common/form/fields/TextField'; +import {useOAuthServices} from 'tg.hooks/useOAuthServices'; +import {useGlobalActions, useGlobalContext,} from 'tg.globalContext/GlobalContext'; +import {ApiError} from 'tg.service/http/ApiError'; const StyledInputFields = styled('div')` display: grid; @@ -41,15 +39,16 @@ export function LoginCredentialsForm(props: LoginViewCredentialsProps) { submitButtons={ - {remoteConfig.nativeEnabled && ( - - + {remoteConfig.nativeEnabled && ( + + + )} @@ -65,37 +64,41 @@ export function LoginCredentialsForm(props: LoginViewCredentialsProps) { )} - - {remoteConfig.nativeEnabled && ( - - - - - - )} - - {!remoteConfig.nativeEnabled && ( + + )} + + {} + {remoteConfig.nativeEnabled && ( )} - {oAuthServices.length > 0 && } {oAuthServices.map((provider) => ( + )} {oAuthServices.map((provider) => ( diff --git a/webapp/src/component/security/Sso/LoginSsoForm.tsx b/webapp/src/component/security/Sso/LoginSsoForm.tsx index 466f6b54a6..f85efd3671 100644 --- a/webapp/src/component/security/Sso/LoginSsoForm.tsx +++ b/webapp/src/component/security/Sso/LoginSsoForm.tsx @@ -24,7 +24,6 @@ type LoginViewCredentialsProps = { }; const LOCAL_STORAGE_STATE_KEY = 'oauth2State'; - export function LoginSsoForm(props: LoginViewCredentialsProps) { const { getSsoAuthLinkByDomain } = useGlobalActions(); const isLoading = useGlobalContext((c) => c.auth.loginLoadable.isLoading); @@ -60,7 +59,6 @@ export function LoginSsoForm(props: LoginViewCredentialsProps) { localStorage.setItem(LOCAL_STORAGE_STATE_KEY, state); const response = await getSsoAuthLinkByDomain(data.domain, state); window.location.href = response.redirectUrl; - }} > diff --git a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx index baa4fa87ca..cbc20f8686 100644 --- a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx +++ b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx @@ -1,19 +1,16 @@ -import { FunctionComponent, useEffect } from 'react'; -import { Redirect, useHistory, useRouteMatch } from 'react-router-dom'; +import {FunctionComponent, useEffect} from 'react'; +import {Redirect, useHistory, useRouteMatch} from 'react-router-dom'; -import { LINKS, PARAMS } from 'tg.constants/links'; +import {LINKS, PARAMS} from 'tg.constants/links'; -import { - useGlobalActions, - useGlobalContext, -} from 'tg.globalContext/GlobalContext'; -import { FullPageLoading } from 'tg.component/common/FullPageLoading'; +import {useGlobalActions, useGlobalContext,} from 'tg.globalContext/GlobalContext'; +import {FullPageLoading} from 'tg.component/common/FullPageLoading'; interface SsoRedirectionHandlerProps {} const LOCAL_STORAGE_STATE_KEY = 'oauth2State'; export const SsoRedirectionHandler: FunctionComponent< - SsoRedirectionHandlerProps + SsoRedirectionHandlerProps > = () => { const allowPrivate = useGlobalContext((c) => c.auth.allowPrivate); const loginLoadable = useGlobalContext((c) => c.auth.authorizeOAuthLoadable); diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index c6cb127472..5296dfbe81 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -78,7 +78,7 @@ export class LINKS { ); static OPENID_RESPONSE = Link.ofRoot( - 'openId/auth_callback/' + p(PARAMS.SERVICE_TYPE) + 'openId/auth_callback/' + p(PARAMS.SERVICE_TYPE) ); static SSO_LOGIN = Link.ofRoot('sso'); diff --git a/webapp/src/globalContext/useAuthService.tsx b/webapp/src/globalContext/useAuthService.tsx index c2d62acbd0..ed7580e5f4 100644 --- a/webapp/src/globalContext/useAuthService.tsx +++ b/webapp/src/globalContext/useAuthService.tsx @@ -1,23 +1,17 @@ -import { useState } from 'react'; -import { T } from '@tolgee/react'; -import { useHistory } from 'react-router-dom'; +import {useState} from 'react'; +import {T} from '@tolgee/react'; +import {useHistory} from 'react-router-dom'; -import { securityService } from 'tg.service/SecurityService'; -import { - ADMIN_JWT_LOCAL_STORAGE_KEY, - tokenService, -} from 'tg.service/TokenService'; -import { components } from 'tg.service/apiSchema.generated'; -import { useApiMutation } from 'tg.service/http/useQueryApi'; -import { useInitialDataService } from './useInitialDataService'; -import { LINKS, PARAMS } from 'tg.constants/links'; -import { - INVITATION_CODE_STORAGE_KEY, - InvitationCodeService, -} from 'tg.service/InvitationCodeService'; -import { messageService } from 'tg.service/MessageService'; -import { TranslatedError } from 'tg.translationTools/TranslatedError'; -import { useLocalStorageState } from 'tg.hooks/useLocalStorageState'; +import {securityService} from 'tg.service/SecurityService'; +import {ADMIN_JWT_LOCAL_STORAGE_KEY, tokenService,} from 'tg.service/TokenService'; +import {components} from 'tg.service/apiSchema.generated'; +import {useApiMutation} from 'tg.service/http/useQueryApi'; +import {useInitialDataService} from './useInitialDataService'; +import {LINKS, PARAMS} from 'tg.constants/links'; +import {INVITATION_CODE_STORAGE_KEY, InvitationCodeService,} from 'tg.service/InvitationCodeService'; +import {messageService} from 'tg.service/MessageService'; +import {TranslatedError} from 'tg.translationTools/TranslatedError'; +import {useLocalStorageState} from 'tg.hooks/useLocalStorageState'; type LoginRequest = components['schemas']['LoginRequest']; type JwtAuthenticationResponse = @@ -202,22 +196,22 @@ export const useAuthService = ( [PARAMS.SERVICE_TYPE]: registrationId, }); const response = await authorizeOpenIdLoadable.mutateAsync( - { - path: { registrationId: registrationId }, - query: { - code, - redirect_uri: redirectUri, - invitationCode: invitationCode, - }, + { + path: { registrationId: registrationId }, + query: { + code, + redirect_uri: redirectUri, + invitationCode: invitationCode, + }, + }, + { + onError: (error) => { + if (error.code === 'invitation_code_does_not_exist_or_expired') { + setInvitationCode(undefined); + } + messageService.error(); }, - { - onError: (error) => { - if (error.code === 'invitation_code_does_not_exist_or_expired') { - setInvitationCode(undefined); - } - messageService.error(); - }, - } + } ); await handleAfterLogin(response!); }, diff --git a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx index c6d8cbf5fe..6045a9db27 100644 --- a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx +++ b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx @@ -15,91 +15,90 @@ const StyledInputFields = styled('div')` `; export function CreateProviderSsoForm({ credentialsRef, disabled }) { + const organization = useOrganization(); + if (!organization) { + return null; + } - const organization = useOrganization(); - if (!organization) { - return null; - } + const providersCreate = useApiMutation({ + url: `/v2/{organizationId}/sso/providers`, + method: 'post', + invalidatePrefix: '/v2/organizations', + }); - const providersCreate = useApiMutation({ - url: `/v2/{organizationId}/sso/providers`, - method: 'post', - invalidatePrefix: '/v2/organizations', - }) - - return ( - { - providersCreate.mutate( - { - path: {organizationId: organization.id}, - content: {'application/json': {...data, isEnabled: !disabled}}, - }, - { - onSuccess(data) { - messageService.success(); - } - } - , - ) - ; - }} - > - - } - minHeight={false} - /> - - - } - minHeight={false} - /> - - - } - minHeight={false} - /> - - - } - minHeight={false} - /> - - - } - minHeight={false} - /> - - - } - minHeight={false} - /> - - - ); + return ( + { + providersCreate.mutate( + { + path: { organizationId: organization.id }, + content: { 'application/json': { ...data, isEnabled: !disabled } }, + }, + { + onSuccess(data) { + messageService.success( + + ); + }, + } + ); + }} + > + + } + minHeight={false} + /> + + + } + minHeight={false} + /> + + + } + minHeight={false} + /> + + + } + minHeight={false} + /> + + + } + minHeight={false} + /> + + + } + minHeight={false} + /> + + + ); } From be3754ca61f9b82595284685afdae10ff5593d16 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sun, 6 Oct 2024 15:02:53 +0200 Subject: [PATCH 035/162] fix: ktlint --- .../tolgee/AuthenticationProperties.kt | 3 +- .../controllers/OAuth2CallbackController.kt | 19 +++++++----- .../v2/controllers/SsoProviderController.kt | 8 ++--- ...namicOAuth2ClientRegistrationRepository.kt | 4 +-- .../io/tolgee/ee/service/OAuthService.kt | 10 ++++++- .../io/tolgee/ee/service/TenantService.kt | 30 +++++++------------ .../src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 5 +++- 7 files changed, 41 insertions(+), 38 deletions(-) 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 b57766a802..8cf1a75e26 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 @@ -148,7 +148,8 @@ class AuthenticationProperties( "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 log in via third-party SSO options", ) - var customLogoUrl: String? = "https://user-images.githubusercontent.com/18496315/188628892-33fcc282-26f1-4035-8105-95952bd93de9.svg", + var customLogoUrl: String? = + "https://user-images.githubusercontent.com/18496315/188628892-33fcc282-26f1-4035-8105-95952bd93de9.svg", @DocProperty( description = "Custom text for the login button.", defaultExplanation = "Defaults to 'Login' if not set.", diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index 1e17b172a2..3f062a4b76 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -32,14 +32,13 @@ class OAuth2CallbackController( private fun buildAuthUrl( clientRegistration: ClientRegistration, state: String, - ): String { - return "${clientRegistration.providerDetails.authorizationUri}?" + + ): String = + "${clientRegistration.providerDetails.authorizationUri}?" + "client_id=${clientRegistration.clientId}&" + "redirect_uri=${clientRegistration.redirectUri}&" + "response_type=code&" + "scope=${clientRegistration.scopes.joinToString(" ")}&" + "state=$state" - } @GetMapping("/{registrationId}") fun handleCallback( @@ -50,8 +49,8 @@ class OAuth2CallbackController( @RequestParam(value = "invitationCode", required = false) invitationCode: String?, response: HttpServletResponse, @PathVariable registrationId: String, - ): JwtAuthenticationResponse? { - return oauthService.handleOAuthCallback( + ): JwtAuthenticationResponse? = + oauthService.handleOAuthCallback( registrationId = registrationId, code = code, redirectUrl = redirectUrl, @@ -59,9 +58,13 @@ class OAuth2CallbackController( errorDescription = error_description, invitationCode = invitationCode, ) - } - data class DomainRequest(val domain: String, val state: String) + data class DomainRequest( + val domain: String, + val state: String, + ) - data class SsoUrlResponse(val redirectUrl: String) + data class SsoUrlResponse( + val redirectUrl: String, + ) } 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 index a3c5cbbd02..3135b00e11 100644 --- 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 @@ -16,14 +16,10 @@ class SsoProviderController( fun addProvider( @RequestBody request: CreateProviderRequest, @PathVariable organizationId: Long, - ): Tenant { - return tenantService.saveOrUpdate(request, organizationId) - } + ): Tenant = tenantService.saveOrUpdate(request, organizationId) @GetMapping("") fun getProvider( @PathVariable organizationId: Long, - ): TenantDto? { - return tenantService.findTenant(organizationId)?.toDto() - } + ): TenantDto? = tenantService.findTenant(organizationId)?.toDto() } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/DynamicOAuth2ClientRegistrationRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/DynamicOAuth2ClientRegistrationRepository.kt index 4db2e2563d..c57ecfc623 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/DynamicOAuth2ClientRegistrationRepository.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/DynamicOAuth2ClientRegistrationRepository.kt @@ -11,7 +11,6 @@ import org.springframework.stereotype.Component class DynamicOAuth2ClientRegistrationRepository( private val tenantService: TenantService, ) { - fun findByRegistrationId(registrationId: String): DynamicOAuth2ClientRegistration { val tenant: Tenant = tenantService.getByDomain(registrationId) val dynamicRegistration = createDynamicClientRegistration(tenant) @@ -20,7 +19,8 @@ class DynamicOAuth2ClientRegistrationRepository( private fun createDynamicClientRegistration(tenant: Tenant): DynamicOAuth2ClientRegistration { val clientRegistration = - ClientRegistration.withRegistrationId(tenant.domain) + ClientRegistration + .withRegistrationId(tenant.domain) .clientId(tenant.clientId) .clientSecret(tenant.clientSecret) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index 8acd870ea5..46cddbf0ca 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -186,7 +186,11 @@ class OAuthService( newUserAccount.thirdPartyAuthType = clientRegistration.registrationId newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY signUpService.signUp(newUserAccount, invitationCode, null) - organizationRoleService.grantRoleToUser(newUserAccount, dynamicOAuth2ClientRegistration.tenant.organizationId, OrganizationRoleType.MEMBER) + organizationRoleService.grantRoleToUser( + newUserAccount, + dynamicOAuth2ClientRegistration.tenant.organizationId, + OrganizationRoleType.MEMBER, + ) newUserAccount } val jwt = jwtService.emitToken(user.id) @@ -201,7 +205,11 @@ class OAuthService( class GenericUserResponse { var sub: String? = null var name: String? = null + + @Suppress("ktlint:standard:property-naming") var given_name: String? = null + + @Suppress("ktlint:standard:property-naming") var family_name: String? = null var email: String? = null } 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 index ea9d1bf211..ca1a1de311 100644 --- 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 @@ -13,21 +13,13 @@ import java.net.URISyntaxException class TenantService( private val tenantRepository: TenantRepository, ) { - fun getById(id: Long): Tenant { - return tenantRepository.findById(id).orElseThrow { NotFoundException() } - } + fun getById(id: Long): Tenant = tenantRepository.findById(id).orElseThrow { NotFoundException() } - fun getByDomain(domain: String): Tenant { - return tenantRepository.findByDomain(domain) ?: throw NotFoundException() - } + fun getByDomain(domain: String): Tenant = tenantRepository.findByDomain(domain) ?: throw NotFoundException() - fun save(tenant: Tenant): Tenant { - return tenantRepository.save(tenant) - } + fun save(tenant: Tenant): Tenant = tenantRepository.save(tenant) - fun findAll(): List { - return tenantRepository.findAll() - } + fun findAll(): List = tenantRepository.findAll() fun save( dto: CreateProviderRequest, @@ -47,8 +39,8 @@ class TenantService( return save(tenant) } - private fun extractDomain(authorizationUri: String): String { - return try { + private fun extractDomain(authorizationUri: String): String = + try { val uri = URI(authorizationUri) val domain = uri.host val port = uri.port @@ -68,13 +60,13 @@ class TenantService( } catch (e: URISyntaxException) { throw BadRequestException("Invalid authorization uri") } - } - fun findTenant(organizationId: Long): Tenant? { - return tenantRepository.findByOrganizationId(organizationId) - } + fun findTenant(organizationId: Long): Tenant? = tenantRepository.findByOrganizationId(organizationId) - fun saveOrUpdate(request: CreateProviderRequest, organizationId: Long): Tenant { + fun saveOrUpdate( + request: CreateProviderRequest, + organizationId: Long, + ): Tenant { val tenant = findTenant(organizationId) return if (tenant == null) { save(request, organizationId) 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 index 44082faa4c..dd207fd4cf 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -48,7 +48,10 @@ class OAuthTest : AbstractControllerTest() { organizationId = 0L }, ) - val clientRegistraion = dynamicOAuth2ClientRegistrationRepository.findByRegistrationId("registrationId").clientRegistration + val clientRegistraion = + dynamicOAuth2ClientRegistrationRepository + .findByRegistrationId("registrationId") + .clientRegistration oAuthMultiTenantsMocks.authorize(clientRegistraion.registrationId) val response = oAuthService.exchangeCodeForToken(clientRegistraion, "code", "redirectUrl") response From 43b4d4a0017891465de8c747ae88cce4877e43d0 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sun, 6 Oct 2024 15:14:42 +0200 Subject: [PATCH 036/162] fix: npm run prettier --- webapp/src/component/RootRouter.tsx | 36 +++--- .../security/Login/LoginCredentialsForm.tsx | 25 ++-- .../component/security/Sso/LoginSsoForm.tsx | 21 +-- .../security/Sso/SsoRedirectionHandler.tsx | 13 +- webapp/src/globalContext/useAuthService.tsx | 32 +++-- .../sso/CreateProviderSsoForm.tsx | 14 +- .../organizations/sso/OrganizationSsoView.tsx | 16 +-- ....timestamp-1728215969537-7889e0fea9aee.mjs | 120 ++++++++++++++++++ 8 files changed, 206 insertions(+), 71 deletions(-) create mode 100644 webapp/vite.config.ts.timestamp-1728215969537-7889e0fea9aee.mjs diff --git a/webapp/src/component/RootRouter.tsx b/webapp/src/component/RootRouter.tsx index db03ea5c3c..7846130b4c 100644 --- a/webapp/src/component/RootRouter.tsx +++ b/webapp/src/component/RootRouter.tsx @@ -1,23 +1,23 @@ -import React, {FC} from 'react'; -import {Redirect, Route, Switch} from 'react-router-dom'; -import {GoogleReCaptchaProvider} from 'react-google-recaptcha-v3'; +import React, { FC } from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3'; -import {LINKS} from 'tg.constants/links'; -import {ProjectsRouter} from 'tg.views/projects/ProjectsRouter'; -import {UserSettingsRouter} from 'tg.views/userSettings/UserSettingsRouter'; -import {OrganizationsRouter} from 'tg.views/organizations/OrganizationsRouter'; -import {useConfig} from 'tg.globalContext/helpers'; -import {AdministrationView} from 'tg.views/administration/AdministrationView'; +import { LINKS } from 'tg.constants/links'; +import { ProjectsRouter } from 'tg.views/projects/ProjectsRouter'; +import { UserSettingsRouter } from 'tg.views/userSettings/UserSettingsRouter'; +import { OrganizationsRouter } from 'tg.views/organizations/OrganizationsRouter'; +import { useConfig } from 'tg.globalContext/helpers'; +import { AdministrationView } from 'tg.views/administration/AdministrationView'; -import {PrivateRoute} from './common/PrivateRoute'; -import {OrganizationBillingRedirect} from './security/OrganizationBillingRedirect'; -import {RequirePreferredOrganization} from '../RequirePreferredOrganization'; -import {HelpMenu} from './HelpMenu'; -import {PublicOnlyRoute} from './common/PublicOnlyRoute'; -import {PreferredOrganizationRedirect} from './security/PreferredOrganizationRedirect'; -import {RootView} from 'tg.views/RootView'; -import {SsoLoginView} from 'tg.component/security/Sso/SsoLoginView'; -import {SsoRedirectionHandler} from 'tg.component/security/Sso/SsoRedirectionHandler'; +import { PrivateRoute } from './common/PrivateRoute'; +import { OrganizationBillingRedirect } from './security/OrganizationBillingRedirect'; +import { RequirePreferredOrganization } from '../RequirePreferredOrganization'; +import { HelpMenu } from './HelpMenu'; +import { PublicOnlyRoute } from './common/PublicOnlyRoute'; +import { PreferredOrganizationRedirect } from './security/PreferredOrganizationRedirect'; +import { RootView } from 'tg.views/RootView'; +import { SsoLoginView } from 'tg.component/security/Sso/SsoLoginView'; +import { SsoRedirectionHandler } from 'tg.component/security/Sso/SsoRedirectionHandler'; const LoginRouter = React.lazy( () => import(/* webpackChunkName: "login" */ './security/Login/LoginRouter') diff --git a/webapp/src/component/security/Login/LoginCredentialsForm.tsx b/webapp/src/component/security/Login/LoginCredentialsForm.tsx index abe0ed4c0c..e016eb94ef 100644 --- a/webapp/src/component/security/Login/LoginCredentialsForm.tsx +++ b/webapp/src/component/security/Login/LoginCredentialsForm.tsx @@ -1,18 +1,21 @@ -import React, {RefObject} from 'react'; -import {Button, Link as MuiLink, styled, Typography} from '@mui/material'; +import React, { RefObject } from 'react'; +import { Button, Link as MuiLink, styled, Typography } from '@mui/material'; import Box from '@mui/material/Box'; -import {T} from '@tolgee/react'; -import {Link} from 'react-router-dom'; +import { T } from '@tolgee/react'; +import { Link } from 'react-router-dom'; import LoginIcon from '@mui/icons-material/Login'; -import {LINKS} from 'tg.constants/links'; -import {useConfig} from 'tg.globalContext/helpers'; +import { LINKS } from 'tg.constants/links'; +import { useConfig } from 'tg.globalContext/helpers'; import LoadingButton from 'tg.component/common/form/LoadingButton'; -import {StandardForm} from 'tg.component/common/form/StandardForm'; -import {TextField} from 'tg.component/common/form/fields/TextField'; -import {useOAuthServices} from 'tg.hooks/useOAuthServices'; -import {useGlobalActions, useGlobalContext,} from 'tg.globalContext/GlobalContext'; -import {ApiError} from 'tg.service/http/ApiError'; +import { StandardForm } from 'tg.component/common/form/StandardForm'; +import { TextField } from 'tg.component/common/form/fields/TextField'; +import { useOAuthServices } from 'tg.hooks/useOAuthServices'; +import { + useGlobalActions, + useGlobalContext, +} from 'tg.globalContext/GlobalContext'; +import { ApiError } from 'tg.service/http/ApiError'; const StyledInputFields = styled('div')` display: grid; diff --git a/webapp/src/component/security/Sso/LoginSsoForm.tsx b/webapp/src/component/security/Sso/LoginSsoForm.tsx index f85efd3671..5e44300f41 100644 --- a/webapp/src/component/security/Sso/LoginSsoForm.tsx +++ b/webapp/src/component/security/Sso/LoginSsoForm.tsx @@ -1,15 +1,18 @@ -import React, {RefObject} from 'react'; -import {Link as MuiLink, styled, Typography} from '@mui/material'; +import React, { RefObject } from 'react'; +import { Link as MuiLink, styled, Typography } from '@mui/material'; import Box from '@mui/material/Box'; -import {T} from '@tolgee/react'; -import {Link} from 'react-router-dom'; +import { T } from '@tolgee/react'; +import { Link } from 'react-router-dom'; -import {LINKS} from 'tg.constants/links'; +import { LINKS } from 'tg.constants/links'; import LoadingButton from 'tg.component/common/form/LoadingButton'; -import {StandardForm} from 'tg.component/common/form/StandardForm'; -import {TextField} from 'tg.component/common/form/fields/TextField'; -import {useGlobalActions, useGlobalContext,} from 'tg.globalContext/GlobalContext'; -import {v4 as uuidv4} from 'uuid'; +import { StandardForm } from 'tg.component/common/form/StandardForm'; +import { TextField } from 'tg.component/common/form/fields/TextField'; +import { + useGlobalActions, + useGlobalContext, +} from 'tg.globalContext/GlobalContext'; +import { v4 as uuidv4 } from 'uuid'; const StyledInputFields = styled('div')` display: grid; diff --git a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx index cbc20f8686..b95e7b7eee 100644 --- a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx +++ b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx @@ -1,10 +1,13 @@ -import {FunctionComponent, useEffect} from 'react'; -import {Redirect, useHistory, useRouteMatch} from 'react-router-dom'; +import { FunctionComponent, useEffect } from 'react'; +import { Redirect, useHistory, useRouteMatch } from 'react-router-dom'; -import {LINKS, PARAMS} from 'tg.constants/links'; +import { LINKS, PARAMS } from 'tg.constants/links'; -import {useGlobalActions, useGlobalContext,} from 'tg.globalContext/GlobalContext'; -import {FullPageLoading} from 'tg.component/common/FullPageLoading'; +import { + useGlobalActions, + useGlobalContext, +} from 'tg.globalContext/GlobalContext'; +import { FullPageLoading } from 'tg.component/common/FullPageLoading'; interface SsoRedirectionHandlerProps {} const LOCAL_STORAGE_STATE_KEY = 'oauth2State'; diff --git a/webapp/src/globalContext/useAuthService.tsx b/webapp/src/globalContext/useAuthService.tsx index ed7580e5f4..915b2ca04f 100644 --- a/webapp/src/globalContext/useAuthService.tsx +++ b/webapp/src/globalContext/useAuthService.tsx @@ -1,17 +1,23 @@ -import {useState} from 'react'; -import {T} from '@tolgee/react'; -import {useHistory} from 'react-router-dom'; +import { useState } from 'react'; +import { T } from '@tolgee/react'; +import { useHistory } from 'react-router-dom'; -import {securityService} from 'tg.service/SecurityService'; -import {ADMIN_JWT_LOCAL_STORAGE_KEY, tokenService,} from 'tg.service/TokenService'; -import {components} from 'tg.service/apiSchema.generated'; -import {useApiMutation} from 'tg.service/http/useQueryApi'; -import {useInitialDataService} from './useInitialDataService'; -import {LINKS, PARAMS} from 'tg.constants/links'; -import {INVITATION_CODE_STORAGE_KEY, InvitationCodeService,} from 'tg.service/InvitationCodeService'; -import {messageService} from 'tg.service/MessageService'; -import {TranslatedError} from 'tg.translationTools/TranslatedError'; -import {useLocalStorageState} from 'tg.hooks/useLocalStorageState'; +import { securityService } from 'tg.service/SecurityService'; +import { + ADMIN_JWT_LOCAL_STORAGE_KEY, + tokenService, +} from 'tg.service/TokenService'; +import { components } from 'tg.service/apiSchema.generated'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { useInitialDataService } from './useInitialDataService'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { + INVITATION_CODE_STORAGE_KEY, + InvitationCodeService, +} from 'tg.service/InvitationCodeService'; +import { messageService } from 'tg.service/MessageService'; +import { TranslatedError } from 'tg.translationTools/TranslatedError'; +import { useLocalStorageState } from 'tg.hooks/useLocalStorageState'; type LoginRequest = components['schemas']['LoginRequest']; type JwtAuthenticationResponse = diff --git a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx index 6045a9db27..74fd3d050b 100644 --- a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx +++ b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import {styled} from '@mui/material'; -import {T} from '@tolgee/react'; -import {StandardForm} from 'tg.component/common/form/StandardForm'; -import {TextField} from 'tg.component/common/form/fields/TextField'; -import {useApiMutation} from 'tg.service/http/useQueryApi'; -import {messageService} from 'tg.service/MessageService'; -import {useOrganization} from 'tg.views/organizations/useOrganization'; +import { styled } from '@mui/material'; +import { T } from '@tolgee/react'; +import { StandardForm } from 'tg.component/common/form/StandardForm'; +import { TextField } from 'tg.component/common/form/fields/TextField'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { messageService } from 'tg.service/MessageService'; +import { useOrganization } from 'tg.views/organizations/useOrganization'; const StyledInputFields = styled('div')` display: grid; diff --git a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx index 28866bab66..32543dd7db 100644 --- a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx +++ b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx @@ -1,11 +1,11 @@ -import React, {FunctionComponent, useEffect, useRef, useState} from 'react'; -import {useTranslate} from '@tolgee/react'; -import {BaseOrganizationSettingsView} from '../components/BaseOrganizationSettingsView'; -import {LINKS, PARAMS} from 'tg.constants/links'; -import {useOrganization} from '../useOrganization'; -import {CreateProviderSsoForm} from 'tg.views/organizations/sso/CreateProviderSsoForm'; -import {useApiQuery} from 'tg.service/http/useQueryApi'; -import {FormControlLabel, Switch} from '@mui/material'; +import React, { FunctionComponent, useEffect, useRef, useState } from 'react'; +import { useTranslate } from '@tolgee/react'; +import { BaseOrganizationSettingsView } from '../components/BaseOrganizationSettingsView'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { useOrganization } from '../useOrganization'; +import { CreateProviderSsoForm } from 'tg.views/organizations/sso/CreateProviderSsoForm'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { FormControlLabel, Switch } from '@mui/material'; import Box from '@mui/material/Box'; export const OrganizationSsoView: FunctionComponent = () => { diff --git a/webapp/vite.config.ts.timestamp-1728215969537-7889e0fea9aee.mjs b/webapp/vite.config.ts.timestamp-1728215969537-7889e0fea9aee.mjs new file mode 100644 index 0000000000..c651c743a4 --- /dev/null +++ b/webapp/vite.config.ts.timestamp-1728215969537-7889e0fea9aee.mjs @@ -0,0 +1,120 @@ +// vite.config.ts +import { defineConfig, loadEnv } from "file:///Users/huglx/tolgee/tolgee-platform/webapp/node_modules/vite/dist/node/index.js"; +import react from "file:///Users/huglx/tolgee/tolgee-platform/webapp/node_modules/@vitejs/plugin-react/dist/index.mjs"; +import viteTsconfigPaths from "file:///Users/huglx/tolgee/tolgee-platform/webapp/node_modules/vite-tsconfig-paths/dist/index.mjs"; +import svgr from "file:///Users/huglx/tolgee/tolgee-platform/webapp/node_modules/vite-plugin-svgr/dist/index.js"; +import { nodePolyfills } from "file:///Users/huglx/tolgee/tolgee-platform/webapp/node_modules/vite-plugin-node-polyfills/dist/index.js"; +import mdx from "file:///Users/huglx/tolgee/tolgee-platform/webapp/node_modules/@mdx-js/rollup/index.js"; +import { viteStaticCopy } from "file:///Users/huglx/tolgee/tolgee-platform/webapp/node_modules/vite-plugin-static-copy/dist/index.js"; +import { resolve as resolve2 } from "path"; + +// dataCy.plugin.ts +import { readdir, readFile, writeFile } from "fs/promises"; +import { resolve } from "path"; +import { existsSync } from "fs"; +var SRC_PATH = resolve("./src"); +function extractDataCy() { + const fileItems = {}; + async function generate(files) { + await processFiles(files); + const sortedItems = getSortedItems(); + const fileContent = await generateFileContent(sortedItems); + await writeToFile(fileContent); + } + async function processFiles(files) { + for (const file of files) { + await processFile(file); + } + } + async function writeToFile(fileContent) { + await writeFile( + resolve(`../e2e/cypress/support/dataCyType.d.ts`), + fileContent + ); + } + async function generateFileContent(sortedItems) { + let fileContent = "declare namespace DataCy {\n"; + fileContent += " export type Value = \n " + sortedItems.map((i) => `"${i}"`).join(" |\n ") + "\n}"; + return fileContent; + } + function getSortedItems() { + const items = Object.values(fileItems).reduce( + (acc, curr) => [...acc, ...curr], + [] + ); + const itemsSet = new Set(items); + return [...itemsSet].sort(); + } + async function processFile(file) { + if (/.*\.tsx?$/.test(file)) { + if (!existsSync(file)) { + fileItems[file] = []; + return; + } + const content = (await readFile(file)).toString(); + const matches = content.matchAll( + /["']?data-?[c|C]y["']?\s*[=:]\s*{?["'`]([A-Za-z0-9-_\s]+)["'`]?}?/g + ); + fileItems[file] = []; + for (const match of matches) { + fileItems[file].push(match[1]); + } + } + } + async function getFiles(dir) { + const dirents = await readdir(dir, { withFileTypes: true }); + const files = await Promise.all( + dirents.map((dirent) => { + const res = resolve(dir, dirent.name); + return dirent.isDirectory() ? getFiles(res) : res; + }) + ); + return Array.prototype.concat(...files); + } + return { + name: "extract-data-cy", + async buildStart() { + const files = await getFiles(SRC_PATH); + await generate(files); + }, + async watchChange(id) { + generate([id]); + } + }; +} + +// vite.config.ts +import rehypeHighlight from "file:///Users/huglx/tolgee/tolgee-platform/webapp/node_modules/rehype-highlight/index.js"; +var vite_config_default = defineConfig(({ mode }) => { + process.env = { ...process.env, ...loadEnv(mode, process.cwd()) }; + return { + // depending on your application, base can also be "/" + base: "/", + plugins: [ + react(), + viteTsconfigPaths(), + svgr(), + mdx({ rehypePlugins: [rehypeHighlight] }), + nodePolyfills(), + extractDataCy(), + viteStaticCopy({ + targets: [ + { + src: resolve2("node_modules/@tginternal/language-util/flags"), + dest: "" + } + ] + }) + ], + server: { + // this ensures that the browser opens upon server start + open: true, + // this sets a default port to 3000 + port: Number(process.env.VITE_PORT) || 3e3 + } + }; +}); +export { + vite_config_default as default +}; +//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiLCAiZGF0YUN5LnBsdWdpbi50cyJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiY29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2Rpcm5hbWUgPSBcIi9Vc2Vycy9odWdseC90b2xnZWUvdG9sZ2VlLXBsYXRmb3JtL3dlYmFwcFwiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9maWxlbmFtZSA9IFwiL1VzZXJzL2h1Z2x4L3RvbGdlZS90b2xnZWUtcGxhdGZvcm0vd2ViYXBwL3ZpdGUuY29uZmlnLnRzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9Vc2Vycy9odWdseC90b2xnZWUvdG9sZ2VlLXBsYXRmb3JtL3dlYmFwcC92aXRlLmNvbmZpZy50c1wiO2ltcG9ydCB7IGRlZmluZUNvbmZpZywgbG9hZEVudiB9IGZyb20gJ3ZpdGUnO1xuaW1wb3J0IHJlYWN0IGZyb20gJ0B2aXRlanMvcGx1Z2luLXJlYWN0JztcbmltcG9ydCB2aXRlVHNjb25maWdQYXRocyBmcm9tICd2aXRlLXRzY29uZmlnLXBhdGhzJztcbmltcG9ydCBzdmdyIGZyb20gJ3ZpdGUtcGx1Z2luLXN2Z3InO1xuaW1wb3J0IHsgbm9kZVBvbHlmaWxscyB9IGZyb20gJ3ZpdGUtcGx1Z2luLW5vZGUtcG9seWZpbGxzJztcbmltcG9ydCBtZHggZnJvbSAnQG1keC1qcy9yb2xsdXAnO1xuaW1wb3J0IHsgdml0ZVN0YXRpY0NvcHkgfSBmcm9tICd2aXRlLXBsdWdpbi1zdGF0aWMtY29weSc7XG5pbXBvcnQgeyByZXNvbHZlIH0gZnJvbSAncGF0aCc7XG5cbmltcG9ydCB7IGV4dHJhY3REYXRhQ3kgfSBmcm9tICcuL2RhdGFDeS5wbHVnaW4nO1xuaW1wb3J0IHJlaHlwZUhpZ2hsaWdodCBmcm9tICdyZWh5cGUtaGlnaGxpZ2h0JztcblxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKCh7IG1vZGUgfSkgPT4ge1xuICBwcm9jZXNzLmVudiA9IHsgLi4ucHJvY2Vzcy5lbnYsIC4uLmxvYWRFbnYobW9kZSwgcHJvY2Vzcy5jd2QoKSkgfTtcblxuICByZXR1cm4ge1xuICAgIC8vIGRlcGVuZGluZyBvbiB5b3VyIGFwcGxpY2F0aW9uLCBiYXNlIGNhbiBhbHNvIGJlIFwiL1wiXG4gICAgYmFzZTogJy8nLFxuICAgIHBsdWdpbnM6IFtcbiAgICAgIHJlYWN0KCksXG4gICAgICB2aXRlVHNjb25maWdQYXRocygpLFxuICAgICAgc3ZncigpLFxuICAgICAgbWR4KHsgcmVoeXBlUGx1Z2luczogW3JlaHlwZUhpZ2hsaWdodF0gfSksXG4gICAgICBub2RlUG9seWZpbGxzKCksXG4gICAgICBleHRyYWN0RGF0YUN5KCksXG4gICAgICB2aXRlU3RhdGljQ29weSh7XG4gICAgICAgIHRhcmdldHM6IFtcbiAgICAgICAgICB7XG4gICAgICAgICAgICBzcmM6IHJlc29sdmUoJ25vZGVfbW9kdWxlcy9AdGdpbnRlcm5hbC9sYW5ndWFnZS11dGlsL2ZsYWdzJyksXG4gICAgICAgICAgICBkZXN0OiAnJyxcbiAgICAgICAgICB9LFxuICAgICAgICBdLFxuICAgICAgfSksXG4gICAgXSxcbiAgICBzZXJ2ZXI6IHtcbiAgICAgIC8vIHRoaXMgZW5zdXJlcyB0aGF0IHRoZSBicm93c2VyIG9wZW5zIHVwb24gc2VydmVyIHN0YXJ0XG4gICAgICBvcGVuOiB0cnVlLFxuICAgICAgLy8gdGhpcyBzZXRzIGEgZGVmYXVsdCBwb3J0IHRvIDMwMDBcbiAgICAgIHBvcnQ6IE51bWJlcihwcm9jZXNzLmVudi5WSVRFX1BPUlQpIHx8IDMwMDAsXG4gICAgfSxcbiAgfTtcbn0pO1xuIiwgImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvVXNlcnMvaHVnbHgvdG9sZ2VlL3RvbGdlZS1wbGF0Zm9ybS93ZWJhcHBcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9Vc2Vycy9odWdseC90b2xnZWUvdG9sZ2VlLXBsYXRmb3JtL3dlYmFwcC9kYXRhQ3kucGx1Z2luLnRzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9Vc2Vycy9odWdseC90b2xnZWUvdG9sZ2VlLXBsYXRmb3JtL3dlYmFwcC9kYXRhQ3kucGx1Z2luLnRzXCI7aW1wb3J0IHsgUGx1Z2luIH0gZnJvbSAndml0ZSc7XG5pbXBvcnQgeyByZWFkZGlyLCByZWFkRmlsZSwgd3JpdGVGaWxlIH0gZnJvbSAnZnMvcHJvbWlzZXMnO1xuaW1wb3J0IHsgcmVzb2x2ZSB9IGZyb20gJ3BhdGgnO1xuaW1wb3J0IHsgZXhpc3RzU3luYyB9IGZyb20gJ2ZzJztcblxuY29uc3QgU1JDX1BBVEggPSByZXNvbHZlKCcuL3NyYycpO1xuXG5leHBvcnQgZnVuY3Rpb24gZXh0cmFjdERhdGFDeSgpOiBQbHVnaW4ge1xuICBjb25zdCBmaWxlSXRlbXM6IFJlY29yZDxzdHJpbmcsIHN0cmluZ1tdPiA9IHt9O1xuXG4gIGFzeW5jIGZ1bmN0aW9uIGdlbmVyYXRlKGZpbGVzOiBzdHJpbmdbXSkge1xuICAgIGF3YWl0IHByb2Nlc3NGaWxlcyhmaWxlcyk7XG4gICAgY29uc3Qgc29ydGVkSXRlbXMgPSBnZXRTb3J0ZWRJdGVtcygpO1xuICAgIGNvbnN0IGZpbGVDb250ZW50ID0gYXdhaXQgZ2VuZXJhdGVGaWxlQ29udGVudChzb3J0ZWRJdGVtcyk7XG4gICAgYXdhaXQgd3JpdGVUb0ZpbGUoZmlsZUNvbnRlbnQpO1xuICB9XG5cbiAgYXN5bmMgZnVuY3Rpb24gcHJvY2Vzc0ZpbGVzKGZpbGVzOiBzdHJpbmdbXSkge1xuICAgIGZvciAoY29uc3QgZmlsZSBvZiBmaWxlcykge1xuICAgICAgYXdhaXQgcHJvY2Vzc0ZpbGUoZmlsZSk7XG4gICAgfVxuICB9XG5cbiAgYXN5bmMgZnVuY3Rpb24gd3JpdGVUb0ZpbGUoZmlsZUNvbnRlbnQ6IHN0cmluZykge1xuICAgIGF3YWl0IHdyaXRlRmlsZShcbiAgICAgIHJlc29sdmUoYC4uL2UyZS9jeXByZXNzL3N1cHBvcnQvZGF0YUN5VHlwZS5kLnRzYCksXG4gICAgICBmaWxlQ29udGVudFxuICAgICk7XG4gIH1cblxuICBhc3luYyBmdW5jdGlvbiBnZW5lcmF0ZUZpbGVDb250ZW50KHNvcnRlZEl0ZW1zKSB7XG4gICAgbGV0IGZpbGVDb250ZW50ID0gJ2RlY2xhcmUgbmFtZXNwYWNlIERhdGFDeSB7XFxuJztcbiAgICBmaWxlQ29udGVudCArPVxuICAgICAgJyAgICBleHBvcnQgdHlwZSBWYWx1ZSA9IFxcbiAgICAgICAgJyArXG4gICAgICBzb3J0ZWRJdGVtcy5tYXAoKGkpID0+IGBcIiR7aX1cImApLmpvaW4oJyB8XFxuICAgICAgICAnKSArXG4gICAgICAnXFxufSc7XG4gICAgcmV0dXJuIGZpbGVDb250ZW50O1xuICB9XG5cbiAgZnVuY3Rpb24gZ2V0U29ydGVkSXRlbXMoKSB7XG4gICAgY29uc3QgaXRlbXMgPSBPYmplY3QudmFsdWVzKGZpbGVJdGVtcykucmVkdWNlKFxuICAgICAgKGFjYywgY3VycikgPT4gWy4uLmFjYywgLi4uY3Vycl0sXG4gICAgICBbXVxuICAgICk7XG4gICAgY29uc3QgaXRlbXNTZXQgPSBuZXcgU2V0KGl0ZW1zKTtcbiAgICByZXR1cm4gWy4uLml0ZW1zU2V0XS5zb3J0KCk7XG4gIH1cblxuICBhc3luYyBmdW5jdGlvbiBwcm9jZXNzRmlsZShmaWxlOiBzdHJpbmcpIHtcbiAgICBpZiAoLy4qXFwudHN4PyQvLnRlc3QoZmlsZSkpIHtcbiAgICAgIGlmICghZXhpc3RzU3luYyhmaWxlKSkge1xuICAgICAgICBmaWxlSXRlbXNbZmlsZV0gPSBbXTtcbiAgICAgICAgcmV0dXJuO1xuICAgICAgfVxuICAgICAgY29uc3QgY29udGVudCA9IChhd2FpdCByZWFkRmlsZShmaWxlKSkudG9TdHJpbmcoKTtcbiAgICAgIGNvbnN0IG1hdGNoZXMgPSBjb250ZW50Lm1hdGNoQWxsKFxuICAgICAgICAvW1wiJ10/ZGF0YS0/W2N8Q115W1wiJ10/XFxzKls9Ol1cXHMqez9bXCInYF0oW0EtWmEtejAtOS1fXFxzXSspW1wiJ2BdP30/L2dcbiAgICAgICk7XG4gICAgICBmaWxlSXRlbXNbZmlsZV0gPSBbXTtcbiAgICAgIGZvciAoY29uc3QgbWF0Y2ggb2YgbWF0Y2hlcykge1xuICAgICAgICBmaWxlSXRlbXNbZmlsZV0ucHVzaChtYXRjaFsxXSk7XG4gICAgICB9XG4gICAgfVxuICB9XG5cbiAgYXN5bmMgZnVuY3Rpb24gZ2V0RmlsZXMoZGlyOiBzdHJpbmcpIHtcbiAgICBjb25zdCBkaXJlbnRzID0gYXdhaXQgcmVhZGRpcihkaXIsIHsgd2l0aEZpbGVUeXBlczogdHJ1ZSB9KTtcbiAgICBjb25zdCBmaWxlcyA9IGF3YWl0IFByb21pc2UuYWxsKFxuICAgICAgZGlyZW50cy5tYXAoKGRpcmVudCkgPT4ge1xuICAgICAgICBjb25zdCByZXMgPSByZXNvbHZlKGRpciwgZGlyZW50Lm5hbWUpO1xuICAgICAgICByZXR1cm4gZGlyZW50LmlzRGlyZWN0b3J5KCkgPyBnZXRGaWxlcyhyZXMpIDogcmVzO1xuICAgICAgfSlcbiAgICApO1xuICAgIHJldHVybiBBcnJheS5wcm90b3R5cGUuY29uY2F0KC4uLmZpbGVzKTtcbiAgfVxuXG4gIHJldHVybiB7XG4gICAgbmFtZTogJ2V4dHJhY3QtZGF0YS1jeScsXG4gICAgYXN5bmMgYnVpbGRTdGFydCgpIHtcbiAgICAgIGNvbnN0IGZpbGVzID0gYXdhaXQgZ2V0RmlsZXMoU1JDX1BBVEgpO1xuICAgICAgYXdhaXQgZ2VuZXJhdGUoZmlsZXMpO1xuICAgIH0sXG4gICAgYXN5bmMgd2F0Y2hDaGFuZ2UoaWQpIHtcbiAgICAgIGdlbmVyYXRlKFtpZF0pO1xuICAgIH0sXG4gIH07XG59XG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQWdULFNBQVMsY0FBYyxlQUFlO0FBQ3RWLE9BQU8sV0FBVztBQUNsQixPQUFPLHVCQUF1QjtBQUM5QixPQUFPLFVBQVU7QUFDakIsU0FBUyxxQkFBcUI7QUFDOUIsT0FBTyxTQUFTO0FBQ2hCLFNBQVMsc0JBQXNCO0FBQy9CLFNBQVMsV0FBQUEsZ0JBQWU7OztBQ054QixTQUFTLFNBQVMsVUFBVSxpQkFBaUI7QUFDN0MsU0FBUyxlQUFlO0FBQ3hCLFNBQVMsa0JBQWtCO0FBRTNCLElBQU0sV0FBVyxRQUFRLE9BQU87QUFFekIsU0FBUyxnQkFBd0I7QUFDdEMsUUFBTSxZQUFzQyxDQUFDO0FBRTdDLGlCQUFlLFNBQVMsT0FBaUI7QUFDdkMsVUFBTSxhQUFhLEtBQUs7QUFDeEIsVUFBTSxjQUFjLGVBQWU7QUFDbkMsVUFBTSxjQUFjLE1BQU0sb0JBQW9CLFdBQVc7QUFDekQsVUFBTSxZQUFZLFdBQVc7QUFBQSxFQUMvQjtBQUVBLGlCQUFlLGFBQWEsT0FBaUI7QUFDM0MsZUFBVyxRQUFRLE9BQU87QUFDeEIsWUFBTSxZQUFZLElBQUk7QUFBQSxJQUN4QjtBQUFBLEVBQ0Y7QUFFQSxpQkFBZSxZQUFZLGFBQXFCO0FBQzlDLFVBQU07QUFBQSxNQUNKLFFBQVEsd0NBQXdDO0FBQUEsTUFDaEQ7QUFBQSxJQUNGO0FBQUEsRUFDRjtBQUVBLGlCQUFlLG9CQUFvQixhQUFhO0FBQzlDLFFBQUksY0FBYztBQUNsQixtQkFDRSx1Q0FDQSxZQUFZLElBQUksQ0FBQyxNQUFNLElBQUksQ0FBQyxHQUFHLEVBQUUsS0FBSyxjQUFjLElBQ3BEO0FBQ0YsV0FBTztBQUFBLEVBQ1Q7QUFFQSxXQUFTLGlCQUFpQjtBQUN4QixVQUFNLFFBQVEsT0FBTyxPQUFPLFNBQVMsRUFBRTtBQUFBLE1BQ3JDLENBQUMsS0FBSyxTQUFTLENBQUMsR0FBRyxLQUFLLEdBQUcsSUFBSTtBQUFBLE1BQy9CLENBQUM7QUFBQSxJQUNIO0FBQ0EsVUFBTSxXQUFXLElBQUksSUFBSSxLQUFLO0FBQzlCLFdBQU8sQ0FBQyxHQUFHLFFBQVEsRUFBRSxLQUFLO0FBQUEsRUFDNUI7QUFFQSxpQkFBZSxZQUFZLE1BQWM7QUFDdkMsUUFBSSxZQUFZLEtBQUssSUFBSSxHQUFHO0FBQzFCLFVBQUksQ0FBQyxXQUFXLElBQUksR0FBRztBQUNyQixrQkFBVSxJQUFJLElBQUksQ0FBQztBQUNuQjtBQUFBLE1BQ0Y7QUFDQSxZQUFNLFdBQVcsTUFBTSxTQUFTLElBQUksR0FBRyxTQUFTO0FBQ2hELFlBQU0sVUFBVSxRQUFRO0FBQUEsUUFDdEI7QUFBQSxNQUNGO0FBQ0EsZ0JBQVUsSUFBSSxJQUFJLENBQUM7QUFDbkIsaUJBQVcsU0FBUyxTQUFTO0FBQzNCLGtCQUFVLElBQUksRUFBRSxLQUFLLE1BQU0sQ0FBQyxDQUFDO0FBQUEsTUFDL0I7QUFBQSxJQUNGO0FBQUEsRUFDRjtBQUVBLGlCQUFlLFNBQVMsS0FBYTtBQUNuQyxVQUFNLFVBQVUsTUFBTSxRQUFRLEtBQUssRUFBRSxlQUFlLEtBQUssQ0FBQztBQUMxRCxVQUFNLFFBQVEsTUFBTSxRQUFRO0FBQUEsTUFDMUIsUUFBUSxJQUFJLENBQUMsV0FBVztBQUN0QixjQUFNLE1BQU0sUUFBUSxLQUFLLE9BQU8sSUFBSTtBQUNwQyxlQUFPLE9BQU8sWUFBWSxJQUFJLFNBQVMsR0FBRyxJQUFJO0FBQUEsTUFDaEQsQ0FBQztBQUFBLElBQ0g7QUFDQSxXQUFPLE1BQU0sVUFBVSxPQUFPLEdBQUcsS0FBSztBQUFBLEVBQ3hDO0FBRUEsU0FBTztBQUFBLElBQ0wsTUFBTTtBQUFBLElBQ04sTUFBTSxhQUFhO0FBQ2pCLFlBQU0sUUFBUSxNQUFNLFNBQVMsUUFBUTtBQUNyQyxZQUFNLFNBQVMsS0FBSztBQUFBLElBQ3RCO0FBQUEsSUFDQSxNQUFNLFlBQVksSUFBSTtBQUNwQixlQUFTLENBQUMsRUFBRSxDQUFDO0FBQUEsSUFDZjtBQUFBLEVBQ0Y7QUFDRjs7O0FENUVBLE9BQU8scUJBQXFCO0FBRTVCLElBQU8sc0JBQVEsYUFBYSxDQUFDLEVBQUUsS0FBSyxNQUFNO0FBQ3hDLFVBQVEsTUFBTSxFQUFFLEdBQUcsUUFBUSxLQUFLLEdBQUcsUUFBUSxNQUFNLFFBQVEsSUFBSSxDQUFDLEVBQUU7QUFFaEUsU0FBTztBQUFBO0FBQUEsSUFFTCxNQUFNO0FBQUEsSUFDTixTQUFTO0FBQUEsTUFDUCxNQUFNO0FBQUEsTUFDTixrQkFBa0I7QUFBQSxNQUNsQixLQUFLO0FBQUEsTUFDTCxJQUFJLEVBQUUsZUFBZSxDQUFDLGVBQWUsRUFBRSxDQUFDO0FBQUEsTUFDeEMsY0FBYztBQUFBLE1BQ2QsY0FBYztBQUFBLE1BQ2QsZUFBZTtBQUFBLFFBQ2IsU0FBUztBQUFBLFVBQ1A7QUFBQSxZQUNFLEtBQUtDLFNBQVEsOENBQThDO0FBQUEsWUFDM0QsTUFBTTtBQUFBLFVBQ1I7QUFBQSxRQUNGO0FBQUEsTUFDRixDQUFDO0FBQUEsSUFDSDtBQUFBLElBQ0EsUUFBUTtBQUFBO0FBQUEsTUFFTixNQUFNO0FBQUE7QUFBQSxNQUVOLE1BQU0sT0FBTyxRQUFRLElBQUksU0FBUyxLQUFLO0FBQUEsSUFDekM7QUFBQSxFQUNGO0FBQ0YsQ0FBQzsiLAogICJuYW1lcyI6IFsicmVzb2x2ZSIsICJyZXNvbHZlIl0KfQo= From 4311e6889f3ac4e937a8748b24318cfd6b9ed942 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sun, 6 Oct 2024 15:46:10 +0200 Subject: [PATCH 037/162] fix: BE build --- .../test/kotlin/io/tolgee/ee/utils/OAuthMultiTenantsMocks.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 7f9cf92d91..c68194058d 100644 --- 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 @@ -37,7 +37,7 @@ class OAuthMultiTenantsMocks( fun authorize(registrationId: String) { val receivedCode = "fake_access_token" - val registration = dynamicOAuth2ClientRegistrationRepository.findByRegistrationId(registrationId) + val registration = dynamicOAuth2ClientRegistrationRepository.findByRegistrationId(registrationId).clientRegistration whenever( restTemplate?.exchange( From 56815f3f190f8373b4aca43f5e3c6bcafc4e97ca Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Tue, 8 Oct 2024 18:04:18 +0200 Subject: [PATCH 038/162] fix: fet rid of calling static function --- .../app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index 46cddbf0ca..47e2175e88 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -1,5 +1,6 @@ package io.tolgee.ee.service +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 @@ -118,7 +119,7 @@ class OAuthService( val jwkSource: JWKSource = RemoteJWKSet(URL(jwkSetUri)) - val keySelector = JWSAlgorithmFamilyJWSKeySelector.fromJWKSource(jwkSource) + val keySelector = JWSAlgorithmFamilyJWSKeySelector(JWSAlgorithm.Family.RSA, jwkSource) jwtProcessor.jwsKeySelector = keySelector val jwtClaimsSet: JWTClaimsSet = jwtProcessor.process(signedJWT, null) From 90dfeda08a5f5b5fbbb4ef7d9a5d9b31418364c3 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Tue, 8 Oct 2024 18:04:36 +0200 Subject: [PATCH 039/162] chore: mock everything for tests --- .../testDataBuilder/data/OAuthTestData.kt | 5 ++ .../src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 38 +++++++--- .../tolgee/ee/utils/OAuthMultiTenantsMocks.kt | 69 ++++++++++++++++--- 3 files changed, 93 insertions(+), 19 deletions(-) create mode 100644 backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/OAuthTestData.kt 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..62aae5d594 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/OAuthTestData.kt @@ -0,0 +1,5 @@ +package io.tolgee.development.testDataBuilder.data + +class OAuthTestData : BaseTestData() { + val organization = this.projectBuilder.self.organizationOwner +} 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 index dd207fd4cf..d7523d347b 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -1,11 +1,16 @@ package io.tolgee.ee +import com.nimbusds.jose.proc.SecurityContext +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor +import io.tolgee.development.testDataBuilder.data.OAuthTestData import io.tolgee.ee.model.Tenant import io.tolgee.ee.repository.DynamicOAuth2ClientRegistrationRepository import io.tolgee.ee.service.OAuthService import io.tolgee.ee.service.TenantService import io.tolgee.ee.utils.OAuthMultiTenantsMocks import io.tolgee.testing.AbstractControllerTest +import io.tolgee.testing.assertions.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.mock.mockito.MockBean @@ -13,6 +18,8 @@ import org.springframework.test.web.servlet.MockMvc import org.springframework.web.client.RestTemplate class OAuthTest : AbstractControllerTest() { + private lateinit var testData: OAuthTestData + @MockBean @Autowired private val restTemplate: RestTemplate? = null @@ -20,6 +27,10 @@ class OAuthTest : AbstractControllerTest() { @Autowired private var authMvc: MockMvc? = null + @MockBean + @Autowired + private val jwtProcessor: ConfigurableJWTProcessor? = null + @Autowired private lateinit var dynamicOAuth2ClientRegistrationRepository: DynamicOAuth2ClientRegistrationRepository @@ -30,11 +41,17 @@ class OAuthTest : AbstractControllerTest() { private lateinit var tenantService: TenantService private val oAuthMultiTenantsMocks: OAuthMultiTenantsMocks by lazy { - OAuthMultiTenantsMocks(authMvc, restTemplate, dynamicOAuth2ClientRegistrationRepository) + OAuthMultiTenantsMocks(authMvc, restTemplate, dynamicOAuth2ClientRegistrationRepository, jwtProcessor) } - @Test - fun authorize() { + @BeforeEach + fun setup() { + testData = OAuthTestData() + testDataService.saveTestData(testData.root) + addTenant() + } + + private fun addTenant() { tenantService.save( Tenant().apply { name = "tenant1" @@ -42,18 +59,21 @@ class OAuthTest : AbstractControllerTest() { clientId = "clientId" clientSecret = "clientSecret" authorizationUri = "authorizationUri" - jwkSetUri = "jwkSetUri" - tokenUri = "tokenUri" + jwkSetUri = "http://jwkSetUri" + tokenUri = "http://tokenUri" redirectUriBase = "redirectUriBase" - organizationId = 0L + organizationId = testData.organization.id }, ) + } + + @Test + fun authorize() { val clientRegistraion = dynamicOAuth2ClientRegistrationRepository .findByRegistrationId("registrationId") .clientRegistration - oAuthMultiTenantsMocks.authorize(clientRegistraion.registrationId) - val response = oAuthService.exchangeCodeForToken(clientRegistraion, "code", "redirectUrl") - response + val response = oAuthMultiTenantsMocks.authorize(clientRegistraion.registrationId) + assertThat(response.response.status).isEqualTo(200) } } 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 index c68194058d..254a3d48c7 100644 --- 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 @@ -1,41 +1,75 @@ 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.repository.DynamicOAuth2ClientRegistrationRepository import io.tolgee.ee.service.OAuthService 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.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 dynamicOAuth2ClientRegistrationRepository: DynamicOAuth2ClientRegistrationRepository, + private val jwtProcessor: ConfigurableJWTProcessor?, ) { companion object { - val defaultUserResponse = - OAuthService.GenericUserResponse().apply { - sub = "fakeId" - given_name = "fakeGiveName" - family_name = "fakeGivenFamilyName" - email = "email@domain.com" - } - val defaultToken = - OAuthService.OAuth2TokenResponse(id_token = "id_token", scope = "scope") + OAuthService.OAuth2TokenResponse(id_token = generateTestJwt(), scope = "scope") 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)) // Время действия 1 час + .claim("name", "Test User") + .claim("given_name", "Test") + .claim("given_name", "Test") + .claim("family_name", "User") + .claim("email", "mail@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) { + fun authorize(registrationId: String): MvcResult { val receivedCode = "fake_access_token" val registration = dynamicOAuth2ClientRegistrationRepository.findByRegistrationId(registrationId).clientRegistration @@ -47,5 +81,20 @@ class OAuthMultiTenantsMocks( eq(OAuthService.OAuth2TokenResponse::class.java), ), ).thenReturn(defaultTokenResponse) + mockJwk() + + return authMvc!! + .perform( + MockMvcRequestBuilders.get("/v2/oauth2/callback/$registrationId?code=$receivedCode&redirect_uri=redirect_uri"), + ).andReturn() + } + + private fun mockJwk() { + whenever( + jwtProcessor?.process( + any(), + isNull(), + ), + ).thenReturn(jwtClaimsSet) } } From c02388f018171c3f1f79d8e082490baa36a15d34 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Tue, 8 Oct 2024 18:13:45 +0200 Subject: [PATCH 040/162] fix: rename, add policy to provider's controller --- .../tolgee/ee/api/v2/controllers/SsoProviderController.kt | 7 ++++++- ...cessorConfiguration.kt => JwtProcessorConfiguration.kt} | 6 ++---- 2 files changed, 8 insertions(+), 5 deletions(-) rename ee/backend/app/src/main/kotlin/io/tolgee/ee/configuration/{JWTProcessorConfiguration.kt => JwtProcessorConfiguration.kt} (84%) 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 index 3135b00e11..ab09ae3d04 100644 --- 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 @@ -5,19 +5,24 @@ import io.tolgee.ee.data.TenantDto import io.tolgee.ee.data.toDto import io.tolgee.ee.model.Tenant import io.tolgee.ee.service.TenantService +import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.security.authorization.RequiresOrganizationRole import org.springframework.web.bind.annotation.* @RestController -@RequestMapping(value = ["/v2/{organizationId:[0-9]+}/sso/providers"]) +@CrossOrigin(origins = ["*"]) +@RequestMapping(value = ["/v2/{organizationId:[0-9]+}/sso/provider"]) class SsoProviderController( private val tenantService: TenantService, ) { + @RequiresOrganizationRole(role = OrganizationRoleType.OWNER) @PostMapping("") fun addProvider( @RequestBody request: CreateProviderRequest, @PathVariable organizationId: Long, ): Tenant = tenantService.saveOrUpdate(request, organizationId) + @RequiresOrganizationRole(role = OrganizationRoleType.OWNER) @GetMapping("") fun getProvider( @PathVariable organizationId: Long, 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 similarity index 84% rename from ee/backend/app/src/main/kotlin/io/tolgee/ee/configuration/JWTProcessorConfiguration.kt rename to ee/backend/app/src/main/kotlin/io/tolgee/ee/configuration/JwtProcessorConfiguration.kt index dad81e1240..5b732a7624 100644 --- 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 @@ -7,9 +7,7 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @Configuration -class JWTProcessorConfiguration { +class JwtProcessorConfiguration { @Bean - fun jwtProcessor(): ConfigurableJWTProcessor { - return DefaultJWTProcessor() - } + fun jwtProcessor(): ConfigurableJWTProcessor = DefaultJWTProcessor() } From a2e70e3a04ad0c64474e76335577f33ae95b1f01 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Tue, 8 Oct 2024 18:20:50 +0200 Subject: [PATCH 041/162] fix: rename oauth2 endpoint --- .../io/tolgee/configuration/WebSecurityConfig.kt | 16 ++++++++-------- .../v2/controllers/OAuth2CallbackController.kt | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt b/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt index 2bfd970ad2..621fad3dfc 100644 --- a/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt +++ b/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt @@ -84,7 +84,7 @@ class WebSecurityConfig( } }, ) - it.requestMatchers("/api/public/**", "/v2/public/**", "v2/oauth2/callback/**").permitAll() + it.requestMatchers("/api/public/**", "/v2/public/**").permitAll() it.requestMatchers("/v2/administration/**", "/v2/ee-license/**").hasRole("ADMIN") it.requestMatchers("/api/**", "/v2/**").authenticated() it.anyRequest().permitAll() @@ -97,30 +97,30 @@ class WebSecurityConfig( headers.referrerPolicy { it.policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN) } - } - .build() + }.build() } @Bean @Order(10) @ConditionalOnProperty(value = ["tolgee.internal.controller-enabled"], havingValue = "false", matchIfMissing = true) - fun internalSecurityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain { - return httpSecurity + fun internalSecurityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain = + httpSecurity .securityMatcher("/internal/**") .authorizeRequests() .anyRequest() .denyAll() .and() .build() - } override fun addInterceptors(registry: InterceptorRegistry) { registry.addInterceptor(rateLimitInterceptor) registry.addInterceptor(authenticationInterceptor) - registry.addInterceptor(organizationAuthorizationInterceptor) + registry + .addInterceptor(organizationAuthorizationInterceptor) .addPathPatterns("/v2/organizations/**") - registry.addInterceptor(projectAuthorizationInterceptor) + registry + .addInterceptor(projectAuthorizationInterceptor) .addPathPatterns("/v2/projects/**", "/api/project/**", "/api/repository/**") } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index 3f062a4b76..b356373188 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -10,7 +10,7 @@ import org.springframework.security.oauth2.client.registration.ClientRegistratio import org.springframework.web.bind.annotation.* @RestController -@RequestMapping("v2/oauth2/callback/") +@RequestMapping("v2/public/oauth2/callback/") class OAuth2CallbackController( private val dynamicOAuth2ClientRegistrationRepository: DynamicOAuth2ClientRegistrationRepository, private val oauthService: OAuthService, From bc14f1bca49e2260e25b7a1c1ac8e3602b4dcedd Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Tue, 8 Oct 2024 19:48:22 +0200 Subject: [PATCH 042/162] fix: code clean up --- .../controllers/OAuth2CallbackController.kt | 22 +++++----- .../data/DynamicOAuth2ClientRegistration.kt | 9 ---- .../ee/data/{TenantDto.kt => SsoTenantDto.kt} | 9 ++-- .../exceptions/OAuthAuthorizationException.kt | 4 +- .../ee/model/{Tenant.kt => SsoTenant.kt} | 4 +- ...namicOAuth2ClientRegistrationRepository.kt | 42 ------------------- .../tolgee/ee/repository/TenantRepository.kt | 8 ++-- .../io/tolgee/ee/service/OAuthService.kt | 35 +++++++--------- .../io/tolgee/ee/service/TenantService.kt | 20 +++++---- .../src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 16 ++----- .../tolgee/ee/utils/OAuthMultiTenantsMocks.kt | 13 +++--- 11 files changed, 59 insertions(+), 123 deletions(-) delete mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/data/DynamicOAuth2ClientRegistration.kt rename ee/backend/app/src/main/kotlin/io/tolgee/ee/data/{TenantDto.kt => SsoTenantDto.kt} (80%) rename ee/backend/app/src/main/kotlin/io/tolgee/ee/model/{Tenant.kt => SsoTenant.kt} (89%) delete mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/DynamicOAuth2ClientRegistrationRepository.kt diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index b356373188..5a55093961 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -2,42 +2,42 @@ package io.tolgee.ee.api.v2.controllers import io.tolgee.constants.Message import io.tolgee.ee.exceptions.OAuthAuthorizationException -import io.tolgee.ee.repository.DynamicOAuth2ClientRegistrationRepository +import io.tolgee.ee.model.SsoTenant import io.tolgee.ee.service.OAuthService +import io.tolgee.ee.service.TenantService import io.tolgee.security.payload.JwtAuthenticationResponse import jakarta.servlet.http.HttpServletResponse -import org.springframework.security.oauth2.client.registration.ClientRegistration import org.springframework.web.bind.annotation.* @RestController @RequestMapping("v2/public/oauth2/callback/") class OAuth2CallbackController( - private val dynamicOAuth2ClientRegistrationRepository: DynamicOAuth2ClientRegistrationRepository, private val oauthService: OAuthService, + private val tenantService: TenantService, ) { @PostMapping("/get-authentication-url") fun getAuthenticationUrl( @RequestBody request: DomainRequest, ): SsoUrlResponse { val registrationId = request.domain - val dynamicOAuth2ClientRegistration = dynamicOAuth2ClientRegistrationRepository.findByRegistrationId(registrationId) - if (!dynamicOAuth2ClientRegistration.tenant.isEnabledForThisOrganization) { + val tenant = tenantService.getByDomain(registrationId) + if (!tenant.isEnabledForThisOrganization) { throw OAuthAuthorizationException(Message.DOMAIN_NOT_ENABLED, "Domain is not enabled for this organization") } - val redirectUrl = buildAuthUrl(dynamicOAuth2ClientRegistration.clientRegistration, state = request.state) + val redirectUrl = buildAuthUrl(tenant, state = request.state) return SsoUrlResponse(redirectUrl) } private fun buildAuthUrl( - clientRegistration: ClientRegistration, + tenant: SsoTenant, state: String, ): String = - "${clientRegistration.providerDetails.authorizationUri}?" + - "client_id=${clientRegistration.clientId}&" + - "redirect_uri=${clientRegistration.redirectUri}&" + + "${tenant.authorizationUri}?" + + "client_id=${tenant.clientId}&" + + "redirect_uri=${tenant.redirectUriBase + "/openId/auth_callback/" + tenant.domain}&" + "response_type=code&" + - "scope=${clientRegistration.scopes.joinToString(" ")}&" + + "scope=openid profile email roles&" + "state=$state" @GetMapping("/{registrationId}") diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/DynamicOAuth2ClientRegistration.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/DynamicOAuth2ClientRegistration.kt deleted file mode 100644 index f1fdc1f15e..0000000000 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/DynamicOAuth2ClientRegistration.kt +++ /dev/null @@ -1,9 +0,0 @@ -package io.tolgee.ee.data - -import io.tolgee.ee.model.Tenant -import org.springframework.security.oauth2.client.registration.ClientRegistration - -class DynamicOAuth2ClientRegistration( - var tenant: Tenant, - var clientRegistration: ClientRegistration, -) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/TenantDto.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/SsoTenantDto.kt similarity index 80% rename from ee/backend/app/src/main/kotlin/io/tolgee/ee/data/TenantDto.kt rename to ee/backend/app/src/main/kotlin/io/tolgee/ee/data/SsoTenantDto.kt index 2e6b2dc490..9063810f45 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/TenantDto.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/SsoTenantDto.kt @@ -1,8 +1,8 @@ package io.tolgee.ee.data -import io.tolgee.ee.model.Tenant +import io.tolgee.ee.model.SsoTenant -data class TenantDto( +data class SsoTenantDto( val authorizationUri: String, val clientId: String, val clientSecret: String, @@ -12,8 +12,8 @@ data class TenantDto( val jwkSetUri: String, ) -fun Tenant.toDto(): TenantDto { - return TenantDto( +fun SsoTenant.toDto(): SsoTenantDto = + SsoTenantDto( authorizationUri = this.authorizationUri, clientId = this.clientId, clientSecret = this.clientSecret, @@ -22,4 +22,3 @@ fun Tenant.toDto(): TenantDto { isEnabled = this.isEnabledForThisOrganization, jwkSetUri = this.jwkSetUri, ) -} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/exceptions/OAuthAuthorizationException.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/exceptions/OAuthAuthorizationException.kt index 2536fe0fea..5ca8ba1133 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/exceptions/OAuthAuthorizationException.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/exceptions/OAuthAuthorizationException.kt @@ -1,9 +1,9 @@ package io.tolgee.ee.exceptions import io.tolgee.constants.Message -import io.tolgee.exceptions.ExpectedException +import io.tolgee.exceptions.BadRequestException data class OAuthAuthorizationException( val msg: Message, val details: String? = null, -) : RuntimeException("${msg.code}: $details"), ExpectedException +) : BadRequestException("${msg.code}: $details") diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/model/Tenant.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/model/SsoTenant.kt similarity index 89% rename from ee/backend/app/src/main/kotlin/io/tolgee/ee/model/Tenant.kt rename to ee/backend/app/src/main/kotlin/io/tolgee/ee/model/SsoTenant.kt index a98a8ee864..6d9c9f1723 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/model/Tenant.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/model/SsoTenant.kt @@ -6,8 +6,8 @@ import jakarta.persistence.Table import org.hibernate.annotations.ColumnDefault @Entity -@Table(schema = "ee") -class Tenant : StandardAuditModel() { +@Table(schema = "ee", name = "tenant") +class SsoTenant : StandardAuditModel() { var name: String = "" var ssoProvider: String = "" var clientId: String = "" diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/DynamicOAuth2ClientRegistrationRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/DynamicOAuth2ClientRegistrationRepository.kt deleted file mode 100644 index c57ecfc623..0000000000 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/DynamicOAuth2ClientRegistrationRepository.kt +++ /dev/null @@ -1,42 +0,0 @@ -package io.tolgee.ee.repository - -import io.tolgee.ee.data.DynamicOAuth2ClientRegistration -import io.tolgee.ee.model.Tenant -import io.tolgee.ee.service.TenantService -import org.springframework.security.oauth2.client.registration.ClientRegistration -import org.springframework.security.oauth2.core.AuthorizationGrantType -import org.springframework.stereotype.Component - -@Component -class DynamicOAuth2ClientRegistrationRepository( - private val tenantService: TenantService, -) { - fun findByRegistrationId(registrationId: String): DynamicOAuth2ClientRegistration { - val tenant: Tenant = tenantService.getByDomain(registrationId) - val dynamicRegistration = createDynamicClientRegistration(tenant) - return dynamicRegistration - } - - private fun createDynamicClientRegistration(tenant: Tenant): DynamicOAuth2ClientRegistration { - val clientRegistration = - ClientRegistration - .withRegistrationId(tenant.domain) - .clientId(tenant.clientId) - .clientSecret(tenant.clientSecret) - .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) - .authorizationUri(tenant.authorizationUri) - .tokenUri(tenant.tokenUri) - .jwkSetUri(tenant.jwkSetUri) - .redirectUri(tenant.redirectUriBase + "/openId/auth_callback/" + tenant.domain) - .scope("openid", "profile", "email", "roles") - .build() - - val dynamicRegistration = - DynamicOAuth2ClientRegistration( - tenant = tenant, - clientRegistration = clientRegistration, - ) - - return dynamicRegistration - } -} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt index e1bce8b67b..978ef91514 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt @@ -1,12 +1,12 @@ package io.tolgee.ee.repository -import io.tolgee.ee.model.Tenant +import io.tolgee.ee.model.SsoTenant import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @Repository -interface TenantRepository : JpaRepository { - fun findByDomain(domain: String): Tenant? +interface TenantRepository : JpaRepository { + fun findByDomain(domain: String): SsoTenant? - fun findByOrganizationId(id: Long): Tenant? + fun findByOrganizationId(id: Long): SsoTenant? } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index 47e2175e88..6d84d4919d 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -10,9 +10,8 @@ import com.nimbusds.jwt.SignedJWT import com.nimbusds.jwt.proc.ConfigurableJWTProcessor import com.posthog.java.shaded.org.json.JSONObject import io.tolgee.constants.Message -import io.tolgee.ee.data.DynamicOAuth2ClientRegistration import io.tolgee.ee.exceptions.OAuthAuthorizationException -import io.tolgee.ee.repository.DynamicOAuth2ClientRegistrationRepository +import io.tolgee.ee.model.SsoTenant import io.tolgee.exceptions.AuthenticationException import io.tolgee.model.UserAccount import io.tolgee.model.enums.OrganizationRoleType @@ -24,7 +23,6 @@ import io.tolgee.service.security.UserAccountService import io.tolgee.util.Logging import io.tolgee.util.logger import org.springframework.http.* -import org.springframework.security.oauth2.client.registration.ClientRegistration import org.springframework.stereotype.Service import org.springframework.util.LinkedMultiValueMap import org.springframework.util.MultiValueMap @@ -35,13 +33,13 @@ import java.util.* @Service class OAuthService( - private val dynamicOAuth2ClientRegistrationRepository: DynamicOAuth2ClientRegistrationRepository, private val jwtService: JwtService, private val userAccountService: UserAccountService, private val signUpService: SignUpService, private val restTemplate: RestTemplate, private val jwtProcessor: ConfigurableJWTProcessor, private val organizationRoleService: OrganizationRoleService, + private val tenantService: TenantService, ) : Logging { fun handleOAuthCallback( registrationId: String, @@ -59,25 +57,21 @@ class OAuthService( ) } - val dynamicOAuth2ClientRegistration = - dynamicOAuth2ClientRegistrationRepository - .findByRegistrationId(registrationId) - - val clientRegistration = dynamicOAuth2ClientRegistration.clientRegistration + val tenant = tenantService.getByDomain(registrationId) val tokenResponse = - exchangeCodeForToken(clientRegistration, code, redirectUrl) + exchangeCodeForToken(tenant, code, redirectUrl) ?: throw OAuthAuthorizationException( Message.TOKEN_EXCHANGE_FAILED, null, ) - val userInfo = verifyAndDecodeIdToken(tokenResponse.id_token, clientRegistration.providerDetails.jwkSetUri) - return register(userInfo, dynamicOAuth2ClientRegistration, invitationCode) + val userInfo = verifyAndDecodeIdToken(tokenResponse.id_token, tenant.jwkSetUri) + return register(userInfo, tenant, invitationCode) } fun exchangeCodeForToken( - clientRegistration: ClientRegistration, + tenant: SsoTenant, code: String, redirectUrl: String, ): OAuth2TokenResponse? { @@ -90,15 +84,15 @@ class OAuthService( body.add("grant_type", "authorization_code") body.add("code", code) body.add("redirect_uri", redirectUrl) - body.add("client_id", clientRegistration.clientId) - body.add("client_secret", clientRegistration.clientSecret) + 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( - clientRegistration.providerDetails.tokenUri, + tenant.tokenUri, HttpMethod.POST, request, OAuth2TokenResponse::class.java, @@ -154,17 +148,16 @@ class OAuthService( private fun register( userResponse: GenericUserResponse, - dynamicOAuth2ClientRegistration: DynamicOAuth2ClientRegistration, + tenant: SsoTenant, invitationCode: String?, ): JwtAuthenticationResponse { - val clientRegistration = dynamicOAuth2ClientRegistration.clientRegistration 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 userAccountOptional = userAccountService.findByThirdParty(clientRegistration.registrationId, userResponse.sub!!) + val userAccountOptional = userAccountService.findByThirdParty(tenant.domain, userResponse.sub!!) val user = userAccountOptional.orElseGet { userAccountService.findActive(email)?.let { @@ -184,12 +177,12 @@ class OAuthService( } newUserAccount.name = name newUserAccount.thirdPartyAuthId = userResponse.sub - newUserAccount.thirdPartyAuthType = clientRegistration.registrationId + newUserAccount.thirdPartyAuthType = tenant.domain newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY signUpService.signUp(newUserAccount, invitationCode, null) organizationRoleService.grantRoleToUser( newUserAccount, - dynamicOAuth2ClientRegistration.tenant.organizationId, + tenant.organizationId, OrganizationRoleType.MEMBER, ) newUserAccount 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 index ca1a1de311..311ef18aa3 100644 --- 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 @@ -1,7 +1,7 @@ package io.tolgee.ee.service import io.tolgee.ee.data.CreateProviderRequest -import io.tolgee.ee.model.Tenant +import io.tolgee.ee.model.SsoTenant import io.tolgee.ee.repository.TenantRepository import io.tolgee.exceptions.BadRequestException import io.tolgee.exceptions.NotFoundException @@ -13,19 +13,19 @@ import java.net.URISyntaxException class TenantService( private val tenantRepository: TenantRepository, ) { - fun getById(id: Long): Tenant = tenantRepository.findById(id).orElseThrow { NotFoundException() } + fun getById(id: Long): SsoTenant = tenantRepository.findById(id).orElseThrow { NotFoundException() } - fun getByDomain(domain: String): Tenant = tenantRepository.findByDomain(domain) ?: throw NotFoundException() + fun getByDomain(domain: String): SsoTenant = tenantRepository.findByDomain(domain) ?: throw NotFoundException() - fun save(tenant: Tenant): Tenant = tenantRepository.save(tenant) + fun save(tenant: SsoTenant): SsoTenant = tenantRepository.save(tenant) - fun findAll(): List = tenantRepository.findAll() + fun findAll(): List = tenantRepository.findAll() fun save( dto: CreateProviderRequest, organizationId: Long, - ): Tenant { - val tenant = Tenant() + ): SsoTenant { + val tenant = SsoTenant() tenant.name = dto.name ?: "" tenant.organizationId = organizationId tenant.domain = extractDomain(dto.authorizationUri) @@ -61,12 +61,14 @@ class TenantService( throw BadRequestException("Invalid authorization uri") } - fun findTenant(organizationId: Long): Tenant? = tenantRepository.findByOrganizationId(organizationId) + fun findTenant(organizationId: Long): SsoTenant? = tenantRepository.findByOrganizationId(organizationId) + + fun getTenant(organizationId: Long): SsoTenant = findTenant(organizationId) ?: throw NotFoundException() fun saveOrUpdate( request: CreateProviderRequest, organizationId: Long, - ): Tenant { + ): SsoTenant { val tenant = findTenant(organizationId) return if (tenant == null) { save(request, organizationId) 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 index d7523d347b..f97a46a2d3 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -3,8 +3,7 @@ package io.tolgee.ee import com.nimbusds.jose.proc.SecurityContext import com.nimbusds.jwt.proc.ConfigurableJWTProcessor import io.tolgee.development.testDataBuilder.data.OAuthTestData -import io.tolgee.ee.model.Tenant -import io.tolgee.ee.repository.DynamicOAuth2ClientRegistrationRepository +import io.tolgee.ee.model.SsoTenant import io.tolgee.ee.service.OAuthService import io.tolgee.ee.service.TenantService import io.tolgee.ee.utils.OAuthMultiTenantsMocks @@ -31,9 +30,6 @@ class OAuthTest : AbstractControllerTest() { @Autowired private val jwtProcessor: ConfigurableJWTProcessor? = null - @Autowired - private lateinit var dynamicOAuth2ClientRegistrationRepository: DynamicOAuth2ClientRegistrationRepository - @Autowired private lateinit var oAuthService: OAuthService @@ -41,7 +37,7 @@ class OAuthTest : AbstractControllerTest() { private lateinit var tenantService: TenantService private val oAuthMultiTenantsMocks: OAuthMultiTenantsMocks by lazy { - OAuthMultiTenantsMocks(authMvc, restTemplate, dynamicOAuth2ClientRegistrationRepository, jwtProcessor) + OAuthMultiTenantsMocks(authMvc, restTemplate, tenantService, jwtProcessor) } @BeforeEach @@ -53,7 +49,7 @@ class OAuthTest : AbstractControllerTest() { private fun addTenant() { tenantService.save( - Tenant().apply { + SsoTenant().apply { name = "tenant1" domain = "registrationId" clientId = "clientId" @@ -69,11 +65,7 @@ class OAuthTest : AbstractControllerTest() { @Test fun authorize() { - val clientRegistraion = - dynamicOAuth2ClientRegistrationRepository - .findByRegistrationId("registrationId") - .clientRegistration - val response = oAuthMultiTenantsMocks.authorize(clientRegistraion.registrationId) + val response = oAuthMultiTenantsMocks.authorize("registrationId") assertThat(response.response.status).isEqualTo(200) } } 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 index 254a3d48c7..cde0971179 100644 --- 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 @@ -7,8 +7,8 @@ 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.repository.DynamicOAuth2ClientRegistrationRepository import io.tolgee.ee.service.OAuthService +import io.tolgee.ee.service.TenantService import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.isNull @@ -25,7 +25,7 @@ import java.util.* class OAuthMultiTenantsMocks( private var authMvc: MockMvc? = null, private val restTemplate: RestTemplate? = null, - private val dynamicOAuth2ClientRegistrationRepository: DynamicOAuth2ClientRegistrationRepository, + private val tenantService: TenantService? = null, private val jwtProcessor: ConfigurableJWTProcessor?, ) { companion object { @@ -71,11 +71,10 @@ class OAuthMultiTenantsMocks( fun authorize(registrationId: String): MvcResult { val receivedCode = "fake_access_token" - val registration = dynamicOAuth2ClientRegistrationRepository.findByRegistrationId(registrationId).clientRegistration - + val tenant = tenantService?.getByDomain(registrationId)!! whenever( restTemplate?.exchange( - eq(registration.providerDetails.tokenUri), + eq(tenant.tokenUri), eq(HttpMethod.POST), any(), eq(OAuthService.OAuth2TokenResponse::class.java), @@ -85,7 +84,9 @@ class OAuthMultiTenantsMocks( return authMvc!! .perform( - MockMvcRequestBuilders.get("/v2/oauth2/callback/$registrationId?code=$receivedCode&redirect_uri=redirect_uri"), + MockMvcRequestBuilders.get( + "/v2/public/oauth2/callback/$registrationId?code=$receivedCode&redirect_uri=redirect_uri", + ), ).andReturn() } From a63094fdbc9f009b4d4a61a22393039e5e7e3d2e Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Tue, 8 Oct 2024 19:48:45 +0200 Subject: [PATCH 043/162] fix: use Model & ModelAssembler approach --- .../io/tolgee/hateoas/ee/SsoTenantModel.kt | 18 +++++++++++++ .../v2/controllers/SsoProviderController.kt | 13 +++++----- .../hateoas/assemblers/SsoTenantAssembler.kt | 25 +++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 backend/api/src/main/kotlin/io/tolgee/hateoas/ee/SsoTenantModel.kt create mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/SsoTenantAssembler.kt 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..9e35cd7d16 --- /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 redirectUri: String, + val tokenUri: String, + val isEnabled: Boolean, + val jwkSetUri: String, +) : RepresentationModel(), + Serializable 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 index ab09ae3d04..80bb570a7a 100644 --- 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 @@ -1,10 +1,10 @@ package io.tolgee.ee.api.v2.controllers +import io.tolgee.ee.api.v2.hateoas.assemblers.SsoTenantAssembler import io.tolgee.ee.data.CreateProviderRequest -import io.tolgee.ee.data.TenantDto import io.tolgee.ee.data.toDto -import io.tolgee.ee.model.Tenant import io.tolgee.ee.service.TenantService +import io.tolgee.hateoas.ee.SsoTenantModel import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.security.authorization.RequiresOrganizationRole import org.springframework.web.bind.annotation.* @@ -14,17 +14,18 @@ import org.springframework.web.bind.annotation.* @RequestMapping(value = ["/v2/{organizationId:[0-9]+}/sso/provider"]) class SsoProviderController( private val tenantService: TenantService, + private val ssoTenantAssembler: SsoTenantAssembler, ) { @RequiresOrganizationRole(role = OrganizationRoleType.OWNER) - @PostMapping("") - fun addProvider( + @PutMapping("") + fun setProvider( @RequestBody request: CreateProviderRequest, @PathVariable organizationId: Long, - ): Tenant = tenantService.saveOrUpdate(request, organizationId) + ): SsoTenantModel = ssoTenantAssembler.toModel(tenantService.saveOrUpdate(request, organizationId).toDto()) @RequiresOrganizationRole(role = OrganizationRoleType.OWNER) @GetMapping("") fun getProvider( @PathVariable organizationId: Long, - ): TenantDto? = tenantService.findTenant(organizationId)?.toDto() + ): SsoTenantModel = ssoTenantAssembler.toModel(tenantService.getTenant(organizationId).toDto()) } 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..c9170b2fc1 --- /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, + redirectUri = entity.redirectUri, + tokenUri = entity.tokenUri, + isEnabled = entity.isEnabled, + jwkSetUri = entity.jwkSetUri, + ) +} From 2b1f8c7301481ca2e1a2851a5b1f6579ebd9f385 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 11 Oct 2024 10:17:16 +0200 Subject: [PATCH 044/162] fix: add RequiresSuperAuthentication and change url on FE --- .../v2/controllers/SsoProviderController.kt | 12 +- webapp/src/service/apiSchema.generated.ts | 648 +++++++++--------- .../organizations/sso/OrganizationSsoView.tsx | 18 +- 3 files changed, 332 insertions(+), 346 deletions(-) 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 index 80bb570a7a..e2a4b6096c 100644 --- 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 @@ -6,6 +6,7 @@ import io.tolgee.ee.data.toDto import io.tolgee.ee.service.TenantService 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 org.springframework.web.bind.annotation.* @@ -18,6 +19,7 @@ class SsoProviderController( ) { @RequiresOrganizationRole(role = OrganizationRoleType.OWNER) @PutMapping("") + @RequiresSuperAuthentication fun setProvider( @RequestBody request: CreateProviderRequest, @PathVariable organizationId: Long, @@ -25,7 +27,13 @@ class SsoProviderController( @RequiresOrganizationRole(role = OrganizationRoleType.OWNER) @GetMapping("") - fun getProvider( + @RequiresSuperAuthentication + fun findProvider( @PathVariable organizationId: Long, - ): SsoTenantModel = ssoTenantAssembler.toModel(tenantService.getTenant(organizationId).toDto()) + ): SsoTenantModel? = + try { + ssoTenantAssembler.toModel(tenantService.getTenant(organizationId).toDto()) + } catch (e: Exception) { + null + } } diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 051bfdaab0..afcde798cb 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -4,6 +4,10 @@ */ export interface paths { + "/v2/{organizationId}/sso/provider": { + get: operations["findProvider"]; + put: operations["setProvider"]; + }; "/v2/user": { /** Returns information about currently authenticated user. */ get: operations["getInfo_2"]; @@ -300,10 +304,6 @@ export interface paths { /** Set's the global role on the Tolgee Platform server. */ put: operations["setRole"]; }; - "/v2/{organizationId}/sso/providers": { - get: operations["getProvider"]; - post: operations["addProvider"]; - }; "/v2/user/send-email-verification": { /** Resends email verification email to currently authenticated user. */ post: operations["sendEmailVerification"]; @@ -337,6 +337,9 @@ export interface paths { */ post: operations["fetchBotEvent"]; }; + "/v2/public/oauth2/callback/get-authentication-url": { + post: operations["getAuthenticationUrl"]; + }; "/v2/public/business-events/report": { post: operations["report"]; }; @@ -488,9 +491,6 @@ export interface paths { */ post: operations["connectWorkspace"]; }; - "/v2/oauth2/callback/get-authentication-url": { - post: operations["getAuthenticationUrl"]; - }; "/v2/image-upload": { post: operations["upload"]; }; @@ -553,6 +553,9 @@ export interface paths { "/v2/public/scope-info/hierarchy": { get: operations["getHierarchy"]; }; + "/v2/public/oauth2/callback/{registrationId}": { + get: operations["handleCallback"]; + }; "/v2/public/machine-translation-providers": { /** Get machine translation providers */ get: operations["getInfo_4"]; @@ -738,9 +741,6 @@ export interface paths { /** Returns all organization projects the user has access to */ get: operations["getAllProjects_1"]; }; - "/v2/oauth2/callback/{registrationId}": { - get: operations["handleCallback"]; - }; "/v2/invitations/{code}/accept": { get: operations["acceptInvitation"]; }; @@ -1065,6 +1065,25 @@ export interface components { code: string; params?: { [key: string]: unknown }[]; }; + CreateProviderRequest: { + name?: string; + clientId: string; + clientSecret: string; + authorizationUri: string; + redirectUri: string; + tokenUri: string; + jwkSetUri: string; + isEnabled: boolean; + }; + SsoTenantModel: { + authorizationUri: string; + clientId: string; + clientSecret: string; + redirectUri: string; + tokenUri: string; + isEnabled: boolean; + jwkSetUri: string; + }; UserUpdateRequestDto: { name: string; email: string; @@ -1131,24 +1150,6 @@ export interface components { | "SERVER_ADMIN"; /** @description The user's permission type. This field is null if uses granular permissions */ type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; - /** - * @deprecated - * @description Deprecated (use translateLanguageIds). - * - * List of languages current user has TRANSLATE permission to. If null, all languages edition is permitted. - * @example 200001,200004 - */ - permittedLanguageIds?: number[]; - /** - * @description List of languages user can translate to. If null, all languages editing is permitted. - * @example 200001,200004 - */ - translateLanguageIds?: number[]; - /** - * @description List of languages user can change state to. If null, changing state of all language values is permitted. - * @example 200001,200004 - */ - stateChangeLanguageIds?: number[]; /** * @description Granted scopes to the user. When user has type permissions, this field contains permission scopes of the type. * @example KEYS_EDIT,TRANSLATIONS_VIEW @@ -1186,6 +1187,24 @@ export interface components { * @example 200001,200004 */ viewLanguageIds?: number[]; + /** + * @description List of languages user can translate to. If null, all languages editing is permitted. + * @example 200001,200004 + */ + translateLanguageIds?: number[]; + /** + * @description List of languages user can change state to. If null, changing state of all language values is permitted. + * @example 200001,200004 + */ + stateChangeLanguageIds?: number[]; + /** + * @deprecated + * @description Deprecated (use translateLanguageIds). + * + * List of languages current user has TRANSLATE permission to. If null, all languages edition is permitted. + * @example 200001,200004 + */ + permittedLanguageIds?: number[]; }; LanguageModel: { /** Format: int64 */ @@ -1987,12 +2006,12 @@ export interface components { createNewKeys: boolean; }; ImportSettingsModel: { - /** @description If true, key descriptions will be overridden by the import */ - overrideKeyDescriptions: boolean; - /** @description If true, placeholders from other formats will be converted to ICU when possible */ - convertPlaceholdersToIcu: boolean; /** @description If false, only updates keys, skipping the creation of new keys */ createNewKeys: boolean; + /** @description If true, placeholders from other formats will be converted to ICU when possible */ + convertPlaceholdersToIcu: boolean; + /** @description If true, key descriptions will be overridden by the import */ + overrideKeyDescriptions: boolean; }; /** @description User who created the comment */ SimpleUserAccountModel: { @@ -2158,17 +2177,17 @@ export interface components { }; RevealedPatModel: { token: string; - description: string; /** Format: int64 */ id: number; + description: string; /** Format: int64 */ - createdAt: number; - /** Format: int64 */ - updatedAt: number; + lastUsedAt?: number; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ - lastUsedAt?: number; + createdAt: number; + /** Format: int64 */ + updatedAt: number; }; SetOrganizationRoleDto: { roleType: "MEMBER" | "OWNER"; @@ -2304,52 +2323,20 @@ export interface components { RevealedApiKeyModel: { /** @description Resulting user's api key */ key: string; - description: string; /** Format: int64 */ id: number; - projectName: string; - username?: string; + description: string; scopes: string[]; /** Format: int64 */ - projectId: number; + lastUsedAt?: number; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ - lastUsedAt?: number; + projectId: number; + username?: string; + projectName: string; userFullName?: string; }; - CreateProviderRequest: { - name?: string; - clientId: string; - clientSecret: string; - authorizationUri: string; - redirectUri: string; - tokenUri: string; - jwkSetUri: string; - isEnabled: boolean; - }; - Tenant: { - /** Format: date-time */ - createdAt?: string; - /** Format: date-time */ - updatedAt?: string; - /** Format: int64 */ - id: number; - name: string; - ssoProvider: string; - clientId: string; - clientSecret: string; - authorizationUri: string; - domain: string; - jwkSetUri: string; - tokenUri: string; - redirectUriBase: string; - /** Format: int64 */ - organizationId: number; - isEnabledForThisOrganization: boolean; - enabledForThisOrganization?: boolean; - disableActivityLogging: boolean; - }; SuperTokenRequest: { /** @description Has to be provided when TOTP enabled */ otp?: string; @@ -2372,6 +2359,13 @@ export interface components { trigger_id?: string; team_domain: string; }; + DomainRequest: { + domain: string; + state: string; + }; + SsoUrlResponse: { + redirectUrl: string; + }; BusinessEventReportRequest: { eventName: string; anonymousUserId?: string; @@ -3174,13 +3168,6 @@ export interface components { ConnectToSlackDto: { code: string; }; - DomainRequest: { - domain: string; - state: string; - }; - SsoUrlResponse: { - redirectUrl: string; - }; ImageUploadInfoDto: { location?: string; }; @@ -3305,15 +3292,6 @@ export interface components { password: string; otp?: string; }; - TenantDto: { - authorizationUri: string; - clientId: string; - clientSecret: string; - redirectUri: string; - tokenUri: string; - isEnabled: boolean; - jwkSetUri: string; - }; CollectionModelSimpleOrganizationModel: { _embedded?: { organizations?: components["schemas"]["SimpleOrganizationModel"][]; @@ -3439,22 +3417,22 @@ export interface components { | "SLACK_INTEGRATION" )[]; quickStart?: components["schemas"]["QuickStartModel"]; - /** @example This is a beautiful organization full of beautiful and clever people */ - description?: string; /** @example Beautiful organization */ name: string; /** Format: int64 */ id: number; + /** @example This is a beautiful organization full of beautiful and clever people */ + description?: string; + avatar?: components["schemas"]["Avatar"]; /** @example btforg */ slug: string; - avatar?: components["schemas"]["Avatar"]; - basePermissions: components["schemas"]["PermissionModel"]; /** * @description The role of currently authorized user. * * Can be null when user has direct access to one of the projects owned by the organization. */ currentUserRole?: "MEMBER" | "OWNER"; + basePermissions: components["schemas"]["PermissionModel"]; }; PublicBillingConfigurationDTO: { enabled: boolean; @@ -3519,9 +3497,9 @@ export interface components { defaultFileStructureTemplate: string; }; DocItem: { - description?: string; - name: string; displayName?: string; + name: string; + description?: string; }; PagedModelProjectModel: { _embedded?: { @@ -3592,20 +3570,20 @@ export interface components { formalitySupported: boolean; }; KeySearchResultView: { - description?: string; name: string; /** Format: int64 */ id: number; + description?: string; translation?: string; namespace?: string; baseTranslation?: string; }; KeySearchSearchResultModel: { view?: components["schemas"]["KeySearchResultView"]; - description?: string; name: string; /** Format: int64 */ id: number; + description?: string; translation?: string; namespace?: string; baseTranslation?: string; @@ -4152,17 +4130,17 @@ export interface components { }; PatWithUserModel: { user: components["schemas"]["SimpleUserAccountModel"]; - description: string; /** Format: int64 */ id: number; + description: string; /** Format: int64 */ - createdAt: number; - /** Format: int64 */ - updatedAt: number; + lastUsedAt?: number; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ - lastUsedAt?: number; + createdAt: number; + /** Format: int64 */ + updatedAt: number; }; PagedModelOrganizationModel: { _embedded?: { @@ -4289,18 +4267,18 @@ export interface components { * @description Languages for which user has translate permission. */ permittedLanguageIds?: number[]; - description: string; /** Format: int64 */ id: number; - projectName: string; - username?: string; + description: string; scopes: string[]; /** Format: int64 */ - projectId: number; + lastUsedAt?: number; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ - lastUsedAt?: number; + projectId: number; + username?: string; + projectName: string; userFullName?: string; }; PagedModelUserAccountModel: { @@ -4331,6 +4309,105 @@ export interface components { } export interface operations { + findProvider: { + parameters: { + path: { + organizationId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["SsoTenantModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + setProvider: { + parameters: { + path: { + organizationId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["SsoTenantModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateProviderRequest"]; + }; + }; + }; /** Returns information about currently authenticated user. */ getInfo_2: { responses: { @@ -9105,102 +9182,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["ApiKeyModel"]; - }; - }; - /** Bad Request */ - 400: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Unauthorized */ - 401: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Forbidden */ - 403: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Not Found */ - 404: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - }; - requestBody: { - content: { - "application/json": components["schemas"]["V2EditApiKeyDto"]; - }; - }; - }; - delete_13: { - parameters: { - path: { - apiKeyId: number; - }; - }; - responses: { - /** OK */ - 200: unknown; - /** Bad Request */ - 400: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Unauthorized */ - 401: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Forbidden */ - 403: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Not Found */ - 404: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - }; - }; - regenerate_1: { - parameters: { - path: { - apiKeyId: number; - }; - }; - responses: { - /** OK */ - 200: { - content: { - "application/json": components["schemas"]["RevealedApiKeyModel"]; + "application/json": components["schemas"]["ApiKeyModel"]; }; }; /** Bad Request */ @@ -9238,15 +9220,14 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["RegenerateApiKeyDto"]; + "application/json": components["schemas"]["V2EditApiKeyDto"]; }; }; }; - /** Enables previously disabled user. */ - enableUser: { + delete_13: { parameters: { path: { - userId: number; + apiKeyId: number; }; }; responses: { @@ -9286,16 +9267,19 @@ export interface operations { }; }; }; - /** Disables user account. User will not be able to log in, but their user data will be preserved, so you can enable the user later using the `enable` endpoint. */ - disableUser: { + regenerate_1: { parameters: { path: { - userId: number; + apiKeyId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["RevealedApiKeyModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -9329,13 +9313,17 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["RegenerateApiKeyDto"]; + }; + }; }; - /** Set's the global role on the Tolgee Platform server. */ - setRole: { + /** Enables previously disabled user. */ + enableUser: { parameters: { path: { userId: number; - role: "USER" | "ADMIN"; }; }; responses: { @@ -9375,19 +9363,16 @@ export interface operations { }; }; }; - getProvider: { + /** Disables user account. User will not be able to log in, but their user data will be preserved, so you can enable the user later using the `enable` endpoint. */ + disableUser: { parameters: { path: { - organizationId: number; + userId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["TenantDto"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -9422,19 +9407,17 @@ export interface operations { }; }; }; - addProvider: { + /** Set's the global role on the Tolgee Platform server. */ + setRole: { parameters: { path: { - organizationId: number; + userId: number; + role: "USER" | "ADMIN"; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["Tenant"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -9468,11 +9451,6 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateProviderRequest"]; - }; - }; }; /** Resends email verification email to currently authenticated user. */ sendEmailVerification: { @@ -9864,6 +9842,53 @@ export interface operations { }; }; }; + getAuthenticationUrl: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["SsoUrlResponse"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DomainRequest"]; + }; + }; + }; report: { responses: { /** OK */ @@ -12561,53 +12586,6 @@ export interface operations { }; }; }; - getAuthenticationUrl: { - responses: { - /** OK */ - 200: { - content: { - "application/json": components["schemas"]["SsoUrlResponse"]; - }; - }; - /** Bad Request */ - 400: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Unauthorized */ - 401: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Forbidden */ - 403: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Not Found */ - 404: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - }; - requestBody: { - content: { - "application/json": components["schemas"]["DomainRequest"]; - }; - }; - }; upload: { responses: { /** Created */ @@ -13423,6 +13401,60 @@ export interface operations { }; }; }; + handleCallback: { + parameters: { + query: { + code: string; + redirect_uri: string; + error?: string; + error_description?: string; + invitationCode?: string; + }; + path: { + registrationId: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["JwtAuthenticationResponse"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; /** Get machine translation providers */ getInfo_4: { responses: { @@ -16115,60 +16147,6 @@ export interface operations { }; }; }; - handleCallback: { - parameters: { - query: { - code: string; - redirect_uri: string; - error?: string; - error_description?: string; - invitationCode?: string; - }; - path: { - registrationId: string; - }; - }; - responses: { - /** OK */ - 200: { - content: { - "application/json": components["schemas"]["JwtAuthenticationResponse"]; - }; - }; - /** Bad Request */ - 400: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Unauthorized */ - 401: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Forbidden */ - 403: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Not Found */ - 404: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - }; - }; acceptInvitation: { parameters: { path: { diff --git a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx index 32543dd7db..12fa963391 100644 --- a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx +++ b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx @@ -1,11 +1,11 @@ -import React, { FunctionComponent, useEffect, useRef, useState } from 'react'; -import { useTranslate } from '@tolgee/react'; -import { BaseOrganizationSettingsView } from '../components/BaseOrganizationSettingsView'; -import { LINKS, PARAMS } from 'tg.constants/links'; -import { useOrganization } from '../useOrganization'; -import { CreateProviderSsoForm } from 'tg.views/organizations/sso/CreateProviderSsoForm'; -import { useApiQuery } from 'tg.service/http/useQueryApi'; -import { FormControlLabel, Switch } from '@mui/material'; +import React, {FunctionComponent, useEffect, useRef, useState} from 'react'; +import {useTranslate} from '@tolgee/react'; +import {BaseOrganizationSettingsView} from '../components/BaseOrganizationSettingsView'; +import {LINKS, PARAMS} from 'tg.constants/links'; +import {useOrganization} from '../useOrganization'; +import {CreateProviderSsoForm} from 'tg.views/organizations/sso/CreateProviderSsoForm'; +import {useApiQuery} from 'tg.service/http/useQueryApi'; +import {FormControlLabel, Switch} from '@mui/material'; import Box from '@mui/material/Box'; export const OrganizationSsoView: FunctionComponent = () => { @@ -16,7 +16,7 @@ export const OrganizationSsoView: FunctionComponent = () => { } const providersLoadable = useApiQuery({ - url: `/v2/{organizationId}/sso/providers`, + url: `/v2/{organizationId}/sso/provider`, method: 'get', path: { organizationId: organization.id, From e943634eb05d8e9b35026d63d68e31f821f1f48e Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 11 Oct 2024 11:39:10 +0200 Subject: [PATCH 045/162] feat: add sso form validation --- .../v2/controllers/SsoProviderController.kt | 3 +- .../tolgee/ee/data/CreateProviderRequest.kt | 10 ++++ .../src/constants/GlobalValidationSchema.tsx | 57 +++++++++++++++++-- .../sso/CreateProviderSsoForm.tsx | 24 +++++--- 4 files changed, 78 insertions(+), 16 deletions(-) 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 index e2a4b6096c..37aa152df4 100644 --- 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 @@ -8,6 +8,7 @@ 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 jakarta.validation.Valid import org.springframework.web.bind.annotation.* @RestController @@ -21,7 +22,7 @@ class SsoProviderController( @PutMapping("") @RequiresSuperAuthentication fun setProvider( - @RequestBody request: CreateProviderRequest, + @RequestBody @Valid request: CreateProviderRequest, @PathVariable organizationId: Long, ): SsoTenantModel = ssoTenantAssembler.toModel(tenantService.saveOrUpdate(request, organizationId).toDto()) 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 index 3872669358..9e315207b8 100644 --- 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 @@ -1,12 +1,22 @@ package io.tolgee.ee.data +import jakarta.validation.constraints.NotEmpty +import org.springframework.validation.annotation.Validated + +@Validated data class CreateProviderRequest( val name: String?, + @field:NotEmpty val clientId: String, + @field:NotEmpty val clientSecret: String, + @field:NotEmpty val authorizationUri: String, + @field:NotEmpty val redirectUri: String, + @field:NotEmpty val tokenUri: String, + @field:NotEmpty val jwkSetUri: String, val isEnabled: Boolean, ) diff --git a/webapp/src/constants/GlobalValidationSchema.tsx b/webapp/src/constants/GlobalValidationSchema.tsx index 550d740a71..3dcda45e93 100644 --- a/webapp/src/constants/GlobalValidationSchema.tsx +++ b/webapp/src/constants/GlobalValidationSchema.tsx @@ -1,11 +1,11 @@ -import { DefaultParamType, T, TFnType, TranslationKey } from '@tolgee/react'; +import {DefaultParamType, T, TFnType, TranslationKey} from '@tolgee/react'; import * as Yup from 'yup'; -import { components } from 'tg.service/apiSchema.generated'; -import { organizationService } from '../service/OrganizationService'; -import { signUpService } from '../service/SignUpService'; -import { checkParamNameIsValid } from '@tginternal/editor'; -import { validateObject } from 'tg.fixtures/validateObject'; +import {components} from 'tg.service/apiSchema.generated'; +import {organizationService} from '../service/OrganizationService'; +import {signUpService} from '../service/SignUpService'; +import {checkParamNameIsValid} from '@tginternal/editor'; +import {validateObject} from 'tg.fixtures/validateObject'; type TFunType = TFnType; @@ -396,6 +396,51 @@ export class Validation { validateObject ), }); + + private static readonly validateUrlWithPort = (value: string | undefined): boolean => { + if (!value) return false; + const urlPattern = /^(http|https):\/\/[\w.-]+(:\d+)?(\/[^\s]*)?$/; + return urlPattern.test(value); + }; + + + static readonly SSO_PROVIDER = (t: TFnType) => Yup.object().shape({ + clientId: Yup.string().required().min(2).max(255), + clientSecret: Yup.string().required().max(255), + authorizationUri: Yup.string() + .required() + .max(255) + .test( + 'is-valid-url-with-port', + t('sso_invalid_url_format'), + Validation.validateUrlWithPort + ), + redirectUri: Yup.string() + .required() + .max(255) + .test( + 'is-valid-url-with-port', + t('sso_invalid_url_format'), + Validation.validateUrlWithPort + ), + tokenUri: Yup.string() + .required() + .max(255) + .test( + 'is-valid-url-with-port', + t('sso_invalid_url_format'), + Validation.validateUrlWithPort + ), + jwkSetUri: Yup.string() + .required() + .max(255) + .test( + 'is-valid-url-with-port', + t('sso_invalid_url_format'), + Validation.validateUrlWithPort + ), + }); + } let GLOBAL_VALIDATION_DEBOUNCE_TIMER: any = undefined; diff --git a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx index 74fd3d050b..0f6cc27fe7 100644 --- a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx +++ b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx @@ -1,11 +1,12 @@ import React from 'react'; -import { styled } from '@mui/material'; -import { T } from '@tolgee/react'; -import { StandardForm } from 'tg.component/common/form/StandardForm'; -import { TextField } from 'tg.component/common/form/fields/TextField'; -import { useApiMutation } from 'tg.service/http/useQueryApi'; -import { messageService } from 'tg.service/MessageService'; -import { useOrganization } from 'tg.views/organizations/useOrganization'; +import {styled} from '@mui/material'; +import {T, TFnType, useTranslate} from '@tolgee/react'; +import {StandardForm} from 'tg.component/common/form/StandardForm'; +import {TextField} from 'tg.component/common/form/fields/TextField'; +import {useApiMutation} from 'tg.service/http/useQueryApi'; +import {messageService} from 'tg.service/MessageService'; +import {useOrganization} from 'tg.views/organizations/useOrganization'; +import {Validation} from "tg.constants/GlobalValidationSchema"; const StyledInputFields = styled('div')` display: grid; @@ -16,20 +17,25 @@ const StyledInputFields = styled('div')` export function CreateProviderSsoForm({ credentialsRef, disabled }) { const organization = useOrganization(); + const { t } = useTranslate(); + + if (!organization) { return null; } const providersCreate = useApiMutation({ - url: `/v2/{organizationId}/sso/providers`, - method: 'post', + url: `/v2/{organizationId}/sso/provider`, + method: 'put', invalidatePrefix: '/v2/organizations', }); return ( { + console.log(data) providersCreate.mutate( { path: { organizationId: organization.id }, From a1c92d01f8821c15836ed922c1e429992f91cac1 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 11 Oct 2024 11:53:20 +0200 Subject: [PATCH 046/162] fix: refactor tenant Service --- .../io/tolgee/ee/service/TenantService.kt | 52 +++++++------------ 1 file changed, 20 insertions(+), 32 deletions(-) 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 index 311ef18aa3..a17ea62fec 100644 --- 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 @@ -21,24 +21,6 @@ class TenantService( fun findAll(): List = tenantRepository.findAll() - fun save( - dto: CreateProviderRequest, - organizationId: Long, - ): SsoTenant { - val tenant = SsoTenant() - tenant.name = dto.name ?: "" - tenant.organizationId = organizationId - tenant.domain = extractDomain(dto.authorizationUri) - tenant.clientId = dto.clientId - tenant.clientSecret = dto.clientSecret - tenant.authorizationUri = dto.authorizationUri - tenant.tokenUri = dto.tokenUri - tenant.redirectUriBase = dto.redirectUri.removeSuffix("/") - tenant.isEnabledForThisOrganization = dto.isEnabled - tenant.jwkSetUri = dto.jwkSetUri - return save(tenant) - } - private fun extractDomain(authorizationUri: String): String = try { val uri = URI(authorizationUri) @@ -69,19 +51,25 @@ class TenantService( request: CreateProviderRequest, organizationId: Long, ): SsoTenant { - val tenant = findTenant(organizationId) - return if (tenant == null) { - save(request, organizationId) - } else { - tenant.name = request.name ?: "" - tenant.clientId = request.clientId - tenant.clientSecret = request.clientSecret - tenant.authorizationUri = request.authorizationUri - tenant.tokenUri = request.tokenUri - tenant.redirectUriBase = request.redirectUri.removeSuffix("/") - tenant.jwkSetUri = request.jwkSetUri - tenant.isEnabledForThisOrganization = request.isEnabled - save(tenant) - } + val tenant = findTenant(organizationId) ?: SsoTenant() + return setAndSaveTenantsFields(tenant, request, organizationId) + } + + private fun setAndSaveTenantsFields( + tenant: SsoTenant, + dto: CreateProviderRequest, + organizationId: Long, + ): SsoTenant { + tenant.name = dto.name ?: "" + tenant.organizationId = organizationId + tenant.domain = extractDomain(dto.authorizationUri) + tenant.clientId = dto.clientId + tenant.clientSecret = dto.clientSecret + tenant.authorizationUri = dto.authorizationUri + tenant.tokenUri = dto.tokenUri + tenant.redirectUriBase = dto.redirectUri.removeSuffix("/") + tenant.jwkSetUri = dto.jwkSetUri + tenant.isEnabledForThisOrganization = dto.isEnabled + return save(tenant) } } From 22e6462b8be5aa9c219944aa5a2582129d5a748b Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 11 Oct 2024 12:36:16 +0200 Subject: [PATCH 047/162] fix: refactor oauth service and delegate --- .../security/thirdParty/OAuth2Delegate.kt | 46 +++++--------- .../security/thirdParty/OAuthUserHandler.kt | 61 +++++++++++++++++++ .../thirdParty/data/OAuthUserDetails.kt | 11 ++++ .../io/tolgee/ee/service/OAuthService.kt | 53 +++++----------- 4 files changed, 100 insertions(+), 71 deletions(-) create mode 100644 backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt create mode 100644 backend/api/src/main/kotlin/io/tolgee/security/thirdParty/data/OAuthUserDetails.kt 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..b84aa9ef0f 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 @@ -4,17 +4,13 @@ import io.tolgee.configuration.tolgee.OAuth2AuthenticationProperties import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.constants.Message import io.tolgee.exceptions.AuthenticationException -import io.tolgee.model.UserAccount import io.tolgee.security.authentication.JwtService import io.tolgee.security.payload.JwtAuthenticationResponse +import io.tolgee.security.thirdParty.data.OAuthUserDetails import io.tolgee.service.security.SignUpService import io.tolgee.service.security.UserAccountService import org.slf4j.LoggerFactory -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod -import org.springframework.http.HttpStatus -import org.springframework.http.MediaType +import org.springframework.http.* import org.springframework.stereotype.Component import org.springframework.util.LinkedMultiValueMap import org.springframework.util.MultiValueMap @@ -28,6 +24,7 @@ class OAuth2Delegate( 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 +87,18 @@ class OAuth2Delegate( 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, + domain = null, + organizationId = null, + ) + val user = oAuthUserHandler.findOrCreateUser(userData, invitationCode, "oauth2") - val userAccountOptional = userAccountService.findByThirdParty("oauth2", userResponse.sub!!) - 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) - - 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..4a1ccfdae9 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt @@ -0,0 +1,61 @@ +package io.tolgee.security.thirdParty + +import io.tolgee.constants.Message +import io.tolgee.exceptions.AuthenticationException +import io.tolgee.model.UserAccount +import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.security.thirdParty.data.OAuthUserDetails +import io.tolgee.service.organization.OrganizationRoleService +import io.tolgee.service.security.SignUpService +import io.tolgee.service.security.UserAccountService +import org.springframework.stereotype.Component + +@Component +class OAuthUserHandler( + private val signUpService: SignUpService, + private val organizationRoleService: OrganizationRoleService, + private val userAccountService: UserAccountService, +) { + fun findOrCreateUser( + userResponse: OAuthUserDetails, + invitationCode: String?, + thirdPartyAuthType: String, + ): UserAccount { + val userAccountOptional = userAccountService.findByThirdParty(thirdPartyAuthType, userResponse.sub!!) + + return userAccountOptional.orElseGet { + 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.accountType = UserAccount.AccountType.THIRD_PARTY + + signUpService.signUp(newUserAccount, invitationCode, null) + + // grant role to user only if request is not from oauth2 delegate + if (userResponse.organizationId != null && thirdPartyAuthType != "oauth2") { + organizationRoleService.grantRoleToUser( + newUserAccount, + userResponse.organizationId, + OrganizationRoleType.MEMBER, + ) + } + + newUserAccount + } + } +} 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..649c2d32f0 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/data/OAuthUserDetails.kt @@ -0,0 +1,11 @@ +package io.tolgee.security.thirdParty.data + +data class OAuthUserDetails( + var sub: String? = null, + var name: String? = null, + var givenName: String? = null, + var familyName: String? = null, + var email: String = "", + val domain: String? = null, + val organizationId: Long? = null, +) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index 6d84d4919d..de2bc1b27d 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -13,13 +13,10 @@ import io.tolgee.constants.Message import io.tolgee.ee.exceptions.OAuthAuthorizationException import io.tolgee.ee.model.SsoTenant import io.tolgee.exceptions.AuthenticationException -import io.tolgee.model.UserAccount -import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.security.authentication.JwtService import io.tolgee.security.payload.JwtAuthenticationResponse -import io.tolgee.service.organization.OrganizationRoleService -import io.tolgee.service.security.SignUpService -import io.tolgee.service.security.UserAccountService +import io.tolgee.security.thirdParty.OAuthUserHandler +import io.tolgee.security.thirdParty.data.OAuthUserDetails import io.tolgee.util.Logging import io.tolgee.util.logger import org.springframework.http.* @@ -34,12 +31,10 @@ import java.util.* @Service class OAuthService( private val jwtService: JwtService, - private val userAccountService: UserAccountService, - private val signUpService: SignUpService, private val restTemplate: RestTemplate, private val jwtProcessor: ConfigurableJWTProcessor, - private val organizationRoleService: OrganizationRoleService, private val tenantService: TenantService, + private val oAuthUserHandler: OAuthUserHandler, ) : Logging { fun handleOAuthCallback( registrationId: String, @@ -156,37 +151,17 @@ class OAuthService( logger.info("Third party user email is null. Missing scope email?") throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL) } - - val userAccountOptional = userAccountService.findByThirdParty(tenant.domain, userResponse.sub!!) - 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 = tenant.domain - newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY - signUpService.signUp(newUserAccount, invitationCode, null) - organizationRoleService.grantRoleToUser( - newUserAccount, - tenant.organizationId, - OrganizationRoleType.MEMBER, - ) - newUserAccount - } + val userData = + OAuthUserDetails( + sub = userResponse.sub!!, + name = userResponse.name, + givenName = userResponse.given_name, + familyName = userResponse.family_name, + email = email, + domain = tenant.domain, + organizationId = tenant.organizationId, + ) + val user = oAuthUserHandler.findOrCreateUser(userData, invitationCode, tenant.domain) val jwt = jwtService.emitToken(user.id) return JwtAuthenticationResponse(jwt) } From 906940113a093a40e192999c535650984fc1905a Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 11 Oct 2024 12:38:58 +0200 Subject: [PATCH 048/162] fix: move data class to data package --- .../io/tolgee/ee/data/GenericUserResponse.kt | 12 ++++++++++++ .../io/tolgee/ee/data/OAuth2TokenResponse.kt | 7 +++++++ .../io/tolgee/ee/service/OAuthService.kt | 19 ++----------------- .../tolgee/ee/utils/OAuthMultiTenantsMocks.kt | 6 +++--- 4 files changed, 24 insertions(+), 20 deletions(-) create mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/data/GenericUserResponse.kt create mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/data/OAuth2TokenResponse.kt 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..c342a9cddf --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/data/OAuth2TokenResponse.kt @@ -0,0 +1,7 @@ +package io.tolgee.ee.data + +@Suppress("PropertyName") +class OAuth2TokenResponse( + val id_token: String, + val scope: String, +) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index de2bc1b27d..5bf38bc422 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -10,6 +10,8 @@ import com.nimbusds.jwt.SignedJWT import com.nimbusds.jwt.proc.ConfigurableJWTProcessor import com.posthog.java.shaded.org.json.JSONObject import io.tolgee.constants.Message +import io.tolgee.ee.data.GenericUserResponse +import io.tolgee.ee.data.OAuth2TokenResponse import io.tolgee.ee.exceptions.OAuthAuthorizationException import io.tolgee.ee.model.SsoTenant import io.tolgee.exceptions.AuthenticationException @@ -165,21 +167,4 @@ class OAuthService( val jwt = jwtService.emitToken(user.id) return JwtAuthenticationResponse(jwt) } - - class OAuth2TokenResponse( - val id_token: String, - val scope: String, - ) - - class GenericUserResponse { - var sub: String? = null - var name: String? = null - - @Suppress("ktlint:standard:property-naming") - var given_name: String? = null - - @Suppress("ktlint:standard:property-naming") - var family_name: String? = null - var email: String? = null - } } 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 index cde0971179..7b9ca8a6b9 100644 --- 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 @@ -7,7 +7,7 @@ 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.service.OAuthService +import io.tolgee.ee.data.OAuth2TokenResponse import io.tolgee.ee.service.TenantService import org.mockito.kotlin.any import org.mockito.kotlin.eq @@ -30,7 +30,7 @@ class OAuthMultiTenantsMocks( ) { companion object { val defaultToken = - OAuthService.OAuth2TokenResponse(id_token = generateTestJwt(), scope = "scope") + OAuth2TokenResponse(id_token = generateTestJwt(), scope = "scope") val defaultTokenResponse = ResponseEntity( @@ -77,7 +77,7 @@ class OAuthMultiTenantsMocks( eq(tenant.tokenUri), eq(HttpMethod.POST), any(), - eq(OAuthService.OAuth2TokenResponse::class.java), + eq(OAuth2TokenResponse::class.java), ), ).thenReturn(defaultTokenResponse) mockJwk() From e39a9e7ef39cf3d9acfedd617ba991e0d892b712 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 11 Oct 2024 12:40:59 +0200 Subject: [PATCH 049/162] fix: delete timestamp file --- ....timestamp-1728215969537-7889e0fea9aee.mjs | 120 ------------------ 1 file changed, 120 deletions(-) delete mode 100644 webapp/vite.config.ts.timestamp-1728215969537-7889e0fea9aee.mjs diff --git a/webapp/vite.config.ts.timestamp-1728215969537-7889e0fea9aee.mjs b/webapp/vite.config.ts.timestamp-1728215969537-7889e0fea9aee.mjs deleted file mode 100644 index c651c743a4..0000000000 --- a/webapp/vite.config.ts.timestamp-1728215969537-7889e0fea9aee.mjs +++ /dev/null @@ -1,120 +0,0 @@ -// vite.config.ts -import { defineConfig, loadEnv } from "file:///Users/huglx/tolgee/tolgee-platform/webapp/node_modules/vite/dist/node/index.js"; -import react from "file:///Users/huglx/tolgee/tolgee-platform/webapp/node_modules/@vitejs/plugin-react/dist/index.mjs"; -import viteTsconfigPaths from "file:///Users/huglx/tolgee/tolgee-platform/webapp/node_modules/vite-tsconfig-paths/dist/index.mjs"; -import svgr from "file:///Users/huglx/tolgee/tolgee-platform/webapp/node_modules/vite-plugin-svgr/dist/index.js"; -import { nodePolyfills } from "file:///Users/huglx/tolgee/tolgee-platform/webapp/node_modules/vite-plugin-node-polyfills/dist/index.js"; -import mdx from "file:///Users/huglx/tolgee/tolgee-platform/webapp/node_modules/@mdx-js/rollup/index.js"; -import { viteStaticCopy } from "file:///Users/huglx/tolgee/tolgee-platform/webapp/node_modules/vite-plugin-static-copy/dist/index.js"; -import { resolve as resolve2 } from "path"; - -// dataCy.plugin.ts -import { readdir, readFile, writeFile } from "fs/promises"; -import { resolve } from "path"; -import { existsSync } from "fs"; -var SRC_PATH = resolve("./src"); -function extractDataCy() { - const fileItems = {}; - async function generate(files) { - await processFiles(files); - const sortedItems = getSortedItems(); - const fileContent = await generateFileContent(sortedItems); - await writeToFile(fileContent); - } - async function processFiles(files) { - for (const file of files) { - await processFile(file); - } - } - async function writeToFile(fileContent) { - await writeFile( - resolve(`../e2e/cypress/support/dataCyType.d.ts`), - fileContent - ); - } - async function generateFileContent(sortedItems) { - let fileContent = "declare namespace DataCy {\n"; - fileContent += " export type Value = \n " + sortedItems.map((i) => `"${i}"`).join(" |\n ") + "\n}"; - return fileContent; - } - function getSortedItems() { - const items = Object.values(fileItems).reduce( - (acc, curr) => [...acc, ...curr], - [] - ); - const itemsSet = new Set(items); - return [...itemsSet].sort(); - } - async function processFile(file) { - if (/.*\.tsx?$/.test(file)) { - if (!existsSync(file)) { - fileItems[file] = []; - return; - } - const content = (await readFile(file)).toString(); - const matches = content.matchAll( - /["']?data-?[c|C]y["']?\s*[=:]\s*{?["'`]([A-Za-z0-9-_\s]+)["'`]?}?/g - ); - fileItems[file] = []; - for (const match of matches) { - fileItems[file].push(match[1]); - } - } - } - async function getFiles(dir) { - const dirents = await readdir(dir, { withFileTypes: true }); - const files = await Promise.all( - dirents.map((dirent) => { - const res = resolve(dir, dirent.name); - return dirent.isDirectory() ? getFiles(res) : res; - }) - ); - return Array.prototype.concat(...files); - } - return { - name: "extract-data-cy", - async buildStart() { - const files = await getFiles(SRC_PATH); - await generate(files); - }, - async watchChange(id) { - generate([id]); - } - }; -} - -// vite.config.ts -import rehypeHighlight from "file:///Users/huglx/tolgee/tolgee-platform/webapp/node_modules/rehype-highlight/index.js"; -var vite_config_default = defineConfig(({ mode }) => { - process.env = { ...process.env, ...loadEnv(mode, process.cwd()) }; - return { - // depending on your application, base can also be "/" - base: "/", - plugins: [ - react(), - viteTsconfigPaths(), - svgr(), - mdx({ rehypePlugins: [rehypeHighlight] }), - nodePolyfills(), - extractDataCy(), - viteStaticCopy({ - targets: [ - { - src: resolve2("node_modules/@tginternal/language-util/flags"), - dest: "" - } - ] - }) - ], - server: { - // this ensures that the browser opens upon server start - open: true, - // this sets a default port to 3000 - port: Number(process.env.VITE_PORT) || 3e3 - } - }; -}); -export { - vite_config_default as default -}; -//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiLCAiZGF0YUN5LnBsdWdpbi50cyJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiY29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2Rpcm5hbWUgPSBcIi9Vc2Vycy9odWdseC90b2xnZWUvdG9sZ2VlLXBsYXRmb3JtL3dlYmFwcFwiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9maWxlbmFtZSA9IFwiL1VzZXJzL2h1Z2x4L3RvbGdlZS90b2xnZWUtcGxhdGZvcm0vd2ViYXBwL3ZpdGUuY29uZmlnLnRzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9Vc2Vycy9odWdseC90b2xnZWUvdG9sZ2VlLXBsYXRmb3JtL3dlYmFwcC92aXRlLmNvbmZpZy50c1wiO2ltcG9ydCB7IGRlZmluZUNvbmZpZywgbG9hZEVudiB9IGZyb20gJ3ZpdGUnO1xuaW1wb3J0IHJlYWN0IGZyb20gJ0B2aXRlanMvcGx1Z2luLXJlYWN0JztcbmltcG9ydCB2aXRlVHNjb25maWdQYXRocyBmcm9tICd2aXRlLXRzY29uZmlnLXBhdGhzJztcbmltcG9ydCBzdmdyIGZyb20gJ3ZpdGUtcGx1Z2luLXN2Z3InO1xuaW1wb3J0IHsgbm9kZVBvbHlmaWxscyB9IGZyb20gJ3ZpdGUtcGx1Z2luLW5vZGUtcG9seWZpbGxzJztcbmltcG9ydCBtZHggZnJvbSAnQG1keC1qcy9yb2xsdXAnO1xuaW1wb3J0IHsgdml0ZVN0YXRpY0NvcHkgfSBmcm9tICd2aXRlLXBsdWdpbi1zdGF0aWMtY29weSc7XG5pbXBvcnQgeyByZXNvbHZlIH0gZnJvbSAncGF0aCc7XG5cbmltcG9ydCB7IGV4dHJhY3REYXRhQ3kgfSBmcm9tICcuL2RhdGFDeS5wbHVnaW4nO1xuaW1wb3J0IHJlaHlwZUhpZ2hsaWdodCBmcm9tICdyZWh5cGUtaGlnaGxpZ2h0JztcblxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKCh7IG1vZGUgfSkgPT4ge1xuICBwcm9jZXNzLmVudiA9IHsgLi4ucHJvY2Vzcy5lbnYsIC4uLmxvYWRFbnYobW9kZSwgcHJvY2Vzcy5jd2QoKSkgfTtcblxuICByZXR1cm4ge1xuICAgIC8vIGRlcGVuZGluZyBvbiB5b3VyIGFwcGxpY2F0aW9uLCBiYXNlIGNhbiBhbHNvIGJlIFwiL1wiXG4gICAgYmFzZTogJy8nLFxuICAgIHBsdWdpbnM6IFtcbiAgICAgIHJlYWN0KCksXG4gICAgICB2aXRlVHNjb25maWdQYXRocygpLFxuICAgICAgc3ZncigpLFxuICAgICAgbWR4KHsgcmVoeXBlUGx1Z2luczogW3JlaHlwZUhpZ2hsaWdodF0gfSksXG4gICAgICBub2RlUG9seWZpbGxzKCksXG4gICAgICBleHRyYWN0RGF0YUN5KCksXG4gICAgICB2aXRlU3RhdGljQ29weSh7XG4gICAgICAgIHRhcmdldHM6IFtcbiAgICAgICAgICB7XG4gICAgICAgICAgICBzcmM6IHJlc29sdmUoJ25vZGVfbW9kdWxlcy9AdGdpbnRlcm5hbC9sYW5ndWFnZS11dGlsL2ZsYWdzJyksXG4gICAgICAgICAgICBkZXN0OiAnJyxcbiAgICAgICAgICB9LFxuICAgICAgICBdLFxuICAgICAgfSksXG4gICAgXSxcbiAgICBzZXJ2ZXI6IHtcbiAgICAgIC8vIHRoaXMgZW5zdXJlcyB0aGF0IHRoZSBicm93c2VyIG9wZW5zIHVwb24gc2VydmVyIHN0YXJ0XG4gICAgICBvcGVuOiB0cnVlLFxuICAgICAgLy8gdGhpcyBzZXRzIGEgZGVmYXVsdCBwb3J0IHRvIDMwMDBcbiAgICAgIHBvcnQ6IE51bWJlcihwcm9jZXNzLmVudi5WSVRFX1BPUlQpIHx8IDMwMDAsXG4gICAgfSxcbiAgfTtcbn0pO1xuIiwgImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvVXNlcnMvaHVnbHgvdG9sZ2VlL3RvbGdlZS1wbGF0Zm9ybS93ZWJhcHBcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9Vc2Vycy9odWdseC90b2xnZWUvdG9sZ2VlLXBsYXRmb3JtL3dlYmFwcC9kYXRhQ3kucGx1Z2luLnRzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9Vc2Vycy9odWdseC90b2xnZWUvdG9sZ2VlLXBsYXRmb3JtL3dlYmFwcC9kYXRhQ3kucGx1Z2luLnRzXCI7aW1wb3J0IHsgUGx1Z2luIH0gZnJvbSAndml0ZSc7XG5pbXBvcnQgeyByZWFkZGlyLCByZWFkRmlsZSwgd3JpdGVGaWxlIH0gZnJvbSAnZnMvcHJvbWlzZXMnO1xuaW1wb3J0IHsgcmVzb2x2ZSB9IGZyb20gJ3BhdGgnO1xuaW1wb3J0IHsgZXhpc3RzU3luYyB9IGZyb20gJ2ZzJztcblxuY29uc3QgU1JDX1BBVEggPSByZXNvbHZlKCcuL3NyYycpO1xuXG5leHBvcnQgZnVuY3Rpb24gZXh0cmFjdERhdGFDeSgpOiBQbHVnaW4ge1xuICBjb25zdCBmaWxlSXRlbXM6IFJlY29yZDxzdHJpbmcsIHN0cmluZ1tdPiA9IHt9O1xuXG4gIGFzeW5jIGZ1bmN0aW9uIGdlbmVyYXRlKGZpbGVzOiBzdHJpbmdbXSkge1xuICAgIGF3YWl0IHByb2Nlc3NGaWxlcyhmaWxlcyk7XG4gICAgY29uc3Qgc29ydGVkSXRlbXMgPSBnZXRTb3J0ZWRJdGVtcygpO1xuICAgIGNvbnN0IGZpbGVDb250ZW50ID0gYXdhaXQgZ2VuZXJhdGVGaWxlQ29udGVudChzb3J0ZWRJdGVtcyk7XG4gICAgYXdhaXQgd3JpdGVUb0ZpbGUoZmlsZUNvbnRlbnQpO1xuICB9XG5cbiAgYXN5bmMgZnVuY3Rpb24gcHJvY2Vzc0ZpbGVzKGZpbGVzOiBzdHJpbmdbXSkge1xuICAgIGZvciAoY29uc3QgZmlsZSBvZiBmaWxlcykge1xuICAgICAgYXdhaXQgcHJvY2Vzc0ZpbGUoZmlsZSk7XG4gICAgfVxuICB9XG5cbiAgYXN5bmMgZnVuY3Rpb24gd3JpdGVUb0ZpbGUoZmlsZUNvbnRlbnQ6IHN0cmluZykge1xuICAgIGF3YWl0IHdyaXRlRmlsZShcbiAgICAgIHJlc29sdmUoYC4uL2UyZS9jeXByZXNzL3N1cHBvcnQvZGF0YUN5VHlwZS5kLnRzYCksXG4gICAgICBmaWxlQ29udGVudFxuICAgICk7XG4gIH1cblxuICBhc3luYyBmdW5jdGlvbiBnZW5lcmF0ZUZpbGVDb250ZW50KHNvcnRlZEl0ZW1zKSB7XG4gICAgbGV0IGZpbGVDb250ZW50ID0gJ2RlY2xhcmUgbmFtZXNwYWNlIERhdGFDeSB7XFxuJztcbiAgICBmaWxlQ29udGVudCArPVxuICAgICAgJyAgICBleHBvcnQgdHlwZSBWYWx1ZSA9IFxcbiAgICAgICAgJyArXG4gICAgICBzb3J0ZWRJdGVtcy5tYXAoKGkpID0+IGBcIiR7aX1cImApLmpvaW4oJyB8XFxuICAgICAgICAnKSArXG4gICAgICAnXFxufSc7XG4gICAgcmV0dXJuIGZpbGVDb250ZW50O1xuICB9XG5cbiAgZnVuY3Rpb24gZ2V0U29ydGVkSXRlbXMoKSB7XG4gICAgY29uc3QgaXRlbXMgPSBPYmplY3QudmFsdWVzKGZpbGVJdGVtcykucmVkdWNlKFxuICAgICAgKGFjYywgY3VycikgPT4gWy4uLmFjYywgLi4uY3Vycl0sXG4gICAgICBbXVxuICAgICk7XG4gICAgY29uc3QgaXRlbXNTZXQgPSBuZXcgU2V0KGl0ZW1zKTtcbiAgICByZXR1cm4gWy4uLml0ZW1zU2V0XS5zb3J0KCk7XG4gIH1cblxuICBhc3luYyBmdW5jdGlvbiBwcm9jZXNzRmlsZShmaWxlOiBzdHJpbmcpIHtcbiAgICBpZiAoLy4qXFwudHN4PyQvLnRlc3QoZmlsZSkpIHtcbiAgICAgIGlmICghZXhpc3RzU3luYyhmaWxlKSkge1xuICAgICAgICBmaWxlSXRlbXNbZmlsZV0gPSBbXTtcbiAgICAgICAgcmV0dXJuO1xuICAgICAgfVxuICAgICAgY29uc3QgY29udGVudCA9IChhd2FpdCByZWFkRmlsZShmaWxlKSkudG9TdHJpbmcoKTtcbiAgICAgIGNvbnN0IG1hdGNoZXMgPSBjb250ZW50Lm1hdGNoQWxsKFxuICAgICAgICAvW1wiJ10/ZGF0YS0/W2N8Q115W1wiJ10/XFxzKls9Ol1cXHMqez9bXCInYF0oW0EtWmEtejAtOS1fXFxzXSspW1wiJ2BdP30/L2dcbiAgICAgICk7XG4gICAgICBmaWxlSXRlbXNbZmlsZV0gPSBbXTtcbiAgICAgIGZvciAoY29uc3QgbWF0Y2ggb2YgbWF0Y2hlcykge1xuICAgICAgICBmaWxlSXRlbXNbZmlsZV0ucHVzaChtYXRjaFsxXSk7XG4gICAgICB9XG4gICAgfVxuICB9XG5cbiAgYXN5bmMgZnVuY3Rpb24gZ2V0RmlsZXMoZGlyOiBzdHJpbmcpIHtcbiAgICBjb25zdCBkaXJlbnRzID0gYXdhaXQgcmVhZGRpcihkaXIsIHsgd2l0aEZpbGVUeXBlczogdHJ1ZSB9KTtcbiAgICBjb25zdCBmaWxlcyA9IGF3YWl0IFByb21pc2UuYWxsKFxuICAgICAgZGlyZW50cy5tYXAoKGRpcmVudCkgPT4ge1xuICAgICAgICBjb25zdCByZXMgPSByZXNvbHZlKGRpciwgZGlyZW50Lm5hbWUpO1xuICAgICAgICByZXR1cm4gZGlyZW50LmlzRGlyZWN0b3J5KCkgPyBnZXRGaWxlcyhyZXMpIDogcmVzO1xuICAgICAgfSlcbiAgICApO1xuICAgIHJldHVybiBBcnJheS5wcm90b3R5cGUuY29uY2F0KC4uLmZpbGVzKTtcbiAgfVxuXG4gIHJldHVybiB7XG4gICAgbmFtZTogJ2V4dHJhY3QtZGF0YS1jeScsXG4gICAgYXN5bmMgYnVpbGRTdGFydCgpIHtcbiAgICAgIGNvbnN0IGZpbGVzID0gYXdhaXQgZ2V0RmlsZXMoU1JDX1BBVEgpO1xuICAgICAgYXdhaXQgZ2VuZXJhdGUoZmlsZXMpO1xuICAgIH0sXG4gICAgYXN5bmMgd2F0Y2hDaGFuZ2UoaWQpIHtcbiAgICAgIGdlbmVyYXRlKFtpZF0pO1xuICAgIH0sXG4gIH07XG59XG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQWdULFNBQVMsY0FBYyxlQUFlO0FBQ3RWLE9BQU8sV0FBVztBQUNsQixPQUFPLHVCQUF1QjtBQUM5QixPQUFPLFVBQVU7QUFDakIsU0FBUyxxQkFBcUI7QUFDOUIsT0FBTyxTQUFTO0FBQ2hCLFNBQVMsc0JBQXNCO0FBQy9CLFNBQVMsV0FBQUEsZ0JBQWU7OztBQ054QixTQUFTLFNBQVMsVUFBVSxpQkFBaUI7QUFDN0MsU0FBUyxlQUFlO0FBQ3hCLFNBQVMsa0JBQWtCO0FBRTNCLElBQU0sV0FBVyxRQUFRLE9BQU87QUFFekIsU0FBUyxnQkFBd0I7QUFDdEMsUUFBTSxZQUFzQyxDQUFDO0FBRTdDLGlCQUFlLFNBQVMsT0FBaUI7QUFDdkMsVUFBTSxhQUFhLEtBQUs7QUFDeEIsVUFBTSxjQUFjLGVBQWU7QUFDbkMsVUFBTSxjQUFjLE1BQU0sb0JBQW9CLFdBQVc7QUFDekQsVUFBTSxZQUFZLFdBQVc7QUFBQSxFQUMvQjtBQUVBLGlCQUFlLGFBQWEsT0FBaUI7QUFDM0MsZUFBVyxRQUFRLE9BQU87QUFDeEIsWUFBTSxZQUFZLElBQUk7QUFBQSxJQUN4QjtBQUFBLEVBQ0Y7QUFFQSxpQkFBZSxZQUFZLGFBQXFCO0FBQzlDLFVBQU07QUFBQSxNQUNKLFFBQVEsd0NBQXdDO0FBQUEsTUFDaEQ7QUFBQSxJQUNGO0FBQUEsRUFDRjtBQUVBLGlCQUFlLG9CQUFvQixhQUFhO0FBQzlDLFFBQUksY0FBYztBQUNsQixtQkFDRSx1Q0FDQSxZQUFZLElBQUksQ0FBQyxNQUFNLElBQUksQ0FBQyxHQUFHLEVBQUUsS0FBSyxjQUFjLElBQ3BEO0FBQ0YsV0FBTztBQUFBLEVBQ1Q7QUFFQSxXQUFTLGlCQUFpQjtBQUN4QixVQUFNLFFBQVEsT0FBTyxPQUFPLFNBQVMsRUFBRTtBQUFBLE1BQ3JDLENBQUMsS0FBSyxTQUFTLENBQUMsR0FBRyxLQUFLLEdBQUcsSUFBSTtBQUFBLE1BQy9CLENBQUM7QUFBQSxJQUNIO0FBQ0EsVUFBTSxXQUFXLElBQUksSUFBSSxLQUFLO0FBQzlCLFdBQU8sQ0FBQyxHQUFHLFFBQVEsRUFBRSxLQUFLO0FBQUEsRUFDNUI7QUFFQSxpQkFBZSxZQUFZLE1BQWM7QUFDdkMsUUFBSSxZQUFZLEtBQUssSUFBSSxHQUFHO0FBQzFCLFVBQUksQ0FBQyxXQUFXLElBQUksR0FBRztBQUNyQixrQkFBVSxJQUFJLElBQUksQ0FBQztBQUNuQjtBQUFBLE1BQ0Y7QUFDQSxZQUFNLFdBQVcsTUFBTSxTQUFTLElBQUksR0FBRyxTQUFTO0FBQ2hELFlBQU0sVUFBVSxRQUFRO0FBQUEsUUFDdEI7QUFBQSxNQUNGO0FBQ0EsZ0JBQVUsSUFBSSxJQUFJLENBQUM7QUFDbkIsaUJBQVcsU0FBUyxTQUFTO0FBQzNCLGtCQUFVLElBQUksRUFBRSxLQUFLLE1BQU0sQ0FBQyxDQUFDO0FBQUEsTUFDL0I7QUFBQSxJQUNGO0FBQUEsRUFDRjtBQUVBLGlCQUFlLFNBQVMsS0FBYTtBQUNuQyxVQUFNLFVBQVUsTUFBTSxRQUFRLEtBQUssRUFBRSxlQUFlLEtBQUssQ0FBQztBQUMxRCxVQUFNLFFBQVEsTUFBTSxRQUFRO0FBQUEsTUFDMUIsUUFBUSxJQUFJLENBQUMsV0FBVztBQUN0QixjQUFNLE1BQU0sUUFBUSxLQUFLLE9BQU8sSUFBSTtBQUNwQyxlQUFPLE9BQU8sWUFBWSxJQUFJLFNBQVMsR0FBRyxJQUFJO0FBQUEsTUFDaEQsQ0FBQztBQUFBLElBQ0g7QUFDQSxXQUFPLE1BQU0sVUFBVSxPQUFPLEdBQUcsS0FBSztBQUFBLEVBQ3hDO0FBRUEsU0FBTztBQUFBLElBQ0wsTUFBTTtBQUFBLElBQ04sTUFBTSxhQUFhO0FBQ2pCLFlBQU0sUUFBUSxNQUFNLFNBQVMsUUFBUTtBQUNyQyxZQUFNLFNBQVMsS0FBSztBQUFBLElBQ3RCO0FBQUEsSUFDQSxNQUFNLFlBQVksSUFBSTtBQUNwQixlQUFTLENBQUMsRUFBRSxDQUFDO0FBQUEsSUFDZjtBQUFBLEVBQ0Y7QUFDRjs7O0FENUVBLE9BQU8scUJBQXFCO0FBRTVCLElBQU8sc0JBQVEsYUFBYSxDQUFDLEVBQUUsS0FBSyxNQUFNO0FBQ3hDLFVBQVEsTUFBTSxFQUFFLEdBQUcsUUFBUSxLQUFLLEdBQUcsUUFBUSxNQUFNLFFBQVEsSUFBSSxDQUFDLEVBQUU7QUFFaEUsU0FBTztBQUFBO0FBQUEsSUFFTCxNQUFNO0FBQUEsSUFDTixTQUFTO0FBQUEsTUFDUCxNQUFNO0FBQUEsTUFDTixrQkFBa0I7QUFBQSxNQUNsQixLQUFLO0FBQUEsTUFDTCxJQUFJLEVBQUUsZUFBZSxDQUFDLGVBQWUsRUFBRSxDQUFDO0FBQUEsTUFDeEMsY0FBYztBQUFBLE1BQ2QsY0FBYztBQUFBLE1BQ2QsZUFBZTtBQUFBLFFBQ2IsU0FBUztBQUFBLFVBQ1A7QUFBQSxZQUNFLEtBQUtDLFNBQVEsOENBQThDO0FBQUEsWUFDM0QsTUFBTTtBQUFBLFVBQ1I7QUFBQSxRQUNGO0FBQUEsTUFDRixDQUFDO0FBQUEsSUFDSDtBQUFBLElBQ0EsUUFBUTtBQUFBO0FBQUEsTUFFTixNQUFNO0FBQUE7QUFBQSxNQUVOLE1BQU0sT0FBTyxRQUFRLElBQUksU0FBUyxLQUFLO0FBQUEsSUFDekM7QUFBQSxFQUNGO0FBQ0YsQ0FBQzsiLAogICJuYW1lcyI6IFsicmVzb2x2ZSIsICJyZXNvbHZlIl0KfQo= From b5a7b0d4a855a309e4b7c938c84799962370211d Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 11 Oct 2024 13:03:20 +0200 Subject: [PATCH 050/162] fix: edit new url on FE --- webapp/src/globalContext/useAuthService.tsx | 36 +++++++++------------ 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/webapp/src/globalContext/useAuthService.tsx b/webapp/src/globalContext/useAuthService.tsx index 915b2ca04f..0ccafd30ce 100644 --- a/webapp/src/globalContext/useAuthService.tsx +++ b/webapp/src/globalContext/useAuthService.tsx @@ -1,23 +1,17 @@ -import { useState } from 'react'; -import { T } from '@tolgee/react'; -import { useHistory } from 'react-router-dom'; +import {useState} from 'react'; +import {T} from '@tolgee/react'; +import {useHistory} from 'react-router-dom'; -import { securityService } from 'tg.service/SecurityService'; -import { - ADMIN_JWT_LOCAL_STORAGE_KEY, - tokenService, -} from 'tg.service/TokenService'; -import { components } from 'tg.service/apiSchema.generated'; -import { useApiMutation } from 'tg.service/http/useQueryApi'; -import { useInitialDataService } from './useInitialDataService'; -import { LINKS, PARAMS } from 'tg.constants/links'; -import { - INVITATION_CODE_STORAGE_KEY, - InvitationCodeService, -} from 'tg.service/InvitationCodeService'; -import { messageService } from 'tg.service/MessageService'; -import { TranslatedError } from 'tg.translationTools/TranslatedError'; -import { useLocalStorageState } from 'tg.hooks/useLocalStorageState'; +import {securityService} from 'tg.service/SecurityService'; +import {ADMIN_JWT_LOCAL_STORAGE_KEY, tokenService,} from 'tg.service/TokenService'; +import {components} from 'tg.service/apiSchema.generated'; +import {useApiMutation} from 'tg.service/http/useQueryApi'; +import {useInitialDataService} from './useInitialDataService'; +import {LINKS, PARAMS} from 'tg.constants/links'; +import {INVITATION_CODE_STORAGE_KEY, InvitationCodeService,} from 'tg.service/InvitationCodeService'; +import {messageService} from 'tg.service/MessageService'; +import {TranslatedError} from 'tg.translationTools/TranslatedError'; +import {useLocalStorageState} from 'tg.hooks/useLocalStorageState'; type LoginRequest = components['schemas']['LoginRequest']; type JwtAuthenticationResponse = @@ -55,12 +49,12 @@ export const useAuthService = ( }); const authorizeOpenIdLoadable = useApiMutation({ - url: '/v2/oauth2/callback/{registrationId}', + url: '/v2/public/oauth2/callback/{registrationId}', method: 'get', }); const openIdAuthUrlLoadable = useApiMutation({ - url: '/v2/oauth2/callback/get-authentication-url', + url: '/v2/public/oauth2/callback/get-authentication-url', method: 'post', }); From 036be10f03e16f901c190e7937781ad2473cd984 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 11 Oct 2024 13:07:19 +0200 Subject: [PATCH 051/162] fix: npm run prettier --- .../src/constants/GlobalValidationSchema.tsx | 59 ++++++++++--------- webapp/src/globalContext/useAuthService.tsx | 32 ++++++---- .../sso/CreateProviderSsoForm.tsx | 19 +++--- .../organizations/sso/OrganizationSsoView.tsx | 16 ++--- 4 files changed, 66 insertions(+), 60 deletions(-) diff --git a/webapp/src/constants/GlobalValidationSchema.tsx b/webapp/src/constants/GlobalValidationSchema.tsx index 3dcda45e93..f9b2f58389 100644 --- a/webapp/src/constants/GlobalValidationSchema.tsx +++ b/webapp/src/constants/GlobalValidationSchema.tsx @@ -1,11 +1,11 @@ -import {DefaultParamType, T, TFnType, TranslationKey} from '@tolgee/react'; +import { DefaultParamType, T, TFnType, TranslationKey } from '@tolgee/react'; import * as Yup from 'yup'; -import {components} from 'tg.service/apiSchema.generated'; -import {organizationService} from '../service/OrganizationService'; -import {signUpService} from '../service/SignUpService'; -import {checkParamNameIsValid} from '@tginternal/editor'; -import {validateObject} from 'tg.fixtures/validateObject'; +import { components } from 'tg.service/apiSchema.generated'; +import { organizationService } from '../service/OrganizationService'; +import { signUpService } from '../service/SignUpService'; +import { checkParamNameIsValid } from '@tginternal/editor'; +import { validateObject } from 'tg.fixtures/validateObject'; type TFunType = TFnType; @@ -397,50 +397,51 @@ export class Validation { ), }); - private static readonly validateUrlWithPort = (value: string | undefined): boolean => { + private static readonly validateUrlWithPort = ( + value: string | undefined + ): boolean => { if (!value) return false; const urlPattern = /^(http|https):\/\/[\w.-]+(:\d+)?(\/[^\s]*)?$/; return urlPattern.test(value); }; - - static readonly SSO_PROVIDER = (t: TFnType) => Yup.object().shape({ - clientId: Yup.string().required().min(2).max(255), - clientSecret: Yup.string().required().max(255), - authorizationUri: Yup.string() + static readonly SSO_PROVIDER = (t: TFnType) => + Yup.object().shape({ + clientId: Yup.string().required().min(2).max(255), + clientSecret: Yup.string().required().max(255), + authorizationUri: Yup.string() .required() .max(255) .test( - 'is-valid-url-with-port', - t('sso_invalid_url_format'), - Validation.validateUrlWithPort + 'is-valid-url-with-port', + t('sso_invalid_url_format'), + Validation.validateUrlWithPort ), - redirectUri: Yup.string() + redirectUri: Yup.string() .required() .max(255) .test( - 'is-valid-url-with-port', - t('sso_invalid_url_format'), - Validation.validateUrlWithPort + 'is-valid-url-with-port', + t('sso_invalid_url_format'), + Validation.validateUrlWithPort ), - tokenUri: Yup.string() + tokenUri: Yup.string() .required() .max(255) .test( - 'is-valid-url-with-port', - t('sso_invalid_url_format'), - Validation.validateUrlWithPort + 'is-valid-url-with-port', + t('sso_invalid_url_format'), + Validation.validateUrlWithPort ), - jwkSetUri: Yup.string() + jwkSetUri: Yup.string() .required() .max(255) .test( - 'is-valid-url-with-port', - t('sso_invalid_url_format'), - Validation.validateUrlWithPort + 'is-valid-url-with-port', + t('sso_invalid_url_format'), + Validation.validateUrlWithPort ), - }); - + }); } let GLOBAL_VALIDATION_DEBOUNCE_TIMER: any = undefined; diff --git a/webapp/src/globalContext/useAuthService.tsx b/webapp/src/globalContext/useAuthService.tsx index 0ccafd30ce..a940074e69 100644 --- a/webapp/src/globalContext/useAuthService.tsx +++ b/webapp/src/globalContext/useAuthService.tsx @@ -1,17 +1,23 @@ -import {useState} from 'react'; -import {T} from '@tolgee/react'; -import {useHistory} from 'react-router-dom'; +import { useState } from 'react'; +import { T } from '@tolgee/react'; +import { useHistory } from 'react-router-dom'; -import {securityService} from 'tg.service/SecurityService'; -import {ADMIN_JWT_LOCAL_STORAGE_KEY, tokenService,} from 'tg.service/TokenService'; -import {components} from 'tg.service/apiSchema.generated'; -import {useApiMutation} from 'tg.service/http/useQueryApi'; -import {useInitialDataService} from './useInitialDataService'; -import {LINKS, PARAMS} from 'tg.constants/links'; -import {INVITATION_CODE_STORAGE_KEY, InvitationCodeService,} from 'tg.service/InvitationCodeService'; -import {messageService} from 'tg.service/MessageService'; -import {TranslatedError} from 'tg.translationTools/TranslatedError'; -import {useLocalStorageState} from 'tg.hooks/useLocalStorageState'; +import { securityService } from 'tg.service/SecurityService'; +import { + ADMIN_JWT_LOCAL_STORAGE_KEY, + tokenService, +} from 'tg.service/TokenService'; +import { components } from 'tg.service/apiSchema.generated'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { useInitialDataService } from './useInitialDataService'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { + INVITATION_CODE_STORAGE_KEY, + InvitationCodeService, +} from 'tg.service/InvitationCodeService'; +import { messageService } from 'tg.service/MessageService'; +import { TranslatedError } from 'tg.translationTools/TranslatedError'; +import { useLocalStorageState } from 'tg.hooks/useLocalStorageState'; type LoginRequest = components['schemas']['LoginRequest']; type JwtAuthenticationResponse = diff --git a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx index 0f6cc27fe7..df59cc051e 100644 --- a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx +++ b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import {styled} from '@mui/material'; -import {T, TFnType, useTranslate} from '@tolgee/react'; -import {StandardForm} from 'tg.component/common/form/StandardForm'; -import {TextField} from 'tg.component/common/form/fields/TextField'; -import {useApiMutation} from 'tg.service/http/useQueryApi'; -import {messageService} from 'tg.service/MessageService'; -import {useOrganization} from 'tg.views/organizations/useOrganization'; -import {Validation} from "tg.constants/GlobalValidationSchema"; +import { styled } from '@mui/material'; +import { T, TFnType, useTranslate } from '@tolgee/react'; +import { StandardForm } from 'tg.component/common/form/StandardForm'; +import { TextField } from 'tg.component/common/form/fields/TextField'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { messageService } from 'tg.service/MessageService'; +import { useOrganization } from 'tg.views/organizations/useOrganization'; +import { Validation } from 'tg.constants/GlobalValidationSchema'; const StyledInputFields = styled('div')` display: grid; @@ -19,7 +19,6 @@ export function CreateProviderSsoForm({ credentialsRef, disabled }) { const organization = useOrganization(); const { t } = useTranslate(); - if (!organization) { return null; } @@ -35,7 +34,7 @@ export function CreateProviderSsoForm({ credentialsRef, disabled }) { initialValues={credentialsRef.current!} validationSchema={Validation.SSO_PROVIDER(t as TFnType)} onSubmit={async (data) => { - console.log(data) + console.log(data); providersCreate.mutate( { path: { organizationId: organization.id }, diff --git a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx index 12fa963391..e621edb73c 100644 --- a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx +++ b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx @@ -1,11 +1,11 @@ -import React, {FunctionComponent, useEffect, useRef, useState} from 'react'; -import {useTranslate} from '@tolgee/react'; -import {BaseOrganizationSettingsView} from '../components/BaseOrganizationSettingsView'; -import {LINKS, PARAMS} from 'tg.constants/links'; -import {useOrganization} from '../useOrganization'; -import {CreateProviderSsoForm} from 'tg.views/organizations/sso/CreateProviderSsoForm'; -import {useApiQuery} from 'tg.service/http/useQueryApi'; -import {FormControlLabel, Switch} from '@mui/material'; +import React, { FunctionComponent, useEffect, useRef, useState } from 'react'; +import { useTranslate } from '@tolgee/react'; +import { BaseOrganizationSettingsView } from '../components/BaseOrganizationSettingsView'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { useOrganization } from '../useOrganization'; +import { CreateProviderSsoForm } from 'tg.views/organizations/sso/CreateProviderSsoForm'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { FormControlLabel, Switch } from '@mui/material'; import Box from '@mui/material/Box'; export const OrganizationSsoView: FunctionComponent = () => { From 333739a801988798a214a2f86d0e9b39d3b5896a Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 11 Oct 2024 13:16:26 +0200 Subject: [PATCH 052/162] fix: move dto to data package --- .../ee/api/v2/controllers/OAuth2CallbackController.kt | 11 ++--------- .../main/kotlin/io/tolgee/ee/data/DomainRequest.kt | 6 ++++++ .../main/kotlin/io/tolgee/ee/data/SsoUrlResponse.kt | 5 +++++ 3 files changed, 13 insertions(+), 9 deletions(-) create mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/data/DomainRequest.kt create mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/data/SsoUrlResponse.kt diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index 5a55093961..63022fea0c 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -1,6 +1,8 @@ package io.tolgee.ee.api.v2.controllers import io.tolgee.constants.Message +import io.tolgee.ee.data.DomainRequest +import io.tolgee.ee.data.SsoUrlResponse import io.tolgee.ee.exceptions.OAuthAuthorizationException import io.tolgee.ee.model.SsoTenant import io.tolgee.ee.service.OAuthService @@ -58,13 +60,4 @@ class OAuth2CallbackController( errorDescription = error_description, invitationCode = invitationCode, ) - - data class DomainRequest( - val domain: String, - val state: String, - ) - - data class SsoUrlResponse( - val redirectUrl: 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/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, +) From 682a9d68a2c809c6d318b12a4ba1cdf001dac905 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 11 Oct 2024 13:20:48 +0200 Subject: [PATCH 053/162] fix: rename FE link --- webapp/src/constants/links.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index 5296dfbe81..6560964f79 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -78,7 +78,7 @@ export class LINKS { ); static OPENID_RESPONSE = Link.ofRoot( - 'openId/auth_callback/' + p(PARAMS.SERVICE_TYPE) + 'openId/auth-callback/' + p(PARAMS.SERVICE_TYPE) ); static SSO_LOGIN = Link.ofRoot('sso'); From 8b39bcc597a4ba811b8d23fe8a841df2abf6b8ea Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 11 Oct 2024 14:00:48 +0200 Subject: [PATCH 054/162] fix: refactor auth service FE --- .../controllers/OAuth2CallbackController.kt | 2 +- .../component/security/Sso/LoginSsoForm.tsx | 24 +++--- .../security/Sso/SsoRedirectionHandler.tsx | 18 ++-- webapp/src/component/security/SsoService.tsx | 68 +++++++++++++++ webapp/src/globalContext/useAuthService.tsx | 82 ++++--------------- 5 files changed, 101 insertions(+), 93 deletions(-) create mode 100644 webapp/src/component/security/SsoService.tsx diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index 63022fea0c..d1a864fecb 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -37,7 +37,7 @@ class OAuth2CallbackController( ): String = "${tenant.authorizationUri}?" + "client_id=${tenant.clientId}&" + - "redirect_uri=${tenant.redirectUriBase + "/openId/auth_callback/" + tenant.domain}&" + + "redirect_uri=${tenant.redirectUriBase + "/openId/auth-callback/" + tenant.domain}&" + "response_type=code&" + "scope=openid profile email roles&" + "state=$state" diff --git a/webapp/src/component/security/Sso/LoginSsoForm.tsx b/webapp/src/component/security/Sso/LoginSsoForm.tsx index 5e44300f41..afb5cdd3e9 100644 --- a/webapp/src/component/security/Sso/LoginSsoForm.tsx +++ b/webapp/src/component/security/Sso/LoginSsoForm.tsx @@ -1,18 +1,16 @@ -import React, { RefObject } from 'react'; -import { Link as MuiLink, styled, Typography } from '@mui/material'; +import React, {RefObject} from 'react'; +import {Link as MuiLink, styled, Typography} from '@mui/material'; import Box from '@mui/material/Box'; -import { T } from '@tolgee/react'; -import { Link } from 'react-router-dom'; +import {T} from '@tolgee/react'; +import {Link} from 'react-router-dom'; -import { LINKS } from 'tg.constants/links'; +import {LINKS} from 'tg.constants/links'; import LoadingButton from 'tg.component/common/form/LoadingButton'; -import { StandardForm } from 'tg.component/common/form/StandardForm'; -import { TextField } from 'tg.component/common/form/fields/TextField'; -import { - useGlobalActions, - useGlobalContext, -} from 'tg.globalContext/GlobalContext'; -import { v4 as uuidv4 } from 'uuid'; +import {StandardForm} from 'tg.component/common/form/StandardForm'; +import {TextField} from 'tg.component/common/form/fields/TextField'; +import {useGlobalContext,} from 'tg.globalContext/GlobalContext'; +import {v4 as uuidv4} from 'uuid'; +import {useSsoService} from 'tg.component/security/SsoService'; const StyledInputFields = styled('div')` display: grid; @@ -28,7 +26,7 @@ type LoginViewCredentialsProps = { const LOCAL_STORAGE_STATE_KEY = 'oauth2State'; export function LoginSsoForm(props: LoginViewCredentialsProps) { - const { getSsoAuthLinkByDomain } = useGlobalActions(); + const { getSsoAuthLinkByDomain } = useSsoService(); const isLoading = useGlobalContext((c) => c.auth.loginLoadable.isLoading); return ( diff --git a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx index b95e7b7eee..38bd8ff76b 100644 --- a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx +++ b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx @@ -1,13 +1,11 @@ -import { FunctionComponent, useEffect } from 'react'; -import { Redirect, useHistory, useRouteMatch } from 'react-router-dom'; +import {FunctionComponent, useEffect} from 'react'; +import {Redirect, useHistory, useRouteMatch} from 'react-router-dom'; -import { LINKS, PARAMS } from 'tg.constants/links'; +import {LINKS, PARAMS} from 'tg.constants/links'; -import { - useGlobalActions, - useGlobalContext, -} from 'tg.globalContext/GlobalContext'; -import { FullPageLoading } from 'tg.component/common/FullPageLoading'; +import {useGlobalContext,} from 'tg.globalContext/GlobalContext'; +import {FullPageLoading} from 'tg.component/common/FullPageLoading'; +import {useSsoService} from 'tg.component/security/SsoService'; interface SsoRedirectionHandlerProps {} const LOCAL_STORAGE_STATE_KEY = 'oauth2State'; @@ -18,19 +16,17 @@ export const SsoRedirectionHandler: FunctionComponent< const allowPrivate = useGlobalContext((c) => c.auth.allowPrivate); const loginLoadable = useGlobalContext((c) => c.auth.authorizeOAuthLoadable); - const { loginWithOAuthCodeOpenId } = useGlobalActions(); + const { loginWithOAuthCodeOpenId } = useSsoService(); const match = useRouteMatch(); const history = useHistory(); useEffect(() => { const url = new URLSearchParams(window.location.search); const code = url.get('code'); - const state = url.get('state'); const storedState = localStorage.getItem(LOCAL_STORAGE_STATE_KEY); if (storedState !== state) { history.replace(LINKS.LOGIN.build()); - return; } else { localStorage.removeItem(LOCAL_STORAGE_STATE_KEY); } diff --git a/webapp/src/component/security/SsoService.tsx b/webapp/src/component/security/SsoService.tsx new file mode 100644 index 0000000000..ca486cf40c --- /dev/null +++ b/webapp/src/component/security/SsoService.tsx @@ -0,0 +1,68 @@ +import {LINKS, PARAMS} from 'tg.constants/links'; +import {messageService} from 'tg.service/MessageService'; +import {TranslatedError} from 'tg.translationTools/TranslatedError'; +import {useGlobalActions} from 'tg.globalContext/GlobalContext'; +import {useApiMutation} from 'tg.service/http/useQueryApi'; +import {useLocalStorageState} from 'tg.hooks/useLocalStorageState'; +import {INVITATION_CODE_STORAGE_KEY} from 'tg.service/InvitationCodeService'; + +export const useSsoService = () => { + const { handleAfterLogin, setInvitationCode } = useGlobalActions(); + + const [invitationCode, _setInvitationCode] = useLocalStorageState< + string | undefined + >({ + initial: undefined, + key: INVITATION_CODE_STORAGE_KEY, + }); + + const authorizeOpenIdLoadable = useApiMutation({ + url: '/v2/public/oauth2/callback/{registrationId}', + method: 'get', + }); + + const openIdAuthUrlLoadable = useApiMutation({ + url: '/v2/public/oauth2/callback/get-authentication-url', + method: 'post', + }); + + return { + async loginWithOAuthCodeOpenId(registrationId: string, code: string) { + const redirectUri = LINKS.OPENID_RESPONSE.buildWithOrigin({ + [PARAMS.SERVICE_TYPE]: registrationId, + }); + const response = await authorizeOpenIdLoadable.mutateAsync( + { + path: { registrationId: registrationId }, + query: { + code, + redirect_uri: redirectUri, + invitationCode: invitationCode, + }, + }, + { + onError: (error) => { + if (error.code === 'invitation_code_does_not_exist_or_expired') { + setInvitationCode(undefined); + } + messageService.error(); + }, + } + ); + await handleAfterLogin(response!); + }, + + async getSsoAuthLinkByDomain(domain: string, state: string) { + return await openIdAuthUrlLoadable.mutateAsync( + { + content: { 'application/json': { domain, state } }, + }, + { + onError: (error) => { + messageService.error(); + }, + } + ); + }, + }; +}; diff --git a/webapp/src/globalContext/useAuthService.tsx b/webapp/src/globalContext/useAuthService.tsx index a940074e69..7ae6c85500 100644 --- a/webapp/src/globalContext/useAuthService.tsx +++ b/webapp/src/globalContext/useAuthService.tsx @@ -1,23 +1,17 @@ -import { useState } from 'react'; -import { T } from '@tolgee/react'; -import { useHistory } from 'react-router-dom'; - -import { securityService } from 'tg.service/SecurityService'; -import { - ADMIN_JWT_LOCAL_STORAGE_KEY, - tokenService, -} from 'tg.service/TokenService'; -import { components } from 'tg.service/apiSchema.generated'; -import { useApiMutation } from 'tg.service/http/useQueryApi'; -import { useInitialDataService } from './useInitialDataService'; -import { LINKS, PARAMS } from 'tg.constants/links'; -import { - INVITATION_CODE_STORAGE_KEY, - InvitationCodeService, -} from 'tg.service/InvitationCodeService'; -import { messageService } from 'tg.service/MessageService'; -import { TranslatedError } from 'tg.translationTools/TranslatedError'; -import { useLocalStorageState } from 'tg.hooks/useLocalStorageState'; +import {useState} from 'react'; +import {T} from '@tolgee/react'; +import {useHistory} from 'react-router-dom'; + +import {securityService} from 'tg.service/SecurityService'; +import {ADMIN_JWT_LOCAL_STORAGE_KEY, tokenService,} from 'tg.service/TokenService'; +import {components} from 'tg.service/apiSchema.generated'; +import {useApiMutation} from 'tg.service/http/useQueryApi'; +import {useInitialDataService} from './useInitialDataService'; +import {LINKS, PARAMS} from 'tg.constants/links'; +import {INVITATION_CODE_STORAGE_KEY, InvitationCodeService,} from 'tg.service/InvitationCodeService'; +import {messageService} from 'tg.service/MessageService'; +import {TranslatedError} from 'tg.translationTools/TranslatedError'; +import {useLocalStorageState} from 'tg.hooks/useLocalStorageState'; type LoginRequest = components['schemas']['LoginRequest']; type JwtAuthenticationResponse = @@ -54,16 +48,6 @@ export const useAuthService = ( method: 'get', }); - const authorizeOpenIdLoadable = useApiMutation({ - url: '/v2/public/oauth2/callback/{registrationId}', - method: 'get', - }); - - const openIdAuthUrlLoadable = useApiMutation({ - url: '/v2/public/oauth2/callback/get-authentication-url', - method: 'post', - }); - const acceptInvitationLoadable = useApiMutation({ url: '/v2/invitations/{code}/accept', method: 'get', @@ -197,44 +181,6 @@ export const useAuthService = ( await handleAfterLogin(response!); }, - async loginWithOAuthCodeOpenId(registrationId: string, code: string) { - const redirectUri = LINKS.OPENID_RESPONSE.buildWithOrigin({ - [PARAMS.SERVICE_TYPE]: registrationId, - }); - const response = await authorizeOpenIdLoadable.mutateAsync( - { - path: { registrationId: registrationId }, - query: { - code, - redirect_uri: redirectUri, - invitationCode: invitationCode, - }, - }, - { - onError: (error) => { - if (error.code === 'invitation_code_does_not_exist_or_expired') { - setInvitationCode(undefined); - } - messageService.error(); - }, - } - ); - await handleAfterLogin(response!); - }, - - async getSsoAuthLinkByDomain(domain: string, state: string) { - return await openIdAuthUrlLoadable.mutateAsync( - { - content: { 'application/json': { domain, state } }, - }, - { - onError: (error) => { - messageService.error(); - }, - } - ); - }, - async signUp(data: Omit) { signupLoadable.mutate( { From ccebc565b4ef7cd09cb676cde1a1ebac24915d70 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 11 Oct 2024 16:23:11 +0200 Subject: [PATCH 055/162] fix: refactor provider form and view --- .../v2/controllers/SsoProviderController.kt | 9 +--- .../security/Sso/SsoRedirectionHandler.tsx | 6 +-- .../sso/CreateProviderSsoForm.tsx | 20 ++++---- .../organizations/sso/OrganizationSsoView.tsx | 49 ++++++------------- 4 files changed, 30 insertions(+), 54 deletions(-) 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 index 37aa152df4..eca776f0e0 100644 --- 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 @@ -29,12 +29,7 @@ class SsoProviderController( @RequiresOrganizationRole(role = OrganizationRoleType.OWNER) @GetMapping("") @RequiresSuperAuthentication - fun findProvider( + fun getProvider( @PathVariable organizationId: Long, - ): SsoTenantModel? = - try { - ssoTenantAssembler.toModel(tenantService.getTenant(organizationId).toDto()) - } catch (e: Exception) { - null - } + ): SsoTenantModel = ssoTenantAssembler.toModel(tenantService.getTenant(organizationId).toDto()) } diff --git a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx index 38bd8ff76b..88121d2b5e 100644 --- a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx +++ b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx @@ -21,9 +21,9 @@ export const SsoRedirectionHandler: FunctionComponent< const history = useHistory(); useEffect(() => { - const url = new URLSearchParams(window.location.search); - const code = url.get('code'); - const state = url.get('state'); + const searchParam = new URLSearchParams(window.location.search); + const code = searchParam.get('code'); + const state = searchParam.get('state'); const storedState = localStorage.getItem(LOCAL_STORAGE_STATE_KEY); if (storedState !== state) { history.replace(LINKS.LOGIN.build()); diff --git a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx index df59cc051e..08fdbfb93c 100644 --- a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx +++ b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import { styled } from '@mui/material'; -import { T, TFnType, useTranslate } from '@tolgee/react'; -import { StandardForm } from 'tg.component/common/form/StandardForm'; -import { TextField } from 'tg.component/common/form/fields/TextField'; -import { useApiMutation } from 'tg.service/http/useQueryApi'; -import { messageService } from 'tg.service/MessageService'; -import { useOrganization } from 'tg.views/organizations/useOrganization'; -import { Validation } from 'tg.constants/GlobalValidationSchema'; +import {styled} from '@mui/material'; +import {T, TFnType, useTranslate} from '@tolgee/react'; +import {StandardForm} from 'tg.component/common/form/StandardForm'; +import {TextField} from 'tg.component/common/form/fields/TextField'; +import {useApiMutation} from 'tg.service/http/useQueryApi'; +import {messageService} from 'tg.service/MessageService'; +import {useOrganization} from 'tg.views/organizations/useOrganization'; +import {Validation} from 'tg.constants/GlobalValidationSchema'; const StyledInputFields = styled('div')` display: grid; @@ -15,7 +15,7 @@ const StyledInputFields = styled('div')` padding-bottom: 32px; `; -export function CreateProviderSsoForm({ credentialsRef, disabled }) { +export function CreateProviderSsoForm({ initialValues, disabled }) { const organization = useOrganization(); const { t } = useTranslate(); @@ -31,7 +31,7 @@ export function CreateProviderSsoForm({ credentialsRef, disabled }) { return ( { console.log(data); diff --git a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx index e621edb73c..60dacad054 100644 --- a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx +++ b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx @@ -1,11 +1,11 @@ -import React, { FunctionComponent, useEffect, useRef, useState } from 'react'; -import { useTranslate } from '@tolgee/react'; -import { BaseOrganizationSettingsView } from '../components/BaseOrganizationSettingsView'; -import { LINKS, PARAMS } from 'tg.constants/links'; -import { useOrganization } from '../useOrganization'; -import { CreateProviderSsoForm } from 'tg.views/organizations/sso/CreateProviderSsoForm'; -import { useApiQuery } from 'tg.service/http/useQueryApi'; -import { FormControlLabel, Switch } from '@mui/material'; +import React, {FunctionComponent, useEffect, useState} from 'react'; +import {useTranslate} from '@tolgee/react'; +import {BaseOrganizationSettingsView} from '../components/BaseOrganizationSettingsView'; +import {LINKS, PARAMS} from 'tg.constants/links'; +import {useOrganization} from '../useOrganization'; +import {CreateProviderSsoForm} from 'tg.views/organizations/sso/CreateProviderSsoForm'; +import {useApiQuery} from 'tg.service/http/useQueryApi'; +import {FormControlLabel, Switch} from '@mui/material'; import Box from '@mui/material/Box'; export const OrganizationSsoView: FunctionComponent = () => { @@ -22,33 +22,14 @@ export const OrganizationSsoView: FunctionComponent = () => { organizationId: organization.id, }, }); - - const credentialsRef = useRef({ - authorizationUri: '', - clientId: '', - clientSecret: '', - redirectUri: '', - tokenUri: '', - jwkSetUri: '', - }); - const [showForm, setShowForm] = useState(false); + const [toggleFormState, setToggleFormState] = useState(false); useEffect(() => { - if (providersLoadable.data) { - credentialsRef.current = { - authorizationUri: providersLoadable.data.authorizationUri || '', - clientId: providersLoadable.data.clientId || '', - clientSecret: providersLoadable.data.clientSecret || '', - redirectUri: providersLoadable.data.redirectUri || '', - tokenUri: providersLoadable.data.tokenUri || '', - jwkSetUri: providersLoadable.data.jwkSetUri || '', - }; - - setShowForm(providersLoadable.data.isEnabled); - } + setToggleFormState(providersLoadable.data?.isEnabled || false); }, [providersLoadable.data]); + const handleSwitchChange = (event) => { - setShowForm(event.target.checked); + setToggleFormState(event.target.checked); }; return ( @@ -68,13 +49,13 @@ export const OrganizationSsoView: FunctionComponent = () => { maxWidth="normal" > } + control={} label={t('organization_sso_switch')} /> From 156afd3fa99f32788b134b22d27e69e64430b6e1 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 11 Oct 2024 20:28:34 +0200 Subject: [PATCH 056/162] fix: add sso_domain in user account --- .../security/thirdParty/OAuthUserHandler.kt | 1 + .../kotlin/io/tolgee/model/UserAccount.kt | 20 +++++++------------ .../main/resources/db/changelog/schema.xml | 5 +++++ .../v2/controllers/SsoProviderController.kt | 11 ++++++++-- .../io/tolgee/ee/service/OAuthService.kt | 2 +- 5 files changed, 23 insertions(+), 16 deletions(-) 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 index 4a1ccfdae9..6b23cf346c 100644 --- a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt @@ -41,6 +41,7 @@ class OAuthUserHandler( } newUserAccount.name = name newUserAccount.thirdPartyAuthId = userResponse.sub + newUserAccount.ssoDomain = userResponse.domain newUserAccount.thirdPartyAuthType = thirdPartyAuthType newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY 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 a58bf9c674..fa57fbf0e3 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt @@ -4,18 +4,7 @@ import io.hypersistence.utils.hibernate.type.array.ListArrayType import io.tolgee.api.IUserAccount import io.tolgee.model.slackIntegration.SlackConfig import io.tolgee.model.slackIntegration.SlackUserConnection -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.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 @@ -35,7 +24,9 @@ data class UserAccount( @Enumerated(EnumType.STRING) @Column(name = "account_type") override var accountType: AccountType? = AccountType.LOCAL, -) : AuditModel(), ModelWithAvatar, IUserAccount { +) : AuditModel(), + ModelWithAvatar, + IUserAccount { @Column(name = "totp_key", columnDefinition = "bytea") override var totpKey: ByteArray? = null @@ -55,6 +46,9 @@ data class UserAccount( @Column(name = "third_party_auth_type") var thirdPartyAuthType: String? = null + @Column(name = "sso_domain") + var ssoDomain: String? = null + @Column(name = "third_party_auth_id") var thirdPartyAuthId: String? = null diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index 754bbe3507..cb3cd12a73 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -3440,4 +3440,9 @@ + + + + + \ No newline at end of file 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 index eca776f0e0..ef24714b01 100644 --- 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 @@ -4,6 +4,7 @@ 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.TenantService +import io.tolgee.exceptions.NotFoundException import io.tolgee.hateoas.ee.SsoTenantModel import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.security.authentication.RequiresSuperAuthentication @@ -29,7 +30,13 @@ class SsoProviderController( @RequiresOrganizationRole(role = OrganizationRoleType.OWNER) @GetMapping("") @RequiresSuperAuthentication - fun getProvider( + fun findProvider( @PathVariable organizationId: Long, - ): SsoTenantModel = ssoTenantAssembler.toModel(tenantService.getTenant(organizationId).toDto()) + ): SsoTenantModel? { + return try { + ssoTenantAssembler.toModel(tenantService.getTenant(organizationId).toDto()) + } catch (e: NotFoundException) { + null + } + } } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index 5bf38bc422..169ce20f33 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -163,7 +163,7 @@ class OAuthService( domain = tenant.domain, organizationId = tenant.organizationId, ) - val user = oAuthUserHandler.findOrCreateUser(userData, invitationCode, tenant.domain) + val user = oAuthUserHandler.findOrCreateUser(userData, invitationCode, "sso") val jwt = jwtService.emitToken(user.id) return JwtAuthenticationResponse(jwt) } From 726b5ea4a570aac8189340bf2ae453dd1fb17d67 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 11 Oct 2024 20:28:51 +0200 Subject: [PATCH 057/162] fix: refactor sso provider form --- .../sso/CreateProviderSsoForm.tsx | 23 ++++++++++++++++--- .../organizations/sso/OrganizationSsoView.tsx | 2 +- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx index 08fdbfb93c..3c42dceddb 100644 --- a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx +++ b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx @@ -1,6 +1,6 @@ import React from 'react'; import {styled} from '@mui/material'; -import {T, TFnType, useTranslate} from '@tolgee/react'; +import {T, useTranslate} from '@tolgee/react'; import {StandardForm} from 'tg.component/common/form/StandardForm'; import {TextField} from 'tg.component/common/form/fields/TextField'; import {useApiMutation} from 'tg.service/http/useQueryApi'; @@ -15,9 +15,26 @@ const StyledInputFields = styled('div')` padding-bottom: 32px; `; -export function CreateProviderSsoForm({ initialValues, disabled }) { +type FormValues = { + authorizationUri: string; + clientId: string; + clientSecret: string; + redirectUri: string; + tokenUri: string; + jwkSetUri: string; +}; + +export function CreateProviderSsoForm({ data, disabled }) { const organization = useOrganization(); const { t } = useTranslate(); + const initialValues: FormValues = { + authorizationUri: data?.authorizationUri ?? '', + clientId: data?.clientId ?? '', + clientSecret: data?.clientSecret ?? '', + redirectUri: data?.redirectUri ?? '', + tokenUri: data?.tokenUri ?? '', + jwkSetUri: data?.jwkSetUri ?? '', + } if (!organization) { return null; @@ -32,7 +49,7 @@ export function CreateProviderSsoForm({ initialValues, disabled }) { return ( { console.log(data); providersCreate.mutate( diff --git a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx index 60dacad054..e5dea755fd 100644 --- a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx +++ b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx @@ -54,7 +54,7 @@ export const OrganizationSsoView: FunctionComponent = () => { /> From d36d18c2557132368fed7bcc2748bfbbb8a7c3b2 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 11 Oct 2024 20:43:28 +0200 Subject: [PATCH 058/162] fix: now user must type his domain --- .../io/tolgee/hateoas/ee/SsoTenantModel.kt | 1 + .../hateoas/assemblers/SsoTenantAssembler.kt | 1 + .../tolgee/ee/data/CreateProviderRequest.kt | 2 + .../kotlin/io/tolgee/ee/data/SsoTenantDto.kt | 2 + .../io/tolgee/ee/service/TenantService.kt | 2 +- .../src/constants/GlobalValidationSchema.tsx | 15 +-- webapp/src/service/apiSchema.generated.ts | 94 ++++++++++--------- .../sso/CreateProviderSsoForm.tsx | 13 ++- 8 files changed, 75 insertions(+), 55 deletions(-) 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 index 9e35cd7d16..a625ef3a66 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/SsoTenantModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/SsoTenantModel.kt @@ -14,5 +14,6 @@ class SsoTenantModel( val tokenUri: String, val isEnabled: Boolean, val jwkSetUri: String, + val domainName: String, ) : RepresentationModel(), Serializable 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 index c9170b2fc1..3428e144e4 100644 --- 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 @@ -21,5 +21,6 @@ class SsoTenantAssembler : tokenUri = entity.tokenUri, isEnabled = entity.isEnabled, jwkSetUri = entity.jwkSetUri, + domainName = entity.domainName, ) } 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 index 9e315207b8..9e4624763d 100644 --- 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 @@ -19,4 +19,6 @@ data class CreateProviderRequest( @field:NotEmpty val jwkSetUri: String, val isEnabled: Boolean, + @field:NotEmpty + val domainName: 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 index 9063810f45..c81fa43846 100644 --- 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 @@ -10,6 +10,7 @@ data class SsoTenantDto( val tokenUri: String, val isEnabled: Boolean, val jwkSetUri: String, + val domainName: String, ) fun SsoTenant.toDto(): SsoTenantDto = @@ -21,4 +22,5 @@ fun SsoTenant.toDto(): SsoTenantDto = tokenUri = this.tokenUri, isEnabled = this.isEnabledForThisOrganization, jwkSetUri = this.jwkSetUri, + domainName = this.domain, ) 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 index a17ea62fec..186e898ca6 100644 --- 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 @@ -62,7 +62,7 @@ class TenantService( ): SsoTenant { tenant.name = dto.name ?: "" tenant.organizationId = organizationId - tenant.domain = extractDomain(dto.authorizationUri) + tenant.domain = dto.domainName tenant.clientId = dto.clientId tenant.clientSecret = dto.clientSecret tenant.authorizationUri = dto.authorizationUri diff --git a/webapp/src/constants/GlobalValidationSchema.tsx b/webapp/src/constants/GlobalValidationSchema.tsx index f9b2f58389..4ecb87c7c9 100644 --- a/webapp/src/constants/GlobalValidationSchema.tsx +++ b/webapp/src/constants/GlobalValidationSchema.tsx @@ -1,11 +1,11 @@ -import { DefaultParamType, T, TFnType, TranslationKey } from '@tolgee/react'; +import {DefaultParamType, T, TFnType, TranslationKey} from '@tolgee/react'; import * as Yup from 'yup'; -import { components } from 'tg.service/apiSchema.generated'; -import { organizationService } from '../service/OrganizationService'; -import { signUpService } from '../service/SignUpService'; -import { checkParamNameIsValid } from '@tginternal/editor'; -import { validateObject } from 'tg.fixtures/validateObject'; +import {components} from 'tg.service/apiSchema.generated'; +import {organizationService} from '../service/OrganizationService'; +import {signUpService} from '../service/SignUpService'; +import {checkParamNameIsValid} from '@tginternal/editor'; +import {validateObject} from 'tg.fixtures/validateObject'; type TFunType = TFnType; @@ -407,7 +407,8 @@ export class Validation { static readonly SSO_PROVIDER = (t: TFnType) => Yup.object().shape({ - clientId: Yup.string().required().min(2).max(255), + clientId: Yup.string().required().max(255), + domainName: Yup.string().required().max(255), clientSecret: Yup.string().required().max(255), authorizationUri: Yup.string() .required() diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index afcde798cb..0d2f9e76cf 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -1074,6 +1074,7 @@ export interface components { tokenUri: string; jwkSetUri: string; isEnabled: boolean; + domainName: string; }; SsoTenantModel: { authorizationUri: string; @@ -1083,6 +1084,7 @@ export interface components { tokenUri: string; isEnabled: boolean; jwkSetUri: string; + domainName: string; }; UserUpdateRequestDto: { name: string; @@ -1150,6 +1152,11 @@ export interface components { | "SERVER_ADMIN"; /** @description The user's permission type. This field is null if uses granular permissions */ type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; + /** + * @description List of languages user can view. If null, all languages view is permitted. + * @example 200001,200004 + */ + viewLanguageIds?: number[]; /** * @description Granted scopes to the user. When user has type permissions, this field contains permission scopes of the type. * @example KEYS_EDIT,TRANSLATIONS_VIEW @@ -1183,10 +1190,13 @@ export interface components { | "webhooks.manage" )[]; /** - * @description List of languages user can view. If null, all languages view is permitted. + * @deprecated + * @description Deprecated (use translateLanguageIds). + * + * List of languages current user has TRANSLATE permission to. If null, all languages edition is permitted. * @example 200001,200004 */ - viewLanguageIds?: number[]; + permittedLanguageIds?: number[]; /** * @description List of languages user can translate to. If null, all languages editing is permitted. * @example 200001,200004 @@ -1197,14 +1207,6 @@ export interface components { * @example 200001,200004 */ stateChangeLanguageIds?: number[]; - /** - * @deprecated - * @description Deprecated (use translateLanguageIds). - * - * List of languages current user has TRANSLATE permission to. If null, all languages edition is permitted. - * @example 200001,200004 - */ - permittedLanguageIds?: number[]; }; LanguageModel: { /** Format: int64 */ @@ -1733,8 +1735,8 @@ export interface components { secretKey?: string; endpoint: string; signingRegion: string; - enabled?: boolean; contentStorageType?: "S3" | "AZURE"; + enabled?: boolean; }; AzureContentStorageConfigModel: { containerName?: string; @@ -2008,10 +2010,10 @@ export interface components { ImportSettingsModel: { /** @description If false, only updates keys, skipping the creation of new keys */ createNewKeys: boolean; - /** @description If true, placeholders from other formats will be converted to ICU when possible */ - convertPlaceholdersToIcu: boolean; /** @description If true, key descriptions will be overridden by the import */ overrideKeyDescriptions: boolean; + /** @description If true, placeholders from other formats will be converted to ICU when possible */ + convertPlaceholdersToIcu: boolean; }; /** @description User who created the comment */ SimpleUserAccountModel: { @@ -2177,17 +2179,17 @@ export interface components { }; RevealedPatModel: { token: string; - /** Format: int64 */ - id: number; description: string; /** Format: int64 */ - lastUsedAt?: number; - /** Format: int64 */ - expiresAt?: number; + id: number; /** Format: int64 */ createdAt: number; /** Format: int64 */ updatedAt: number; + /** Format: int64 */ + lastUsedAt?: number; + /** Format: int64 */ + expiresAt?: number; }; SetOrganizationRoleDto: { roleType: "MEMBER" | "OWNER"; @@ -2323,19 +2325,19 @@ export interface components { RevealedApiKeyModel: { /** @description Resulting user's api key */ key: string; + description: string; /** Format: int64 */ id: number; - description: string; + userFullName?: string; + projectName: string; + username?: string; + /** Format: int64 */ + projectId: number; scopes: string[]; /** Format: int64 */ lastUsedAt?: number; /** Format: int64 */ expiresAt?: number; - /** Format: int64 */ - projectId: number; - username?: string; - projectName: string; - userFullName?: string; }; SuperTokenRequest: { /** @description Has to be provided when TOTP enabled */ @@ -3417,22 +3419,22 @@ export interface components { | "SLACK_INTEGRATION" )[]; quickStart?: components["schemas"]["QuickStartModel"]; + /** @example This is a beautiful organization full of beautiful and clever people */ + description?: string; /** @example Beautiful organization */ name: string; /** Format: int64 */ id: number; - /** @example This is a beautiful organization full of beautiful and clever people */ - description?: string; - avatar?: components["schemas"]["Avatar"]; - /** @example btforg */ - slug: string; + basePermissions: components["schemas"]["PermissionModel"]; /** * @description The role of currently authorized user. * * Can be null when user has direct access to one of the projects owned by the organization. */ currentUserRole?: "MEMBER" | "OWNER"; - basePermissions: components["schemas"]["PermissionModel"]; + /** @example btforg */ + slug: string; + avatar?: components["schemas"]["Avatar"]; }; PublicBillingConfigurationDTO: { enabled: boolean; @@ -3497,9 +3499,9 @@ export interface components { defaultFileStructureTemplate: string; }; DocItem: { + description?: string; displayName?: string; name: string; - description?: string; }; PagedModelProjectModel: { _embedded?: { @@ -3570,23 +3572,23 @@ export interface components { formalitySupported: boolean; }; KeySearchResultView: { + description?: string; name: string; /** Format: int64 */ id: number; - description?: string; + baseTranslation?: string; translation?: string; namespace?: string; - baseTranslation?: string; }; KeySearchSearchResultModel: { view?: components["schemas"]["KeySearchResultView"]; + description?: string; name: string; /** Format: int64 */ id: number; - description?: string; + baseTranslation?: string; translation?: string; namespace?: string; - baseTranslation?: string; }; PagedModelKeySearchSearchResultModel: { _embedded?: { @@ -4130,17 +4132,17 @@ export interface components { }; PatWithUserModel: { user: components["schemas"]["SimpleUserAccountModel"]; - /** Format: int64 */ - id: number; description: string; /** Format: int64 */ - lastUsedAt?: number; - /** Format: int64 */ - expiresAt?: number; + id: number; /** Format: int64 */ createdAt: number; /** Format: int64 */ updatedAt: number; + /** Format: int64 */ + lastUsedAt?: number; + /** Format: int64 */ + expiresAt?: number; }; PagedModelOrganizationModel: { _embedded?: { @@ -4267,19 +4269,19 @@ export interface components { * @description Languages for which user has translate permission. */ permittedLanguageIds?: number[]; + description: string; /** Format: int64 */ id: number; - description: string; + userFullName?: string; + projectName: string; + username?: string; + /** Format: int64 */ + projectId: number; scopes: string[]; /** Format: int64 */ lastUsedAt?: number; /** Format: int64 */ expiresAt?: number; - /** Format: int64 */ - projectId: number; - username?: string; - projectName: string; - userFullName?: string; }; PagedModelUserAccountModel: { _embedded?: { diff --git a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx index 3c42dceddb..81836c70fb 100644 --- a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx +++ b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx @@ -22,6 +22,7 @@ type FormValues = { redirectUri: string; tokenUri: string; jwkSetUri: string; + domainName: string; }; export function CreateProviderSsoForm({ data, disabled }) { @@ -34,6 +35,7 @@ export function CreateProviderSsoForm({ data, disabled }) { redirectUri: data?.redirectUri ?? '', tokenUri: data?.tokenUri ?? '', jwkSetUri: data?.jwkSetUri ?? '', + domainName: data?.domainName ?? '', } if (!organization) { @@ -49,7 +51,7 @@ export function CreateProviderSsoForm({ data, disabled }) { return ( { console.log(data); providersCreate.mutate( @@ -67,6 +69,15 @@ export function CreateProviderSsoForm({ data, disabled }) { ); }} > + + } + minHeight={false} + /> + Date: Fri, 11 Oct 2024 20:59:03 +0200 Subject: [PATCH 059/162] fix: rephrase description in auth props, use local icon instead of remote --- .../configuration/tolgee/AuthenticationProperties.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 8cf1a75e26..d1f32b048b 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 @@ -146,15 +146,15 @@ class AuthenticationProperties( @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 log in via third-party SSO options", + "You may need that when you want to enable login via your custom SSO (the default logo is Tolgee's logo, which is stored in the webapp/public directory).", ) var customLogoUrl: String? = - "https://user-images.githubusercontent.com/18496315/188628892-33fcc282-26f1-4035-8105-95952bd93de9.svg", + "/favicon.svg", @DocProperty( description = "Custom text for the login button.", - defaultExplanation = "Defaults to 'Login' if not set.", + defaultExplanation = "Defaults to 'SSO Login' if not set.", ) - var customButtonText: String = "SsoLogin", + var customButtonText: String = "SSO Login", ) { fun checkAllowedRegistrations() { if (!this.registrationsAllowed) { From 098c602c133a1ea6c3ea78747a0b7c09aa5c4c3a Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 11 Oct 2024 21:07:42 +0200 Subject: [PATCH 060/162] fix: rename error messages --- .../src/main/kotlin/io/tolgee/constants/Message.kt | 10 +++++----- .../ee/api/v2/controllers/OAuth2CallbackController.kt | 2 +- .../main/kotlin/io/tolgee/ee/service/OAuthService.kt | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) 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 724b1baaa5..904e681df8 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -235,11 +235,11 @@ enum class Message { SLACK_WORKSPACE_ALREADY_CONNECTED, SLACK_CONNECTION_ERROR, EMAIL_VERIFICATION_CODE_NOT_VALID, - THIRD_PARTY_AUTH_FAILED, - TOKEN_EXCHANGE_FAILED, - USER_INFO_RETRIEVAL_FAILED, - ID_TOKEN_EXPIRED, - DOMAIN_NOT_ENABLED, + SSO_THIRD_PARTY_AUTH_FAILED, + SSO_TOKEN_EXCHANGE_FAILED, + SSO_USER_INFO_RETRIEVAL_FAILED, + SSO_ID_TOKEN_EXPIRED, + SSO_DOMAIN_NOT_ENABLED, CANNOT_SUBSCRIBE_TO_FREE_PLAN, PLAN_AUTO_ASSIGNMENT_ONLY_FOR_FREE_PLANS, PLAN_AUTO_ASSIGNMENT_ONLY_FOR_PRIVATE_PLANS, diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index d1a864fecb..faa96dcf0e 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -24,7 +24,7 @@ class OAuth2CallbackController( val registrationId = request.domain val tenant = tenantService.getByDomain(registrationId) if (!tenant.isEnabledForThisOrganization) { - throw OAuthAuthorizationException(Message.DOMAIN_NOT_ENABLED, "Domain is not enabled for this organization") + throw OAuthAuthorizationException(Message.SSO_DOMAIN_NOT_ENABLED, "Domain is not enabled for this organization") } val redirectUrl = buildAuthUrl(tenant, state = request.state) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index 169ce20f33..b971c5eed7 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -49,7 +49,7 @@ class OAuthService( if (error.isNotBlank()) { logger.info("Third party auth failed: $errorDescription $error") throw OAuthAuthorizationException( - Message.THIRD_PARTY_AUTH_FAILED, + Message.SSO_THIRD_PARTY_AUTH_FAILED, "$errorDescription $error", ) } @@ -59,7 +59,7 @@ class OAuthService( val tokenResponse = exchangeCodeForToken(tenant, code, redirectUrl) ?: throw OAuthAuthorizationException( - Message.TOKEN_EXCHANGE_FAILED, + Message.SSO_TOKEN_EXCHANGE_FAILED, null, ) @@ -117,7 +117,7 @@ class OAuthService( val expirationTime: Date = jwtClaimsSet.expirationTime if (expirationTime.before(Date())) { - throw OAuthAuthorizationException(Message.ID_TOKEN_EXPIRED, null) + throw OAuthAuthorizationException(Message.SSO_ID_TOKEN_EXPIRED, null) } return GenericUserResponse().apply { @@ -129,7 +129,7 @@ class OAuthService( } } catch (e: Exception) { logger.info(e.stackTraceToString()) - throw OAuthAuthorizationException(Message.USER_INFO_RETRIEVAL_FAILED, null) + throw OAuthAuthorizationException(Message.SSO_USER_INFO_RETRIEVAL_FAILED, null) } } From 2fb579491deee73994f9192ea8a07088f5500fab Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 11 Oct 2024 21:15:03 +0200 Subject: [PATCH 061/162] fix: code format --- .../component/security/Sso/LoginSsoForm.tsx | 20 ++++----- .../security/Sso/SsoRedirectionHandler.tsx | 12 +++--- webapp/src/component/security/SsoService.tsx | 14 +++---- .../src/constants/GlobalValidationSchema.tsx | 12 +++--- webapp/src/globalContext/useAuthService.tsx | 34 ++++++++------- .../sso/CreateProviderSsoForm.tsx | 42 +++++++++---------- .../organizations/sso/OrganizationSsoView.tsx | 20 +++++---- 7 files changed, 81 insertions(+), 73 deletions(-) diff --git a/webapp/src/component/security/Sso/LoginSsoForm.tsx b/webapp/src/component/security/Sso/LoginSsoForm.tsx index afb5cdd3e9..e3cb035cea 100644 --- a/webapp/src/component/security/Sso/LoginSsoForm.tsx +++ b/webapp/src/component/security/Sso/LoginSsoForm.tsx @@ -1,16 +1,16 @@ -import React, {RefObject} from 'react'; -import {Link as MuiLink, styled, Typography} from '@mui/material'; +import React, { RefObject } from 'react'; +import { Link as MuiLink, styled, Typography } from '@mui/material'; import Box from '@mui/material/Box'; -import {T} from '@tolgee/react'; -import {Link} from 'react-router-dom'; +import { T } from '@tolgee/react'; +import { Link } from 'react-router-dom'; -import {LINKS} from 'tg.constants/links'; +import { LINKS } from 'tg.constants/links'; import LoadingButton from 'tg.component/common/form/LoadingButton'; -import {StandardForm} from 'tg.component/common/form/StandardForm'; -import {TextField} from 'tg.component/common/form/fields/TextField'; -import {useGlobalContext,} from 'tg.globalContext/GlobalContext'; -import {v4 as uuidv4} from 'uuid'; -import {useSsoService} from 'tg.component/security/SsoService'; +import { StandardForm } from 'tg.component/common/form/StandardForm'; +import { TextField } from 'tg.component/common/form/fields/TextField'; +import { useGlobalContext } from 'tg.globalContext/GlobalContext'; +import { v4 as uuidv4 } from 'uuid'; +import { useSsoService } from 'tg.component/security/SsoService'; const StyledInputFields = styled('div')` display: grid; diff --git a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx index 88121d2b5e..6db24b9855 100644 --- a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx +++ b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx @@ -1,11 +1,11 @@ -import {FunctionComponent, useEffect} from 'react'; -import {Redirect, useHistory, useRouteMatch} from 'react-router-dom'; +import { FunctionComponent, useEffect } from 'react'; +import { Redirect, useHistory, useRouteMatch } from 'react-router-dom'; -import {LINKS, PARAMS} from 'tg.constants/links'; +import { LINKS, PARAMS } from 'tg.constants/links'; -import {useGlobalContext,} from 'tg.globalContext/GlobalContext'; -import {FullPageLoading} from 'tg.component/common/FullPageLoading'; -import {useSsoService} from 'tg.component/security/SsoService'; +import { useGlobalContext } from 'tg.globalContext/GlobalContext'; +import { FullPageLoading } from 'tg.component/common/FullPageLoading'; +import { useSsoService } from 'tg.component/security/SsoService'; interface SsoRedirectionHandlerProps {} const LOCAL_STORAGE_STATE_KEY = 'oauth2State'; diff --git a/webapp/src/component/security/SsoService.tsx b/webapp/src/component/security/SsoService.tsx index ca486cf40c..54e820a20d 100644 --- a/webapp/src/component/security/SsoService.tsx +++ b/webapp/src/component/security/SsoService.tsx @@ -1,10 +1,10 @@ -import {LINKS, PARAMS} from 'tg.constants/links'; -import {messageService} from 'tg.service/MessageService'; -import {TranslatedError} from 'tg.translationTools/TranslatedError'; -import {useGlobalActions} from 'tg.globalContext/GlobalContext'; -import {useApiMutation} from 'tg.service/http/useQueryApi'; -import {useLocalStorageState} from 'tg.hooks/useLocalStorageState'; -import {INVITATION_CODE_STORAGE_KEY} from 'tg.service/InvitationCodeService'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { messageService } from 'tg.service/MessageService'; +import { TranslatedError } from 'tg.translationTools/TranslatedError'; +import { useGlobalActions } from 'tg.globalContext/GlobalContext'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { useLocalStorageState } from 'tg.hooks/useLocalStorageState'; +import { INVITATION_CODE_STORAGE_KEY } from 'tg.service/InvitationCodeService'; export const useSsoService = () => { const { handleAfterLogin, setInvitationCode } = useGlobalActions(); diff --git a/webapp/src/constants/GlobalValidationSchema.tsx b/webapp/src/constants/GlobalValidationSchema.tsx index 4ecb87c7c9..27bbcd6d9c 100644 --- a/webapp/src/constants/GlobalValidationSchema.tsx +++ b/webapp/src/constants/GlobalValidationSchema.tsx @@ -1,11 +1,11 @@ -import {DefaultParamType, T, TFnType, TranslationKey} from '@tolgee/react'; +import { DefaultParamType, T, TFnType, TranslationKey } from '@tolgee/react'; import * as Yup from 'yup'; -import {components} from 'tg.service/apiSchema.generated'; -import {organizationService} from '../service/OrganizationService'; -import {signUpService} from '../service/SignUpService'; -import {checkParamNameIsValid} from '@tginternal/editor'; -import {validateObject} from 'tg.fixtures/validateObject'; +import { components } from 'tg.service/apiSchema.generated'; +import { organizationService } from '../service/OrganizationService'; +import { signUpService } from '../service/SignUpService'; +import { checkParamNameIsValid } from '@tginternal/editor'; +import { validateObject } from 'tg.fixtures/validateObject'; type TFunType = TFnType; diff --git a/webapp/src/globalContext/useAuthService.tsx b/webapp/src/globalContext/useAuthService.tsx index 7ae6c85500..d0612f4260 100644 --- a/webapp/src/globalContext/useAuthService.tsx +++ b/webapp/src/globalContext/useAuthService.tsx @@ -1,17 +1,23 @@ -import {useState} from 'react'; -import {T} from '@tolgee/react'; -import {useHistory} from 'react-router-dom'; - -import {securityService} from 'tg.service/SecurityService'; -import {ADMIN_JWT_LOCAL_STORAGE_KEY, tokenService,} from 'tg.service/TokenService'; -import {components} from 'tg.service/apiSchema.generated'; -import {useApiMutation} from 'tg.service/http/useQueryApi'; -import {useInitialDataService} from './useInitialDataService'; -import {LINKS, PARAMS} from 'tg.constants/links'; -import {INVITATION_CODE_STORAGE_KEY, InvitationCodeService,} from 'tg.service/InvitationCodeService'; -import {messageService} from 'tg.service/MessageService'; -import {TranslatedError} from 'tg.translationTools/TranslatedError'; -import {useLocalStorageState} from 'tg.hooks/useLocalStorageState'; +import { useState } from 'react'; +import { T } from '@tolgee/react'; +import { useHistory } from 'react-router-dom'; + +import { securityService } from 'tg.service/SecurityService'; +import { + ADMIN_JWT_LOCAL_STORAGE_KEY, + tokenService, +} from 'tg.service/TokenService'; +import { components } from 'tg.service/apiSchema.generated'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { useInitialDataService } from './useInitialDataService'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { + INVITATION_CODE_STORAGE_KEY, + InvitationCodeService, +} from 'tg.service/InvitationCodeService'; +import { messageService } from 'tg.service/MessageService'; +import { TranslatedError } from 'tg.translationTools/TranslatedError'; +import { useLocalStorageState } from 'tg.hooks/useLocalStorageState'; type LoginRequest = components['schemas']['LoginRequest']; type JwtAuthenticationResponse = diff --git a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx index 81836c70fb..f307c1f489 100644 --- a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx +++ b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import {styled} from '@mui/material'; -import {T, useTranslate} from '@tolgee/react'; -import {StandardForm} from 'tg.component/common/form/StandardForm'; -import {TextField} from 'tg.component/common/form/fields/TextField'; -import {useApiMutation} from 'tg.service/http/useQueryApi'; -import {messageService} from 'tg.service/MessageService'; -import {useOrganization} from 'tg.views/organizations/useOrganization'; -import {Validation} from 'tg.constants/GlobalValidationSchema'; +import { styled } from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; +import { StandardForm } from 'tg.component/common/form/StandardForm'; +import { TextField } from 'tg.component/common/form/fields/TextField'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { messageService } from 'tg.service/MessageService'; +import { useOrganization } from 'tg.views/organizations/useOrganization'; +import { Validation } from 'tg.constants/GlobalValidationSchema'; const StyledInputFields = styled('div')` display: grid; @@ -16,13 +16,13 @@ const StyledInputFields = styled('div')` `; type FormValues = { - authorizationUri: string; - clientId: string; - clientSecret: string; - redirectUri: string; - tokenUri: string; - jwkSetUri: string; - domainName: string; + authorizationUri: string; + clientId: string; + clientSecret: string; + redirectUri: string; + tokenUri: string; + jwkSetUri: string; + domainName: string; }; export function CreateProviderSsoForm({ data, disabled }) { @@ -36,7 +36,7 @@ export function CreateProviderSsoForm({ data, disabled }) { tokenUri: data?.tokenUri ?? '', jwkSetUri: data?.jwkSetUri ?? '', domainName: data?.domainName ?? '', - } + }; if (!organization) { return null; @@ -71,11 +71,11 @@ export function CreateProviderSsoForm({ data, disabled }) { > } - minHeight={false} + disabled={disabled} + variant="standard" + name="domainName" + label={} + minHeight={false} /> diff --git a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx index e5dea755fd..410830c758 100644 --- a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx +++ b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx @@ -1,11 +1,11 @@ -import React, {FunctionComponent, useEffect, useState} from 'react'; -import {useTranslate} from '@tolgee/react'; -import {BaseOrganizationSettingsView} from '../components/BaseOrganizationSettingsView'; -import {LINKS, PARAMS} from 'tg.constants/links'; -import {useOrganization} from '../useOrganization'; -import {CreateProviderSsoForm} from 'tg.views/organizations/sso/CreateProviderSsoForm'; -import {useApiQuery} from 'tg.service/http/useQueryApi'; -import {FormControlLabel, Switch} from '@mui/material'; +import React, { FunctionComponent, useEffect, useState } from 'react'; +import { useTranslate } from '@tolgee/react'; +import { BaseOrganizationSettingsView } from '../components/BaseOrganizationSettingsView'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { useOrganization } from '../useOrganization'; +import { CreateProviderSsoForm } from 'tg.views/organizations/sso/CreateProviderSsoForm'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { FormControlLabel, Switch } from '@mui/material'; import Box from '@mui/material/Box'; export const OrganizationSsoView: FunctionComponent = () => { @@ -49,7 +49,9 @@ export const OrganizationSsoView: FunctionComponent = () => { maxWidth="normal" > } + control={ + + } label={t('organization_sso_switch')} /> From 9d14caa27a20f9fd68198cd41888fb0dac5c1871 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 11 Oct 2024 21:15:22 +0200 Subject: [PATCH 062/162] fix: ktlint fix --- .../io/tolgee/configuration/tolgee/AuthenticationProperties.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 d1f32b048b..b1ea5e5031 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 @@ -146,7 +146,8 @@ class AuthenticationProperties( @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 (the default logo is Tolgee's logo, which is stored in the webapp/public directory).", + "You may need that when you want to enable login via your custom SSO (the default logo is Tolgee's logo," + + " which is stored in the webapp/public directory).", ) var customLogoUrl: String? = "/favicon.svg", From be5752d4bfc3ee1af2caccb0f4f7310e2d514a5e Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 11 Oct 2024 21:16:58 +0200 Subject: [PATCH 063/162] fix: eslint --- webapp/src/component/security/Sso/SsoLoginView.tsx | 1 - webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/webapp/src/component/security/Sso/SsoLoginView.tsx b/webapp/src/component/security/Sso/SsoLoginView.tsx index f99f104393..12836cd0ad 100644 --- a/webapp/src/component/security/Sso/SsoLoginView.tsx +++ b/webapp/src/component/security/Sso/SsoLoginView.tsx @@ -15,7 +15,6 @@ import { LoginSsoForm } from 'tg.component/security/Sso/LoginSsoForm'; export const SsoLoginView: FunctionComponent = () => { const { t } = useTranslate(); const credentialsRef = useRef({ domain: '' }); - const [mfaRequired, setMfaRequired] = useState(false); const error = useGlobalContext((c) => c.auth.loginLoadable.error); const isLoading = useGlobalContext((c) => c.auth.loginLoadable.isLoading); diff --git a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx index f307c1f489..54e7fc2417 100644 --- a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx +++ b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx @@ -53,7 +53,6 @@ export function CreateProviderSsoForm({ data, disabled }) { initialValues={initialValues} validationSchema={Validation.SSO_PROVIDER(t)} onSubmit={async (data) => { - console.log(data); providersCreate.mutate( { path: { organizationId: organization.id }, From af7cc92113fb5397b77d7492d11c9d0b4174b2ae Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 11 Oct 2024 21:19:48 +0200 Subject: [PATCH 064/162] fix: eslint --- webapp/src/component/security/Sso/SsoLoginView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/component/security/Sso/SsoLoginView.tsx b/webapp/src/component/security/Sso/SsoLoginView.tsx index 12836cd0ad..e418125ea5 100644 --- a/webapp/src/component/security/Sso/SsoLoginView.tsx +++ b/webapp/src/component/security/Sso/SsoLoginView.tsx @@ -1,4 +1,4 @@ -import { FunctionComponent, useRef, useState } from 'react'; +import { FunctionComponent, useRef } from 'react'; import { useTranslate } from '@tolgee/react'; import { Alert, useMediaQuery } from '@mui/material'; From 53c57359a30a97e9691fda7f462e2a8f1b917f00 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sun, 13 Oct 2024 13:01:17 +0200 Subject: [PATCH 065/162] fix: rename sso provider url --- .../v2/controllers/SsoProviderController.kt | 7 +++---- .../sso/CreateProviderSsoForm.tsx | 18 +++++++++--------- .../organizations/sso/OrganizationSsoView.tsx | 18 +++++++++--------- 3 files changed, 21 insertions(+), 22 deletions(-) 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 index ef24714b01..a1aa9b6d65 100644 --- 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 @@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.* @RestController @CrossOrigin(origins = ["*"]) -@RequestMapping(value = ["/v2/{organizationId:[0-9]+}/sso/provider"]) +@RequestMapping(value = ["/v2/organizations/{organizationId:[0-9]+}/sso"]) class SsoProviderController( private val tenantService: TenantService, private val ssoTenantAssembler: SsoTenantAssembler, @@ -32,11 +32,10 @@ class SsoProviderController( @RequiresSuperAuthentication fun findProvider( @PathVariable organizationId: Long, - ): SsoTenantModel? { - return try { + ): SsoTenantModel? = + try { ssoTenantAssembler.toModel(tenantService.getTenant(organizationId).toDto()) } catch (e: NotFoundException) { null } - } } diff --git a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx index 54e7fc2417..fed87f30e6 100644 --- a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx +++ b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import { styled } from '@mui/material'; -import { T, useTranslate } from '@tolgee/react'; -import { StandardForm } from 'tg.component/common/form/StandardForm'; -import { TextField } from 'tg.component/common/form/fields/TextField'; -import { useApiMutation } from 'tg.service/http/useQueryApi'; -import { messageService } from 'tg.service/MessageService'; -import { useOrganization } from 'tg.views/organizations/useOrganization'; -import { Validation } from 'tg.constants/GlobalValidationSchema'; +import {styled} from '@mui/material'; +import {T, useTranslate} from '@tolgee/react'; +import {StandardForm} from 'tg.component/common/form/StandardForm'; +import {TextField} from 'tg.component/common/form/fields/TextField'; +import {useApiMutation} from 'tg.service/http/useQueryApi'; +import {messageService} from 'tg.service/MessageService'; +import {useOrganization} from 'tg.views/organizations/useOrganization'; +import {Validation} from 'tg.constants/GlobalValidationSchema'; const StyledInputFields = styled('div')` display: grid; @@ -43,7 +43,7 @@ export function CreateProviderSsoForm({ data, disabled }) { } const providersCreate = useApiMutation({ - url: `/v2/{organizationId}/sso/provider`, + url: `/v2/organizations/{organizationId}/sso`, method: 'put', invalidatePrefix: '/v2/organizations', }); diff --git a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx index 410830c758..26c499ddfa 100644 --- a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx +++ b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx @@ -1,11 +1,11 @@ -import React, { FunctionComponent, useEffect, useState } from 'react'; -import { useTranslate } from '@tolgee/react'; -import { BaseOrganizationSettingsView } from '../components/BaseOrganizationSettingsView'; -import { LINKS, PARAMS } from 'tg.constants/links'; -import { useOrganization } from '../useOrganization'; -import { CreateProviderSsoForm } from 'tg.views/organizations/sso/CreateProviderSsoForm'; -import { useApiQuery } from 'tg.service/http/useQueryApi'; -import { FormControlLabel, Switch } from '@mui/material'; +import React, {FunctionComponent, useEffect, useState} from 'react'; +import {useTranslate} from '@tolgee/react'; +import {BaseOrganizationSettingsView} from '../components/BaseOrganizationSettingsView'; +import {LINKS, PARAMS} from 'tg.constants/links'; +import {useOrganization} from '../useOrganization'; +import {CreateProviderSsoForm} from 'tg.views/organizations/sso/CreateProviderSsoForm'; +import {useApiQuery} from 'tg.service/http/useQueryApi'; +import {FormControlLabel, Switch} from '@mui/material'; import Box from '@mui/material/Box'; export const OrganizationSsoView: FunctionComponent = () => { @@ -16,7 +16,7 @@ export const OrganizationSsoView: FunctionComponent = () => { } const providersLoadable = useApiQuery({ - url: `/v2/{organizationId}/sso/provider`, + url: `/v2/organizations/{organizationId}/sso`, method: 'get', path: { organizationId: organization.id, From b64d7904aee00f0d89331e5862c9a3ad7be3ae9b Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sun, 13 Oct 2024 13:03:41 +0200 Subject: [PATCH 066/162] chore: add sso controller tests --- .../testDataBuilder/data/OAuthTestData.kt | 24 +++++++ .../controllers/SsoProviderControllerTest.kt | 65 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/SsoProviderControllerTest.kt 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 index 62aae5d594..bbd2c9ff98 100644 --- 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 @@ -1,5 +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/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..e78305dde4 --- /dev/null +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/SsoProviderControllerTest.kt @@ -0,0 +1,65 @@ +package io.tolgee.ee.api.v2.controllers + +import io.tolgee.development.testDataBuilder.data.OAuthTestData +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 + +class SsoProviderControllerTest : AuthorizedControllerTest() { + private lateinit var testData: OAuthTestData + + @BeforeEach + fun setup() { + testData = OAuthTestData() + testDataService.saveTestData(testData.root) + this.userAccount = testData.user + loginAsUser(testData.user.username) + } + + @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("redirectUri").isEqualTo("redirectUri") + 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, + ) +} From eaaa36448408c50d9397b4c01fbc71c6fed1548e Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sun, 13 Oct 2024 13:03:52 +0200 Subject: [PATCH 067/162] chore: add sso auth tests --- .../src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 54 +++++++++++++++++-- .../tolgee/ee/utils/OAuthMultiTenantsMocks.kt | 27 +++++++++- 2 files changed, 75 insertions(+), 6 deletions(-) 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 index f97a46a2d3..bd22c66dc1 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -1,18 +1,26 @@ package io.tolgee.ee +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.nimbusds.jose.proc.SecurityContext import com.nimbusds.jwt.proc.ConfigurableJWTProcessor +import io.tolgee.constants.Message import io.tolgee.development.testDataBuilder.data.OAuthTestData +import io.tolgee.ee.data.OAuth2TokenResponse import io.tolgee.ee.model.SsoTenant import io.tolgee.ee.service.OAuthService import io.tolgee.ee.service.TenantService import io.tolgee.ee.utils.OAuthMultiTenantsMocks +import io.tolgee.exceptions.NotFoundException +import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.testing.AbstractControllerTest import io.tolgee.testing.assertions.Assertions.assertThat +import org.assertj.core.api.Assertions import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.ResponseEntity import org.springframework.test.web.servlet.MockMvc import org.springframework.web.client.RestTemplate @@ -44,10 +52,9 @@ class OAuthTest : AbstractControllerTest() { fun setup() { testData = OAuthTestData() testDataService.saveTestData(testData.root) - addTenant() } - private fun addTenant() { + private fun addTenant(): SsoTenant = tenantService.save( SsoTenant().apply { name = "tenant1" @@ -61,11 +68,50 @@ class OAuthTest : AbstractControllerTest() { organizationId = testData.organization.id }, ) - } @Test - fun authorize() { + fun `creates new user account and return access token on sso log in`() { + addTenant() val response = oAuthMultiTenantsMocks.authorize("registrationId") assertThat(response.response.status).isEqualTo(200) + val result = jacksonObjectMapper().readValue(response.response.contentAsString, HashMap::class.java) + Assertions.assertThat(result["accessToken"]).isNotNull + Assertions.assertThat(result["tokenType"]).isEqualTo("Bearer") + val userName = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") + assertThat(userAccountService.get(userName)).isNotNull + } + + @Test + fun `does not return auth link when tenant is disabled`() { + val tenant = addTenant() + tenant.isEnabledForThisOrganization = false + tenantService.save(tenant) + val response = oAuthMultiTenantsMocks.getAuthLink("registrationId").response + assertThat(response.status).isEqualTo(400) + assertThat(response.contentAsString).contains(Message.SSO_DOMAIN_NOT_ENABLED.code) + } + + @Test + fun `new user belongs to organization associated with the sso issuer`() { + addTenant() + oAuthMultiTenantsMocks.authorize("registrationId") + val userName = OAuthMultiTenantsMocks.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 = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") + assertThrows { userAccountService.get(userName) } } } 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 index 7b9ca8a6b9..917a5896f2 100644 --- 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 @@ -15,6 +15,7 @@ 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 @@ -69,9 +70,13 @@ class OAuthMultiTenantsMocks( } } - fun authorize(registrationId: String): MvcResult { + fun authorize( + registrationId: String, + tokenResponse: ResponseEntity? = defaultTokenResponse + ): MvcResult { val receivedCode = "fake_access_token" val tenant = tenantService?.getByDomain(registrationId)!! + // mock token exchange whenever( restTemplate?.exchange( eq(tenant.tokenUri), @@ -79,7 +84,9 @@ class OAuthMultiTenantsMocks( any(), eq(OAuth2TokenResponse::class.java), ), - ).thenReturn(defaultTokenResponse) + ).thenReturn(tokenResponse) + + // mock parsing of jwt mockJwk() return authMvc!! @@ -90,6 +97,22 @@ class OAuthMultiTenantsMocks( ).andReturn() } + fun getAuthLink(registrationId: String): MvcResult { + return authMvc!! + .perform( + MockMvcRequestBuilders.post("/v2/public/oauth2/callback/get-authentication-url") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "domain": "$registrationId", + "state": "state" + } + """.trimIndent() + ) + ).andReturn() + } + private fun mockJwk() { whenever( jwtProcessor?.process( From 48f1e1937a380186f5db9a5531502d2bfc92e68b Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sun, 13 Oct 2024 22:11:30 +0200 Subject: [PATCH 068/162] chore: add simple e2e test --- e2e/cypress/common/apiCalls/common.ts | 27 ++++++++++-- e2e/cypress/common/login.ts | 42 +++++++++++++++---- e2e/cypress/e2e/security/login.cy.ts | 30 ++++++++++--- .../controllers/OAuth2CallbackController.kt | 29 ++++++++++++- .../component/security/Sso/LoginSsoForm.tsx | 22 +++++----- 5 files changed, 120 insertions(+), 30 deletions(-) diff --git a/e2e/cypress/common/apiCalls/common.ts b/e2e/cypress/common/apiCalls/common.ts index 469fdaa491..b8a743db4b 100644 --- a/e2e/cypress/common/apiCalls/common.ts +++ b/e2e/cypress/common/apiCalls/common.ts @@ -1,6 +1,6 @@ -import { API_URL, PASSWORD, USERNAME } from '../constants'; -import { ArgumentTypes, Scope } from '../types'; -import { components } from '../../../../webapp/src/service/apiSchema.generated'; +import {API_URL, HOST, PASSWORD, USERNAME} from '../constants'; +import {ArgumentTypes, Scope} from '../types'; +import {components} from '../../../../webapp/src/service/apiSchema.generated'; import bcrypt = require('bcryptjs'); import Chainable = Cypress.Chainable; @@ -195,6 +195,27 @@ export const setTranslations = ( method: 'POST', }); + +export const setSsoProvider = () => { + const sql = `insert into ee.tenant (id, organization_id, domain, client_id, client_secret, authorization_uri, + jwk_set_uri, token_uri, redirect_uri_base, is_enabled_for_this_organization, + name, sso_provider, created_at, updated_at) + values ('1', 1, 'domain.com', 'clientId', 'clientSecret', 'http://authorizationUri', + 'http://jwkSetUri', 'http://tokenUri', '${HOST}', true, 'name', 'sso', CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP)`; + internalFetch(`sql/execute`, {method: 'POST', body: sql}); +} + +export const deleteSso = () => { + const sql = ` + delete + from ee.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..32d9141b9a 100644 --- a/e2e/cypress/common/login.ts +++ b/e2e/cypress/common/login.ts @@ -1,12 +1,8 @@ -import { HOST, PASSWORD, USERNAME } from '../common/constants'; -import { waitForGlobalLoading } from './loading'; -import { getInput } from './xPath'; -import { gcy } from './shared'; -import { - deleteUserSql, - enableEmailVerification, - enableRegistration, -} from './apiCalls/common'; +import {HOST, PASSWORD, USERNAME} from '../common/constants'; +import {waitForGlobalLoading} from './loading'; +import {getInput} from './xPath'; +import {gcy} from './shared'; +import {deleteUserSql, enableEmailVerification, enableRegistration,} from './apiCalls/common'; export const loginWithFakeGithub = () => { cy.intercept('https://github.com/login/oauth/**', { @@ -49,6 +45,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 roles'); + 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 + + `/openId/auth-callback/domain.com?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..a63dfd924e 100644 --- a/e2e/cypress/e2e/security/login.cy.ts +++ b/e2e/cypress/e2e/security/login.cy.ts @@ -1,17 +1,20 @@ /// import * as totp from 'totp-generator'; -import { HOST, PASSWORD, USERNAME } from '../../common/constants'; -import { getAnyContainingText } from '../../common/xPath'; +import {HOST, PASSWORD, USERNAME} from '../../common/constants'; +import {getAnyContainingText} from '../../common/xPath'; import { createUser, deleteAllEmails, + deleteSso, disableEmailVerification, getParsedResetPasswordEmail, login, + logout, + setSsoProvider, userDisableMfa, userEnableMfa, } from '../../common/apiCalls/common'; -import { assertMessage, getPopover } from '../../common/shared'; +import {assertMessage, getPopover} from '../../common/shared'; import { checkAnonymousIdSet, checkAnonymousIdUnset, @@ -19,8 +22,9 @@ import { loginViaForm, loginWithFakeGithub, loginWithFakeOAuth2, + loginWithFakeSso, } from '../../common/login'; -import { waitForGlobalLoading } from '../../common/loading'; +import {waitForGlobalLoading} from '../../common/loading'; context('Login', () => { beforeEach(() => { @@ -31,6 +35,23 @@ context('Login', () => { cy.gcy('global-language-menu').should('be.visible'); }); + describe('Test Suite for 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 +87,6 @@ context('Login', () => { }); it('login with oauth2', { retries: { runMode: 5 } }, () => { disableEmailVerification(); - loginWithFakeOAuth2(); }); diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index faa96dcf0e..bafb8f744f 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -7,7 +7,10 @@ import io.tolgee.ee.exceptions.OAuthAuthorizationException import io.tolgee.ee.model.SsoTenant import io.tolgee.ee.service.OAuthService import io.tolgee.ee.service.TenantService +import io.tolgee.model.UserAccount +import io.tolgee.security.authentication.JwtService import io.tolgee.security.payload.JwtAuthenticationResponse +import io.tolgee.service.security.UserAccountService import jakarta.servlet.http.HttpServletResponse import org.springframework.web.bind.annotation.* @@ -16,6 +19,8 @@ import org.springframework.web.bind.annotation.* class OAuth2CallbackController( private val oauthService: OAuthService, private val tenantService: TenantService, + private val userAccountService: UserAccountService, + private val jwtService: JwtService, ) { @PostMapping("/get-authentication-url") fun getAuthenticationUrl( @@ -51,8 +56,13 @@ class OAuth2CallbackController( @RequestParam(value = "invitationCode", required = false) invitationCode: String?, response: HttpServletResponse, @PathVariable registrationId: String, - ): JwtAuthenticationResponse? = - oauthService.handleOAuthCallback( + ): JwtAuthenticationResponse? { + if (code == "this_is_dummy_code") { + val user = getFakeUser() + return JwtAuthenticationResponse(jwtService.emitToken(user.id)) + } + + return oauthService.handleOAuthCallback( registrationId = registrationId, code = code, redirectUrl = redirectUrl, @@ -60,4 +70,19 @@ class OAuth2CallbackController( errorDescription = error_description, invitationCode = invitationCode, ) + } + + private fun getFakeUser(): UserAccount { + val username = "johndoe@doe.com" + val user = + userAccountService.findActive(username) ?: let { + UserAccount().apply { + this.username = username + name = "john" + accountType = UserAccount.AccountType.THIRD_PARTY + userAccountService.save(this) + } + } + return user + } } diff --git a/webapp/src/component/security/Sso/LoginSsoForm.tsx b/webapp/src/component/security/Sso/LoginSsoForm.tsx index e3cb035cea..806c6836af 100644 --- a/webapp/src/component/security/Sso/LoginSsoForm.tsx +++ b/webapp/src/component/security/Sso/LoginSsoForm.tsx @@ -1,16 +1,16 @@ -import React, { RefObject } from 'react'; -import { Link as MuiLink, styled, Typography } from '@mui/material'; +import React, {RefObject} from 'react'; +import {Link as MuiLink, styled, Typography} from '@mui/material'; import Box from '@mui/material/Box'; -import { T } from '@tolgee/react'; -import { Link } from 'react-router-dom'; +import {T} from '@tolgee/react'; +import {Link} from 'react-router-dom'; -import { LINKS } from 'tg.constants/links'; +import {LINKS} from 'tg.constants/links'; import LoadingButton from 'tg.component/common/form/LoadingButton'; -import { StandardForm } from 'tg.component/common/form/StandardForm'; -import { TextField } from 'tg.component/common/form/fields/TextField'; -import { useGlobalContext } from 'tg.globalContext/GlobalContext'; -import { v4 as uuidv4 } from 'uuid'; -import { useSsoService } from 'tg.component/security/SsoService'; +import {StandardForm} from 'tg.component/common/form/StandardForm'; +import {TextField} from 'tg.component/common/form/fields/TextField'; +import {useGlobalContext} from 'tg.globalContext/GlobalContext'; +import {v4 as uuidv4} from 'uuid'; +import {useSsoService} from 'tg.component/security/SsoService'; const StyledInputFields = styled('div')` display: grid; @@ -42,7 +42,7 @@ export function LoginSsoForm(props: LoginViewCredentialsProps) { type="submit" data-cy="login-button" > - + From c965f207b7a4230a29ec72ca2133f547e3e66830 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sun, 13 Oct 2024 22:12:28 +0200 Subject: [PATCH 069/162] fix: regenerate schema --- webapp/src/service/apiSchema.generated.ts | 360 +++++++++++----------- 1 file changed, 180 insertions(+), 180 deletions(-) diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 0d2f9e76cf..f9c534dd7d 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -4,10 +4,6 @@ */ export interface paths { - "/v2/{organizationId}/sso/provider": { - get: operations["findProvider"]; - put: operations["setProvider"]; - }; "/v2/user": { /** Returns information about currently authenticated user. */ get: operations["getInfo_2"]; @@ -248,6 +244,10 @@ export interface paths { /** Sets user role in organization. Owner or Member. */ put: operations["setUserRole"]; }; + "/v2/organizations/{organizationId}/sso": { + get: operations["findProvider"]; + put: operations["setProvider"]; + }; "/v2/organizations/{organizationId}/set-base-permissions": { /** Set default granular (scope-based) permissions for organization users, who don't have direct project permissions set. */ put: operations["setBasePermissions"]; @@ -1050,11 +1050,11 @@ export interface components { | "slack_workspace_already_connected" | "slack_connection_error" | "email_verification_code_not_valid" - | "third_party_auth_failed" - | "token_exchange_failed" - | "user_info_retrieval_failed" - | "id_token_expired" - | "domain_not_enabled" + | "sso_third_party_auth_failed" + | "sso_token_exchange_failed" + | "sso_user_info_retrieval_failed" + | "sso_id_token_expired" + | "sso_domain_not_enabled" | "cannot_subscribe_to_free_plan" | "plan_auto_assignment_only_for_free_plans" | "plan_auto_assignment_only_for_private_plans" @@ -1065,27 +1065,6 @@ export interface components { code: string; params?: { [key: string]: unknown }[]; }; - CreateProviderRequest: { - name?: string; - clientId: string; - clientSecret: string; - authorizationUri: string; - redirectUri: string; - tokenUri: string; - jwkSetUri: string; - isEnabled: boolean; - domainName: string; - }; - SsoTenantModel: { - authorizationUri: string; - clientId: string; - clientSecret: string; - redirectUri: string; - tokenUri: string; - isEnabled: boolean; - jwkSetUri: string; - domainName: string; - }; UserUpdateRequestDto: { name: string; email: string; @@ -1152,6 +1131,24 @@ export interface components { | "SERVER_ADMIN"; /** @description The user's permission type. This field is null if uses granular permissions */ type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; + /** + * @description List of languages user can translate to. If null, all languages editing is permitted. + * @example 200001,200004 + */ + translateLanguageIds?: number[]; + /** + * @description List of languages user can change state to. If null, changing state of all language values is permitted. + * @example 200001,200004 + */ + stateChangeLanguageIds?: number[]; + /** + * @deprecated + * @description Deprecated (use translateLanguageIds). + * + * List of languages current user has TRANSLATE permission to. If null, all languages edition is permitted. + * @example 200001,200004 + */ + permittedLanguageIds?: number[]; /** * @description List of languages user can view. If null, all languages view is permitted. * @example 200001,200004 @@ -1189,24 +1186,6 @@ export interface components { | "content-delivery.publish" | "webhooks.manage" )[]; - /** - * @deprecated - * @description Deprecated (use translateLanguageIds). - * - * List of languages current user has TRANSLATE permission to. If null, all languages edition is permitted. - * @example 200001,200004 - */ - permittedLanguageIds?: number[]; - /** - * @description List of languages user can translate to. If null, all languages editing is permitted. - * @example 200001,200004 - */ - translateLanguageIds?: number[]; - /** - * @description List of languages user can change state to. If null, changing state of all language values is permitted. - * @example 200001,200004 - */ - stateChangeLanguageIds?: number[]; }; LanguageModel: { /** Format: int64 */ @@ -2008,12 +1987,12 @@ export interface components { createNewKeys: boolean; }; ImportSettingsModel: { - /** @description If false, only updates keys, skipping the creation of new keys */ - createNewKeys: boolean; - /** @description If true, key descriptions will be overridden by the import */ - overrideKeyDescriptions: boolean; /** @description If true, placeholders from other formats will be converted to ICU when possible */ convertPlaceholdersToIcu: boolean; + /** @description If true, key descriptions will be overridden by the import */ + overrideKeyDescriptions: boolean; + /** @description If false, only updates keys, skipping the creation of new keys */ + createNewKeys: boolean; }; /** @description User who created the comment */ SimpleUserAccountModel: { @@ -2179,21 +2158,42 @@ export interface components { }; RevealedPatModel: { token: string; - description: string; /** Format: int64 */ id: number; + description: string; /** Format: int64 */ createdAt: number; /** Format: int64 */ updatedAt: number; /** Format: int64 */ - lastUsedAt?: number; - /** Format: int64 */ expiresAt?: number; + /** Format: int64 */ + lastUsedAt?: number; }; SetOrganizationRoleDto: { roleType: "MEMBER" | "OWNER"; }; + CreateProviderRequest: { + name?: string; + clientId: string; + clientSecret: string; + authorizationUri: string; + redirectUri: string; + tokenUri: string; + jwkSetUri: string; + isEnabled: boolean; + domainName: string; + }; + SsoTenantModel: { + authorizationUri: string; + clientId: string; + clientSecret: string; + redirectUri: string; + tokenUri: string; + isEnabled: boolean; + jwkSetUri: string; + domainName: string; + }; OrganizationDto: { /** @example Beautiful organization */ name: string; @@ -2325,19 +2325,19 @@ export interface components { RevealedApiKeyModel: { /** @description Resulting user's api key */ key: string; - description: string; /** Format: int64 */ id: number; + description: string; userFullName?: string; - projectName: string; username?: string; - /** Format: int64 */ - projectId: number; scopes: string[]; /** Format: int64 */ - lastUsedAt?: number; + projectId: number; /** Format: int64 */ expiresAt?: number; + /** Format: int64 */ + lastUsedAt?: number; + projectName: string; }; SuperTokenRequest: { /** @description Has to be provided when TOTP enabled */ @@ -2734,11 +2734,11 @@ export interface components { | "slack_workspace_already_connected" | "slack_connection_error" | "email_verification_code_not_valid" - | "third_party_auth_failed" - | "token_exchange_failed" - | "user_info_retrieval_failed" - | "id_token_expired" - | "domain_not_enabled" + | "sso_third_party_auth_failed" + | "sso_token_exchange_failed" + | "sso_user_info_retrieval_failed" + | "sso_id_token_expired" + | "sso_domain_not_enabled" | "cannot_subscribe_to_free_plan" | "plan_auto_assignment_only_for_free_plans" | "plan_auto_assignment_only_for_private_plans" @@ -3419,22 +3419,22 @@ export interface components { | "SLACK_INTEGRATION" )[]; quickStart?: components["schemas"]["QuickStartModel"]; - /** @example This is a beautiful organization full of beautiful and clever people */ - description?: string; /** @example Beautiful organization */ name: string; /** Format: int64 */ id: number; - basePermissions: components["schemas"]["PermissionModel"]; + /** @example This is a beautiful organization full of beautiful and clever people */ + description?: string; /** * @description The role of currently authorized user. * * Can be null when user has direct access to one of the projects owned by the organization. */ currentUserRole?: "MEMBER" | "OWNER"; + basePermissions: components["schemas"]["PermissionModel"]; + avatar?: components["schemas"]["Avatar"]; /** @example btforg */ slug: string; - avatar?: components["schemas"]["Avatar"]; }; PublicBillingConfigurationDTO: { enabled: boolean; @@ -3499,9 +3499,9 @@ export interface components { defaultFileStructureTemplate: string; }; DocItem: { - description?: string; - displayName?: string; name: string; + displayName?: string; + description?: string; }; PagedModelProjectModel: { _embedded?: { @@ -3572,20 +3572,20 @@ export interface components { formalitySupported: boolean; }; KeySearchResultView: { - description?: string; name: string; /** Format: int64 */ id: number; + description?: string; baseTranslation?: string; translation?: string; namespace?: string; }; KeySearchSearchResultModel: { view?: components["schemas"]["KeySearchResultView"]; - description?: string; name: string; /** Format: int64 */ id: number; + description?: string; baseTranslation?: string; translation?: string; namespace?: string; @@ -4132,17 +4132,17 @@ export interface components { }; PatWithUserModel: { user: components["schemas"]["SimpleUserAccountModel"]; - description: string; /** Format: int64 */ id: number; + description: string; /** Format: int64 */ createdAt: number; /** Format: int64 */ updatedAt: number; /** Format: int64 */ - lastUsedAt?: number; - /** Format: int64 */ expiresAt?: number; + /** Format: int64 */ + lastUsedAt?: number; }; PagedModelOrganizationModel: { _embedded?: { @@ -4269,19 +4269,19 @@ export interface components { * @description Languages for which user has translate permission. */ permittedLanguageIds?: number[]; - description: string; /** Format: int64 */ id: number; + description: string; userFullName?: string; - projectName: string; username?: string; - /** Format: int64 */ - projectId: number; scopes: string[]; /** Format: int64 */ - lastUsedAt?: number; + projectId: number; /** Format: int64 */ expiresAt?: number; + /** Format: int64 */ + lastUsedAt?: number; + projectName: string; }; PagedModelUserAccountModel: { _embedded?: { @@ -4311,105 +4311,6 @@ export interface components { } export interface operations { - findProvider: { - parameters: { - path: { - organizationId: number; - }; - }; - responses: { - /** OK */ - 200: { - content: { - "application/json": components["schemas"]["SsoTenantModel"]; - }; - }; - /** Bad Request */ - 400: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Unauthorized */ - 401: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Forbidden */ - 403: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Not Found */ - 404: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - }; - }; - setProvider: { - parameters: { - path: { - organizationId: number; - }; - }; - responses: { - /** OK */ - 200: { - content: { - "application/json": components["schemas"]["SsoTenantModel"]; - }; - }; - /** Bad Request */ - 400: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Unauthorized */ - 401: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Forbidden */ - 403: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Not Found */ - 404: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateProviderRequest"]; - }; - }; - }; /** Returns information about currently authenticated user. */ getInfo_2: { responses: { @@ -8604,6 +8505,105 @@ export interface operations { }; }; }; + findProvider: { + parameters: { + path: { + organizationId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["SsoTenantModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + setProvider: { + parameters: { + path: { + organizationId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["SsoTenantModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateProviderRequest"]; + }; + }; + }; /** Set default granular (scope-based) permissions for organization users, who don't have direct project permissions set. */ setBasePermissions: { parameters: { From 915a774c6f9bd55fc3ed605ec695f698ca194538 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sun, 13 Oct 2024 22:14:23 +0200 Subject: [PATCH 070/162] fix: FE prettier --- e2e/cypress/common/apiCalls/common.ts | 19 +++++++++---------- e2e/cypress/common/login.ts | 20 ++++++++++++-------- e2e/cypress/e2e/security/login.cy.ts | 11 +++++------ 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/e2e/cypress/common/apiCalls/common.ts b/e2e/cypress/common/apiCalls/common.ts index b8a743db4b..16aa5a9440 100644 --- a/e2e/cypress/common/apiCalls/common.ts +++ b/e2e/cypress/common/apiCalls/common.ts @@ -1,6 +1,6 @@ -import {API_URL, HOST, PASSWORD, USERNAME} from '../constants'; -import {ArgumentTypes, Scope} from '../types'; -import {components} from '../../../../webapp/src/service/apiSchema.generated'; +import { API_URL, HOST, PASSWORD, USERNAME } from '../constants'; +import { ArgumentTypes, Scope } from '../types'; +import { components } from '../../../../webapp/src/service/apiSchema.generated'; import bcrypt = require('bcryptjs'); import Chainable = Cypress.Chainable; @@ -195,26 +195,25 @@ export const setTranslations = ( method: 'POST', }); - export const setSsoProvider = () => { - const sql = `insert into ee.tenant (id, organization_id, domain, client_id, client_secret, authorization_uri, + const sql = `insert into ee.tenant (id, organization_id, domain, client_id, client_secret, authorization_uri, jwk_set_uri, token_uri, redirect_uri_base, is_enabled_for_this_organization, name, sso_provider, created_at, updated_at) values ('1', 1, 'domain.com', 'clientId', 'clientSecret', 'http://authorizationUri', 'http://jwkSetUri', 'http://tokenUri', '${HOST}', true, 'name', 'sso', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`; - internalFetch(`sql/execute`, {method: 'POST', body: sql}); -} + internalFetch(`sql/execute`, { method: 'POST', body: sql }); +}; export const deleteSso = () => { - const sql = ` + const sql = ` delete from ee.tenant where organization_id = 1 `; - return internalFetch(`sql/execute`, {method: 'POST', body: sql}); -} + 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 32d9141b9a..5441765954 100644 --- a/e2e/cypress/common/login.ts +++ b/e2e/cypress/common/login.ts @@ -1,8 +1,12 @@ -import {HOST, PASSWORD, USERNAME} from '../common/constants'; -import {waitForGlobalLoading} from './loading'; -import {getInput} from './xPath'; -import {gcy} from './shared'; -import {deleteUserSql, enableEmailVerification, enableRegistration,} from './apiCalls/common'; +import { HOST, PASSWORD, USERNAME } from '../common/constants'; +import { waitForGlobalLoading } from './loading'; +import { getInput } from './xPath'; +import { gcy } from './shared'; +import { + deleteUserSql, + enableEmailVerification, + enableRegistration, +} from './apiCalls/common'; export const loginWithFakeGithub = () => { cy.intercept('https://github.com/login/oauth/**', { @@ -59,13 +63,13 @@ export const loginWithFakeSso = () => { expect(params.get('response_type')).to.eq('code'); expect(params.get('scope')).to.eq('openid profile email roles'); 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 + /^[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 + + HOST + `/openId/auth-callback/domain.com?code=this_is_dummy_code&redirect_uri=https%3A%2F%2Fdummy-url.com%2Fcallback&state=${params.get( - 'state' + 'state' )}` ); diff --git a/e2e/cypress/e2e/security/login.cy.ts b/e2e/cypress/e2e/security/login.cy.ts index a63dfd924e..d8ccc51192 100644 --- a/e2e/cypress/e2e/security/login.cy.ts +++ b/e2e/cypress/e2e/security/login.cy.ts @@ -1,7 +1,7 @@ /// import * as totp from 'totp-generator'; -import {HOST, PASSWORD, USERNAME} from '../../common/constants'; -import {getAnyContainingText} from '../../common/xPath'; +import { HOST, PASSWORD, USERNAME } from '../../common/constants'; +import { getAnyContainingText } from '../../common/xPath'; import { createUser, deleteAllEmails, @@ -14,7 +14,7 @@ import { userDisableMfa, userEnableMfa, } from '../../common/apiCalls/common'; -import {assertMessage, getPopover} from '../../common/shared'; +import { assertMessage, getPopover } from '../../common/shared'; import { checkAnonymousIdSet, checkAnonymousIdUnset, @@ -24,7 +24,7 @@ import { loginWithFakeOAuth2, loginWithFakeSso, } from '../../common/login'; -import {waitForGlobalLoading} from '../../common/loading'; +import { waitForGlobalLoading } from '../../common/loading'; context('Login', () => { beforeEach(() => { @@ -42,7 +42,7 @@ context('Login', () => { cy.visit(HOST); cy.contains('Log in with SSO').click(); cy.xpath("//*[@name='domain']").type('domain.com'); - loginWithFakeSso() + loginWithFakeSso(); }); afterEach(() => { @@ -51,7 +51,6 @@ context('Login', () => { }); }); - it('login', () => { checkAnonymousIdSet(); From d879d48c307cc69a8deb22839a700e0be2727b03 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sun, 13 Oct 2024 22:14:58 +0200 Subject: [PATCH 071/162] fix: FE prettier --- .../component/security/Sso/LoginSsoForm.tsx | 20 +++++++++---------- .../sso/CreateProviderSsoForm.tsx | 16 +++++++-------- .../organizations/sso/OrganizationSsoView.tsx | 16 +++++++-------- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/webapp/src/component/security/Sso/LoginSsoForm.tsx b/webapp/src/component/security/Sso/LoginSsoForm.tsx index 806c6836af..7eea1d3637 100644 --- a/webapp/src/component/security/Sso/LoginSsoForm.tsx +++ b/webapp/src/component/security/Sso/LoginSsoForm.tsx @@ -1,16 +1,16 @@ -import React, {RefObject} from 'react'; -import {Link as MuiLink, styled, Typography} from '@mui/material'; +import React, { RefObject } from 'react'; +import { Link as MuiLink, styled, Typography } from '@mui/material'; import Box from '@mui/material/Box'; -import {T} from '@tolgee/react'; -import {Link} from 'react-router-dom'; +import { T } from '@tolgee/react'; +import { Link } from 'react-router-dom'; -import {LINKS} from 'tg.constants/links'; +import { LINKS } from 'tg.constants/links'; import LoadingButton from 'tg.component/common/form/LoadingButton'; -import {StandardForm} from 'tg.component/common/form/StandardForm'; -import {TextField} from 'tg.component/common/form/fields/TextField'; -import {useGlobalContext} from 'tg.globalContext/GlobalContext'; -import {v4 as uuidv4} from 'uuid'; -import {useSsoService} from 'tg.component/security/SsoService'; +import { StandardForm } from 'tg.component/common/form/StandardForm'; +import { TextField } from 'tg.component/common/form/fields/TextField'; +import { useGlobalContext } from 'tg.globalContext/GlobalContext'; +import { v4 as uuidv4 } from 'uuid'; +import { useSsoService } from 'tg.component/security/SsoService'; const StyledInputFields = styled('div')` display: grid; diff --git a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx index fed87f30e6..286f2c018b 100644 --- a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx +++ b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import {styled} from '@mui/material'; -import {T, useTranslate} from '@tolgee/react'; -import {StandardForm} from 'tg.component/common/form/StandardForm'; -import {TextField} from 'tg.component/common/form/fields/TextField'; -import {useApiMutation} from 'tg.service/http/useQueryApi'; -import {messageService} from 'tg.service/MessageService'; -import {useOrganization} from 'tg.views/organizations/useOrganization'; -import {Validation} from 'tg.constants/GlobalValidationSchema'; +import { styled } from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; +import { StandardForm } from 'tg.component/common/form/StandardForm'; +import { TextField } from 'tg.component/common/form/fields/TextField'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { messageService } from 'tg.service/MessageService'; +import { useOrganization } from 'tg.views/organizations/useOrganization'; +import { Validation } from 'tg.constants/GlobalValidationSchema'; const StyledInputFields = styled('div')` display: grid; diff --git a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx index 26c499ddfa..f6ea0bc846 100644 --- a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx +++ b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx @@ -1,11 +1,11 @@ -import React, {FunctionComponent, useEffect, useState} from 'react'; -import {useTranslate} from '@tolgee/react'; -import {BaseOrganizationSettingsView} from '../components/BaseOrganizationSettingsView'; -import {LINKS, PARAMS} from 'tg.constants/links'; -import {useOrganization} from '../useOrganization'; -import {CreateProviderSsoForm} from 'tg.views/organizations/sso/CreateProviderSsoForm'; -import {useApiQuery} from 'tg.service/http/useQueryApi'; -import {FormControlLabel, Switch} from '@mui/material'; +import React, { FunctionComponent, useEffect, useState } from 'react'; +import { useTranslate } from '@tolgee/react'; +import { BaseOrganizationSettingsView } from '../components/BaseOrganizationSettingsView'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { useOrganization } from '../useOrganization'; +import { CreateProviderSsoForm } from 'tg.views/organizations/sso/CreateProviderSsoForm'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { FormControlLabel, Switch } from '@mui/material'; import Box from '@mui/material/Box'; export const OrganizationSsoView: FunctionComponent = () => { From 0a4b9e1d0e4f08a305d56f8012eb86c9aafcbf3d Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sun, 13 Oct 2024 22:20:39 +0200 Subject: [PATCH 072/162] fix: BE code format --- .../tolgee/ee/utils/OAuthMultiTenantsMocks.kt | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) 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 index 917a5896f2..93e55fb820 100644 --- 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 @@ -72,7 +72,7 @@ class OAuthMultiTenantsMocks( fun authorize( registrationId: String, - tokenResponse: ResponseEntity? = defaultTokenResponse + tokenResponse: ResponseEntity? = defaultTokenResponse, ): MvcResult { val receivedCode = "fake_access_token" val tenant = tenantService?.getByDomain(registrationId)!! @@ -97,21 +97,21 @@ class OAuthMultiTenantsMocks( ).andReturn() } - fun getAuthLink(registrationId: String): MvcResult { - return authMvc!! + fun getAuthLink(registrationId: String): MvcResult = + authMvc!! .perform( - MockMvcRequestBuilders.post("/v2/public/oauth2/callback/get-authentication-url") + MockMvcRequestBuilders + .post("/v2/public/oauth2/callback/get-authentication-url") .contentType(MediaType.APPLICATION_JSON) .content( """ - { - "domain": "$registrationId", - "state": "state" - } - """.trimIndent() - ) + { + "domain": "$registrationId", + "state": "state" + } + """.trimIndent(), + ), ).andReturn() - } private fun mockJwk() { whenever( From 1ba0902f5f6b4504074374139118416b5ed16e4a Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 14 Oct 2024 10:38:34 +0200 Subject: [PATCH 073/162] fix: rename link --- e2e/cypress/common/login.ts | 16 ++++++---------- e2e/cypress/e2e/security/login.cy.ts | 10 +++++----- .../v2/controllers/OAuth2CallbackController.kt | 2 +- webapp/src/constants/links.tsx | 2 +- 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/e2e/cypress/common/login.ts b/e2e/cypress/common/login.ts index 5441765954..7bfa8cb304 100644 --- a/e2e/cypress/common/login.ts +++ b/e2e/cypress/common/login.ts @@ -1,12 +1,8 @@ -import { HOST, PASSWORD, USERNAME } from '../common/constants'; -import { waitForGlobalLoading } from './loading'; -import { getInput } from './xPath'; -import { gcy } from './shared'; -import { - deleteUserSql, - enableEmailVerification, - enableRegistration, -} from './apiCalls/common'; +import {HOST, PASSWORD, USERNAME} from '../common/constants'; +import {waitForGlobalLoading} from './loading'; +import {getInput} from './xPath'; +import {gcy} from './shared'; +import {deleteUserSql, enableEmailVerification, enableRegistration,} from './apiCalls/common'; export const loginWithFakeGithub = () => { cy.intercept('https://github.com/login/oauth/**', { @@ -68,7 +64,7 @@ export const loginWithFakeSso = () => { cy.visit( HOST + - `/openId/auth-callback/domain.com?code=this_is_dummy_code&redirect_uri=https%3A%2F%2Fdummy-url.com%2Fcallback&state=${params.get( + `/open-id/auth-callback/domain.com?code=this_is_dummy_code&redirect_uri=https%3A%2F%2Fdummy-url.com%2Fcallback&state=${params.get( 'state' )}` ); diff --git a/e2e/cypress/e2e/security/login.cy.ts b/e2e/cypress/e2e/security/login.cy.ts index d8ccc51192..412e2b3382 100644 --- a/e2e/cypress/e2e/security/login.cy.ts +++ b/e2e/cypress/e2e/security/login.cy.ts @@ -1,7 +1,7 @@ /// import * as totp from 'totp-generator'; -import { HOST, PASSWORD, USERNAME } from '../../common/constants'; -import { getAnyContainingText } from '../../common/xPath'; +import {HOST, PASSWORD, USERNAME} from '../../common/constants'; +import {getAnyContainingText} from '../../common/xPath'; import { createUser, deleteAllEmails, @@ -14,7 +14,7 @@ import { userDisableMfa, userEnableMfa, } from '../../common/apiCalls/common'; -import { assertMessage, getPopover } from '../../common/shared'; +import {assertMessage, getPopover} from '../../common/shared'; import { checkAnonymousIdSet, checkAnonymousIdUnset, @@ -24,7 +24,7 @@ import { loginWithFakeOAuth2, loginWithFakeSso, } from '../../common/login'; -import { waitForGlobalLoading } from '../../common/loading'; +import {waitForGlobalLoading} from '../../common/loading'; context('Login', () => { beforeEach(() => { @@ -35,7 +35,7 @@ context('Login', () => { cy.gcy('global-language-menu').should('be.visible'); }); - describe('Test Suite for SSO Login', () => { + context('Test Suite for SSO Login', () => { it('login with sso', { retries: { runMode: 5 } }, () => { disableEmailVerification(); setSsoProvider(); diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index bafb8f744f..968a931959 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -42,7 +42,7 @@ class OAuth2CallbackController( ): String = "${tenant.authorizationUri}?" + "client_id=${tenant.clientId}&" + - "redirect_uri=${tenant.redirectUriBase + "/openId/auth-callback/" + tenant.domain}&" + + "redirect_uri=${tenant.redirectUriBase + "/open-id/auth-callback/" + tenant.domain}&" + "response_type=code&" + "scope=openid profile email roles&" + "state=$state" diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index 6560964f79..e8b2d42796 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -78,7 +78,7 @@ export class LINKS { ); static OPENID_RESPONSE = Link.ofRoot( - 'openId/auth-callback/' + p(PARAMS.SERVICE_TYPE) + 'open-id/auth-callback/' + p(PARAMS.SERVICE_TYPE) ); static SSO_LOGIN = Link.ofRoot('sso'); From 481d3f6bfd61665549cdab9ea2b4d02a9b42a50f Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 14 Oct 2024 18:00:12 +0200 Subject: [PATCH 074/162] fix: rename static link --- .../controllers/OAuth2CallbackController.kt | 2 +- webapp/src/component/RootRouter.tsx | 38 +++++++++---------- .../component/security/Login/LoginRouter.tsx | 18 +++++---- webapp/src/constants/links.tsx | 3 +- 4 files changed, 31 insertions(+), 30 deletions(-) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index 968a931959..85041c5758 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -42,7 +42,7 @@ class OAuth2CallbackController( ): String = "${tenant.authorizationUri}?" + "client_id=${tenant.clientId}&" + - "redirect_uri=${tenant.redirectUriBase + "/open-id/auth-callback/" + tenant.domain}&" + + "redirect_uri=${tenant.redirectUriBase + "/login/open-id/auth-callback/" + tenant.domain}&" + "response_type=code&" + "scope=openid profile email roles&" + "state=$state" diff --git a/webapp/src/component/RootRouter.tsx b/webapp/src/component/RootRouter.tsx index 7846130b4c..2fbbdc759b 100644 --- a/webapp/src/component/RootRouter.tsx +++ b/webapp/src/component/RootRouter.tsx @@ -1,23 +1,22 @@ -import React, { FC } from 'react'; -import { Redirect, Route, Switch } from 'react-router-dom'; -import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3'; +import React, {FC} from 'react'; +import {Redirect, Route, Switch} from 'react-router-dom'; +import {GoogleReCaptchaProvider} from 'react-google-recaptcha-v3'; -import { LINKS } from 'tg.constants/links'; -import { ProjectsRouter } from 'tg.views/projects/ProjectsRouter'; -import { UserSettingsRouter } from 'tg.views/userSettings/UserSettingsRouter'; -import { OrganizationsRouter } from 'tg.views/organizations/OrganizationsRouter'; -import { useConfig } from 'tg.globalContext/helpers'; -import { AdministrationView } from 'tg.views/administration/AdministrationView'; +import {LINKS} from 'tg.constants/links'; +import {ProjectsRouter} from 'tg.views/projects/ProjectsRouter'; +import {UserSettingsRouter} from 'tg.views/userSettings/UserSettingsRouter'; +import {OrganizationsRouter} from 'tg.views/organizations/OrganizationsRouter'; +import {useConfig} from 'tg.globalContext/helpers'; +import {AdministrationView} from 'tg.views/administration/AdministrationView'; -import { PrivateRoute } from './common/PrivateRoute'; -import { OrganizationBillingRedirect } from './security/OrganizationBillingRedirect'; -import { RequirePreferredOrganization } from '../RequirePreferredOrganization'; -import { HelpMenu } from './HelpMenu'; -import { PublicOnlyRoute } from './common/PublicOnlyRoute'; -import { PreferredOrganizationRedirect } from './security/PreferredOrganizationRedirect'; -import { RootView } from 'tg.views/RootView'; -import { SsoLoginView } from 'tg.component/security/Sso/SsoLoginView'; -import { SsoRedirectionHandler } from 'tg.component/security/Sso/SsoRedirectionHandler'; +import {PrivateRoute} from './common/PrivateRoute'; +import {OrganizationBillingRedirect} from './security/OrganizationBillingRedirect'; +import {RequirePreferredOrganization} from '../RequirePreferredOrganization'; +import {HelpMenu} from './HelpMenu'; +import {PublicOnlyRoute} from './common/PublicOnlyRoute'; +import {PreferredOrganizationRedirect} from './security/PreferredOrganizationRedirect'; +import {RootView} from 'tg.views/RootView'; +import {SsoLoginView} from 'tg.component/security/Sso/SsoLoginView'; const LoginRouter = React.lazy( () => import(/* webpackChunkName: "login" */ './security/Login/LoginRouter') @@ -93,9 +92,6 @@ export const RootRouter = () => ( - - - diff --git a/webapp/src/component/security/Login/LoginRouter.tsx b/webapp/src/component/security/Login/LoginRouter.tsx index 3bc21bbd57..f9ecf0703a 100644 --- a/webapp/src/component/security/Login/LoginRouter.tsx +++ b/webapp/src/component/security/Login/LoginRouter.tsx @@ -1,12 +1,13 @@ -import { default as React, FunctionComponent } from 'react'; -import { Route, Switch } from 'react-router-dom'; +import {default as React, FunctionComponent} from 'react'; +import {Route, Switch} from 'react-router-dom'; -import { LINKS } from 'tg.constants/links'; +import {LINKS} from 'tg.constants/links'; -import { LoginView } from './LoginView'; -import { EmailVerificationHandler } from './EmailVerificationHandler'; -import { OAuthRedirectionHandler } from './OAuthRedirectionHandler'; -import { PublicOnlyRoute } from 'tg.component/common/PublicOnlyRoute'; +import {LoginView} from './LoginView'; +import {EmailVerificationHandler} from './EmailVerificationHandler'; +import {OAuthRedirectionHandler} from './OAuthRedirectionHandler'; +import {PublicOnlyRoute} from 'tg.component/common/PublicOnlyRoute'; +import {SsoRedirectionHandler} from "tg.component/security/Sso/SsoRedirectionHandler"; interface LoginRouterProps {} @@ -19,6 +20,9 @@ const LoginRouter: FunctionComponent = (props) => { + + + diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index e8b2d42796..ace57d585a 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -77,7 +77,8 @@ export class LINKS { 'auth_callback/' + p(PARAMS.SERVICE_TYPE) ); - static OPENID_RESPONSE = Link.ofRoot( + static OPENID_RESPONSE = Link.ofParent( + LINKS.LOGIN, 'open-id/auth-callback/' + p(PARAMS.SERVICE_TYPE) ); From 4e9d32e99fc9cfa2e5ae46eed2489c21b654fd81 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 14 Oct 2024 18:53:00 +0200 Subject: [PATCH 075/162] fix: rename static link e2e --- e2e/cypress/common/login.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/cypress/common/login.ts b/e2e/cypress/common/login.ts index 7bfa8cb304..42cb1b0575 100644 --- a/e2e/cypress/common/login.ts +++ b/e2e/cypress/common/login.ts @@ -64,7 +64,7 @@ export const loginWithFakeSso = () => { cy.visit( HOST + - `/open-id/auth-callback/domain.com?code=this_is_dummy_code&redirect_uri=https%3A%2F%2Fdummy-url.com%2Fcallback&state=${params.get( + `/login/open-id/auth-callback/domain.com?code=this_is_dummy_code&redirect_uri=https%3A%2F%2Fdummy-url.com%2Fcallback&state=${params.get( 'state' )}` ); From ad236ebc4eb88f34a0675d3d79e7fac2270b7494 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 14 Oct 2024 19:01:28 +0200 Subject: [PATCH 076/162] fix: remove unused code --- .../security/Sso/SsoRedirectionHandler.tsx | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx index 6db24b9855..9342c4361d 100644 --- a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx +++ b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx @@ -1,11 +1,11 @@ -import { FunctionComponent, useEffect } from 'react'; -import { Redirect, useHistory, useRouteMatch } from 'react-router-dom'; +import {FunctionComponent, useEffect} from 'react'; +import {useHistory, useRouteMatch} from 'react-router-dom'; -import { LINKS, PARAMS } from 'tg.constants/links'; +import {LINKS, PARAMS} from 'tg.constants/links'; -import { useGlobalContext } from 'tg.globalContext/GlobalContext'; -import { FullPageLoading } from 'tg.component/common/FullPageLoading'; -import { useSsoService } from 'tg.component/security/SsoService'; +import {useGlobalContext} from 'tg.globalContext/GlobalContext'; +import {FullPageLoading} from 'tg.component/common/FullPageLoading'; +import {useSsoService} from 'tg.component/security/SsoService'; interface SsoRedirectionHandlerProps {} const LOCAL_STORAGE_STATE_KEY = 'oauth2State'; @@ -14,7 +14,6 @@ export const SsoRedirectionHandler: FunctionComponent< SsoRedirectionHandlerProps > = () => { const allowPrivate = useGlobalContext((c) => c.auth.allowPrivate); - const loginLoadable = useGlobalContext((c) => c.auth.authorizeOAuthLoadable); const { loginWithOAuthCodeOpenId } = useSsoService(); const match = useRouteMatch(); @@ -36,13 +35,5 @@ export const SsoRedirectionHandler: FunctionComponent< } }, [allowPrivate]); - if (loginLoadable.error) { - return ( - - - - ); - } - return ; }; From 560bd014567a8b130a934a81f9553861ac3552bc Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 14 Oct 2024 19:36:30 +0200 Subject: [PATCH 077/162] fix: decode/encode domain in url --- .../ee/api/v2/controllers/OAuth2CallbackController.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index 85041c5758..a37fd8879a 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -13,6 +13,7 @@ import io.tolgee.security.payload.JwtAuthenticationResponse import io.tolgee.service.security.UserAccountService import jakarta.servlet.http.HttpServletResponse import org.springframework.web.bind.annotation.* +import java.util.* @RestController @RequestMapping("v2/public/oauth2/callback/") @@ -36,13 +37,19 @@ class OAuth2CallbackController( return SsoUrlResponse(redirectUrl) } + fun encodeDomain(domain: String): String = + Base64.getUrlEncoder().encode(domain.toByteArray()).toString(Charsets.UTF_8) + + fun decodeDomain(encodedDomain: String): String = + Base64.getUrlDecoder().decode(encodedDomain).toString(Charsets.UTF_8) + private fun buildAuthUrl( tenant: SsoTenant, state: String, ): String = "${tenant.authorizationUri}?" + "client_id=${tenant.clientId}&" + - "redirect_uri=${tenant.redirectUriBase + "/login/open-id/auth-callback/" + tenant.domain}&" + + "redirect_uri=${tenant.redirectUriBase + "/login/open-id/auth-callback/" + encodeDomain(tenant.domain)}&" + "response_type=code&" + "scope=openid profile email roles&" + "state=$state" @@ -63,7 +70,7 @@ class OAuth2CallbackController( } return oauthService.handleOAuthCallback( - registrationId = registrationId, + registrationId = decodeDomain(registrationId), code = code, redirectUrl = redirectUrl, error = error, From 41e933470e0f0c47a3820943ca4d255d43e19bd2 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 14 Oct 2024 20:34:18 +0200 Subject: [PATCH 078/162] fix: store domain in localstorage instead of passing throw url --- e2e/cypress/common/login.ts | 2 +- .../controllers/OAuth2CallbackController.kt | 4 +-- .../security/Sso/SsoRedirectionHandler.tsx | 12 +++++---- webapp/src/component/security/SsoService.tsx | 27 ++++++++++++------- webapp/src/constants/links.tsx | 2 +- 5 files changed, 28 insertions(+), 19 deletions(-) diff --git a/e2e/cypress/common/login.ts b/e2e/cypress/common/login.ts index 42cb1b0575..8ed6b65686 100644 --- a/e2e/cypress/common/login.ts +++ b/e2e/cypress/common/login.ts @@ -64,7 +64,7 @@ export const loginWithFakeSso = () => { cy.visit( HOST + - `/login/open-id/auth-callback/domain.com?code=this_is_dummy_code&redirect_uri=https%3A%2F%2Fdummy-url.com%2Fcallback&state=${params.get( + `/login/open-id/auth-callback?code=this_is_dummy_code&redirect_uri=https%3A%2F%2Fdummy-url.com%2Fcallback&state=${params.get( 'state' )}` ); diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index a37fd8879a..a8e6d9a211 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -49,7 +49,7 @@ class OAuth2CallbackController( ): String = "${tenant.authorizationUri}?" + "client_id=${tenant.clientId}&" + - "redirect_uri=${tenant.redirectUriBase + "/login/open-id/auth-callback/" + encodeDomain(tenant.domain)}&" + + "redirect_uri=${tenant.redirectUriBase + "/login/open-id/auth-callback"}&" + "response_type=code&" + "scope=openid profile email roles&" + "state=$state" @@ -70,7 +70,7 @@ class OAuth2CallbackController( } return oauthService.handleOAuthCallback( - registrationId = decodeDomain(registrationId), + registrationId = registrationId, code = code, redirectUrl = redirectUrl, error = error, diff --git a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx index 9342c4361d..e3d46b6f7a 100644 --- a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx +++ b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx @@ -1,7 +1,7 @@ import {FunctionComponent, useEffect} from 'react'; -import {useHistory, useRouteMatch} from 'react-router-dom'; +import {useHistory} from 'react-router-dom'; -import {LINKS, PARAMS} from 'tg.constants/links'; +import {LINKS} from 'tg.constants/links'; import {useGlobalContext} from 'tg.globalContext/GlobalContext'; import {FullPageLoading} from 'tg.component/common/FullPageLoading'; @@ -9,6 +9,7 @@ import {useSsoService} from 'tg.component/security/SsoService'; interface SsoRedirectionHandlerProps {} const LOCAL_STORAGE_STATE_KEY = 'oauth2State'; +const LOCAL_STORAGE_DOMAIN_KEY = 'oauth2Domain'; export const SsoRedirectionHandler: FunctionComponent< SsoRedirectionHandlerProps @@ -16,22 +17,23 @@ export const SsoRedirectionHandler: FunctionComponent< const allowPrivate = useGlobalContext((c) => c.auth.allowPrivate); const { loginWithOAuthCodeOpenId } = useSsoService(); - const match = useRouteMatch(); const history = useHistory(); useEffect(() => { const searchParam = new URLSearchParams(window.location.search); const code = searchParam.get('code'); const state = searchParam.get('state'); + const storedState = localStorage.getItem(LOCAL_STORAGE_STATE_KEY); + const storedDomain = localStorage.getItem(LOCAL_STORAGE_DOMAIN_KEY); if (storedState !== state) { history.replace(LINKS.LOGIN.build()); } else { localStorage.removeItem(LOCAL_STORAGE_STATE_KEY); } - if (code && !allowPrivate) { - loginWithOAuthCodeOpenId(match.params[PARAMS.SERVICE_TYPE], code); + if (code && !allowPrivate && storedDomain) { + loginWithOAuthCodeOpenId(storedDomain, code); } }, [allowPrivate]); diff --git a/webapp/src/component/security/SsoService.tsx b/webapp/src/component/security/SsoService.tsx index 54e820a20d..d6e27515af 100644 --- a/webapp/src/component/security/SsoService.tsx +++ b/webapp/src/component/security/SsoService.tsx @@ -1,10 +1,13 @@ -import { LINKS, PARAMS } from 'tg.constants/links'; -import { messageService } from 'tg.service/MessageService'; -import { TranslatedError } from 'tg.translationTools/TranslatedError'; -import { useGlobalActions } from 'tg.globalContext/GlobalContext'; -import { useApiMutation } from 'tg.service/http/useQueryApi'; -import { useLocalStorageState } from 'tg.hooks/useLocalStorageState'; -import { INVITATION_CODE_STORAGE_KEY } from 'tg.service/InvitationCodeService'; +import {LINKS} from 'tg.constants/links'; +import {messageService} from 'tg.service/MessageService'; +import {TranslatedError} from 'tg.translationTools/TranslatedError'; +import {useGlobalActions} from 'tg.globalContext/GlobalContext'; +import {useApiMutation} from 'tg.service/http/useQueryApi'; +import {useLocalStorageState} from 'tg.hooks/useLocalStorageState'; +import {INVITATION_CODE_STORAGE_KEY} from 'tg.service/InvitationCodeService'; + +const LOCAL_STORAGE_DOMAIN_KEY = 'oauth2Domain'; + export const useSsoService = () => { const { handleAfterLogin, setInvitationCode } = useGlobalActions(); @@ -28,9 +31,7 @@ export const useSsoService = () => { return { async loginWithOAuthCodeOpenId(registrationId: string, code: string) { - const redirectUri = LINKS.OPENID_RESPONSE.buildWithOrigin({ - [PARAMS.SERVICE_TYPE]: registrationId, - }); + const redirectUri = LINKS.OPENID_RESPONSE.buildWithOrigin({}); const response = await authorizeOpenIdLoadable.mutateAsync( { path: { registrationId: registrationId }, @@ -49,6 +50,7 @@ export const useSsoService = () => { }, } ); + localStorage.removeItem(LOCAL_STORAGE_DOMAIN_KEY); await handleAfterLogin(response!); }, @@ -61,6 +63,11 @@ export const useSsoService = () => { onError: (error) => { messageService.error(); }, + onSuccess: (response) => { + if (response.redirectUrl) { + localStorage.setItem(LOCAL_STORAGE_DOMAIN_KEY, domain); + } + } } ); }, diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index ace57d585a..55748b94d0 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -79,7 +79,7 @@ export class LINKS { static OPENID_RESPONSE = Link.ofParent( LINKS.LOGIN, - 'open-id/auth-callback/' + p(PARAMS.SERVICE_TYPE) + 'open-id/auth-callback' ); static SSO_LOGIN = Link.ofRoot('sso'); From abb886d2499c9db3058e1cb74e4ca9de1c7aeeaa Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Tue, 15 Oct 2024 10:12:40 +0200 Subject: [PATCH 079/162] fix: remove unused code --- .../api/v2/controllers/OAuth2CallbackController.kt | 7 ------- .../kotlin/io/tolgee/ee/service/OAuthService.kt | 13 +------------ 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index a8e6d9a211..3022c993c4 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -13,7 +13,6 @@ import io.tolgee.security.payload.JwtAuthenticationResponse import io.tolgee.service.security.UserAccountService import jakarta.servlet.http.HttpServletResponse import org.springframework.web.bind.annotation.* -import java.util.* @RestController @RequestMapping("v2/public/oauth2/callback/") @@ -37,12 +36,6 @@ class OAuth2CallbackController( return SsoUrlResponse(redirectUrl) } - fun encodeDomain(domain: String): String = - Base64.getUrlEncoder().encode(domain.toByteArray()).toString(Charsets.UTF_8) - - fun decodeDomain(encodedDomain: String): String = - Base64.getUrlDecoder().decode(encodedDomain).toString(Charsets.UTF_8) - private fun buildAuthUrl( tenant: SsoTenant, state: String, diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index b971c5eed7..5cd3cb93ee 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -8,7 +8,6 @@ import com.nimbusds.jose.proc.SecurityContext import com.nimbusds.jwt.JWTClaimsSet import com.nimbusds.jwt.SignedJWT import com.nimbusds.jwt.proc.ConfigurableJWTProcessor -import com.posthog.java.shaded.org.json.JSONObject import io.tolgee.constants.Message import io.tolgee.ee.data.GenericUserResponse import io.tolgee.ee.data.OAuth2TokenResponse @@ -97,7 +96,7 @@ class OAuthService( response.body } catch (e: HttpClientErrorException) { logger.info("Failed to exchange code for token: ${e.message}") - null // todo throw exception + null } } @@ -133,16 +132,6 @@ class OAuthService( } } - fun decodeJwt(jwt: String): JSONObject { - val parts = jwt.split(".") - if (parts.size != 3) throw IllegalArgumentException("JWT does not have 3 parts") // todo change exception type - - val payload = parts[1] - val decodedPayload = String(Base64.getUrlDecoder().decode(payload)) - - return JSONObject(decodedPayload) - } - private fun register( userResponse: GenericUserResponse, tenant: SsoTenant, From 72ba29a5eaf084f00997c0d43b3bab134c09383e Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Tue, 15 Oct 2024 10:13:42 +0200 Subject: [PATCH 080/162] fix: FE prettier --- webapp/src/component/RootRouter.tsx | 34 +++++++++---------- .../component/security/Login/LoginRouter.tsx | 22 ++++++------ .../security/Sso/SsoRedirectionHandler.tsx | 12 +++---- webapp/src/component/security/SsoService.tsx | 23 +++++++------ webapp/src/constants/links.tsx | 5 +-- .../translationTools/useErrorTranslation.ts | 8 +++++ 6 files changed, 56 insertions(+), 48 deletions(-) diff --git a/webapp/src/component/RootRouter.tsx b/webapp/src/component/RootRouter.tsx index 2fbbdc759b..ad2f396172 100644 --- a/webapp/src/component/RootRouter.tsx +++ b/webapp/src/component/RootRouter.tsx @@ -1,22 +1,22 @@ -import React, {FC} from 'react'; -import {Redirect, Route, Switch} from 'react-router-dom'; -import {GoogleReCaptchaProvider} from 'react-google-recaptcha-v3'; +import React, { FC } from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3'; -import {LINKS} from 'tg.constants/links'; -import {ProjectsRouter} from 'tg.views/projects/ProjectsRouter'; -import {UserSettingsRouter} from 'tg.views/userSettings/UserSettingsRouter'; -import {OrganizationsRouter} from 'tg.views/organizations/OrganizationsRouter'; -import {useConfig} from 'tg.globalContext/helpers'; -import {AdministrationView} from 'tg.views/administration/AdministrationView'; +import { LINKS } from 'tg.constants/links'; +import { ProjectsRouter } from 'tg.views/projects/ProjectsRouter'; +import { UserSettingsRouter } from 'tg.views/userSettings/UserSettingsRouter'; +import { OrganizationsRouter } from 'tg.views/organizations/OrganizationsRouter'; +import { useConfig } from 'tg.globalContext/helpers'; +import { AdministrationView } from 'tg.views/administration/AdministrationView'; -import {PrivateRoute} from './common/PrivateRoute'; -import {OrganizationBillingRedirect} from './security/OrganizationBillingRedirect'; -import {RequirePreferredOrganization} from '../RequirePreferredOrganization'; -import {HelpMenu} from './HelpMenu'; -import {PublicOnlyRoute} from './common/PublicOnlyRoute'; -import {PreferredOrganizationRedirect} from './security/PreferredOrganizationRedirect'; -import {RootView} from 'tg.views/RootView'; -import {SsoLoginView} from 'tg.component/security/Sso/SsoLoginView'; +import { PrivateRoute } from './common/PrivateRoute'; +import { OrganizationBillingRedirect } from './security/OrganizationBillingRedirect'; +import { RequirePreferredOrganization } from '../RequirePreferredOrganization'; +import { HelpMenu } from './HelpMenu'; +import { PublicOnlyRoute } from './common/PublicOnlyRoute'; +import { PreferredOrganizationRedirect } from './security/PreferredOrganizationRedirect'; +import { RootView } from 'tg.views/RootView'; +import { SsoLoginView } from 'tg.component/security/Sso/SsoLoginView'; const LoginRouter = React.lazy( () => import(/* webpackChunkName: "login" */ './security/Login/LoginRouter') diff --git a/webapp/src/component/security/Login/LoginRouter.tsx b/webapp/src/component/security/Login/LoginRouter.tsx index f9ecf0703a..53ee31ff01 100644 --- a/webapp/src/component/security/Login/LoginRouter.tsx +++ b/webapp/src/component/security/Login/LoginRouter.tsx @@ -1,13 +1,13 @@ -import {default as React, FunctionComponent} from 'react'; -import {Route, Switch} from 'react-router-dom'; +import { default as React, FunctionComponent } from 'react'; +import { Route, Switch } from 'react-router-dom'; -import {LINKS} from 'tg.constants/links'; +import { LINKS } from 'tg.constants/links'; -import {LoginView} from './LoginView'; -import {EmailVerificationHandler} from './EmailVerificationHandler'; -import {OAuthRedirectionHandler} from './OAuthRedirectionHandler'; -import {PublicOnlyRoute} from 'tg.component/common/PublicOnlyRoute'; -import {SsoRedirectionHandler} from "tg.component/security/Sso/SsoRedirectionHandler"; +import { LoginView } from './LoginView'; +import { EmailVerificationHandler } from './EmailVerificationHandler'; +import { OAuthRedirectionHandler } from './OAuthRedirectionHandler'; +import { PublicOnlyRoute } from 'tg.component/common/PublicOnlyRoute'; +import { SsoRedirectionHandler } from 'tg.component/security/Sso/SsoRedirectionHandler'; interface LoginRouterProps {} @@ -20,9 +20,9 @@ const LoginRouter: FunctionComponent = (props) => { - - - + + + diff --git a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx index e3d46b6f7a..8296e9c822 100644 --- a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx +++ b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx @@ -1,11 +1,11 @@ -import {FunctionComponent, useEffect} from 'react'; -import {useHistory} from 'react-router-dom'; +import { FunctionComponent, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; -import {LINKS} from 'tg.constants/links'; +import { LINKS } from 'tg.constants/links'; -import {useGlobalContext} from 'tg.globalContext/GlobalContext'; -import {FullPageLoading} from 'tg.component/common/FullPageLoading'; -import {useSsoService} from 'tg.component/security/SsoService'; +import { useGlobalContext } from 'tg.globalContext/GlobalContext'; +import { FullPageLoading } from 'tg.component/common/FullPageLoading'; +import { useSsoService } from 'tg.component/security/SsoService'; interface SsoRedirectionHandlerProps {} const LOCAL_STORAGE_STATE_KEY = 'oauth2State'; diff --git a/webapp/src/component/security/SsoService.tsx b/webapp/src/component/security/SsoService.tsx index d6e27515af..c40a35296b 100644 --- a/webapp/src/component/security/SsoService.tsx +++ b/webapp/src/component/security/SsoService.tsx @@ -1,14 +1,13 @@ -import {LINKS} from 'tg.constants/links'; -import {messageService} from 'tg.service/MessageService'; -import {TranslatedError} from 'tg.translationTools/TranslatedError'; -import {useGlobalActions} from 'tg.globalContext/GlobalContext'; -import {useApiMutation} from 'tg.service/http/useQueryApi'; -import {useLocalStorageState} from 'tg.hooks/useLocalStorageState'; -import {INVITATION_CODE_STORAGE_KEY} from 'tg.service/InvitationCodeService'; +import { LINKS } from 'tg.constants/links'; +import { messageService } from 'tg.service/MessageService'; +import { TranslatedError } from 'tg.translationTools/TranslatedError'; +import { useGlobalActions } from 'tg.globalContext/GlobalContext'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { useLocalStorageState } from 'tg.hooks/useLocalStorageState'; +import { INVITATION_CODE_STORAGE_KEY } from 'tg.service/InvitationCodeService'; const LOCAL_STORAGE_DOMAIN_KEY = 'oauth2Domain'; - export const useSsoService = () => { const { handleAfterLogin, setInvitationCode } = useGlobalActions(); @@ -46,7 +45,11 @@ export const useSsoService = () => { if (error.code === 'invitation_code_does_not_exist_or_expired') { setInvitationCode(undefined); } - messageService.error(); + let errorCode = error.code; + if (errorCode && errorCode.endsWith(': null')) { + errorCode = errorCode.replace(': null', ''); + } + messageService.error(); }, } ); @@ -67,7 +70,7 @@ export const useSsoService = () => { if (response.redirectUrl) { localStorage.setItem(LOCAL_STORAGE_DOMAIN_KEY, domain); } - } + }, } ); }, diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index 55748b94d0..a2a93a8f30 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -77,10 +77,7 @@ export class LINKS { 'auth_callback/' + p(PARAMS.SERVICE_TYPE) ); - static OPENID_RESPONSE = Link.ofParent( - LINKS.LOGIN, - 'open-id/auth-callback' - ); + static OPENID_RESPONSE = Link.ofParent(LINKS.LOGIN, 'open-id/auth-callback'); static SSO_LOGIN = Link.ofRoot('sso'); diff --git a/webapp/src/translationTools/useErrorTranslation.ts b/webapp/src/translationTools/useErrorTranslation.ts index cd636d3cf0..7460c8f841 100644 --- a/webapp/src/translationTools/useErrorTranslation.ts +++ b/webapp/src/translationTools/useErrorTranslation.ts @@ -129,6 +129,14 @@ export function useErrorTranslation() { return t('verify_email_verification_code_not_valid'); case 'user_is_subscribed_to_paid_plan': return t('user_is_subscribed_to_paid_plan'); + case 'sso_token_exchange_failed': + return t('sso_token_exchange_failed'); + case 'sso_id_token_expired': + return t('sso_id_token_expired'); + case 'sso_user_info_retrieval_failed': + return t('sso_user_info_retrieval_failed'); + case 'sso_domain_not_enabled': + return t('sso_domain_not_enabled'); default: return code; } From f537f59c68d8a29e438ad1b6235c881a2b0cfbb4 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Tue, 15 Oct 2024 10:14:15 +0200 Subject: [PATCH 081/162] fix: FE prettier --- e2e/cypress/common/login.ts | 14 +++++++++----- e2e/cypress/e2e/security/login.cy.ts | 8 ++++---- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/e2e/cypress/common/login.ts b/e2e/cypress/common/login.ts index 8ed6b65686..14f1233cee 100644 --- a/e2e/cypress/common/login.ts +++ b/e2e/cypress/common/login.ts @@ -1,8 +1,12 @@ -import {HOST, PASSWORD, USERNAME} from '../common/constants'; -import {waitForGlobalLoading} from './loading'; -import {getInput} from './xPath'; -import {gcy} from './shared'; -import {deleteUserSql, enableEmailVerification, enableRegistration,} from './apiCalls/common'; +import { HOST, PASSWORD, USERNAME } from '../common/constants'; +import { waitForGlobalLoading } from './loading'; +import { getInput } from './xPath'; +import { gcy } from './shared'; +import { + deleteUserSql, + enableEmailVerification, + enableRegistration, +} from './apiCalls/common'; export const loginWithFakeGithub = () => { cy.intercept('https://github.com/login/oauth/**', { diff --git a/e2e/cypress/e2e/security/login.cy.ts b/e2e/cypress/e2e/security/login.cy.ts index 412e2b3382..f8c408559c 100644 --- a/e2e/cypress/e2e/security/login.cy.ts +++ b/e2e/cypress/e2e/security/login.cy.ts @@ -1,7 +1,7 @@ /// import * as totp from 'totp-generator'; -import {HOST, PASSWORD, USERNAME} from '../../common/constants'; -import {getAnyContainingText} from '../../common/xPath'; +import { HOST, PASSWORD, USERNAME } from '../../common/constants'; +import { getAnyContainingText } from '../../common/xPath'; import { createUser, deleteAllEmails, @@ -14,7 +14,7 @@ import { userDisableMfa, userEnableMfa, } from '../../common/apiCalls/common'; -import {assertMessage, getPopover} from '../../common/shared'; +import { assertMessage, getPopover } from '../../common/shared'; import { checkAnonymousIdSet, checkAnonymousIdUnset, @@ -24,7 +24,7 @@ import { loginWithFakeOAuth2, loginWithFakeSso, } from '../../common/login'; -import {waitForGlobalLoading} from '../../common/loading'; +import { waitForGlobalLoading } from '../../common/loading'; context('Login', () => { beforeEach(() => { From 923cf488e7ba5dc5decf83af4405749f5f093882 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 18 Oct 2024 09:49:47 +0200 Subject: [PATCH 082/162] fit: remove score role from auth request --- .../io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index 3022c993c4..edab3c5cad 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -44,7 +44,7 @@ class OAuth2CallbackController( "client_id=${tenant.clientId}&" + "redirect_uri=${tenant.redirectUriBase + "/login/open-id/auth-callback"}&" + "response_type=code&" + - "scope=openid profile email roles&" + + "scope=openid profile email&" + "state=$state" @GetMapping("/{registrationId}") From 1493068dc99446ed10bb85cffe3d256230ad1155 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 21 Oct 2024 18:26:55 +0200 Subject: [PATCH 083/162] feat: do not create user's organization on sso login --- .../main/kotlin/io/tolgee/service/security/SignUpService.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 cf2bb713e4..8c2e712775 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 @@ -58,6 +58,10 @@ class SignUpService( invitationService.accept(invitation.code, user) } + if (user.thirdPartyAuthType == "sso") { + return user + } + val canCreateOrganization = tolgeeProperties.authentication.userCanCreateOrganizations if (canCreateOrganization && (invitation == null || !organizationName.isNullOrBlank())) { val name = if (organizationName.isNullOrBlank()) user.name else organizationName From ebec37e40f1a4fa929b1039225a67ff76035987a Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 21 Oct 2024 18:27:28 +0200 Subject: [PATCH 084/162] chore: test sso login doesnt create user's organization --- .../tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 index bd22c66dc1..a66c5a74be 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -14,6 +14,7 @@ import io.tolgee.exceptions.NotFoundException import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.testing.AbstractControllerTest import io.tolgee.testing.assertions.Assertions.assertThat +import jakarta.transaction.Transactional import org.assertj.core.api.Assertions import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -114,4 +115,15 @@ class OAuthTest : AbstractControllerTest() { val userName = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") assertThrows { userAccountService.get(userName) } } + + @Transactional + @Test + fun `sso auth doesn't create demo project and user organization`() { + addTenant() + oAuthMultiTenantsMocks.authorize("registrationId") + val userName = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") + val user = userAccountService.get(userName) + assertThat(user.organizationRoles.size).isEqualTo(1) + assertThat(user.organizationRoles[0].organization?.id).isEqualTo(testData.organization.id) + } } From 1d7061bf72ebbe56734268e5a0fb6a0e71f93def Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 21 Oct 2024 18:38:41 +0200 Subject: [PATCH 085/162] feat: check if organization has sso feature --- .../kotlin/io/tolgee/constants/Feature.kt | 1 + .../v2/controllers/SsoProviderController.kt | 21 ++++++++++++++++++- .../controllers/SsoProviderControllerTest.kt | 7 +++++++ 3 files changed, 28 insertions(+), 1 deletion(-) 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 a7450579fc..2a1b46518e 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Feature.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Feature.kt @@ -18,4 +18,5 @@ enum class Feature { MULTIPLE_CONTENT_DELIVERY_CONFIGS, AI_PROMPT_CUSTOMIZATION, SLACK_INTEGRATION, + SSO, } 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 index a1aa9b6d65..13bb963ca4 100644 --- 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 @@ -1,5 +1,7 @@ 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 @@ -7,6 +9,7 @@ import io.tolgee.ee.service.TenantService import io.tolgee.exceptions.NotFoundException import io.tolgee.hateoas.ee.SsoTenantModel import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.security.OrganizationHolder import io.tolgee.security.authentication.RequiresSuperAuthentication import io.tolgee.security.authorization.RequiresOrganizationRole import jakarta.validation.Valid @@ -18,6 +21,8 @@ import org.springframework.web.bind.annotation.* class SsoProviderController( private val tenantService: TenantService, private val ssoTenantAssembler: SsoTenantAssembler, + private val enabledFeaturesProvider: EnabledFeaturesProvider, + private val organizationHolder: OrganizationHolder, ) { @RequiresOrganizationRole(role = OrganizationRoleType.OWNER) @PutMapping("") @@ -25,7 +30,15 @@ class SsoProviderController( fun setProvider( @RequestBody @Valid request: CreateProviderRequest, @PathVariable organizationId: Long, - ): SsoTenantModel = ssoTenantAssembler.toModel(tenantService.saveOrUpdate(request, organizationId).toDto()) + ): SsoTenantModel { + enabledFeaturesProvider.checkFeatureEnabled( + organizationId = + organizationHolder.organization.id, + Feature.SSO, + ) + + return ssoTenantAssembler.toModel(tenantService.saveOrUpdate(request, organizationId).toDto()) + } @RequiresOrganizationRole(role = OrganizationRoleType.OWNER) @GetMapping("") @@ -34,6 +47,12 @@ class SsoProviderController( @PathVariable organizationId: Long, ): SsoTenantModel? = try { + enabledFeaturesProvider.checkFeatureEnabled( + organizationId = + organizationHolder.organization.id, + Feature.SSO, + ) + ssoTenantAssembler.toModel(tenantService.getTenant(organizationId).toDto()) } catch (e: NotFoundException) { null 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 index e78305dde4..774e5edd3f 100644 --- 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 @@ -1,22 +1,29 @@ 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 From 642d6fdae16b655719f4bfc34734b91fe7c6211a Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 21 Oct 2024 19:13:20 +0200 Subject: [PATCH 086/162] feat: show banner if user doesnt have feature enabled --- webapp/src/service/apiSchema.generated.ts | 17 +++-- .../organizations/sso/OrganizationSsoView.tsx | 69 +++++++++++-------- 2 files changed, 50 insertions(+), 36 deletions(-) diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index f9c534dd7d..57c481675d 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -2259,6 +2259,7 @@ export interface components { | "MULTIPLE_CONTENT_DELIVERY_CONFIGS" | "AI_PROMPT_CUSTOMIZATION" | "SLACK_INTEGRATION" + | "SSO" )[]; /** Format: int64 */ currentPeriodEnd?: number; @@ -2332,11 +2333,11 @@ export interface components { username?: string; scopes: string[]; /** Format: int64 */ - projectId: number; - /** Format: int64 */ expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; + /** Format: int64 */ + projectId: number; projectName: string; }; SuperTokenRequest: { @@ -3231,6 +3232,7 @@ export interface components { | "MULTIPLE_CONTENT_DELIVERY_CONFIGS" | "AI_PROMPT_CUSTOMIZATION" | "SLACK_INTEGRATION" + | "SSO" )[]; prices: components["schemas"]["PlanPricesModel"]; includedUsage: components["schemas"]["PlanIncludedUsageModel"]; @@ -3417,6 +3419,7 @@ export interface components { | "MULTIPLE_CONTENT_DELIVERY_CONFIGS" | "AI_PROMPT_CUSTOMIZATION" | "SLACK_INTEGRATION" + | "SSO" )[]; quickStart?: components["schemas"]["QuickStartModel"]; /** @example Beautiful organization */ @@ -3432,9 +3435,9 @@ export interface components { */ currentUserRole?: "MEMBER" | "OWNER"; basePermissions: components["schemas"]["PermissionModel"]; - avatar?: components["schemas"]["Avatar"]; /** @example btforg */ slug: string; + avatar?: components["schemas"]["Avatar"]; }; PublicBillingConfigurationDTO: { enabled: boolean; @@ -3577,8 +3580,8 @@ export interface components { id: number; description?: string; baseTranslation?: string; - translation?: string; namespace?: string; + translation?: string; }; KeySearchSearchResultModel: { view?: components["schemas"]["KeySearchResultView"]; @@ -3587,8 +3590,8 @@ export interface components { id: number; description?: string; baseTranslation?: string; - translation?: string; namespace?: string; + translation?: string; }; PagedModelKeySearchSearchResultModel: { _embedded?: { @@ -4276,11 +4279,11 @@ export interface components { username?: string; scopes: string[]; /** Format: int64 */ - projectId: number; - /** Format: int64 */ expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; + /** Format: int64 */ + projectId: number; projectName: string; }; PagedModelUserAccountModel: { diff --git a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx index f6ea0bc846..ab7a9ce2c6 100644 --- a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx +++ b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx @@ -1,32 +1,41 @@ -import React, { FunctionComponent, useEffect, useState } from 'react'; -import { useTranslate } from '@tolgee/react'; -import { BaseOrganizationSettingsView } from '../components/BaseOrganizationSettingsView'; -import { LINKS, PARAMS } from 'tg.constants/links'; -import { useOrganization } from '../useOrganization'; -import { CreateProviderSsoForm } from 'tg.views/organizations/sso/CreateProviderSsoForm'; -import { useApiQuery } from 'tg.service/http/useQueryApi'; -import { FormControlLabel, Switch } from '@mui/material'; +import React, {FunctionComponent, useEffect, useState} from 'react'; +import {useTranslate} from '@tolgee/react'; +import {BaseOrganizationSettingsView} from '../components/BaseOrganizationSettingsView'; +import {LINKS, PARAMS} from 'tg.constants/links'; +import {useOrganization} from '../useOrganization'; +import {CreateProviderSsoForm} from 'tg.views/organizations/sso/CreateProviderSsoForm'; +import {useApiQuery} from 'tg.service/http/useQueryApi'; +import {FormControlLabel, styled, Switch} from '@mui/material'; import Box from '@mui/material/Box'; +import {useEnabledFeatures} from "tg.globalContext/helpers"; +import {PaidFeatureBanner} from "tg.ee/common/PaidFeatureBanner"; +const StyledContainer = styled('div')` + background: ${({ theme }) => theme.palette.background.paper}; +`; export const OrganizationSsoView: FunctionComponent = () => { const organization = useOrganization(); + const { isEnabled } = useEnabledFeatures(); + const featureNotEnabled = !isEnabled('SSO'); const { t } = useTranslate(); if (!organization) { return null; } - const providersLoadable = useApiQuery({ - url: `/v2/organizations/{organizationId}/sso`, - method: 'get', - path: { - organizationId: organization.id, - }, - }); + const providersLoadable = featureNotEnabled + ? null + : useApiQuery({ + url: `/v2/organizations/{organizationId}/sso`, + method: 'get', + path: { + organizationId: organization.id, + }, + }); const [toggleFormState, setToggleFormState] = useState(false); useEffect(() => { - setToggleFormState(providersLoadable.data?.isEnabled || false); - }, [providersLoadable.data]); + setToggleFormState(providersLoadable?.data?.isEnabled || false); + }, [providersLoadable?.data]); const handleSwitchChange = (event) => { setToggleFormState(event.target.checked); @@ -48,18 +57,20 @@ export const OrganizationSsoView: FunctionComponent = () => { hideChildrenOnLoading={false} maxWidth="normal" > - - } - label={t('organization_sso_switch')} - /> - - - + {featureNotEnabled ? ( + + + + ) : ( + <>} + label={t('organization_sso_switch')}/> + + + + )} ); }; From 1dee3741e40b638fe78d7422e671f4da9ff5d1d4 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 21 Oct 2024 19:14:14 +0200 Subject: [PATCH 087/162] fix: prettier --- .../organizations/sso/OrganizationSsoView.tsx | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx index ab7a9ce2c6..9724e8a948 100644 --- a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx +++ b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx @@ -1,14 +1,14 @@ -import React, {FunctionComponent, useEffect, useState} from 'react'; -import {useTranslate} from '@tolgee/react'; -import {BaseOrganizationSettingsView} from '../components/BaseOrganizationSettingsView'; -import {LINKS, PARAMS} from 'tg.constants/links'; -import {useOrganization} from '../useOrganization'; -import {CreateProviderSsoForm} from 'tg.views/organizations/sso/CreateProviderSsoForm'; -import {useApiQuery} from 'tg.service/http/useQueryApi'; -import {FormControlLabel, styled, Switch} from '@mui/material'; +import React, { FunctionComponent, useEffect, useState } from 'react'; +import { useTranslate } from '@tolgee/react'; +import { BaseOrganizationSettingsView } from '../components/BaseOrganizationSettingsView'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { useOrganization } from '../useOrganization'; +import { CreateProviderSsoForm } from 'tg.views/organizations/sso/CreateProviderSsoForm'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { FormControlLabel, styled, Switch } from '@mui/material'; import Box from '@mui/material/Box'; -import {useEnabledFeatures} from "tg.globalContext/helpers"; -import {PaidFeatureBanner} from "tg.ee/common/PaidFeatureBanner"; +import { useEnabledFeatures } from 'tg.globalContext/helpers'; +import { PaidFeatureBanner } from 'tg.ee/common/PaidFeatureBanner'; const StyledContainer = styled('div')` background: ${({ theme }) => theme.palette.background.paper}; @@ -23,8 +23,8 @@ export const OrganizationSsoView: FunctionComponent = () => { } const providersLoadable = featureNotEnabled - ? null - : useApiQuery({ + ? null + : useApiQuery({ url: `/v2/organizations/{organizationId}/sso`, method: 'get', path: { @@ -58,18 +58,24 @@ export const OrganizationSsoView: FunctionComponent = () => { maxWidth="normal" > {featureNotEnabled ? ( - - - + + + ) : ( - <>} - label={t('organization_sso_switch')}/> - + <> + + } + label={t('organization_sso_switch')} + /> + - + data={providersLoadable?.data} + disabled={!toggleFormState} + /> + + )} ); From a3d655edae544078295edc3fb0e702689fed9cc1 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 21 Oct 2024 19:30:38 +0200 Subject: [PATCH 088/162] fix: add sso feature message --- webapp/src/translationTools/useFeatures.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/webapp/src/translationTools/useFeatures.tsx b/webapp/src/translationTools/useFeatures.tsx index 68fc5fa01e..bd63e492b1 100644 --- a/webapp/src/translationTools/useFeatures.tsx +++ b/webapp/src/translationTools/useFeatures.tsx @@ -1,6 +1,6 @@ -import { useTranslate } from '@tolgee/react'; -import { FeatureLink } from 'tg.component/billing/FeatureLink'; -import { components } from 'tg.service/apiSchema.generated'; +import {useTranslate} from '@tolgee/react'; +import {FeatureLink} from 'tg.component/billing/FeatureLink'; +import {components} from 'tg.service/apiSchema.generated'; type Feature = components['schemas']['SelfHostedEePlanModel']['enabledFeatures'][number]; @@ -33,6 +33,7 @@ export function useFeatures() { 'billing_subscriptions_prioritized_feature_requests' ), SLACK_INTEGRATION: t('billing_subscriptions_slack_integration'), + SSO: t('billing_subscriptions_sso'), } as const satisfies Record; } From 0672f2694c44fa438f4d23298be0be96edbd2e11 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 21 Oct 2024 19:30:52 +0200 Subject: [PATCH 089/162] fix: renaming --- e2e/cypress/e2e/security/login.cy.ts | 10 +++++----- .../tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 6 +++--- .../io/tolgee/ee/utils/OAuthMultiTenantsMocks.kt | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/e2e/cypress/e2e/security/login.cy.ts b/e2e/cypress/e2e/security/login.cy.ts index f8c408559c..7aad364107 100644 --- a/e2e/cypress/e2e/security/login.cy.ts +++ b/e2e/cypress/e2e/security/login.cy.ts @@ -1,7 +1,7 @@ /// import * as totp from 'totp-generator'; -import { HOST, PASSWORD, USERNAME } from '../../common/constants'; -import { getAnyContainingText } from '../../common/xPath'; +import {HOST, PASSWORD, USERNAME} from '../../common/constants'; +import {getAnyContainingText} from '../../common/xPath'; import { createUser, deleteAllEmails, @@ -14,7 +14,7 @@ import { userDisableMfa, userEnableMfa, } from '../../common/apiCalls/common'; -import { assertMessage, getPopover } from '../../common/shared'; +import {assertMessage, getPopover} from '../../common/shared'; import { checkAnonymousIdSet, checkAnonymousIdUnset, @@ -24,7 +24,7 @@ import { loginWithFakeOAuth2, loginWithFakeSso, } from '../../common/login'; -import { waitForGlobalLoading } from '../../common/loading'; +import {waitForGlobalLoading} from '../../common/loading'; context('Login', () => { beforeEach(() => { @@ -35,7 +35,7 @@ context('Login', () => { cy.gcy('global-language-menu').should('be.visible'); }); - context('Test Suite for SSO Login', () => { + context('SSO Login', () => { it('login with sso', { retries: { runMode: 5 } }, () => { disableEmailVerification(); setSsoProvider(); 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 index a66c5a74be..f0b8ded3ee 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -13,9 +13,9 @@ import io.tolgee.ee.utils.OAuthMultiTenantsMocks import io.tolgee.exceptions.NotFoundException import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.testing.AbstractControllerTest +import io.tolgee.testing.assert import io.tolgee.testing.assertions.Assertions.assertThat import jakarta.transaction.Transactional -import org.assertj.core.api.Assertions import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows @@ -76,8 +76,8 @@ class OAuthTest : AbstractControllerTest() { val response = oAuthMultiTenantsMocks.authorize("registrationId") assertThat(response.response.status).isEqualTo(200) val result = jacksonObjectMapper().readValue(response.response.contentAsString, HashMap::class.java) - Assertions.assertThat(result["accessToken"]).isNotNull - Assertions.assertThat(result["tokenType"]).isEqualTo("Bearer") + result["accessToken"].assert.isNotNull + result["tokenType"].assert.isEqualTo("Bearer") val userName = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") assertThat(userAccountService.get(userName)).isNotNull } 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 index 93e55fb820..0bfa3cbb57 100644 --- 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 @@ -87,7 +87,7 @@ class OAuthMultiTenantsMocks( ).thenReturn(tokenResponse) // mock parsing of jwt - mockJwk() + mockJwt() return authMvc!! .perform( @@ -113,7 +113,7 @@ class OAuthMultiTenantsMocks( ), ).andReturn() - private fun mockJwk() { + private fun mockJwt() { whenever( jwtProcessor?.process( any(), From 72b531efa57dead6f9bfccb904be88c05e11a10d Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 21 Oct 2024 19:31:09 +0200 Subject: [PATCH 090/162] fix: prettier --- webapp/src/translationTools/useFeatures.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/webapp/src/translationTools/useFeatures.tsx b/webapp/src/translationTools/useFeatures.tsx index bd63e492b1..70a4a3a8bb 100644 --- a/webapp/src/translationTools/useFeatures.tsx +++ b/webapp/src/translationTools/useFeatures.tsx @@ -1,6 +1,6 @@ -import {useTranslate} from '@tolgee/react'; -import {FeatureLink} from 'tg.component/billing/FeatureLink'; -import {components} from 'tg.service/apiSchema.generated'; +import { useTranslate } from '@tolgee/react'; +import { FeatureLink } from 'tg.component/billing/FeatureLink'; +import { components } from 'tg.service/apiSchema.generated'; type Feature = components['schemas']['SelfHostedEePlanModel']['enabledFeatures'][number]; From f232ae663df9372eddd37f559093603af98c6db2 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 21 Oct 2024 19:31:32 +0200 Subject: [PATCH 091/162] fix: prettier --- e2e/cypress/e2e/security/login.cy.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/e2e/cypress/e2e/security/login.cy.ts b/e2e/cypress/e2e/security/login.cy.ts index 7aad364107..a91bb3177b 100644 --- a/e2e/cypress/e2e/security/login.cy.ts +++ b/e2e/cypress/e2e/security/login.cy.ts @@ -1,7 +1,7 @@ /// import * as totp from 'totp-generator'; -import {HOST, PASSWORD, USERNAME} from '../../common/constants'; -import {getAnyContainingText} from '../../common/xPath'; +import { HOST, PASSWORD, USERNAME } from '../../common/constants'; +import { getAnyContainingText } from '../../common/xPath'; import { createUser, deleteAllEmails, @@ -14,7 +14,7 @@ import { userDisableMfa, userEnableMfa, } from '../../common/apiCalls/common'; -import {assertMessage, getPopover} from '../../common/shared'; +import { assertMessage, getPopover } from '../../common/shared'; import { checkAnonymousIdSet, checkAnonymousIdUnset, @@ -24,7 +24,7 @@ import { loginWithFakeOAuth2, loginWithFakeSso, } from '../../common/login'; -import {waitForGlobalLoading} from '../../common/loading'; +import { waitForGlobalLoading } from '../../common/loading'; context('Login', () => { beforeEach(() => { From 61e5fd34a179d030cefd2877e96e40ec46318c96 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Mon, 21 Oct 2024 19:46:29 +0200 Subject: [PATCH 092/162] fix: remove scope --- e2e/cypress/common/login.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/cypress/common/login.ts b/e2e/cypress/common/login.ts index 14f1233cee..fefc082b8c 100644 --- a/e2e/cypress/common/login.ts +++ b/e2e/cypress/common/login.ts @@ -61,7 +61,7 @@ export const loginWithFakeSso = () => { 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 roles'); + 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 From ac207b6e831ee9793ba25fe972eaddb375e9c2fe Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Tue, 22 Oct 2024 11:20:24 +0200 Subject: [PATCH 093/162] feat: prevent sso user from create organizations --- .../kotlin/io/tolgee/constants/Message.kt | 1 + .../organization/OrganizationService.kt | 88 +++++++------------ .../src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 41 +++++++-- .../translationTools/useErrorTranslation.ts | 4 +- 4 files changed, 70 insertions(+), 64 deletions(-) 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 904e681df8..1d51e9ce00 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -244,6 +244,7 @@ enum class Message { PLAN_AUTO_ASSIGNMENT_ONLY_FOR_FREE_PLANS, PLAN_AUTO_ASSIGNMENT_ONLY_FOR_PRIVATE_PLANS, PLAN_AUTO_ASSIGNMENT_ORGANIZATION_IDS_NOT_IN_FOR_ORGANIZATION_IDS, + SSO_USER_CANNOT_CREATE_ORGANIZATION, ; val code: String 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..5940c10a8e 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 @@ -11,6 +11,7 @@ import io.tolgee.dtos.request.organization.OrganizationRequestParamsDto import io.tolgee.dtos.request.validators.exceptions.ValidationException import io.tolgee.events.BeforeOrganizationDeleteEvent import io.tolgee.exceptions.NotFoundException +import io.tolgee.exceptions.PermissionException import io.tolgee.model.Organization import io.tolgee.model.Permission import io.tolgee.model.UserAccount @@ -66,9 +67,7 @@ class OrganizationService( lateinit var projectService: ProjectService @Transactional - fun create(createDto: OrganizationDto): Organization { - return create(createDto, authenticationFacade.authenticatedUserEntity) - } + fun create(createDto: OrganizationDto): Organization = create(createDto, authenticationFacade.authenticatedUserEntity) @Transactional fun create( @@ -79,6 +78,10 @@ class OrganizationService( throw ValidationException(Message.ADDRESS_PART_NOT_UNIQUE) } + if (userAccount.thirdPartyAuthType == "sso") { + throw PermissionException(Message.SSO_USER_CANNOT_CREATE_ORGANIZATION) + } + val slug = createDto.slug ?: generateSlug(createDto.name) @@ -129,13 +132,14 @@ class OrganizationService( fun findPreferred( userAccountId: Long, exceptOrganizationId: Long = 0, - ): Organization? { - return organizationRepository.findPreferred( - userId = userAccountId, - exceptOrganizationId, - PageRequest.of(0, 1), - ).content.firstOrNull() - } + ): Organization? = + organizationRepository + .findPreferred( + userId = userAccountId, + exceptOrganizationId, + PageRequest.of(0, 1), + ).content + .firstOrNull() /** * Returns existing or created organization which seems to be potentially preferred. @@ -156,55 +160,42 @@ class OrganizationService( pageable: Pageable, requestParamsDto: OrganizationRequestParamsDto, exceptOrganizationId: Long? = null, - ): Page { - return findPermittedPaged( + ): Page = + findPermittedPaged( pageable, requestParamsDto.filterCurrentUserOwner, requestParamsDto.search, exceptOrganizationId, ) - } fun findPermittedPaged( pageable: Pageable, filterCurrentUserOwner: Boolean = false, search: String? = null, exceptOrganizationId: Long? = null, - ): Page { - return organizationRepository.findAllPermitted( + ): Page = + organizationRepository.findAllPermitted( userId = authenticationFacade.authenticatedUser.id, pageable = pageable, roleType = if (filterCurrentUserOwner) OrganizationRoleType.OWNER else null, search = search, exceptOrganizationId = exceptOrganizationId, ) - } - fun get(id: Long): Organization { - return organizationRepository.find(id) ?: throw NotFoundException(Message.ORGANIZATION_NOT_FOUND) - } + fun get(id: Long): Organization = + organizationRepository.find(id) ?: throw NotFoundException(Message.ORGANIZATION_NOT_FOUND) - fun find(id: Long): Organization? { - return organizationRepository.find(id) - } + fun find(id: Long): Organization? = organizationRepository.find(id) - fun get(slug: String): Organization { - return find(slug) ?: throw NotFoundException(Message.ORGANIZATION_NOT_FOUND) - } + fun get(slug: String): Organization = find(slug) ?: throw NotFoundException(Message.ORGANIZATION_NOT_FOUND) - fun find(slug: String): Organization? { - return organizationRepository.findBySlug(slug) - } + fun find(slug: String): Organization? = organizationRepository.findBySlug(slug) @Cacheable(cacheNames = [Caches.ORGANIZATIONS], key = "{'id', #id}") - fun findDto(id: Long): CachedOrganizationDto? { - return find(id)?.let { CachedOrganizationDto.fromEntity(it) } - } + fun findDto(id: Long): CachedOrganizationDto? = find(id)?.let { CachedOrganizationDto.fromEntity(it) } @Cacheable(cacheNames = [Caches.ORGANIZATIONS], key = "{'slug', #slug}") - fun findDto(slug: String): CachedOrganizationDto? { - return find(slug)?.let { CachedOrganizationDto.fromEntity(it) } - } + fun findDto(slug: String): CachedOrganizationDto? = find(slug)?.let { CachedOrganizationDto.fromEntity(it) } @CacheEvict(cacheNames = [Caches.ORGANIZATIONS], key = "{'id', #id}") fun edit( @@ -308,13 +299,9 @@ class OrganizationService( * Checks slug uniqueness * @return Returns true if valid */ - fun validateSlugUniqueness(slug: String): Boolean { - return !organizationRepository.organizationWithSlugExists(slug) - } + fun validateSlugUniqueness(slug: String): Boolean = !organizationRepository.organizationWithSlugExists(slug) - fun isThereAnotherOwner(id: Long): Boolean { - return organizationRoleService.isAnotherOwnerInOrganization(id) - } + fun isThereAnotherOwner(id: Long): Boolean = organizationRoleService.isAnotherOwnerInOrganization(id) fun generateSlug( name: String, @@ -353,17 +340,11 @@ class OrganizationService( pageable: Pageable, search: String?, userId: Long, - ): Page { - return organizationRepository.findAllViews(pageable, search, userId) - } + ): Page = organizationRepository.findAllViews(pageable, search, userId) - fun findAllByName(name: String): List { - return organizationRepository.findAllByName(name) - } + fun findAllByName(name: String): List = organizationRepository.findAllByName(name) - fun getProjectOwner(projectId: Long): Organization { - return organizationRepository.getProjectOwner(projectId) - } + fun getProjectOwner(projectId: Long): Organization = organizationRepository.getProjectOwner(projectId) fun setBasePermission( organizationId: Long, @@ -380,17 +361,14 @@ class OrganizationService( fun findPrivateView( id: Long, currentUserId: Long, - ): PrivateOrganizationView? { - return findView(id, currentUserId)?.let { + ): PrivateOrganizationView? = + findView(id, currentUserId)?.let { val quickStart = quickStartService.findView(currentUserId, id) PrivateOrganizationView(it, quickStart) } - } fun findView( id: Long, currentUserId: Long, - ): OrganizationView? { - return organizationRepository.findView(id, currentUserId) - } + ): OrganizationView? = organizationRepository.findView(id, currentUserId) } 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 index f0b8ded3ee..41a2879296 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -5,14 +5,16 @@ import com.nimbusds.jose.proc.SecurityContext import com.nimbusds.jwt.proc.ConfigurableJWTProcessor import io.tolgee.constants.Message import io.tolgee.development.testDataBuilder.data.OAuthTestData +import io.tolgee.dtos.request.organization.OrganizationDto import io.tolgee.ee.data.OAuth2TokenResponse import io.tolgee.ee.model.SsoTenant import io.tolgee.ee.service.OAuthService import io.tolgee.ee.service.TenantService import io.tolgee.ee.utils.OAuthMultiTenantsMocks import io.tolgee.exceptions.NotFoundException +import io.tolgee.fixtures.andIsForbidden import io.tolgee.model.enums.OrganizationRoleType -import io.tolgee.testing.AbstractControllerTest +import io.tolgee.testing.AuthorizedControllerTest import io.tolgee.testing.assert import io.tolgee.testing.assertions.Assertions.assertThat import jakarta.transaction.Transactional @@ -23,9 +25,10 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.mock.mockito.MockBean 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 -class OAuthTest : AbstractControllerTest() { +class OAuthTest : AuthorizedControllerTest() { private lateinit var testData: OAuthTestData @MockBean @@ -72,8 +75,7 @@ class OAuthTest : AbstractControllerTest() { @Test fun `creates new user account and return access token on sso log in`() { - addTenant() - val response = oAuthMultiTenantsMocks.authorize("registrationId") + val response = loginAsSsoUser() assertThat(response.response.status).isEqualTo(200) val result = jacksonObjectMapper().readValue(response.response.contentAsString, HashMap::class.java) result["accessToken"].assert.isNotNull @@ -94,8 +96,7 @@ class OAuthTest : AbstractControllerTest() { @Test fun `new user belongs to organization associated with the sso issuer`() { - addTenant() - oAuthMultiTenantsMocks.authorize("registrationId") + loginAsSsoUser() val userName = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") val user = userAccountService.get(userName) assertThat(organizationRoleService.isUserOfRole(user.id, testData.organization.id, OrganizationRoleType.MEMBER)) @@ -119,11 +120,35 @@ class OAuthTest : AbstractControllerTest() { @Transactional @Test fun `sso auth doesn't create demo project and user organization`() { - addTenant() - oAuthMultiTenantsMocks.authorize("registrationId") + loginAsSsoUser() val userName = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") val user = userAccountService.get(userName) 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 = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") + val user = userAccountService.get(userName) + loginAsUser(user) + performAuthPost( + "/v2/organizations", + organizationDto(), + ).andIsForbidden + } + + fun organizationDto() = + OrganizationDto( + "Test org", + "This is description", + "test-org", + ) + + fun loginAsSsoUser(): MvcResult { + addTenant() + return oAuthMultiTenantsMocks.authorize("registrationId") + } } diff --git a/webapp/src/translationTools/useErrorTranslation.ts b/webapp/src/translationTools/useErrorTranslation.ts index 7460c8f841..50468fc859 100644 --- a/webapp/src/translationTools/useErrorTranslation.ts +++ b/webapp/src/translationTools/useErrorTranslation.ts @@ -1,4 +1,4 @@ -import { useTranslate } from '@tolgee/react'; +import {useTranslate} from '@tolgee/react'; export function useErrorTranslation() { const { t } = useTranslate(); @@ -137,6 +137,8 @@ export function useErrorTranslation() { return t('sso_user_info_retrieval_failed'); case 'sso_domain_not_enabled': return t('sso_domain_not_enabled'); + case 'sso_user_cannot_create_organization': + return t('sso_user_cannot_create_organization'); default: return code; } From 53df0c0eedbdeb11a7a3a94738df459ed053628c Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Tue, 22 Oct 2024 12:30:35 +0200 Subject: [PATCH 094/162] fix: if it's sso user find by sso domain --- .../security/thirdParty/OAuthUserHandler.kt | 7 +- .../repository/UserAccountRepository.kt | 14 ++++ .../service/security/UserAccountService.kt | 78 +++++++------------ 3 files changed, 47 insertions(+), 52 deletions(-) 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 index 6b23cf346c..36a6e912a3 100644 --- a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt @@ -21,7 +21,12 @@ class OAuthUserHandler( invitationCode: String?, thirdPartyAuthType: String, ): UserAccount { - val userAccountOptional = userAccountService.findByThirdParty(thirdPartyAuthType, userResponse.sub!!) + val userAccountOptional = + if (thirdPartyAuthType == "sso" && userResponse.domain != null) { + userAccountService.findByDomainSso(userResponse.domain, userResponse.sub!!) + } else { + userAccountService.findByThirdParty(thirdPartyAuthType, userResponse.sub!!) + } return userAccountOptional.orElseGet { userAccountService.findActive(userResponse.email)?.let { 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 bc0de7fb04..ec394d4269 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt @@ -82,6 +82,20 @@ interface UserAccountRepository : JpaRepository { thirdPartyAuthType: String, ): Optional + @Query( + """ + from UserAccount ua + where ua.thirdPartyAuthId = :thirdPartyAuthId + and ua.ssoDomain = :domain + and ua.deletedAt is null + and ua.disabledAt is null + """, + ) + fun findBySsoDomain( + thirdPartyAuthId: String, + domain: String, + ): Optional + @Query( """ select ua.id as id, ua.name as name, ua.username as username, mr.type as organizationRole, ua.avatarHash as avatarHash 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 3f15d8091b..0b35c6192a 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 @@ -70,40 +70,28 @@ class UserAccountService( private val emailValidator = EmailValidator() - fun findActive(username: String): UserAccount? { - return userAccountRepository.findActive(username) - } + fun findActive(username: String): UserAccount? = userAccountRepository.findActive(username) - operator fun get(username: String): UserAccount { - return this.findActive(username) ?: throw NotFoundException(Message.USER_NOT_FOUND) - } + operator fun get(username: String): UserAccount = + this.findActive(username) ?: throw NotFoundException(Message.USER_NOT_FOUND) @Transactional - fun findActive(id: Long): UserAccount? { - return userAccountRepository.findActive(id) - } + fun findActive(id: Long): UserAccount? = userAccountRepository.findActive(id) @Transactional - fun findInitialUser(): UserAccount? { - return userAccountRepository.findInitialUser() - } + fun findInitialUser(): UserAccount? = userAccountRepository.findInitialUser() - fun get(id: Long): UserAccount { - return this.findActive(id) ?: throw NotFoundException(Message.USER_NOT_FOUND) - } + fun get(id: Long): UserAccount = this.findActive(id) ?: throw NotFoundException(Message.USER_NOT_FOUND) @Cacheable(cacheNames = [Caches.USER_ACCOUNTS], key = "#id") @Transactional - fun findDto(id: Long): UserAccountDto? { - return userAccountRepository.findActive(id)?.let { + fun findDto(id: Long): UserAccountDto? = + userAccountRepository.findActive(id)?.let { UserAccountDto.fromEntity(it) } - } @Transactional - fun getDto(id: Long): UserAccountDto { - return self.findDto(id) ?: throw NotFoundException(Message.USER_NOT_FOUND) - } + fun getDto(id: Long): UserAccountDto = self.findDto(id) ?: throw NotFoundException(Message.USER_NOT_FOUND) fun getOrCreateDemoUsers(demoUsers: List): Map { val usernames = demoUsers.map { it.username } @@ -222,9 +210,12 @@ class UserAccountService( fun findByThirdParty( type: String, id: String, - ): Optional { - return userAccountRepository.findThirdByThirdParty(id, type) - } + ): Optional = userAccountRepository.findThirdByThirdParty(id, type) + + fun findByDomainSso( + type: String, + idSub: String, + ): Optional = userAccountRepository.findBySsoDomain(idSub, type) @Transactional @CacheEvict(cacheNames = [Caches.USER_ACCOUNTS], key = "#result.id") @@ -261,9 +252,7 @@ class UserAccountService( fun isResetCodeValid( userAccount: UserAccount, code: String?, - ): Boolean { - return passwordEncoder.matches(code, userAccount.resetPasswordCode) - } + ): Boolean = passwordEncoder.matches(code, userAccount.resetPasswordCode) @Transactional @CacheEvict(cacheNames = [Caches.USER_ACCOUNTS], key = "#result.id") @@ -336,18 +325,16 @@ class UserAccountService( organizationId: Long, pageable: Pageable, search: String?, - ): Page { - return userAccountRepository.getAllInOrganization(organizationId, pageable, search = search ?: "") - } + ): Page = + userAccountRepository.getAllInOrganization(organizationId, pageable, search = search ?: "") fun getAllInProject( projectId: Long, pageable: Pageable, search: String?, exceptUserId: Long? = null, - ): Page { - return userAccountRepository.getAllInProject(projectId, pageable, search = search, exceptUserId) - } + ): Page = + userAccountRepository.getAllInProject(projectId, pageable, search = search, exceptUserId) fun getAllInProjectWithPermittedLanguages( projectId: Long, @@ -453,33 +440,22 @@ class UserAccountService( userAccountRepository.saveAll(userAccounts) @CacheEvict(cacheNames = [Caches.USER_ACCOUNTS], key = "#result.id") - fun save(user: UserAccount): UserAccount { - return userAccountRepository.save(user) - } + fun save(user: UserAccount): UserAccount = userAccountRepository.save(user) @CacheEvict(cacheNames = [Caches.USER_ACCOUNTS], key = "#result.id") - fun saveAndFlush(user: UserAccount): UserAccount { - return userAccountRepository.saveAndFlush(user) - } + fun saveAndFlush(user: UserAccount): UserAccount = userAccountRepository.saveAndFlush(user) - fun getAllByIdsIncludingDeleted(ids: Set): MutableList { - return userAccountRepository.getAllByIdsIncludingDeleted(ids) - } + fun getAllByIdsIncludingDeleted(ids: Set): MutableList = + userAccountRepository.getAllByIdsIncludingDeleted(ids) fun findAllWithDisabledPaged( pageable: Pageable, search: String?, - ): Page { - return userAccountRepository.findAllWithDisabledPaged(search, pageable) - } + ): Page = userAccountRepository.findAllWithDisabledPaged(search, pageable) - fun countAll(): Long { - return userAccountRepository.count() - } + fun countAll(): Long = userAccountRepository.count() - fun countAllEnabled(): Long { - return userAccountRepository.countAllEnabled() - } + fun countAllEnabled(): Long = userAccountRepository.countAllEnabled() @Transactional @CacheEvict(cacheNames = [Caches.USER_ACCOUNTS], key = "#userId") From b8a4962dba85b62198e433c0f043fe6e0f64b0a2 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Tue, 22 Oct 2024 12:43:52 +0200 Subject: [PATCH 095/162] fix: change default sso login logo --- .../tolgee/configuration/tolgee/AuthenticationProperties.kt | 4 ++-- webapp/public/sso_login.svg | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 webapp/public/sso_login.svg 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 b1ea5e5031..1bacfce224 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 @@ -146,11 +146,11 @@ class AuthenticationProperties( @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 (the default logo is Tolgee's logo," + + "You may need that when you want to enable login via your custom SSO (the default logo is sso_login.svg," + " which is stored in the webapp/public directory).", ) var customLogoUrl: String? = - "/favicon.svg", + "/sso_login.svg", @DocProperty( description = "Custom text for the login button.", defaultExplanation = "Defaults to 'SSO Login' if not set.", diff --git a/webapp/public/sso_login.svg b/webapp/public/sso_login.svg new file mode 100644 index 0000000000..ce351e096e --- /dev/null +++ b/webapp/public/sso_login.svg @@ -0,0 +1 @@ + \ No newline at end of file From af85aff69601212bace285c504435e1b0048b6ca Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 25 Oct 2024 10:00:27 +0200 Subject: [PATCH 096/162] feat: add sso valid user cache --- .../CacheWithExpirationManager.kt | 14 ++++++++++++-- .../src/main/kotlin/io/tolgee/constants/Caches.kt | 2 ++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/backend/data/src/main/kotlin/io/tolgee/component/cacheWithExpiration/CacheWithExpirationManager.kt b/backend/data/src/main/kotlin/io/tolgee/component/cacheWithExpiration/CacheWithExpirationManager.kt index 74617aa5cb..e5e01e68a3 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/cacheWithExpiration/CacheWithExpirationManager.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/cacheWithExpiration/CacheWithExpirationManager.kt @@ -3,13 +3,23 @@ package io.tolgee.component.cacheWithExpiration import io.tolgee.component.CurrentDateProvider import org.springframework.cache.CacheManager import org.springframework.stereotype.Component +import java.time.Duration @Component class CacheWithExpirationManager( private val cacheManager: CacheManager, private val currentDateProvider: CurrentDateProvider, ) { - fun getCache(name: String): CacheWithExpiration? { - return cacheManager.getCache(name)?.let { CacheWithExpiration(it, currentDateProvider) } + fun getCache(name: String): CacheWithExpiration? = + cacheManager.getCache(name)?.let { CacheWithExpiration(it, currentDateProvider) } + + fun putCache( + cacheName: String, + userId: Long, + isUserStillValid: Boolean, + expiration: Duration = Duration.ofMinutes(10), + ) { + val cache = getCache(cacheName) + cache?.put(userId, isUserStillValid, expiration) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/constants/Caches.kt b/backend/data/src/main/kotlin/io/tolgee/constants/Caches.kt index 4a2908ef63..aa1589c7ca 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Caches.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Caches.kt @@ -19,6 +19,7 @@ interface Caches { const val EE_SUBSCRIPTION = "eeSubscription" const val LANGUAGES = "languages" const val ORGANIZATION_ROLES = "organizationRoles" + const val IS_SSO_USER_VALID = "ssoUserValid" val caches = listOf( @@ -37,6 +38,7 @@ interface Caches { EE_SUBSCRIPTION, LANGUAGES, ORGANIZATION_ROLES, + IS_SSO_USER_VALID, ) } } From 284d7a6dfcd7f73142eed2d5f479f65862cd92f0 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 25 Oct 2024 10:01:37 +0200 Subject: [PATCH 097/162] feat: add validation that user is still an employee --- .../security/thirdParty/OAuthUserHandler.kt | 30 ++++++++ .../thirdParty/data/OAuthUserDetails.kt | 1 + .../kotlin/io/tolgee/constants/Message.kt | 1 + .../tolgee/dtos/cacheable/UserAccountDto.kt | 10 ++- .../kotlin/io/tolgee/model/UserAccount.kt | 2 + .../authentication/AuthenticationFilter.kt | 25 ++++++- .../controllers/OAuth2CallbackController.kt | 2 +- .../io/tolgee/ee/data/OAuth2TokenResponse.kt | 1 + .../io/tolgee/ee/service/OAuthService.kt | 69 ++++++++++++++++++- 9 files changed, 132 insertions(+), 9 deletions(-) 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 index 36a6e912a3..17541f2a68 100644 --- a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt @@ -1,5 +1,7 @@ package io.tolgee.security.thirdParty +import io.tolgee.component.cacheWithExpiration.CacheWithExpirationManager +import io.tolgee.constants.Caches import io.tolgee.constants.Message import io.tolgee.exceptions.AuthenticationException import io.tolgee.model.UserAccount @@ -15,6 +17,7 @@ class OAuthUserHandler( private val signUpService: SignUpService, private val organizationRoleService: OrganizationRoleService, private val userAccountService: UserAccountService, + private val cacheWithExpirationManager: CacheWithExpirationManager, ) { fun findOrCreateUser( userResponse: OAuthUserDetails, @@ -28,6 +31,11 @@ class OAuthUserHandler( userAccountService.findByThirdParty(thirdPartyAuthType, userResponse.sub!!) } + if (userAccountOptional.isPresent && thirdPartyAuthType == "sso") { + updateRefreshToken(userAccountOptional.get(), userResponse.refreshToken) + cacheSsoUser(userAccountOptional.get().id, thirdPartyAuthType) + } + return userAccountOptional.orElseGet { userAccountService.findActive(userResponse.email)?.let { throw AuthenticationException(Message.USERNAME_ALREADY_EXISTS) @@ -48,6 +56,7 @@ class OAuthUserHandler( newUserAccount.thirdPartyAuthId = userResponse.sub newUserAccount.ssoDomain = userResponse.domain newUserAccount.thirdPartyAuthType = thirdPartyAuthType + newUserAccount.ssoRefreshToken = userResponse.refreshToken newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY signUpService.signUp(newUserAccount, invitationCode, null) @@ -61,7 +70,28 @@ class OAuthUserHandler( ) } + cacheSsoUser(newUserAccount.id, thirdPartyAuthType) + newUserAccount } } + + private fun updateRefreshToken( + userAccount: UserAccount, + refreshToken: String?, + ) { + if (userAccount.ssoRefreshToken != refreshToken) { + userAccount.ssoRefreshToken = refreshToken + userAccountService.save(userAccount) + } + } + + private fun cacheSsoUser( + userId: Long, + thirdPartyAuthType: String, + ) { + if (thirdPartyAuthType == "sso") { + cacheWithExpirationManager.putCache(Caches.IS_SSO_USER_VALID, userId, true) + } + } } 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 index 649c2d32f0..1ad2cf19f8 100644 --- 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 @@ -8,4 +8,5 @@ data class OAuthUserDetails( var email: String = "", val domain: String? = null, val organizationId: Long? = null, + val refreshToken: String? = null, ) 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 1d51e9ce00..f1f70d2a9c 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -245,6 +245,7 @@ enum class Message { PLAN_AUTO_ASSIGNMENT_ONLY_FOR_PRIVATE_PLANS, PLAN_AUTO_ASSIGNMENT_ORGANIZATION_IDS_NOT_IN_FOR_ORGANIZATION_IDS, SSO_USER_CANNOT_CREATE_ORGANIZATION, + SSO_CANT_VERIFY_USER, ; val code: String 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..f4752ae8ce 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 @@ -14,6 +14,9 @@ data class UserAccountDto( val deleted: Boolean, val tokensValidNotBefore: Date?, val emailVerified: Boolean, + val thirdPartyAuth: String?, + val ssoRefreshToken: String?, + val ssoDomain: String?, ) : Serializable { companion object { fun fromEntity(entity: UserAccount) = @@ -27,10 +30,11 @@ data class UserAccountDto( deleted = entity.deletedAt != null, tokensValidNotBefore = entity.tokensValidNotBefore, emailVerified = entity.emailVerification == null, + thirdPartyAuth = entity.thirdPartyAuthType, + ssoRefreshToken = entity.ssoRefreshToken, + ssoDomain = entity.ssoDomain, ) } - override fun toString(): String { - return username - } + override fun toString(): String = username } 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 fa57fbf0e3..75d15b16c0 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt @@ -49,6 +49,8 @@ data class UserAccount( @Column(name = "sso_domain") var ssoDomain: String? = null + var ssoRefreshToken: String? = null + @Column(name = "third_party_auth_id") var thirdPartyAuthId: String? = null 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..e2b44aad1b 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 @@ -23,6 +23,7 @@ import io.tolgee.dtos.cacheable.UserAccountDto import io.tolgee.exceptions.AuthenticationException import io.tolgee.security.PAT_PREFIX import io.tolgee.security.ratelimit.RateLimitService +import io.tolgee.security.service.OAuthServiceEe import io.tolgee.service.security.ApiKeyService import io.tolgee.service.security.PatService import io.tolgee.service.security.UserAccountService @@ -50,6 +51,8 @@ class AuthenticationFilter( private val apiKeyService: ApiKeyService, @Lazy private val patService: PatService, + @Lazy + private val oAuthService: OAuthServiceEe, ) : OncePerRequestFilter() { override fun doFilterInternal( request: HttpServletRequest, @@ -70,15 +73,15 @@ class AuthenticationFilter( filterChain.doFilter(request, response) } - override fun shouldNotFilter(request: HttpServletRequest): Boolean { - return request.method == "OPTIONS" - } + override fun shouldNotFilter(request: HttpServletRequest): Boolean = request.method == "OPTIONS" private fun doAuthenticate(request: HttpServletRequest) { val authorization = request.getHeader("Authorization") if (authorization != null) { if (authorization.startsWith("Bearer ")) { val auth = jwtService.validateToken(authorization.substring(7)) + checkIfSsoUserStillExists(auth.principal) + SecurityContextHolder.getContext().authentication = auth return } @@ -111,6 +114,18 @@ class AuthenticationFilter( } } + private fun checkIfSsoUserStillExists(userDto: UserAccountDto) { + if (!oAuthService.verifyUserIsStillEmployed( + userDto.ssoDomain, + userDto.id, + userDto.ssoRefreshToken, + userDto.thirdPartyAuth, + ) + ) { + throw AuthenticationException(Message.SSO_CANT_VERIFY_USER) + } + } + private fun pakAuth(key: String) { val parsed = apiKeyService.parseApiKey(key) @@ -129,6 +144,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 +169,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/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index edab3c5cad..3327fa8b69 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -44,7 +44,7 @@ class OAuth2CallbackController( "client_id=${tenant.clientId}&" + "redirect_uri=${tenant.redirectUriBase + "/login/open-id/auth-callback"}&" + "response_type=code&" + - "scope=openid profile email&" + + "scope=openid profile email offline_access&" + "state=$state" @GetMapping("/{registrationId}") 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 index c342a9cddf..405f17c083 100644 --- 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 @@ -4,4 +4,5 @@ package io.tolgee.ee.data 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/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index 5cd3cb93ee..b57d503281 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -8,6 +8,8 @@ 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.cacheWithExpiration.CacheWithExpirationManager +import io.tolgee.constants.Caches import io.tolgee.constants.Message import io.tolgee.ee.data.GenericUserResponse import io.tolgee.ee.data.OAuth2TokenResponse @@ -16,6 +18,7 @@ import io.tolgee.ee.model.SsoTenant import io.tolgee.exceptions.AuthenticationException import io.tolgee.security.authentication.JwtService import io.tolgee.security.payload.JwtAuthenticationResponse +import io.tolgee.security.service.OAuthServiceEe import io.tolgee.security.thirdParty.OAuthUserHandler import io.tolgee.security.thirdParty.data.OAuthUserDetails import io.tolgee.util.Logging @@ -36,7 +39,9 @@ class OAuthService( private val jwtProcessor: ConfigurableJWTProcessor, private val tenantService: TenantService, private val oAuthUserHandler: OAuthUserHandler, -) : Logging { + private val cacheWithExpirationManager: CacheWithExpirationManager, +) : OAuthServiceEe, + Logging { fun handleOAuthCallback( registrationId: String, code: String, @@ -63,7 +68,7 @@ class OAuthService( ) val userInfo = verifyAndDecodeIdToken(tokenResponse.id_token, tenant.jwkSetUri) - return register(userInfo, tenant, invitationCode) + return register(userInfo, tenant, invitationCode, tokenResponse.refresh_token) } fun exchangeCodeForToken( @@ -136,6 +141,7 @@ class OAuthService( userResponse: GenericUserResponse, tenant: SsoTenant, invitationCode: String?, + refreshToken: String, ): JwtAuthenticationResponse { val email = userResponse.email ?: let { @@ -151,9 +157,68 @@ class OAuthService( email = email, domain = tenant.domain, organizationId = tenant.organizationId, + refreshToken = refreshToken, ) val user = oAuthUserHandler.findOrCreateUser(userData, invitationCode, "sso") val jwt = jwtService.emitToken(user.id) return JwtAuthenticationResponse(jwt) } + + override fun verifyUserIsStillEmployed( + ssoDomain: String?, + userId: Long, + refreshToken: String?, + thirdPartyAuth: String?, + ): Boolean { + if (thirdPartyAuth != "sso") { + return true + } + + if (ssoDomain == null || refreshToken == null) { + throw AuthenticationException(Message.SSO_CANT_VERIFY_USER) + } + + val isValid = + cacheWithExpirationManager + .getCache( + Caches.IS_SSO_USER_VALID, + )?.getWrapper(userId) + ?.get() as? Boolean + + if (isValid == true) { + return true + } + + val tenant = tenantService.getByDomain(ssoDomain) + 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", refreshToken) + + 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 == refreshToken) { + cacheWithExpirationManager.putCache(Caches.IS_SSO_USER_VALID, userId, true) + return true + } + false + } catch (e: HttpClientErrorException) { + logger.info("Failed to refresh token: ${e.message}") + false + } + } } From e73c0aad083d4b76fb7548350e6dc488ff8b3b74 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 25 Oct 2024 10:02:08 +0200 Subject: [PATCH 098/162] chore: test new validation --- .../tolgee/security/service/OAuthServiceEe.kt | 13 ++++ .../AuthenticationDisabledFilterTest.kt | 2 +- .../AuthenticationFilterTest.kt | 25 ++++--- ee/backend/tests/build.gradle | 1 + .../src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 65 +++++++++++++++++++ .../tolgee/ee/utils/OAuthMultiTenantsMocks.kt | 16 ++++- 6 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 backend/security/src/main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt diff --git a/backend/security/src/main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt b/backend/security/src/main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt new file mode 100644 index 0000000000..79fbdf379e --- /dev/null +++ b/backend/security/src/main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt @@ -0,0 +1,13 @@ +package io.tolgee.security.service + +import org.springframework.stereotype.Component + +@Component +interface OAuthServiceEe { + fun verifyUserIsStillEmployed( + ssoDomain: String?, + userId: Long, + refreshToken: String?, + thirdPartyAuth: String?, + ): 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..5830141b13 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,15 +27,12 @@ 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.OAuthServiceEe import io.tolgee.service.security.ApiKeyService import io.tolgee.service.security.PatService import io.tolgee.service.security.UserAccountService import io.tolgee.testing.assertions.Assertions.assertThat -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertDoesNotThrow -import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.* import org.mockito.Mockito import org.mockito.kotlin.any import org.springframework.mock.web.MockFilterChain @@ -80,6 +77,8 @@ class AuthenticationFilterTest { private val userAccount = Mockito.mock(UserAccount::class.java, Mockito.RETURNS_DEFAULTS) + private val oAuthServiceEe = Mockito.mock(OAuthServiceEe::class.java) + private val authenticationFilter = AuthenticationFilter( authProperties, @@ -89,6 +88,7 @@ class AuthenticationFilterTest { userAccountService, pakService, patService, + oAuthServiceEe, ) private val authenticationFacade = @@ -105,18 +105,21 @@ class AuthenticationFilterTest { Mockito.`when`(authProperties.enabled).thenReturn(true) - Mockito.`when`(rateLimitService.getIpAuthRateLimitPolicy(any())) + Mockito + .`when`(rateLimitService.getIpAuthRateLimitPolicy(any())) .thenReturn( RateLimitPolicy("test policy", 5, Duration.ofSeconds(1), true), ) - Mockito.`when`(rateLimitService.consumeBucketUnless(any(), any())) + Mockito + .`when`(rateLimitService.consumeBucketUnless(any(), any())) .then { val fn = it.getArgument<() -> Boolean>(1) fn() } - Mockito.`when`(jwtService.validateToken(TEST_VALID_TOKEN)) + Mockito + .`when`(jwtService.validateToken(TEST_VALID_TOKEN)) .thenReturn( TolgeeAuthentication( "uwu", @@ -125,7 +128,8 @@ class AuthenticationFilterTest { ), ) - Mockito.`when`(jwtService.validateToken(TEST_INVALID_TOKEN)) + Mockito + .`when`(jwtService.validateToken(TEST_INVALID_TOKEN)) .thenThrow(AuthenticationException(Message.INVALID_JWT_TOKEN)) Mockito.`when`(pakService.parseApiKey(TEST_VALID_PAK)).thenReturn(TEST_VALID_PAK) @@ -293,7 +297,8 @@ class AuthenticationFilterTest { val res = MockHttpServletResponse() val chain = MockFilterChain() - Mockito.`when`(rateLimitService.consumeBucketUnless(any(), any())) + Mockito + .`when`(rateLimitService.consumeBucketUnless(any(), any())) .thenThrow(RateLimitedException(1000L, true)) req.addHeader("Authorization", "Bearer $TEST_VALID_TOKEN") diff --git a/ee/backend/tests/build.gradle b/ee/backend/tests/build.gradle index 87af96c163..5c85f5e8a5 100644 --- a/ee/backend/tests/build.gradle +++ b/ee/backend/tests/build.gradle @@ -46,6 +46,7 @@ dependencies { 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 index 41a2879296..5b3b7a8bb3 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -3,6 +3,8 @@ package io.tolgee.ee import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.nimbusds.jose.proc.SecurityContext import com.nimbusds.jwt.proc.ConfigurableJWTProcessor +import io.tolgee.component.cacheWithExpiration.CacheWithExpirationManager +import io.tolgee.constants.Caches import io.tolgee.constants.Message import io.tolgee.development.testDataBuilder.data.OAuthTestData import io.tolgee.dtos.request.organization.OrganizationDto @@ -21,13 +23,25 @@ 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.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 @@ -52,8 +66,12 @@ class OAuthTest : AuthorizedControllerTest() { OAuthMultiTenantsMocks(authMvc, restTemplate, tenantService, jwtProcessor) } + @Autowired + private lateinit var cacheWithExpirationManager: CacheWithExpirationManager + @BeforeEach fun setup() { + currentDateProvider.forcedDate = currentDateProvider.date testData = OAuthTestData() testDataService.saveTestData(testData.root) } @@ -140,6 +158,53 @@ class OAuthTest : AuthorizedControllerTest() { ).andIsForbidden } + @Test + fun `sso auth saves refresh token`() { + loginAsSsoUser() + val userName = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") + val user = userAccountService.get(userName) + assertThat(user.ssoRefreshToken).isNotNull + assertThat(user.ssoDomain).isNotNull + assertThat(user.thirdPartyAuthType).isEqualTo("sso") + val isValid = + cacheWithExpirationManager + .getCache( + Caches.IS_SSO_USER_VALID, + )?.getWrapper(user.id) + ?.get() as? Boolean + + assertThat(isValid).isTrue + } + + @Test + fun `user is employee validation works`() { + loginAsSsoUser() + val userName = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") + val user = userAccountService.get(userName) + assertThat( + oAuthService.verifyUserIsStillEmployed(user.ssoDomain, user.id, user.ssoRefreshToken, user.thirdPartyAuthType), + ).isTrue + } + + @Test + fun `after timeout should call token endpoint `() { + loginAsSsoUser() + val userName = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") + val user = userAccountService.get(userName) + currentDateProvider.forcedDate = Date(currentDateProvider.date.time + 600_000) + + oAuthMultiTenantsMocks.mockTokenExchange("http://tokenUri") + assertThat( + oAuthService.verifyUserIsStillEmployed(user.ssoDomain, user.id, user.ssoRefreshToken, user.thirdPartyAuthType), + ).isTrue + verify(restTemplate, times(2))?.exchange( // first call is in loginAsSsoUser + anyString(), + eq(HttpMethod.POST), + any(HttpEntity::class.java), + eq(OAuth2TokenResponse::class.java), + ) + } + fun organizationDto() = OrganizationDto( "Test org", 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 index 0bfa3cbb57..36a0fcb619 100644 --- 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 @@ -31,7 +31,7 @@ class OAuthMultiTenantsMocks( ) { companion object { val defaultToken = - OAuth2TokenResponse(id_token = generateTestJwt(), scope = "scope") + OAuth2TokenResponse(id_token = generateTestJwt(), scope = "scope", refresh_token = "refresh_token") val defaultTokenResponse = ResponseEntity( @@ -121,4 +121,18 @@ class OAuthMultiTenantsMocks( ), ).thenReturn(jwtClaimsSet) } + + fun mockTokenExchange( + tokenUri: String, + tokenResponse: ResponseEntity? = defaultTokenResponse, + ) { + whenever( + restTemplate?.exchange( + eq(tokenUri), + eq(HttpMethod.POST), + any(), + eq(OAuth2TokenResponse::class.java), + ), + ).thenReturn(tokenResponse) + } } From 6c0b685a31a7b862751eb086ad6dcf3cb004703c Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sat, 26 Oct 2024 14:25:17 +0200 Subject: [PATCH 099/162] feat: move ssoDomain from UserAccount to separate entity --- .../security/thirdParty/OAuthUserHandler.kt | 6 ++++- .../tolgee/dtos/cacheable/UserAccountDto.kt | 2 +- .../main/kotlin/io/tolgee/model/SsoConfig.kt | 15 +++++++++++ .../kotlin/io/tolgee/model/UserAccount.kt | 4 +-- .../tolgee/repository/SsoConfigRepository.kt | 17 ++++++++++++ .../repository/UserAccountRepository.kt | 8 +----- .../io/tolgee/service/SsoConfigService.kt | 26 +++++++++++++++++++ 7 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 backend/data/src/main/kotlin/io/tolgee/model/SsoConfig.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/repository/SsoConfigRepository.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/service/SsoConfigService.kt 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 index 17541f2a68..1fff467eed 100644 --- a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt @@ -7,6 +7,7 @@ import io.tolgee.exceptions.AuthenticationException import io.tolgee.model.UserAccount import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.security.thirdParty.data.OAuthUserDetails +import io.tolgee.service.SsoConfigService import io.tolgee.service.organization.OrganizationRoleService import io.tolgee.service.security.SignUpService import io.tolgee.service.security.UserAccountService @@ -17,6 +18,7 @@ class OAuthUserHandler( private val signUpService: SignUpService, private val organizationRoleService: OrganizationRoleService, private val userAccountService: UserAccountService, + private val ssoConfService: SsoConfigService, private val cacheWithExpirationManager: CacheWithExpirationManager, ) { fun findOrCreateUser( @@ -54,7 +56,9 @@ class OAuthUserHandler( } newUserAccount.name = name newUserAccount.thirdPartyAuthId = userResponse.sub - newUserAccount.ssoDomain = userResponse.domain + if (userResponse.domain != null) { + newUserAccount.ssoConfig = ssoConfService.save(newUserAccount, userResponse.domain!!) + } newUserAccount.thirdPartyAuthType = thirdPartyAuthType newUserAccount.ssoRefreshToken = userResponse.refreshToken newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY 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 f4752ae8ce..ec1d5e76ab 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 @@ -32,7 +32,7 @@ data class UserAccountDto( emailVerified = entity.emailVerification == null, thirdPartyAuth = entity.thirdPartyAuthType, ssoRefreshToken = entity.ssoRefreshToken, - ssoDomain = entity.ssoDomain, + ssoDomain = entity.ssoConfig?.domainName ?: "", ) } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/SsoConfig.kt b/backend/data/src/main/kotlin/io/tolgee/model/SsoConfig.kt new file mode 100644 index 0000000000..00e6ecdcb4 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/SsoConfig.kt @@ -0,0 +1,15 @@ +package io.tolgee.model + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.OneToMany + +@Entity +class SsoConfig : StandardAuditModel() { + @Column(name = "domain_name", unique = true, nullable = false) + var domainName: String = "" + + @OneToMany(fetch = FetchType.LAZY, mappedBy = "ssoConfig") + var userAccounts: MutableSet = mutableSetOf() +} 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 75d15b16c0..6734c8ac94 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt @@ -46,8 +46,8 @@ data class UserAccount( @Column(name = "third_party_auth_type") var thirdPartyAuthType: String? = null - @Column(name = "sso_domain") - var ssoDomain: String? = null + @ManyToOne() + var ssoConfig: SsoConfig? = null var ssoRefreshToken: String? = null diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/SsoConfigRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/SsoConfigRepository.kt new file mode 100644 index 0000000000..54f071a359 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/repository/SsoConfigRepository.kt @@ -0,0 +1,17 @@ +package io.tolgee.repository + +import io.tolgee.model.SsoConfig +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository + +@Repository +interface SsoConfigRepository : JpaRepository { + @Query("SELECT s FROM SsoConfig s JOIN s.userAccounts u WHERE s.domainName = :domain AND u.thirdPartyAuthId = :sub") + fun findByDomainAndSub( + domain: String, + sub: String, + ): SsoConfig? + + fun findByDomainName(domain: String): SsoConfig? +} 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 ec394d4269..5be98cdb54 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt @@ -83,13 +83,7 @@ interface UserAccountRepository : JpaRepository { ): Optional @Query( - """ - from UserAccount ua - where ua.thirdPartyAuthId = :thirdPartyAuthId - and ua.ssoDomain = :domain - and ua.deletedAt is null - and ua.disabledAt is null - """, + "SELECT u FROM UserAccount u JOIN u.ssoConfig s WHERE s.domainName = :domain AND u.thirdPartyAuthId = :thirdPartyAuthId", ) fun findBySsoDomain( thirdPartyAuthId: String, diff --git a/backend/data/src/main/kotlin/io/tolgee/service/SsoConfigService.kt b/backend/data/src/main/kotlin/io/tolgee/service/SsoConfigService.kt new file mode 100644 index 0000000000..394b984186 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/service/SsoConfigService.kt @@ -0,0 +1,26 @@ +package io.tolgee.service + +import io.tolgee.model.SsoConfig +import io.tolgee.model.UserAccount +import io.tolgee.repository.SsoConfigRepository +import org.springframework.stereotype.Service + +@Service +class SsoConfigService( + private val ssoConfigRepository: SsoConfigRepository, +) { + fun findByDomainName(domain: String) = ssoConfigRepository.findByDomainName(domain) + + fun save( + userAccount: UserAccount, + domain: String, + ): SsoConfig { + val ssoConfig = + ssoConfigRepository.findByDomainName(domain) + ?: SsoConfig().apply { domainName = domain } + + ssoConfig.userAccounts.add(userAccount) + userAccount.ssoConfig = ssoConfig + return ssoConfigRepository.save(ssoConfig) + } +} From fc831743e37fd8481d0a4c8090835d73c93ea68f Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sat, 26 Oct 2024 14:26:51 +0200 Subject: [PATCH 100/162] chore: update tests --- .../src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) 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 index 5b3b7a8bb3..6352163032 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -16,6 +16,7 @@ import io.tolgee.ee.utils.OAuthMultiTenantsMocks import io.tolgee.exceptions.NotFoundException import io.tolgee.fixtures.andIsForbidden import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.service.SsoConfigService import io.tolgee.testing.AuthorizedControllerTest import io.tolgee.testing.assert import io.tolgee.testing.assertions.Assertions.assertThat @@ -25,6 +26,7 @@ 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 @@ -62,6 +64,9 @@ class OAuthTest : AuthorizedControllerTest() { @Autowired private lateinit var tenantService: TenantService + @Autowired + lateinit var ssoConfigService: SsoConfigService + private val oAuthMultiTenantsMocks: OAuthMultiTenantsMocks by lazy { OAuthMultiTenantsMocks(authMvc, restTemplate, tenantService, jwtProcessor) } @@ -164,7 +169,7 @@ class OAuthTest : AuthorizedControllerTest() { val userName = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") val user = userAccountService.get(userName) assertThat(user.ssoRefreshToken).isNotNull - assertThat(user.ssoDomain).isNotNull + assertThat(user.ssoConfig).isNotNull assertThat(user.thirdPartyAuthType).isEqualTo("sso") val isValid = cacheWithExpirationManager @@ -182,12 +187,18 @@ class OAuthTest : AuthorizedControllerTest() { val userName = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") val user = userAccountService.get(userName) assertThat( - oAuthService.verifyUserIsStillEmployed(user.ssoDomain, user.id, user.ssoRefreshToken, user.thirdPartyAuthType), + oAuthService.verifyUserIsStillEmployed( + user.ssoConfig?.domainName, + user.id, + user.ssoRefreshToken, + user.thirdPartyAuthType, + ), ).isTrue } @Test fun `after timeout should call token endpoint `() { + clearInvocations(restTemplate) loginAsSsoUser() val userName = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") val user = userAccountService.get(userName) @@ -195,7 +206,12 @@ class OAuthTest : AuthorizedControllerTest() { oAuthMultiTenantsMocks.mockTokenExchange("http://tokenUri") assertThat( - oAuthService.verifyUserIsStillEmployed(user.ssoDomain, user.id, user.ssoRefreshToken, user.thirdPartyAuthType), + oAuthService.verifyUserIsStillEmployed( + user.ssoConfig?.domainName, + user.id, + user.ssoRefreshToken, + user.thirdPartyAuthType, + ), ).isTrue verify(restTemplate, times(2))?.exchange( // first call is in loginAsSsoUser anyString(), From b69c9b6463aa724160a2742cb7ea0472e497066c Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sat, 26 Oct 2024 15:03:33 +0200 Subject: [PATCH 101/162] fix: use enum as ThirdPartyAuthType instead of string --- .../security/thirdParty/GithubOAuthDelegate.kt | 18 ++++++++++-------- .../security/thirdParty/GoogleOAuthDelegate.kt | 5 +++-- .../security/thirdParty/OAuth2Delegate.kt | 3 ++- .../security/thirdParty/OAuthUserHandler.kt | 13 +++++++------ .../io/tolgee/dtos/cacheable/UserAccountDto.kt | 4 ++-- .../main/kotlin/io/tolgee/model/UserAccount.kt | 6 ++++-- .../tolgee/model/enums/ThirdPartyAuthType.kt | 11 +++++++++++ .../tolgee/repository/UserAccountRepository.kt | 3 ++- .../organization/OrganizationService.kt | 3 ++- .../tolgee/service/security/SignUpService.kt | 3 ++- .../service/security/UserAccountService.kt | 3 ++- .../io/tolgee/ee/service/OAuthService.kt | 10 ++++++++-- .../src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 6 +++--- 13 files changed, 58 insertions(+), 30 deletions(-) create mode 100644 backend/data/src/main/kotlin/io/tolgee/model/enums/ThirdPartyAuthType.kt 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..d9880f7a09 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,6 +5,7 @@ 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 @@ -57,12 +58,13 @@ class GithubOAuthDelegate( // get github user emails val emails = - restTemplate.exchange( - githubConfigurationProperties.userUrl + "/emails", - HttpMethod.GET, - entity, - Array::class.java, - ).body + restTemplate + .exchange( + githubConfigurationProperties.userUrl + "/emails", + HttpMethod.GET, + entity, + Array::class.java, + ).body ?: throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL) val verifiedEmails = Arrays.stream(emails).filter { it.verified }.collect(Collectors.toList()) @@ -74,7 +76,7 @@ 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!!) val user = userAccountOptional.orElseGet { userAccountService.findActive(githubEmail)?.let { @@ -85,7 +87,7 @@ class GithubOAuthDelegate( newUserAccount.username = githubEmail newUserAccount.name = userResponse.name ?: userResponse.login newUserAccount.thirdPartyAuthId = userResponse.id - newUserAccount.thirdPartyAuthType = "github" + newUserAccount.thirdPartyAuthType = ThirdPartyAuthType.GITHUB newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY signUpService.signUp(newUserAccount, invitationCode, null) 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..a1a3a97f84 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,6 +5,7 @@ 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 @@ -76,7 +77,7 @@ 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!!) val user = userAccountOptional.orElseGet { userAccountService.findActive(googleEmail)?.let { @@ -88,7 +89,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) 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 b84aa9ef0f..581dfe0998 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 @@ -4,6 +4,7 @@ import io.tolgee.configuration.tolgee.OAuth2AuthenticationProperties import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.constants.Message import io.tolgee.exceptions.AuthenticationException +import io.tolgee.model.enums.ThirdPartyAuthType import io.tolgee.security.authentication.JwtService import io.tolgee.security.payload.JwtAuthenticationResponse import io.tolgee.security.thirdParty.data.OAuthUserDetails @@ -97,7 +98,7 @@ class OAuth2Delegate( domain = null, organizationId = null, ) - val user = oAuthUserHandler.findOrCreateUser(userData, invitationCode, "oauth2") + val user = oAuthUserHandler.findOrCreateUser(userData, invitationCode, ThirdPartyAuthType.OAUTH2) 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 index 1fff467eed..13319f19a4 100644 --- a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt @@ -6,6 +6,7 @@ import io.tolgee.constants.Message import io.tolgee.exceptions.AuthenticationException import io.tolgee.model.UserAccount import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.model.enums.ThirdPartyAuthType import io.tolgee.security.thirdParty.data.OAuthUserDetails import io.tolgee.service.SsoConfigService import io.tolgee.service.organization.OrganizationRoleService @@ -24,16 +25,16 @@ class OAuthUserHandler( fun findOrCreateUser( userResponse: OAuthUserDetails, invitationCode: String?, - thirdPartyAuthType: String, + thirdPartyAuthType: ThirdPartyAuthType, ): UserAccount { val userAccountOptional = - if (thirdPartyAuthType == "sso" && userResponse.domain != null) { + if (thirdPartyAuthType == ThirdPartyAuthType.SSO && userResponse.domain != null) { userAccountService.findByDomainSso(userResponse.domain, userResponse.sub!!) } else { userAccountService.findByThirdParty(thirdPartyAuthType, userResponse.sub!!) } - if (userAccountOptional.isPresent && thirdPartyAuthType == "sso") { + if (userAccountOptional.isPresent && thirdPartyAuthType == ThirdPartyAuthType.SSO) { updateRefreshToken(userAccountOptional.get(), userResponse.refreshToken) cacheSsoUser(userAccountOptional.get().id, thirdPartyAuthType) } @@ -66,7 +67,7 @@ class OAuthUserHandler( signUpService.signUp(newUserAccount, invitationCode, null) // grant role to user only if request is not from oauth2 delegate - if (userResponse.organizationId != null && thirdPartyAuthType != "oauth2") { + if (userResponse.organizationId != null && thirdPartyAuthType != ThirdPartyAuthType.OAUTH2) { organizationRoleService.grantRoleToUser( newUserAccount, userResponse.organizationId, @@ -92,9 +93,9 @@ class OAuthUserHandler( private fun cacheSsoUser( userId: Long, - thirdPartyAuthType: String, + thirdPartyAuthType: ThirdPartyAuthType, ) { - if (thirdPartyAuthType == "sso") { + if (thirdPartyAuthType == ThirdPartyAuthType.SSO) { cacheWithExpirationManager.putCache(Caches.IS_SSO_USER_VALID, userId, true) } } 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 ec1d5e76ab..46c69a33c6 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 @@ -30,9 +30,9 @@ data class UserAccountDto( deleted = entity.deletedAt != null, tokensValidNotBefore = entity.tokensValidNotBefore, emailVerified = entity.emailVerification == null, - thirdPartyAuth = entity.thirdPartyAuthType, + thirdPartyAuth = entity.thirdPartyAuthType?.code(), ssoRefreshToken = entity.ssoRefreshToken, - ssoDomain = entity.ssoConfig?.domainName ?: "", + ssoDomain = entity.ssoConfig?.domainName, ) } 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 6734c8ac94..56eb26dbff 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt @@ -2,6 +2,7 @@ package io.tolgee.model import io.hypersistence.utils.hibernate.type.array.ListArrayType import io.tolgee.api.IUserAccount +import io.tolgee.model.enums.ThirdPartyAuthType import io.tolgee.model.slackIntegration.SlackConfig import io.tolgee.model.slackIntegration.SlackUserConnection import jakarta.persistence.* @@ -44,7 +45,8 @@ data class UserAccount( var emailVerification: EmailVerification? = null @Column(name = "third_party_auth_type") - var thirdPartyAuthType: String? = null + @Enumerated(EnumType.STRING) + var thirdPartyAuthType: ThirdPartyAuthType? = null @ManyToOne() var ssoConfig: SsoConfig? = null @@ -106,7 +108,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..2a80773d87 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/model/enums/ThirdPartyAuthType.kt @@ -0,0 +1,11 @@ +package io.tolgee.model.enums + +enum class ThirdPartyAuthType { + GOOGLE, + GITHUB, + OAUTH2, + SSO, + ; + + fun code(): String = name.lowercase() +} 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 5be98cdb54..1382153116 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt @@ -2,6 +2,7 @@ package io.tolgee.repository import io.tolgee.dtos.queryResults.UserAccountView 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 @@ -79,7 +80,7 @@ interface UserAccountRepository : JpaRepository { ) fun findThirdByThirdParty( thirdPartyAuthId: String, - thirdPartyAuthType: String, + thirdPartyAuthType: ThirdPartyAuthType, ): Optional @Query( 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 5940c10a8e..d1dcf69140 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 @@ -17,6 +17,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 @@ -78,7 +79,7 @@ class OrganizationService( throw ValidationException(Message.ADDRESS_PART_NOT_UNIQUE) } - if (userAccount.thirdPartyAuthType == "sso") { + if (userAccount.thirdPartyAuthType == ThirdPartyAuthType.SSO) { throw PermissionException(Message.SSO_USER_CANNOT_CREATE_ORGANIZATION) } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/SignUpService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/SignUpService.kt index 8c2e712775..2c5e922ed9 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 @@ -7,6 +7,7 @@ import io.tolgee.exceptions.AuthenticationException import io.tolgee.exceptions.BadRequestException import io.tolgee.model.Invitation 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.EmailVerificationService @@ -58,7 +59,7 @@ class SignUpService( invitationService.accept(invitation.code, user) } - if (user.thirdPartyAuthType == "sso") { + if (user.thirdPartyAuthType == ThirdPartyAuthType.SSO) { return user } 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 0b35c6192a..25ae6c6651 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 @@ -18,6 +18,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 @@ -208,7 +209,7 @@ class UserAccountService( } fun findByThirdParty( - type: String, + type: ThirdPartyAuthType, id: String, ): Optional = userAccountRepository.findThirdByThirdParty(id, type) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index b57d503281..8d40002c1b 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -16,6 +16,7 @@ import io.tolgee.ee.data.OAuth2TokenResponse import io.tolgee.ee.exceptions.OAuthAuthorizationException import io.tolgee.ee.model.SsoTenant import io.tolgee.exceptions.AuthenticationException +import io.tolgee.model.enums.ThirdPartyAuthType import io.tolgee.security.authentication.JwtService import io.tolgee.security.payload.JwtAuthenticationResponse import io.tolgee.security.service.OAuthServiceEe @@ -159,7 +160,7 @@ class OAuthService( organizationId = tenant.organizationId, refreshToken = refreshToken, ) - val user = oAuthUserHandler.findOrCreateUser(userData, invitationCode, "sso") + val user = oAuthUserHandler.findOrCreateUser(userData, invitationCode, ThirdPartyAuthType.SSO) val jwt = jwtService.emitToken(user.id) return JwtAuthenticationResponse(jwt) } @@ -170,7 +171,12 @@ class OAuthService( refreshToken: String?, thirdPartyAuth: String?, ): Boolean { - if (thirdPartyAuth != "sso") { + val thirdPartyAuthType = + thirdPartyAuth?.let { + runCatching { ThirdPartyAuthType.valueOf(it.uppercase()) }.getOrNull() + } + + if (thirdPartyAuthType != ThirdPartyAuthType.SSO) { return true } 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 index 6352163032..45b6d29228 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -170,7 +170,7 @@ class OAuthTest : AuthorizedControllerTest() { val user = userAccountService.get(userName) assertThat(user.ssoRefreshToken).isNotNull assertThat(user.ssoConfig).isNotNull - assertThat(user.thirdPartyAuthType).isEqualTo("sso") + assertThat(user.thirdPartyAuthType?.code()).isEqualTo("sso") val isValid = cacheWithExpirationManager .getCache( @@ -191,7 +191,7 @@ class OAuthTest : AuthorizedControllerTest() { user.ssoConfig?.domainName, user.id, user.ssoRefreshToken, - user.thirdPartyAuthType, + user.thirdPartyAuthType?.code(), ), ).isTrue } @@ -210,7 +210,7 @@ class OAuthTest : AuthorizedControllerTest() { user.ssoConfig?.domainName, user.id, user.ssoRefreshToken, - user.thirdPartyAuthType, + user.thirdPartyAuthType?.code(), ), ).isTrue verify(restTemplate, times(2))?.exchange( // first call is in loginAsSsoUser From ffdcbe4b271278f0bd4692643ce0a5e0333735c8 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sat, 26 Oct 2024 15:11:40 +0200 Subject: [PATCH 102/162] feat: prevent sso user to login --- .../app/src/test/kotlin/io/tolgee/AuthTest.kt | 66 +++++++++++-------- .../kotlin/io/tolgee/constants/Message.kt | 1 + .../security/UserCredentialsService.kt | 5 ++ 3 files changed, 45 insertions(+), 27 deletions(-) diff --git a/backend/app/src/test/kotlin/io/tolgee/AuthTest.kt b/backend/app/src/test/kotlin/io/tolgee/AuthTest.kt index bb0b12b217..4e432f5179 100644 --- a/backend/app/src/test/kotlin/io/tolgee/AuthTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/AuthTest.kt @@ -4,12 +4,9 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import io.tolgee.constants.Message import io.tolgee.controllers.PublicController -import io.tolgee.fixtures.andAssertThatJson -import io.tolgee.fixtures.andIsForbidden -import io.tolgee.fixtures.andIsUnauthorized -import io.tolgee.fixtures.generateUniqueString -import io.tolgee.fixtures.mapResponseTo +import io.tolgee.fixtures.* 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 @@ -84,16 +81,18 @@ class AuthTest : AbstractControllerTest() { fun userWithTokenHasAccess() { val response = doAuthentication(initialUsername, initialPassword) - .andReturn().response.contentAsString + .andReturn() + .response.contentAsString val token = mapper.readValue(response, HashMap::class.java)["accessToken"] as String? val mvcResult = - mvc.perform( - MockMvcRequestBuilders.get("/api/projects") - .accept(MediaType.ALL) - .header("Authorization", String.format("Bearer %s", token)) - .contentType(MediaType.APPLICATION_JSON), - ) - .andReturn() + mvc + .perform( + MockMvcRequestBuilders + .get("/api/projects") + .accept(MediaType.ALL) + .header("Authorization", String.format("Bearer %s", token)) + .contentType(MediaType.APPLICATION_JSON), + ).andReturn() assertThat(mvcResult.response.status).isEqualTo(200) } @@ -109,12 +108,14 @@ class AuthTest : AbstractControllerTest() { currentDateProvider.forcedDate = baseline val mvcResult = - mvc.perform( - MockMvcRequestBuilders.get("/api/projects") - .accept(MediaType.ALL) - .header("Authorization", String.format("Bearer %s", token)) - .contentType(MediaType.APPLICATION_JSON), - ).andReturn() + mvc + .perform( + MockMvcRequestBuilders + .get("/api/projects") + .accept(MediaType.ALL) + .header("Authorization", String.format("Bearer %s", token)) + .contentType(MediaType.APPLICATION_JSON), + ).andReturn() assertThat(mvcResult.response.status).isEqualTo(401) assertThat(mvcResult.response.contentAsString).contains(Message.EXPIRED_JWT_TOKEN.code) @@ -271,14 +272,25 @@ 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") - .accept(MediaType.ALL) - .header("Authorization", String.format("Bearer %s", token)) - .contentType(MediaType.APPLICATION_JSON), - ).andIsForbidden.andAssertThatJson { - node("code").isEqualTo(Message.EXPIRED_SUPER_JWT_TOKEN.code) - } + mvc + .perform( + MockMvcRequestBuilders + .put("/v2/projects/${project.id}/users/${project.id}/revoke-access") + .accept(MediaType.ALL) + .header("Authorization", String.format("Bearer %s", token)) + .contentType(MediaType.APPLICATION_JSON), + ).andIsForbidden + .andAssertThatJson { + node("code").isEqualTo(Message.EXPIRED_SUPER_JWT_TOKEN.code) + } } } 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 f1f70d2a9c..4cdd0bb092 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -246,6 +246,7 @@ enum class Message { PLAN_AUTO_ASSIGNMENT_ORGANIZATION_IDS_NOT_IN_FOR_ORGANIZATION_IDS, SSO_USER_CANNOT_CREATE_ORGANIZATION, SSO_CANT_VERIFY_USER, + SSO_USER_CANT_LOGIN_WITH_NATIVE, ; val code: String diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/UserCredentialsService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/UserCredentialsService.kt index 678938895e..767d51c8c6 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/UserCredentialsService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/UserCredentialsService.kt @@ -3,6 +3,7 @@ package io.tolgee.service.security import io.tolgee.constants.Message import io.tolgee.exceptions.AuthenticationException import io.tolgee.model.UserAccount +import io.tolgee.model.enums.ThirdPartyAuthType import org.springframework.beans.factory.annotation.Autowired import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service @@ -26,6 +27,10 @@ class UserCredentialsService( throw AuthenticationException(Message.OPERATION_UNAVAILABLE_FOR_ACCOUNT_TYPE) } + if (userAccount.thirdPartyAuthType == ThirdPartyAuthType.SSO) { + throw AuthenticationException(Message.SSO_USER_CANT_LOGIN_WITH_NATIVE) + } + checkNativeUserCredentials(userAccount, password) return userAccount } From c77ee5e2c29877297a34926695dc3dddc3e63392 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sat, 26 Oct 2024 15:31:59 +0200 Subject: [PATCH 103/162] feat: prevent sso user to change password --- backend/data/src/main/kotlin/io/tolgee/constants/Message.kt | 1 + .../kotlin/io/tolgee/service/security/UserAccountService.kt | 4 ++++ .../io/tolgee/service/security/UserCredentialsService.kt | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) 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 4cdd0bb092..8b76015d0f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -247,6 +247,7 @@ enum class Message { SSO_USER_CANNOT_CREATE_ORGANIZATION, SSO_CANT_VERIFY_USER, SSO_USER_CANT_LOGIN_WITH_NATIVE, + SSO_USER_OPERATION_UNAVAILABLE, ; val code: String 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 25ae6c6651..64cdf42dd8 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 @@ -400,6 +400,10 @@ class UserAccountService( throw BadRequestException(Message.OPERATION_UNAVAILABLE_FOR_ACCOUNT_TYPE) } + if (userAccount.thirdPartyAuthType == ThirdPartyAuthType.SSO) { + throw BadRequestException(Message.SSO_USER_OPERATION_UNAVAILABLE) + } + val matches = passwordEncoder.matches(dto.currentPassword, userAccount.password) if (!matches) throw PermissionException(Message.WRONG_CURRENT_PASSWORD) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/UserCredentialsService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/UserCredentialsService.kt index 767d51c8c6..2bf719cf35 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/UserCredentialsService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/UserCredentialsService.kt @@ -28,7 +28,7 @@ class UserCredentialsService( } if (userAccount.thirdPartyAuthType == ThirdPartyAuthType.SSO) { - throw AuthenticationException(Message.SSO_USER_CANT_LOGIN_WITH_NATIVE) + throw AuthenticationException(Message.SSO_USER_OPERATION_UNAVAILABLE) } checkNativeUserCredentials(userAccount, password) From 370a51e81d97c51a793b9bd48f6fdcf58adf32ac Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sun, 27 Oct 2024 22:39:28 +0100 Subject: [PATCH 104/162] feat: add global sso config --- .../configuration/PublicConfigurationDTO.kt | 8 ++-- .../security/thirdParty/OAuthUserHandler.kt | 19 +++++++- .../tolgee/AuthenticationProperties.kt | 14 +----- .../tolgee/SsoGlobalProperties.kt | 47 +++++++++++++++++++ .../kotlin/io/tolgee/constants/Message.kt | 1 + .../kotlin/io/tolgee/model/UserAccount.kt | 1 + .../io/tolgee/ee/service/OAuthService.kt | 3 +- .../io/tolgee/ee/service/TenantService.kt | 31 +++++++++++- .../security/Login/LoginCredentialsForm.tsx | 46 ++++++++++-------- 9 files changed, 130 insertions(+), 40 deletions(-) create mode 100644 backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoGlobalProperties.kt 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 6e31e909b3..7ec5bc8e97 100644 --- a/backend/api/src/main/kotlin/io/tolgee/configuration/PublicConfigurationDTO.kt +++ b/backend/api/src/main/kotlin/io/tolgee/configuration/PublicConfigurationDTO.kt @@ -29,8 +29,8 @@ class PublicConfigurationDTO( val recaptchaSiteKey = properties.recaptcha.siteKey val chatwootToken = properties.chatwootToken val nativeEnabled = properties.authentication.nativeEnabled - val customLoginLogo = properties.authentication.customLogoUrl - val customLoginText = properties.authentication.customButtonText + val customLoginLogo = properties.authentication.sso.customLogoUrl + val customLoginText = properties.authentication.sso.customButtonText val capterraTracker = properties.capterraTracker val ga4Tag = properties.ga4Tag val postHogApiKey: String? = properties.postHog.apiKey @@ -52,7 +52,9 @@ class PublicConfigurationDTO( val oauth2: OAuthPublicExtendsConfigDTO, ) - data class OAuthPublicConfigDTO(val clientId: String?) { + data class OAuthPublicConfigDTO( + val clientId: String?, + ) { val enabled: Boolean = clientId != null && clientId.isNotEmpty() } 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 index 13319f19a4..e4c06990da 100644 --- a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt @@ -67,7 +67,10 @@ class OAuthUserHandler( signUpService.signUp(newUserAccount, invitationCode, null) // grant role to user only if request is not from oauth2 delegate - if (userResponse.organizationId != null && thirdPartyAuthType != ThirdPartyAuthType.OAUTH2) { + if (userResponse.organizationId != null && + thirdPartyAuthType != ThirdPartyAuthType.OAUTH2 && + invitationCode == null + ) { organizationRoleService.grantRoleToUser( newUserAccount, userResponse.organizationId, @@ -81,7 +84,7 @@ class OAuthUserHandler( } } - private fun updateRefreshToken( + fun updateRefreshToken( userAccount: UserAccount, refreshToken: String?, ) { @@ -91,6 +94,18 @@ class OAuthUserHandler( } } + fun updateRefreshToken( + userAccountId: Long, + refreshToken: String?, + ) { + val userAccount = userAccountService.get(userAccountId) + + if (userAccount.ssoRefreshToken != refreshToken) { + userAccount.ssoRefreshToken = refreshToken + userAccountService.save(userAccount) + } + } + private fun cacheSsoUser( userId: Long, thirdPartyAuthType: ThirdPartyAuthType, 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 1bacfce224..c19d25f6e4 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 @@ -143,19 +143,7 @@ class AuthenticationProperties( var github: GithubAuthenticationProperties = GithubAuthenticationProperties(), var google: GoogleAuthenticationProperties = GoogleAuthenticationProperties(), var oauth2: OAuth2AuthenticationProperties = OAuth2AuthenticationProperties(), - @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 (the default logo is sso_login.svg," + - " which is stored in the webapp/public directory).", - ) - var customLogoUrl: String? = - "/sso_login.svg", - @DocProperty( - description = "Custom text for the login button.", - defaultExplanation = "Defaults to 'SSO Login' if not set.", - ) - var customButtonText: String = "SSO Login", + var sso: SsoGlobalProperties = SsoGlobalProperties(), ) { 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..6ddb81515b --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoGlobalProperties.kt @@ -0,0 +1,47 @@ +package io.tolgee.configuration.tolgee + +import io.tolgee.configuration.annotations.DocProperty +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "tolgee.authentication.sso") +@DocProperty( + description = + "Single sign-on (SSO) is an authentication process that allows a user to" + + " access multiple applications with one set of login credentials.", + displayName = "Single Sign-On", +) +class SsoGlobalProperties { + var enabled: Boolean = false + + @DocProperty(description = "SSO Client ID") + var clientId: String? = null + + @DocProperty(description = "SSO Client secret") + var clientSecret: String? = null + + @DocProperty(description = "URL to SSO authorize API endpoint. This endpoint will be exposed to the frontend.") + var authorizationUrl: String? = null + + @DocProperty(description = "URL to SSO token API endpoint.") + var tokenUrl: String? = null + + var domain: String? = null + + var redirectUriBase: String? = null + + var jwkSetUri: String? = null + + @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 (the default logo is sso_login.svg," + + " which is stored in the webapp/public directory).", + ) + var customLogoUrl: String? = null + + @DocProperty( + description = "Custom text for the login button.", + defaultExplanation = "Defaults to 'SSO Login' if not set.", + ) + var customButtonText: String? = null +} 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 8b76015d0f..84b4e70482 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -248,6 +248,7 @@ enum class Message { SSO_CANT_VERIFY_USER, SSO_USER_CANT_LOGIN_WITH_NATIVE, SSO_USER_OPERATION_UNAVAILABLE, + SSO_GLOBAL_CONFIG_MISSING_PROPERTIES, ; val code: String 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 56eb26dbff..33005de9cd 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt @@ -51,6 +51,7 @@ data class UserAccount( @ManyToOne() var ssoConfig: SsoConfig? = null + @Column(name = "sso_refresh_token", columnDefinition = "TEXT") var ssoRefreshToken: String? = null @Column(name = "third_party_auth_id") diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index 8d40002c1b..39b5fa0519 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -217,8 +217,9 @@ class OAuthService( request, OAuth2TokenResponse::class.java, ) - if (response.body?.refresh_token == refreshToken) { + if (response.body?.refresh_token != null) { cacheWithExpirationManager.putCache(Caches.IS_SSO_USER_VALID, userId, true) + oAuthUserHandler.updateRefreshToken(userId, response.body?.refresh_token) return true } 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 index 186e898ca6..e180ac17d5 100644 --- 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 @@ -1,6 +1,9 @@ package io.tolgee.ee.service +import io.tolgee.configuration.tolgee.SsoGlobalProperties +import io.tolgee.constants.Message import io.tolgee.ee.data.CreateProviderRequest +import io.tolgee.ee.exceptions.OAuthAuthorizationException import io.tolgee.ee.model.SsoTenant import io.tolgee.ee.repository.TenantRepository import io.tolgee.exceptions.BadRequestException @@ -12,10 +15,36 @@ import java.net.URISyntaxException @Service class TenantService( private val tenantRepository: TenantRepository, + private val ssoGlobalProperties: SsoGlobalProperties, ) { fun getById(id: Long): SsoTenant = tenantRepository.findById(id).orElseThrow { NotFoundException() } - fun getByDomain(domain: String): SsoTenant = tenantRepository.findByDomain(domain) ?: throw NotFoundException() + fun getByDomain(domain: String): SsoTenant = + if (ssoGlobalProperties.enabled) { + buildGlobalTenant() + } else { + tenantRepository.findByDomain(domain) ?: throw NotFoundException() + } + + private fun buildGlobalTenant(): SsoTenant = + SsoTenant().apply { + domain = validateProperty(ssoGlobalProperties.domain, "domain") + clientId = validateProperty(ssoGlobalProperties.clientId, "clientId") + clientSecret = validateProperty(ssoGlobalProperties.clientSecret, "clientSecret") + authorizationUri = validateProperty(ssoGlobalProperties.authorizationUrl, "authorizationUrl") + tokenUri = validateProperty(ssoGlobalProperties.tokenUrl, "tokenUrl") + redirectUriBase = validateProperty(ssoGlobalProperties.redirectUriBase, "redirectUriBase") + jwkSetUri = validateProperty(ssoGlobalProperties.jwkSetUri, "jwkSetUri") + } + + private fun validateProperty( + property: String?, + propertyName: String, + ): String = + property ?: throw OAuthAuthorizationException( + Message.SSO_GLOBAL_CONFIG_MISSING_PROPERTIES, + "$propertyName is missing in global SSO configuration", + ) fun save(tenant: SsoTenant): SsoTenant = tenantRepository.save(tenant) diff --git a/webapp/src/component/security/Login/LoginCredentialsForm.tsx b/webapp/src/component/security/Login/LoginCredentialsForm.tsx index e016eb94ef..40c794b0ac 100644 --- a/webapp/src/component/security/Login/LoginCredentialsForm.tsx +++ b/webapp/src/component/security/Login/LoginCredentialsForm.tsx @@ -1,21 +1,18 @@ -import React, { RefObject } from 'react'; -import { Button, Link as MuiLink, styled, Typography } from '@mui/material'; +import React, {RefObject} from 'react'; +import {Button, Link as MuiLink, styled, Typography} from '@mui/material'; import Box from '@mui/material/Box'; -import { T } from '@tolgee/react'; -import { Link } from 'react-router-dom'; +import {T, useTranslate} from '@tolgee/react'; +import {Link} from 'react-router-dom'; import LoginIcon from '@mui/icons-material/Login'; -import { LINKS } from 'tg.constants/links'; -import { useConfig } from 'tg.globalContext/helpers'; +import {LINKS} from 'tg.constants/links'; +import {useConfig} from 'tg.globalContext/helpers'; import LoadingButton from 'tg.component/common/form/LoadingButton'; -import { StandardForm } from 'tg.component/common/form/StandardForm'; -import { TextField } from 'tg.component/common/form/fields/TextField'; -import { useOAuthServices } from 'tg.hooks/useOAuthServices'; -import { - useGlobalActions, - useGlobalContext, -} from 'tg.globalContext/GlobalContext'; -import { ApiError } from 'tg.service/http/ApiError'; +import {StandardForm} from 'tg.component/common/form/StandardForm'; +import {TextField} from 'tg.component/common/form/fields/TextField'; +import {useOAuthServices} from 'tg.hooks/useOAuthServices'; +import {useGlobalActions, useGlobalContext,} from 'tg.globalContext/GlobalContext'; +import {ApiError} from 'tg.service/http/ApiError'; const StyledInputFields = styled('div')` display: grid; @@ -33,6 +30,7 @@ type LoginViewCredentialsProps = { export function LoginCredentialsForm(props: LoginViewCredentialsProps) { const remoteConfig = useConfig(); const { login } = useGlobalActions(); + const t = useTranslate(); const isLoading = useGlobalContext((c) => c.auth.loginLoadable.isLoading); const oAuthServices = useOAuthServices(); @@ -73,17 +71,25 @@ export function LoginCredentialsForm(props: LoginViewCredentialsProps) { to={LINKS.SSO_LOGIN.build()} size="medium" endIcon={ - Custom Logo + remoteConfig.customLoginLogo ? ( + Custom Logo + ) : ( + + ) } variant="outlined" style={{ marginBottom: '0.5rem' }} color="inherit" > - {remoteConfig.customLoginText} + {remoteConfig.customLoginText ? ( + {remoteConfig.customLoginText} + ) : ( + + )} )} From b9681d3604474612aa6c3c3bf752ef473bb490e5 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Sun, 27 Oct 2024 22:39:58 +0100 Subject: [PATCH 105/162] chore: test global sso config --- .../src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 index 45b6d29228..ff9cdb5f29 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -4,6 +4,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.nimbusds.jose.proc.SecurityContext import com.nimbusds.jwt.proc.ConfigurableJWTProcessor import io.tolgee.component.cacheWithExpiration.CacheWithExpirationManager +import io.tolgee.configuration.tolgee.SsoGlobalProperties import io.tolgee.constants.Caches import io.tolgee.constants.Message import io.tolgee.development.testDataBuilder.data.OAuthTestData @@ -67,6 +68,9 @@ class OAuthTest : AuthorizedControllerTest() { @Autowired lateinit var ssoConfigService: SsoConfigService + @Autowired + lateinit var ssoGlobalProperties: SsoGlobalProperties + private val oAuthMultiTenantsMocks: OAuthMultiTenantsMocks by lazy { OAuthMultiTenantsMocks(authMvc, restTemplate, tenantService, jwtProcessor) } @@ -221,6 +225,19 @@ class OAuthTest : AuthorizedControllerTest() { ) } + @Test + fun `sso auth works via global config`() { + ssoGlobalProperties.enabled = true + ssoGlobalProperties.domain = "registrationId" + ssoGlobalProperties.clientId = "clientId" + ssoGlobalProperties.clientSecret = "clientSecret" + ssoGlobalProperties.authorizationUrl = "authorizationUri" + ssoGlobalProperties.tokenUrl = "http://tokenUri" + ssoGlobalProperties.redirectUriBase = "http://redirectUriBase" + ssoGlobalProperties.jwkSetUri = "http://jwkSetUri" + oAuthMultiTenantsMocks.authorize("registrationId") + } + fun organizationDto() = OrganizationDto( "Test org", From e0464160cc04dd629aeb22b2723bc56264ba2004 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Tue, 29 Oct 2024 16:24:10 +0100 Subject: [PATCH 106/162] fix: use frontend url from config, instead of saving it to db --- .../tolgee/SsoGlobalProperties.kt | 2 - .../main/resources/db/changelog/schema.xml | 51 ++++++++++ .../controllers/OAuth2CallbackController.kt | 4 +- .../hateoas/assemblers/SsoTenantAssembler.kt | 1 - .../tolgee/ee/data/CreateProviderRequest.kt | 2 - .../kotlin/io/tolgee/ee/data/SsoTenantDto.kt | 2 - .../kotlin/io/tolgee/ee/model/SsoTenant.kt | 1 - .../main/resources/db/changelog/ee-schema.xml | 3 + .../src/constants/GlobalValidationSchema.tsx | 20 ++-- webapp/src/service/apiSchema.generated.ts | 98 ++++++++++--------- .../sso/CreateProviderSsoForm.tsx | 34 +++---- 11 files changed, 131 insertions(+), 87 deletions(-) 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 index 6ddb81515b..73b4ac70d1 100644 --- a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoGlobalProperties.kt +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoGlobalProperties.kt @@ -27,8 +27,6 @@ class SsoGlobalProperties { var domain: String? = null - var redirectUriBase: String? = null - var jwkSetUri: String? = null @DocProperty( diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index cb3cd12a73..1969d071ff 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -3445,4 +3445,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index 3327fa8b69..8b31e08034 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -1,5 +1,6 @@ package io.tolgee.ee.api.v2.controllers +import io.tolgee.component.FrontendUrlProvider import io.tolgee.constants.Message import io.tolgee.ee.data.DomainRequest import io.tolgee.ee.data.SsoUrlResponse @@ -21,6 +22,7 @@ class OAuth2CallbackController( private val tenantService: TenantService, private val userAccountService: UserAccountService, private val jwtService: JwtService, + private val frontendUrlProvider: FrontendUrlProvider, ) { @PostMapping("/get-authentication-url") fun getAuthenticationUrl( @@ -42,7 +44,7 @@ class OAuth2CallbackController( ): String = "${tenant.authorizationUri}?" + "client_id=${tenant.clientId}&" + - "redirect_uri=${tenant.redirectUriBase + "/login/open-id/auth-callback"}&" + + "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/hateoas/assemblers/SsoTenantAssembler.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/SsoTenantAssembler.kt index 3428e144e4..2edf088b8d 100644 --- 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 @@ -17,7 +17,6 @@ class SsoTenantAssembler : authorizationUri = entity.authorizationUri, clientId = entity.clientId, clientSecret = entity.clientSecret, - redirectUri = entity.redirectUri, tokenUri = entity.tokenUri, isEnabled = entity.isEnabled, jwkSetUri = entity.jwkSetUri, 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 index 9e4624763d..4b1eb8eed4 100644 --- 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 @@ -13,8 +13,6 @@ data class CreateProviderRequest( @field:NotEmpty val authorizationUri: String, @field:NotEmpty - val redirectUri: String, - @field:NotEmpty val tokenUri: String, @field:NotEmpty val jwkSetUri: 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 index c81fa43846..a478b90c6f 100644 --- 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 @@ -6,7 +6,6 @@ data class SsoTenantDto( val authorizationUri: String, val clientId: String, val clientSecret: String, - val redirectUri: String, val tokenUri: String, val isEnabled: Boolean, val jwkSetUri: String, @@ -18,7 +17,6 @@ fun SsoTenant.toDto(): SsoTenantDto = authorizationUri = this.authorizationUri, clientId = this.clientId, clientSecret = this.clientSecret, - redirectUri = this.redirectUriBase, tokenUri = this.tokenUri, isEnabled = this.isEnabledForThisOrganization, jwkSetUri = this.jwkSetUri, diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/model/SsoTenant.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/model/SsoTenant.kt index 6d9c9f1723..c3ba05b155 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/model/SsoTenant.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/model/SsoTenant.kt @@ -16,7 +16,6 @@ class SsoTenant : StandardAuditModel() { var domain: String = "" var jwkSetUri: String = "" var tokenUri: String = "" - var redirectUriBase: String = "" // base Tolgee frontend url can be different for different users so need to store it var organizationId: Long = 0L @ColumnDefault("true") diff --git a/ee/backend/app/src/main/resources/db/changelog/ee-schema.xml b/ee/backend/app/src/main/resources/db/changelog/ee-schema.xml index 905fa06246..e1813e1f73 100644 --- a/ee/backend/app/src/main/resources/db/changelog/ee-schema.xml +++ b/ee/backend/app/src/main/resources/db/changelog/ee-schema.xml @@ -88,4 +88,7 @@ + + + diff --git a/webapp/src/constants/GlobalValidationSchema.tsx b/webapp/src/constants/GlobalValidationSchema.tsx index 27bbcd6d9c..e74e3f7e62 100644 --- a/webapp/src/constants/GlobalValidationSchema.tsx +++ b/webapp/src/constants/GlobalValidationSchema.tsx @@ -1,11 +1,11 @@ -import { DefaultParamType, T, TFnType, TranslationKey } from '@tolgee/react'; +import {DefaultParamType, T, TFnType, TranslationKey} from '@tolgee/react'; import * as Yup from 'yup'; -import { components } from 'tg.service/apiSchema.generated'; -import { organizationService } from '../service/OrganizationService'; -import { signUpService } from '../service/SignUpService'; -import { checkParamNameIsValid } from '@tginternal/editor'; -import { validateObject } from 'tg.fixtures/validateObject'; +import {components} from 'tg.service/apiSchema.generated'; +import {organizationService} from '../service/OrganizationService'; +import {signUpService} from '../service/SignUpService'; +import {checkParamNameIsValid} from '@tginternal/editor'; +import {validateObject} from 'tg.fixtures/validateObject'; type TFunType = TFnType; @@ -418,14 +418,6 @@ export class Validation { t('sso_invalid_url_format'), Validation.validateUrlWithPort ), - redirectUri: Yup.string() - .required() - .max(255) - .test( - 'is-valid-url-with-port', - t('sso_invalid_url_format'), - Validation.validateUrlWithPort - ), tokenUri: Yup.string() .required() .max(255) diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 57c481675d..85427336ef 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -1058,7 +1058,12 @@ export interface components { | "cannot_subscribe_to_free_plan" | "plan_auto_assignment_only_for_free_plans" | "plan_auto_assignment_only_for_private_plans" - | "plan_auto_assignment_organization_ids_not_in_for_organization_ids"; + | "plan_auto_assignment_organization_ids_not_in_for_organization_ids" + | "sso_user_cannot_create_organization" + | "sso_cant_verify_user" + | "sso_user_cant_login_with_native" + | "sso_user_operation_unavailable" + | "sso_global_config_missing_properties"; params?: { [key: string]: unknown }[]; }; ErrorResponseBody: { @@ -1131,16 +1136,6 @@ export interface components { | "SERVER_ADMIN"; /** @description The user's permission type. This field is null if uses granular permissions */ type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; - /** - * @description List of languages user can translate to. If null, all languages editing is permitted. - * @example 200001,200004 - */ - translateLanguageIds?: number[]; - /** - * @description List of languages user can change state to. If null, changing state of all language values is permitted. - * @example 200001,200004 - */ - stateChangeLanguageIds?: number[]; /** * @deprecated * @description Deprecated (use translateLanguageIds). @@ -1150,10 +1145,15 @@ export interface components { */ permittedLanguageIds?: number[]; /** - * @description List of languages user can view. If null, all languages view is permitted. + * @description List of languages user can translate to. If null, all languages editing is permitted. * @example 200001,200004 */ - viewLanguageIds?: number[]; + translateLanguageIds?: number[]; + /** + * @description List of languages user can change state to. If null, changing state of all language values is permitted. + * @example 200001,200004 + */ + stateChangeLanguageIds?: number[]; /** * @description Granted scopes to the user. When user has type permissions, this field contains permission scopes of the type. * @example KEYS_EDIT,TRANSLATIONS_VIEW @@ -1186,6 +1186,11 @@ export interface components { | "content-delivery.publish" | "webhooks.manage" )[]; + /** + * @description List of languages user can view. If null, all languages view is permitted. + * @example 200001,200004 + */ + viewLanguageIds?: number[]; }; LanguageModel: { /** Format: int64 */ @@ -1714,8 +1719,8 @@ export interface components { secretKey?: string; endpoint: string; signingRegion: string; - contentStorageType?: "S3" | "AZURE"; enabled?: boolean; + contentStorageType?: "S3" | "AZURE"; }; AzureContentStorageConfigModel: { containerName?: string; @@ -1987,10 +1992,10 @@ export interface components { createNewKeys: boolean; }; ImportSettingsModel: { - /** @description If true, placeholders from other formats will be converted to ICU when possible */ - convertPlaceholdersToIcu: boolean; /** @description If true, key descriptions will be overridden by the import */ overrideKeyDescriptions: boolean; + /** @description If true, placeholders from other formats will be converted to ICU when possible */ + convertPlaceholdersToIcu: boolean; /** @description If false, only updates keys, skipping the creation of new keys */ createNewKeys: boolean; }; @@ -2158,9 +2163,9 @@ export interface components { }; RevealedPatModel: { token: string; + description: string; /** Format: int64 */ id: number; - description: string; /** Format: int64 */ createdAt: number; /** Format: int64 */ @@ -2178,7 +2183,6 @@ export interface components { clientId: string; clientSecret: string; authorizationUri: string; - redirectUri: string; tokenUri: string; jwkSetUri: string; isEnabled: boolean; @@ -2188,7 +2192,6 @@ export interface components { authorizationUri: string; clientId: string; clientSecret: string; - redirectUri: string; tokenUri: string; isEnabled: boolean; jwkSetUri: string; @@ -2326,19 +2329,19 @@ export interface components { RevealedApiKeyModel: { /** @description Resulting user's api key */ key: string; + description: string; /** Format: int64 */ id: number; - description: string; - userFullName?: string; + projectName: string; username?: string; scopes: string[]; /** Format: int64 */ + projectId: number; + /** Format: int64 */ expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; - /** Format: int64 */ - projectId: number; - projectName: string; + userFullName?: string; }; SuperTokenRequest: { /** @description Has to be provided when TOTP enabled */ @@ -2743,7 +2746,12 @@ export interface components { | "cannot_subscribe_to_free_plan" | "plan_auto_assignment_only_for_free_plans" | "plan_auto_assignment_only_for_private_plans" - | "plan_auto_assignment_organization_ids_not_in_for_organization_ids"; + | "plan_auto_assignment_organization_ids_not_in_for_organization_ids" + | "sso_user_cannot_create_organization" + | "sso_cant_verify_user" + | "sso_user_cant_login_with_native" + | "sso_user_operation_unavailable" + | "sso_global_config_missing_properties"; params?: { [key: string]: unknown }[]; }; UntagKeysRequest: { @@ -3422,22 +3430,22 @@ export interface components { | "SSO" )[]; quickStart?: components["schemas"]["QuickStartModel"]; + /** @example This is a beautiful organization full of beautiful and clever people */ + description?: string; /** @example Beautiful organization */ name: string; /** Format: int64 */ id: number; - /** @example This is a beautiful organization full of beautiful and clever people */ - description?: string; + /** @example btforg */ + slug: string; + avatar?: components["schemas"]["Avatar"]; + basePermissions: components["schemas"]["PermissionModel"]; /** * @description The role of currently authorized user. * * Can be null when user has direct access to one of the projects owned by the organization. */ currentUserRole?: "MEMBER" | "OWNER"; - basePermissions: components["schemas"]["PermissionModel"]; - /** @example btforg */ - slug: string; - avatar?: components["schemas"]["Avatar"]; }; PublicBillingConfigurationDTO: { enabled: boolean; @@ -3465,7 +3473,7 @@ export interface components { chatwootToken?: string; nativeEnabled: boolean; customLoginLogo?: string; - customLoginText: string; + customLoginText?: string; capterraTracker?: string; ga4Tag?: string; postHogApiKey?: string; @@ -3502,9 +3510,9 @@ export interface components { defaultFileStructureTemplate: string; }; DocItem: { - name: string; displayName?: string; description?: string; + name: string; }; PagedModelProjectModel: { _embedded?: { @@ -3575,23 +3583,23 @@ export interface components { formalitySupported: boolean; }; KeySearchResultView: { + description?: string; name: string; /** Format: int64 */ id: number; - description?: string; - baseTranslation?: string; - namespace?: string; translation?: string; + namespace?: string; + baseTranslation?: string; }; KeySearchSearchResultModel: { view?: components["schemas"]["KeySearchResultView"]; + description?: string; name: string; /** Format: int64 */ id: number; - description?: string; - baseTranslation?: string; - namespace?: string; translation?: string; + namespace?: string; + baseTranslation?: string; }; PagedModelKeySearchSearchResultModel: { _embedded?: { @@ -4135,9 +4143,9 @@ export interface components { }; PatWithUserModel: { user: components["schemas"]["SimpleUserAccountModel"]; + description: string; /** Format: int64 */ id: number; - description: string; /** Format: int64 */ createdAt: number; /** Format: int64 */ @@ -4272,19 +4280,19 @@ export interface components { * @description Languages for which user has translate permission. */ permittedLanguageIds?: number[]; + description: string; /** Format: int64 */ id: number; - description: string; - userFullName?: string; + projectName: string; username?: string; scopes: string[]; /** Format: int64 */ + projectId: number; + /** Format: int64 */ expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; - /** Format: int64 */ - projectId: number; - projectName: string; + userFullName?: string; }; PagedModelUserAccountModel: { _embedded?: { diff --git a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx index 286f2c018b..5b879a0401 100644 --- a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx +++ b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import { styled } from '@mui/material'; -import { T, useTranslate } from '@tolgee/react'; -import { StandardForm } from 'tg.component/common/form/StandardForm'; -import { TextField } from 'tg.component/common/form/fields/TextField'; -import { useApiMutation } from 'tg.service/http/useQueryApi'; -import { messageService } from 'tg.service/MessageService'; -import { useOrganization } from 'tg.views/organizations/useOrganization'; -import { Validation } from 'tg.constants/GlobalValidationSchema'; +import {styled} from '@mui/material'; +import {T, useTranslate} from '@tolgee/react'; +import {StandardForm} from 'tg.component/common/form/StandardForm'; +import {TextField} from 'tg.component/common/form/fields/TextField'; +import {useApiMutation} from 'tg.service/http/useQueryApi'; +import {messageService} from 'tg.service/MessageService'; +import {useOrganization} from 'tg.views/organizations/useOrganization'; +import {Validation} from 'tg.constants/GlobalValidationSchema'; const StyledInputFields = styled('div')` display: grid; @@ -19,7 +19,6 @@ type FormValues = { authorizationUri: string; clientId: string; clientSecret: string; - redirectUri: string; tokenUri: string; jwkSetUri: string; domainName: string; @@ -32,7 +31,6 @@ export function CreateProviderSsoForm({ data, disabled }) { authorizationUri: data?.authorizationUri ?? '', clientId: data?.clientId ?? '', clientSecret: data?.clientSecret ?? '', - redirectUri: data?.redirectUri ?? '', tokenUri: data?.tokenUri ?? '', jwkSetUri: data?.jwkSetUri ?? '', domainName: data?.domainName ?? '', @@ -75,6 +73,7 @@ export function CreateProviderSsoForm({ data, disabled }) { name="domainName" label={} minHeight={false} + helperText={} /> @@ -84,6 +83,8 @@ export function CreateProviderSsoForm({ data, disabled }) { name="authorizationUri" label={} minHeight={false} + helperText={} + /> @@ -93,6 +94,7 @@ export function CreateProviderSsoForm({ data, disabled }) { name="clientId" label={} minHeight={false} + helperText={} /> @@ -102,15 +104,7 @@ export function CreateProviderSsoForm({ data, disabled }) { name="clientSecret" label={} minHeight={false} - /> - - - } - minHeight={false} + helperText={} /> @@ -120,6 +114,7 @@ export function CreateProviderSsoForm({ data, disabled }) { name="tokenUri" label={} minHeight={false} + helperText={} /> @@ -129,6 +124,7 @@ export function CreateProviderSsoForm({ data, disabled }) { name="jwkSetUri" label={} minHeight={false} + helperText={} /> From 873324d888c785789877af81c0fe5f8beedd2d6f Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Tue, 29 Oct 2024 16:24:55 +0100 Subject: [PATCH 107/162] fix: use frontend url from config, instead of saving it to db --- .../api/src/main/kotlin/io/tolgee/hateoas/ee/SsoTenantModel.kt | 1 - .../app/src/main/kotlin/io/tolgee/ee/service/TenantService.kt | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) 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 index a625ef3a66..f2ad755394 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/SsoTenantModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/ee/SsoTenantModel.kt @@ -10,7 +10,6 @@ class SsoTenantModel( val authorizationUri: String, val clientId: String, val clientSecret: String, - val redirectUri: String, val tokenUri: String, val isEnabled: Boolean, val jwkSetUri: String, 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 index e180ac17d5..a899c6e483 100644 --- 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 @@ -28,12 +28,12 @@ class TenantService( private fun buildGlobalTenant(): SsoTenant = SsoTenant().apply { + isEnabledForThisOrganization = validateProperty(ssoGlobalProperties.enabled.toString(), "enabled").toBoolean() domain = validateProperty(ssoGlobalProperties.domain, "domain") clientId = validateProperty(ssoGlobalProperties.clientId, "clientId") clientSecret = validateProperty(ssoGlobalProperties.clientSecret, "clientSecret") authorizationUri = validateProperty(ssoGlobalProperties.authorizationUrl, "authorizationUrl") tokenUri = validateProperty(ssoGlobalProperties.tokenUrl, "tokenUrl") - redirectUriBase = validateProperty(ssoGlobalProperties.redirectUriBase, "redirectUriBase") jwkSetUri = validateProperty(ssoGlobalProperties.jwkSetUri, "jwkSetUri") } @@ -96,7 +96,6 @@ class TenantService( tenant.clientSecret = dto.clientSecret tenant.authorizationUri = dto.authorizationUri tenant.tokenUri = dto.tokenUri - tenant.redirectUriBase = dto.redirectUri.removeSuffix("/") tenant.jwkSetUri = dto.jwkSetUri tenant.isEnabledForThisOrganization = dto.isEnabled return save(tenant) From a960a6c44cbc2e507d2a0664fc9f64550b80743b Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Tue, 29 Oct 2024 16:25:22 +0100 Subject: [PATCH 108/162] chore: update tests --- .../src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 28 +++++++++---------- .../tolgee/ee/utils/OAuthMultiTenantsMocks.kt | 24 ++++++++++++++-- 2 files changed, 35 insertions(+), 17 deletions(-) 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 index ff9cdb5f29..7aab96eea2 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -2,6 +2,7 @@ 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.component.cacheWithExpiration.CacheWithExpirationManager import io.tolgee.configuration.tolgee.SsoGlobalProperties @@ -14,6 +15,7 @@ import io.tolgee.ee.model.SsoTenant import io.tolgee.ee.service.OAuthService import io.tolgee.ee.service.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.enums.OrganizationRoleType @@ -81,6 +83,7 @@ class OAuthTest : AuthorizedControllerTest() { @BeforeEach fun setup() { currentDateProvider.forcedDate = currentDateProvider.date + ssoGlobalProperties.enabled = false testData = OAuthTestData() testDataService.saveTestData(testData.root) } @@ -95,7 +98,6 @@ class OAuthTest : AuthorizedControllerTest() { authorizationUri = "authorizationUri" jwkSetUri = "http://jwkSetUri" tokenUri = "http://tokenUri" - redirectUriBase = "redirectUriBase" organizationId = testData.organization.id }, ) @@ -107,7 +109,7 @@ class OAuthTest : AuthorizedControllerTest() { val result = jacksonObjectMapper().readValue(response.response.contentAsString, HashMap::class.java) result["accessToken"].assert.isNotNull result["tokenType"].assert.isEqualTo("Bearer") - val userName = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") + val userName = jwtClaimsSet.getStringClaim("email") assertThat(userAccountService.get(userName)).isNotNull } @@ -124,7 +126,7 @@ class OAuthTest : AuthorizedControllerTest() { @Test fun `new user belongs to organization associated with the sso issuer`() { loginAsSsoUser() - val userName = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") + val userName = jwtClaimsSet.getStringClaim("email") val user = userAccountService.get(userName) assertThat(organizationRoleService.isUserOfRole(user.id, testData.organization.id, OrganizationRoleType.MEMBER)) .isEqualTo(true) @@ -140,16 +142,15 @@ class OAuthTest : AuthorizedControllerTest() { ) assertThat(response.response.status).isEqualTo(400) assertThat(response.response.contentAsString).contains(Message.SSO_TOKEN_EXCHANGE_FAILED.code) - val userName = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") + val userName = jwtClaimsSet.getStringClaim("email") assertThrows { userAccountService.get(userName) } } @Transactional @Test fun `sso auth doesn't create demo project and user organization`() { - loginAsSsoUser() - val userName = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") - val user = userAccountService.get(userName) + 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) } @@ -158,7 +159,7 @@ class OAuthTest : AuthorizedControllerTest() { @Test fun `sso user can't create organization`() { loginAsSsoUser() - val userName = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") + val userName = jwtClaimsSet.getStringClaim("email") val user = userAccountService.get(userName) loginAsUser(user) performAuthPost( @@ -170,7 +171,7 @@ class OAuthTest : AuthorizedControllerTest() { @Test fun `sso auth saves refresh token`() { loginAsSsoUser() - val userName = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") + val userName = jwtClaimsSet.getStringClaim("email") val user = userAccountService.get(userName) assertThat(user.ssoRefreshToken).isNotNull assertThat(user.ssoConfig).isNotNull @@ -188,7 +189,7 @@ class OAuthTest : AuthorizedControllerTest() { @Test fun `user is employee validation works`() { loginAsSsoUser() - val userName = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") + val userName = jwtClaimsSet.getStringClaim("email") val user = userAccountService.get(userName) assertThat( oAuthService.verifyUserIsStillEmployed( @@ -204,7 +205,7 @@ class OAuthTest : AuthorizedControllerTest() { fun `after timeout should call token endpoint `() { clearInvocations(restTemplate) loginAsSsoUser() - val userName = OAuthMultiTenantsMocks.jwtClaimsSet.getStringClaim("email") + val userName = jwtClaimsSet.getStringClaim("email") val user = userAccountService.get(userName) currentDateProvider.forcedDate = Date(currentDateProvider.date.time + 600_000) @@ -233,7 +234,6 @@ class OAuthTest : AuthorizedControllerTest() { ssoGlobalProperties.clientSecret = "clientSecret" ssoGlobalProperties.authorizationUrl = "authorizationUri" ssoGlobalProperties.tokenUrl = "http://tokenUri" - ssoGlobalProperties.redirectUriBase = "http://redirectUriBase" ssoGlobalProperties.jwkSetUri = "http://jwkSetUri" oAuthMultiTenantsMocks.authorize("registrationId") } @@ -245,8 +245,8 @@ class OAuthTest : AuthorizedControllerTest() { "test-org", ) - fun loginAsSsoUser(): MvcResult { + fun loginAsSsoUser(jwtClaims: JWTClaimsSet = jwtClaimsSet): MvcResult { addTenant() - return oAuthMultiTenantsMocks.authorize("registrationId") + return oAuthMultiTenantsMocks.authorize("registrationId", jwtClaims = jwtClaims) } } 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 index 36a0fcb619..6254c39f1f 100644 --- 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 @@ -56,6 +56,23 @@ class OAuthMultiTenantsMocks( return claimsSet } + val jwtClaimsSet2: JWTClaimsSet + get() { + val claimsSet = + JWTClaimsSet + .Builder() + .subject("testSubject") + .issuer("https://test-oauth-provider.com") + .expirationTime(Date(System.currentTimeMillis() + 3600 * 1000)) // Время действия 1 час + .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) @@ -73,6 +90,7 @@ class OAuthMultiTenantsMocks( fun authorize( registrationId: String, tokenResponse: ResponseEntity? = defaultTokenResponse, + jwtClaims: JWTClaimsSet = jwtClaimsSet, ): MvcResult { val receivedCode = "fake_access_token" val tenant = tenantService?.getByDomain(registrationId)!! @@ -87,7 +105,7 @@ class OAuthMultiTenantsMocks( ).thenReturn(tokenResponse) // mock parsing of jwt - mockJwt() + mockJwt(jwtClaims) return authMvc!! .perform( @@ -113,13 +131,13 @@ class OAuthMultiTenantsMocks( ), ).andReturn() - private fun mockJwt() { + private fun mockJwt(jwtClaims: JWTClaimsSet) { whenever( jwtProcessor?.process( any(), isNull(), ), - ).thenReturn(jwtClaimsSet) + ).thenReturn(jwtClaims) } fun mockTokenExchange( From 2397931592f21dbfa3ad8486794d5d8761bbbd76 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Tue, 29 Oct 2024 16:28:23 +0100 Subject: [PATCH 109/162] chore: update docs property descriptions --- .../tolgee/configuration/tolgee/SsoGlobalProperties.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 index 73b4ac70d1..a106e28f7b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoGlobalProperties.kt +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoGlobalProperties.kt @@ -13,20 +13,22 @@ import org.springframework.boot.context.properties.ConfigurationProperties class SsoGlobalProperties { var enabled: Boolean = false - @DocProperty(description = "SSO Client ID") + @DocProperty(description = "Unique identifier for an application") var clientId: String? = null - @DocProperty(description = "SSO Client secret") + @DocProperty(description = "Key used to authenticate the application") var clientSecret: String? = null - @DocProperty(description = "URL to SSO authorize API endpoint. This endpoint will be exposed to the frontend.") + @DocProperty(description = "URL to redirect users for authentication") var authorizationUrl: String? = null - @DocProperty(description = "URL to SSO token API endpoint.") + @DocProperty(description = "URL for exchanging authorization code for tokens") var tokenUrl: 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( From 1cfa8df1be04b986c0e0f6f9fff0838cf15991d7 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Tue, 29 Oct 2024 16:37:35 +0100 Subject: [PATCH 110/162] chore: fix ktlint --- .../main/kotlin/io/tolgee/repository/UserAccountRepository.kt | 3 ++- ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) 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 1382153116..a77d2afaf8 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt @@ -84,7 +84,8 @@ interface UserAccountRepository : JpaRepository { ): Optional @Query( - "SELECT u FROM UserAccount u JOIN u.ssoConfig s WHERE s.domainName = :domain AND u.thirdPartyAuthId = :thirdPartyAuthId", + "SELECT u FROM UserAccount u JOIN u.ssoConfig s" + + " WHERE s.domainName = :domain AND u.thirdPartyAuthId = :thirdPartyAuthId", ) fun findBySsoDomain( thirdPartyAuthId: String, 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 index 7aab96eea2..76af457e72 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -218,7 +218,9 @@ class OAuthTest : AuthorizedControllerTest() { user.thirdPartyAuthType?.code(), ), ).isTrue - verify(restTemplate, times(2))?.exchange( // first call is in loginAsSsoUser + + // first call is in loginAsSsoUser + verify(restTemplate, times(2))?.exchange( anyString(), eq(HttpMethod.POST), any(HttpEntity::class.java), From 4914735e69587f0eb6797ccb2c6a7a33f06dd986 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 1 Nov 2024 12:19:58 +0100 Subject: [PATCH 111/162] chore: move SsoTenant from ee repo, link user account and SsoTenant, organization's FK to ssoTenant --- .../security/thirdParty/OAuth2Delegate.kt | 2 - .../security/thirdParty/OAuthUserHandler.kt | 17 +++--- .../thirdParty/data/OAuthUserDetails.kt | 5 +- .../kotlin/io/tolgee/model/Organization.kt | 15 ++--- .../main/kotlin/io/tolgee/model/SsoConfig.kt | 15 ----- .../main/kotlin/io/tolgee}/model/SsoTenant.kt | 18 ++++-- .../tolgee/repository/SsoConfigRepository.kt | 17 ------ .../io/tolgee/service/SsoConfigService.kt | 26 --------- .../organization/OrganizationService.kt | 7 +++ .../main/resources/db/changelog/schema.xml | 56 +++++++++++++++++++ .../controllers/OAuth2CallbackController.kt | 2 +- .../kotlin/io/tolgee/ee/data/SsoTenantDto.kt | 2 +- .../tolgee/ee/repository/TenantRepository.kt | 2 +- .../io/tolgee/ee/service/OAuthService.kt | 5 +- .../main/resources/db/changelog/ee-schema.xml | 3 + .../src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 14 ++--- 16 files changed, 104 insertions(+), 102 deletions(-) delete mode 100644 backend/data/src/main/kotlin/io/tolgee/model/SsoConfig.kt rename {ee/backend/app/src/main/kotlin/io/tolgee/ee => backend/data/src/main/kotlin/io/tolgee}/model/SsoTenant.kt (53%) delete mode 100644 backend/data/src/main/kotlin/io/tolgee/repository/SsoConfigRepository.kt delete mode 100644 backend/data/src/main/kotlin/io/tolgee/service/SsoConfigService.kt 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 581dfe0998..3daeb993a4 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 @@ -95,8 +95,6 @@ class OAuth2Delegate( givenName = userResponse.given_name, familyName = userResponse.family_name, email = email, - domain = null, - organizationId = null, ) val user = oAuthUserHandler.findOrCreateUser(userData, invitationCode, ThirdPartyAuthType.OAUTH2) 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 index e4c06990da..e5ae21a7cd 100644 --- a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt @@ -8,7 +8,6 @@ import io.tolgee.model.UserAccount import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.model.enums.ThirdPartyAuthType import io.tolgee.security.thirdParty.data.OAuthUserDetails -import io.tolgee.service.SsoConfigService import io.tolgee.service.organization.OrganizationRoleService import io.tolgee.service.security.SignUpService import io.tolgee.service.security.UserAccountService @@ -19,7 +18,6 @@ class OAuthUserHandler( private val signUpService: SignUpService, private val organizationRoleService: OrganizationRoleService, private val userAccountService: UserAccountService, - private val ssoConfService: SsoConfigService, private val cacheWithExpirationManager: CacheWithExpirationManager, ) { fun findOrCreateUser( @@ -27,9 +25,11 @@ class OAuthUserHandler( invitationCode: String?, thirdPartyAuthType: ThirdPartyAuthType, ): UserAccount { + val tenant = userResponse.tenant + val userAccountOptional = - if (thirdPartyAuthType == ThirdPartyAuthType.SSO && userResponse.domain != null) { - userAccountService.findByDomainSso(userResponse.domain, userResponse.sub!!) + if (thirdPartyAuthType == ThirdPartyAuthType.SSO && tenant?.domain != null) { + userAccountService.findByDomainSso(tenant.domain, userResponse.sub!!) } else { userAccountService.findByThirdParty(thirdPartyAuthType, userResponse.sub!!) } @@ -57,8 +57,8 @@ class OAuthUserHandler( } newUserAccount.name = name newUserAccount.thirdPartyAuthId = userResponse.sub - if (userResponse.domain != null) { - newUserAccount.ssoConfig = ssoConfService.save(newUserAccount, userResponse.domain!!) + if (tenant?.domain != null) { + newUserAccount.ssoTenant = tenant } newUserAccount.thirdPartyAuthType = thirdPartyAuthType newUserAccount.ssoRefreshToken = userResponse.refreshToken @@ -67,13 +67,14 @@ class OAuthUserHandler( signUpService.signUp(newUserAccount, invitationCode, null) // grant role to user only if request is not from oauth2 delegate - if (userResponse.organizationId != null && + val organization = tenant?.organization + if (organization?.id != null && thirdPartyAuthType != ThirdPartyAuthType.OAUTH2 && invitationCode == null ) { organizationRoleService.grantRoleToUser( newUserAccount, - userResponse.organizationId, + organization.id, OrganizationRoleType.MEMBER, ) } 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 index 1ad2cf19f8..9fd9d5ba9a 100644 --- 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 @@ -1,12 +1,13 @@ package io.tolgee.security.thirdParty.data +import io.tolgee.model.SsoTenant + data class OAuthUserDetails( var sub: String? = null, var name: String? = null, var givenName: String? = null, var familyName: String? = null, var email: String = "", - val domain: String? = null, - val organizationId: Long? = null, val refreshToken: String? = null, + val tenant: SsoTenant? = null, ) 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..9600bdfa87 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/Organization.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/Organization.kt @@ -2,17 +2,7 @@ package io.tolgee.model import com.fasterxml.jackson.annotation.JsonIgnore import io.tolgee.model.slackIntegration.OrganizationSlackWorkspace -import jakarta.persistence.CascadeType -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.FetchType -import jakarta.persistence.GeneratedValue -import jakarta.persistence.GenerationType -import jakarta.persistence.Id -import jakarta.persistence.OneToMany -import jakarta.persistence.OneToOne -import jakarta.persistence.Table -import jakarta.persistence.UniqueConstraint +import jakarta.persistence.* import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Pattern import jakarta.validation.constraints.Size @@ -62,6 +52,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/SsoConfig.kt b/backend/data/src/main/kotlin/io/tolgee/model/SsoConfig.kt deleted file mode 100644 index 00e6ecdcb4..0000000000 --- a/backend/data/src/main/kotlin/io/tolgee/model/SsoConfig.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.tolgee.model - -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.FetchType -import jakarta.persistence.OneToMany - -@Entity -class SsoConfig : StandardAuditModel() { - @Column(name = "domain_name", unique = true, nullable = false) - var domainName: String = "" - - @OneToMany(fetch = FetchType.LAZY, mappedBy = "ssoConfig") - var userAccounts: MutableSet = mutableSetOf() -} diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/model/SsoTenant.kt b/backend/data/src/main/kotlin/io/tolgee/model/SsoTenant.kt similarity index 53% rename from ee/backend/app/src/main/kotlin/io/tolgee/ee/model/SsoTenant.kt rename to backend/data/src/main/kotlin/io/tolgee/model/SsoTenant.kt index c3ba05b155..502d82efd0 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/model/SsoTenant.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/SsoTenant.kt @@ -1,22 +1,28 @@ -package io.tolgee.ee.model +package io.tolgee.model -import io.tolgee.model.StandardAuditModel -import jakarta.persistence.Entity -import jakarta.persistence.Table +import jakarta.persistence.* import org.hibernate.annotations.ColumnDefault @Entity -@Table(schema = "ee", name = "tenant") +@Table(name = "tenant") class SsoTenant : StandardAuditModel() { var name: String = "" var ssoProvider: String = "" var clientId: String = "" var clientSecret: String = "" var authorizationUri: String = "" + + @Column(unique = true, nullable = false) var domain: String = "" var jwkSetUri: String = "" var tokenUri: String = "" - var organizationId: Long = 0L + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organization_id") + var organization: Organization? = null + + @OneToMany(fetch = FetchType.LAZY, mappedBy = "ssoTenant") + var userAccounts: MutableSet = mutableSetOf() @ColumnDefault("true") var isEnabledForThisOrganization: Boolean = true diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/SsoConfigRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/SsoConfigRepository.kt deleted file mode 100644 index 54f071a359..0000000000 --- a/backend/data/src/main/kotlin/io/tolgee/repository/SsoConfigRepository.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.tolgee.repository - -import io.tolgee.model.SsoConfig -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Query -import org.springframework.stereotype.Repository - -@Repository -interface SsoConfigRepository : JpaRepository { - @Query("SELECT s FROM SsoConfig s JOIN s.userAccounts u WHERE s.domainName = :domain AND u.thirdPartyAuthId = :sub") - fun findByDomainAndSub( - domain: String, - sub: String, - ): SsoConfig? - - fun findByDomainName(domain: String): SsoConfig? -} diff --git a/backend/data/src/main/kotlin/io/tolgee/service/SsoConfigService.kt b/backend/data/src/main/kotlin/io/tolgee/service/SsoConfigService.kt deleted file mode 100644 index 394b984186..0000000000 --- a/backend/data/src/main/kotlin/io/tolgee/service/SsoConfigService.kt +++ /dev/null @@ -1,26 +0,0 @@ -package io.tolgee.service - -import io.tolgee.model.SsoConfig -import io.tolgee.model.UserAccount -import io.tolgee.repository.SsoConfigRepository -import org.springframework.stereotype.Service - -@Service -class SsoConfigService( - private val ssoConfigRepository: SsoConfigRepository, -) { - fun findByDomainName(domain: String) = ssoConfigRepository.findByDomainName(domain) - - fun save( - userAccount: UserAccount, - domain: String, - ): SsoConfig { - val ssoConfig = - ssoConfigRepository.findByDomainName(domain) - ?: SsoConfig().apply { domainName = domain } - - ssoConfig.userAccounts.add(userAccount) - userAccount.ssoConfig = ssoConfig - return ssoConfigRepository.save(ssoConfig) - } -} 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 d1dcf69140..acbd381d0d 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 @@ -14,6 +14,7 @@ import io.tolgee.exceptions.NotFoundException import io.tolgee.exceptions.PermissionException import io.tolgee.model.Organization import io.tolgee.model.Permission +import io.tolgee.model.SsoTenant import io.tolgee.model.UserAccount import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.model.enums.ProjectPermissionType @@ -372,4 +373,10 @@ class OrganizationService( id: Long, currentUserId: Long, ): OrganizationView? = organizationRepository.findView(id, currentUserId) + + fun updateSsoProvider(organizationId: Long, tenant: SsoTenant) { + val organization = get(organizationId) + organization.ssoTenant = tenant + save(organization) + } } diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index 1969d071ff..0f690fba92 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -3496,4 +3496,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index 8b31e08034..91b817b03a 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -5,9 +5,9 @@ import io.tolgee.constants.Message import io.tolgee.ee.data.DomainRequest import io.tolgee.ee.data.SsoUrlResponse import io.tolgee.ee.exceptions.OAuthAuthorizationException -import io.tolgee.ee.model.SsoTenant import io.tolgee.ee.service.OAuthService import io.tolgee.ee.service.TenantService +import io.tolgee.model.SsoTenant import io.tolgee.model.UserAccount import io.tolgee.security.authentication.JwtService import io.tolgee.security.payload.JwtAuthenticationResponse 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 index a478b90c6f..64f69ecd72 100644 --- 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 @@ -1,6 +1,6 @@ package io.tolgee.ee.data -import io.tolgee.ee.model.SsoTenant +import io.tolgee.model.SsoTenant data class SsoTenantDto( val authorizationUri: String, diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt index 978ef91514..3cc2ad480a 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt @@ -1,6 +1,6 @@ package io.tolgee.ee.repository -import io.tolgee.ee.model.SsoTenant +import io.tolgee.model.SsoTenant import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index 39b5fa0519..a6161c0ae5 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -14,8 +14,8 @@ import io.tolgee.constants.Message import io.tolgee.ee.data.GenericUserResponse import io.tolgee.ee.data.OAuth2TokenResponse import io.tolgee.ee.exceptions.OAuthAuthorizationException -import io.tolgee.ee.model.SsoTenant import io.tolgee.exceptions.AuthenticationException +import io.tolgee.model.SsoTenant import io.tolgee.model.enums.ThirdPartyAuthType import io.tolgee.security.authentication.JwtService import io.tolgee.security.payload.JwtAuthenticationResponse @@ -156,9 +156,8 @@ class OAuthService( givenName = userResponse.given_name, familyName = userResponse.family_name, email = email, - domain = tenant.domain, - organizationId = tenant.organizationId, refreshToken = refreshToken, + tenant = tenant, ) val user = oAuthUserHandler.findOrCreateUser(userData, invitationCode, ThirdPartyAuthType.SSO) val jwt = jwtService.emitToken(user.id) diff --git a/ee/backend/app/src/main/resources/db/changelog/ee-schema.xml b/ee/backend/app/src/main/resources/db/changelog/ee-schema.xml index e1813e1f73..f6ad3be104 100644 --- a/ee/backend/app/src/main/resources/db/changelog/ee-schema.xml +++ b/ee/backend/app/src/main/resources/db/changelog/ee-schema.xml @@ -91,4 +91,7 @@ + + + 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 index 76af457e72..f0d28e9fca 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -11,15 +11,14 @@ import io.tolgee.constants.Message import io.tolgee.development.testDataBuilder.data.OAuthTestData import io.tolgee.dtos.request.organization.OrganizationDto import io.tolgee.ee.data.OAuth2TokenResponse -import io.tolgee.ee.model.SsoTenant import io.tolgee.ee.service.OAuthService import io.tolgee.ee.service.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.service.SsoConfigService import io.tolgee.testing.AuthorizedControllerTest import io.tolgee.testing.assert import io.tolgee.testing.assertions.Assertions.assertThat @@ -67,9 +66,6 @@ class OAuthTest : AuthorizedControllerTest() { @Autowired private lateinit var tenantService: TenantService - @Autowired - lateinit var ssoConfigService: SsoConfigService - @Autowired lateinit var ssoGlobalProperties: SsoGlobalProperties @@ -98,7 +94,7 @@ class OAuthTest : AuthorizedControllerTest() { authorizationUri = "authorizationUri" jwkSetUri = "http://jwkSetUri" tokenUri = "http://tokenUri" - organizationId = testData.organization.id + organization = testData.organization }, ) @@ -174,7 +170,7 @@ class OAuthTest : AuthorizedControllerTest() { val userName = jwtClaimsSet.getStringClaim("email") val user = userAccountService.get(userName) assertThat(user.ssoRefreshToken).isNotNull - assertThat(user.ssoConfig).isNotNull + assertThat(user.ssoTenant).isNotNull assertThat(user.thirdPartyAuthType?.code()).isEqualTo("sso") val isValid = cacheWithExpirationManager @@ -193,7 +189,7 @@ class OAuthTest : AuthorizedControllerTest() { val user = userAccountService.get(userName) assertThat( oAuthService.verifyUserIsStillEmployed( - user.ssoConfig?.domainName, + user.ssoTenant?.domain, user.id, user.ssoRefreshToken, user.thirdPartyAuthType?.code(), @@ -212,7 +208,7 @@ class OAuthTest : AuthorizedControllerTest() { oAuthMultiTenantsMocks.mockTokenExchange("http://tokenUri") assertThat( oAuthService.verifyUserIsStillEmployed( - user.ssoConfig?.domainName, + user.ssoTenant?.domain, user.id, user.ssoRefreshToken, user.thirdPartyAuthType?.code(), From 4b307144bf38de518b88cc61682799b46996cfbc Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 1 Nov 2024 12:29:00 +0100 Subject: [PATCH 112/162] chore: rename ssoTenant column --- .../io/tolgee/dtos/cacheable/UserAccountDto.kt | 2 +- .../src/main/kotlin/io/tolgee/model/SsoTenant.kt | 2 +- .../src/main/kotlin/io/tolgee/model/UserAccount.kt | 4 ++-- .../io/tolgee/repository/UserAccountRepository.kt | 4 ++-- .../src/main/resources/db/changelog/schema.xml | 10 ++++++++++ .../api/v2/controllers/OAuth2CallbackController.kt | 2 +- .../main/kotlin/io/tolgee/ee/data/SsoTenantDto.kt | 2 +- .../kotlin/io/tolgee/ee/service/TenantService.kt | 14 +++++++++----- .../src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 2 +- 9 files changed, 28 insertions(+), 14 deletions(-) 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 46c69a33c6..57b5c6bfce 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 @@ -32,7 +32,7 @@ data class UserAccountDto( emailVerified = entity.emailVerification == null, thirdPartyAuth = entity.thirdPartyAuthType?.code(), ssoRefreshToken = entity.ssoRefreshToken, - ssoDomain = entity.ssoConfig?.domainName, + ssoDomain = entity.ssoTenant?.domain, ) } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/SsoTenant.kt b/backend/data/src/main/kotlin/io/tolgee/model/SsoTenant.kt index 502d82efd0..ecffb45e1f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/SsoTenant.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/SsoTenant.kt @@ -25,5 +25,5 @@ class SsoTenant : StandardAuditModel() { var userAccounts: MutableSet = mutableSetOf() @ColumnDefault("true") - var isEnabledForThisOrganization: Boolean = 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 33005de9cd..5eaeeb855e 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt @@ -48,8 +48,8 @@ data class UserAccount( @Enumerated(EnumType.STRING) var thirdPartyAuthType: ThirdPartyAuthType? = null - @ManyToOne() - var ssoConfig: SsoConfig? = null + @ManyToOne + var ssoTenant: SsoTenant? = null @Column(name = "sso_refresh_token", columnDefinition = "TEXT") var ssoRefreshToken: String? = null 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 a77d2afaf8..03abfaa0df 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt @@ -84,8 +84,8 @@ interface UserAccountRepository : JpaRepository { ): Optional @Query( - "SELECT u FROM UserAccount u JOIN u.ssoConfig s" + - " WHERE s.domainName = :domain AND u.thirdPartyAuthId = :thirdPartyAuthId", + "SELECT u FROM UserAccount u JOIN u.ssoTenant s" + + " WHERE s.domain = :domain AND u.thirdPartyAuthId = :thirdPartyAuthId", ) fun findBySsoDomain( thirdPartyAuthId: String, diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index 0f690fba92..f4d8636b18 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -3552,4 +3552,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index 91b817b03a..85b9841d0d 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -30,7 +30,7 @@ class OAuth2CallbackController( ): SsoUrlResponse { val registrationId = request.domain val tenant = tenantService.getByDomain(registrationId) - if (!tenant.isEnabledForThisOrganization) { + if (!tenant.enabled) { throw OAuthAuthorizationException(Message.SSO_DOMAIN_NOT_ENABLED, "Domain is not enabled for this organization") } val redirectUrl = buildAuthUrl(tenant, state = request.state) 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 index 64f69ecd72..833b61d720 100644 --- 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 @@ -18,7 +18,7 @@ fun SsoTenant.toDto(): SsoTenantDto = clientId = this.clientId, clientSecret = this.clientSecret, tokenUri = this.tokenUri, - isEnabled = this.isEnabledForThisOrganization, + isEnabled = this.enabled, jwkSetUri = this.jwkSetUri, domainName = this.domain, ) 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 index a899c6e483..2a6d372820 100644 --- 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 @@ -4,10 +4,11 @@ import io.tolgee.configuration.tolgee.SsoGlobalProperties import io.tolgee.constants.Message import io.tolgee.ee.data.CreateProviderRequest import io.tolgee.ee.exceptions.OAuthAuthorizationException -import io.tolgee.ee.model.SsoTenant import io.tolgee.ee.repository.TenantRepository import io.tolgee.exceptions.BadRequestException import io.tolgee.exceptions.NotFoundException +import io.tolgee.model.SsoTenant +import io.tolgee.service.organization.OrganizationService import org.springframework.stereotype.Service import java.net.URI import java.net.URISyntaxException @@ -16,6 +17,7 @@ import java.net.URISyntaxException class TenantService( private val tenantRepository: TenantRepository, private val ssoGlobalProperties: SsoGlobalProperties, + private val organizationService: OrganizationService ) { fun getById(id: Long): SsoTenant = tenantRepository.findById(id).orElseThrow { NotFoundException() } @@ -28,7 +30,7 @@ class TenantService( private fun buildGlobalTenant(): SsoTenant = SsoTenant().apply { - isEnabledForThisOrganization = validateProperty(ssoGlobalProperties.enabled.toString(), "enabled").toBoolean() + enabled = validateProperty(ssoGlobalProperties.enabled.toString(), "enabled").toBoolean() domain = validateProperty(ssoGlobalProperties.domain, "domain") clientId = validateProperty(ssoGlobalProperties.clientId, "clientId") clientSecret = validateProperty(ssoGlobalProperties.clientSecret, "clientSecret") @@ -90,14 +92,16 @@ class TenantService( organizationId: Long, ): SsoTenant { tenant.name = dto.name ?: "" - tenant.organizationId = organizationId + tenant.organization = organizationService.get(organizationId) 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.isEnabledForThisOrganization = dto.isEnabled - return save(tenant) + tenant.enabled = dto.isEnabled + val saved = save(tenant) + organizationService.updateSsoProvider(organizationId, saved) + return saved } } 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 index f0d28e9fca..e6f4f52699 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -112,7 +112,7 @@ class OAuthTest : AuthorizedControllerTest() { @Test fun `does not return auth link when tenant is disabled`() { val tenant = addTenant() - tenant.isEnabledForThisOrganization = false + tenant.enabled = false tenantService.save(tenant) val response = oAuthMultiTenantsMocks.getAuthLink("registrationId").response assertThat(response.status).isEqualTo(400) From 44e7167d88a57bd026b681083a8900b198e0c7d0 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 1 Nov 2024 12:31:03 +0100 Subject: [PATCH 113/162] chore: rename OAuthService's function --- .../io/tolgee/security/authentication/AuthenticationFilter.kt | 2 +- .../main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt | 2 +- .../app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt | 2 +- ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) 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 e2b44aad1b..445cf224ba 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 @@ -115,7 +115,7 @@ class AuthenticationFilter( } private fun checkIfSsoUserStillExists(userDto: UserAccountDto) { - if (!oAuthService.verifyUserIsStillEmployed( + if (!oAuthService.verifyUserSsoAccountAvailable( userDto.ssoDomain, userDto.id, userDto.ssoRefreshToken, diff --git a/backend/security/src/main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt b/backend/security/src/main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt index 79fbdf379e..ee3274eeeb 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt @@ -4,7 +4,7 @@ import org.springframework.stereotype.Component @Component interface OAuthServiceEe { - fun verifyUserIsStillEmployed( + fun verifyUserSsoAccountAvailable( ssoDomain: String?, userId: Long, refreshToken: String?, diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index a6161c0ae5..252e0c3813 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -164,7 +164,7 @@ class OAuthService( return JwtAuthenticationResponse(jwt) } - override fun verifyUserIsStillEmployed( + override fun verifyUserSsoAccountAvailable( ssoDomain: String?, userId: Long, refreshToken: String?, 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 index e6f4f52699..7a037705e3 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -188,7 +188,7 @@ class OAuthTest : AuthorizedControllerTest() { val userName = jwtClaimsSet.getStringClaim("email") val user = userAccountService.get(userName) assertThat( - oAuthService.verifyUserIsStillEmployed( + oAuthService.verifyUserSsoAccountAvailable( user.ssoTenant?.domain, user.id, user.ssoRefreshToken, @@ -207,7 +207,7 @@ class OAuthTest : AuthorizedControllerTest() { oAuthMultiTenantsMocks.mockTokenExchange("http://tokenUri") assertThat( - oAuthService.verifyUserIsStillEmployed( + oAuthService.verifyUserSsoAccountAvailable( user.ssoTenant?.domain, user.id, user.ssoRefreshToken, From df249816fa5b2f771307a6bd9c8670e3eb27ef5b Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 1 Nov 2024 12:41:37 +0100 Subject: [PATCH 114/162] fix: use @Convert instead of @Enumerated to avoid incompatibility with the previous values in db --- .../component/ThirdPartyAuthTypeConverter.kt | 16 ++++++++++++++++ .../main/kotlin/io/tolgee/model/UserAccount.kt | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 backend/data/src/main/kotlin/io/tolgee/component/ThirdPartyAuthTypeConverter.kt 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..087e0622d6 --- /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(autoApply = true) +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/model/UserAccount.kt b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt index 5eaeeb855e..54a3a56561 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt @@ -2,6 +2,7 @@ package io.tolgee.model import io.hypersistence.utils.hibernate.type.array.ListArrayType 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 @@ -45,7 +46,7 @@ data class UserAccount( var emailVerification: EmailVerification? = null @Column(name = "third_party_auth_type") - @Enumerated(EnumType.STRING) + @Convert(converter = ThirdPartyAuthTypeConverter::class) var thirdPartyAuthType: ThirdPartyAuthType? = null @ManyToOne From 5c7718516e1419d0ada86d37678bd88d05f5d01b Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 1 Nov 2024 13:05:51 +0100 Subject: [PATCH 115/162] fix: use getEnabledByDomain instead of getByDomain to prevent using disabled tenant --- .../src/main/kotlin/io/tolgee/constants/Message.kt | 5 +++-- .../api/v2/controllers/OAuth2CallbackController.kt | 7 +------ .../io/tolgee/ee/repository/TenantRepository.kt | 6 ++++++ .../kotlin/io/tolgee/ee/service/OAuthService.kt | 4 ++-- .../kotlin/io/tolgee/ee/service/TenantService.kt | 7 +++++++ .../src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 14 ++++++++++++-- 6 files changed, 31 insertions(+), 12 deletions(-) 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 84b4e70482..badc80b460 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -249,9 +249,10 @@ enum class Message { SSO_USER_CANT_LOGIN_WITH_NATIVE, SSO_USER_OPERATION_UNAVAILABLE, SSO_GLOBAL_CONFIG_MISSING_PROPERTIES, - ; + SSO_DOMAIN_NOT_FOUND_OR_DISABLED, + ; - val code: String + val code: String get() = name.lowercase(Locale.getDefault()) @JsonValue diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index 85b9841d0d..df119f5cfb 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -1,10 +1,8 @@ package io.tolgee.ee.api.v2.controllers import io.tolgee.component.FrontendUrlProvider -import io.tolgee.constants.Message import io.tolgee.ee.data.DomainRequest import io.tolgee.ee.data.SsoUrlResponse -import io.tolgee.ee.exceptions.OAuthAuthorizationException import io.tolgee.ee.service.OAuthService import io.tolgee.ee.service.TenantService import io.tolgee.model.SsoTenant @@ -29,10 +27,7 @@ class OAuth2CallbackController( @RequestBody request: DomainRequest, ): SsoUrlResponse { val registrationId = request.domain - val tenant = tenantService.getByDomain(registrationId) - if (!tenant.enabled) { - throw OAuthAuthorizationException(Message.SSO_DOMAIN_NOT_ENABLED, "Domain is not enabled for this organization") - } + val tenant = tenantService.getEnabledByDomain(registrationId) val redirectUrl = buildAuthUrl(tenant, state = request.state) return SsoUrlResponse(redirectUrl) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt index 3cc2ad480a..0bd2cc5110 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt @@ -2,6 +2,7 @@ package io.tolgee.ee.repository import io.tolgee.model.SsoTenant import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository @Repository @@ -9,4 +10,9 @@ 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/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index 252e0c3813..5abb9946d2 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -59,7 +59,7 @@ class OAuthService( ) } - val tenant = tenantService.getByDomain(registrationId) + val tenant = tenantService.getEnabledByDomain(registrationId) val tokenResponse = exchangeCodeForToken(tenant, code, redirectUrl) @@ -194,7 +194,7 @@ class OAuthService( return true } - val tenant = tenantService.getByDomain(ssoDomain) + val tenant = tenantService.getEnabledByDomain(ssoDomain) val headers = HttpHeaders().apply { contentType = MediaType.APPLICATION_FORM_URLENCODED 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 index 2a6d372820..02bc939304 100644 --- 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 @@ -21,6 +21,13 @@ class TenantService( ) { fun getById(id: Long): SsoTenant = tenantRepository.findById(id).orElseThrow { NotFoundException() } + fun getEnabledByDomain(domain: String): SsoTenant = + if (ssoGlobalProperties.enabled) { + buildGlobalTenant() + } else { + tenantRepository.findEnabledByDomain(domain) ?: throw NotFoundException(Message.SSO_DOMAIN_NOT_FOUND_OR_DISABLED) + } + fun getByDomain(domain: String): SsoTenant = if (ssoGlobalProperties.enabled) { buildGlobalTenant() 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 index 7a037705e3..0b7671bed6 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -115,8 +115,18 @@ class OAuthTest : AuthorizedControllerTest() { tenant.enabled = false tenantService.save(tenant) val response = oAuthMultiTenantsMocks.getAuthLink("registrationId").response - assertThat(response.status).isEqualTo(400) - assertThat(response.contentAsString).contains(Message.SSO_DOMAIN_NOT_ENABLED.code) + 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 From adb316ded3104a5d87d1fbda6c082f20d5fe2010 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 1 Nov 2024 16:07:26 +0100 Subject: [PATCH 116/162] fix: throw if native auth is disabled and login endpoint is called --- .../main/kotlin/io/tolgee/controllers/PublicController.kt | 7 +++++++ .../data/src/main/kotlin/io/tolgee/constants/Message.kt | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) 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..bc3ef39eb0 100644 --- a/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.node.TextNode import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import io.tolgee.component.email.TolgeeEmailSender +import io.tolgee.configuration.tolgee.AuthenticationProperties import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.constants.Message import io.tolgee.dtos.misc.EmailParams @@ -11,6 +12,7 @@ import io.tolgee.dtos.request.auth.ResetPassword import io.tolgee.dtos.request.auth.ResetPasswordRequest import io.tolgee.dtos.request.auth.SignUpDto import io.tolgee.dtos.security.LoginRequest +import io.tolgee.exceptions.AuthenticationException import io.tolgee.exceptions.BadRequestException import io.tolgee.exceptions.NotFoundException import io.tolgee.model.UserAccount @@ -49,6 +51,7 @@ class PublicController( private val signUpService: SignUpService, private val mfaService: MfaService, private val userCredentialsService: UserCredentialsService, + private val authProperties: AuthenticationProperties ) { @Operation(summary = "Generate JWT token") @PostMapping("/generatetoken") @@ -57,6 +60,10 @@ class PublicController( @RequestBody @Valid loginRequest: LoginRequest, ): JwtAuthenticationResponse { + if(!authProperties.nativeEnabled) { + throw AuthenticationException(Message.NATIVE_AUTHENTICATION_DISABLED) + } + val userAccount = userCredentialsService.checkUserCredentials(loginRequest.username, loginRequest.password) mfaService.checkMfa(userAccount, loginRequest.otp) 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 badc80b460..5a5b932cee 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -250,9 +250,10 @@ enum class Message { SSO_USER_OPERATION_UNAVAILABLE, SSO_GLOBAL_CONFIG_MISSING_PROPERTIES, SSO_DOMAIN_NOT_FOUND_OR_DISABLED, - ; + NATIVE_AUTHENTICATION_DISABLED, + ; - val code: String + val code: String get() = name.lowercase(Locale.getDefault()) @JsonValue From a7b2f267ba57350002caa0d2e0addbde74026adb Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 1 Nov 2024 16:10:04 +0100 Subject: [PATCH 117/162] chore: code cleaning --- .../organization/OrganizationRoleService.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 9b5e781266..929c28691b 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 @@ -206,6 +206,16 @@ class OrganizationRoleService( } } + fun grantRoleToUser( + user: UserAccount, + organizationId: Long, + organizationRoleType: OrganizationRoleType, + ) { + val organization = organizationRepository.findById(organizationId).orElseThrow { NotFoundException() } + + self.grantRoleToUser(user, organization, organizationRoleType = organizationRoleType) + } + fun leave(organizationId: Long) { this.removeUser(organizationId, authenticationFacade.authenticatedUser.id) } @@ -240,16 +250,6 @@ class OrganizationRoleService( self.grantRoleToUser(user, organization, organizationRoleType = OrganizationRoleType.MEMBER) } - fun grantRoleToUser( - user: UserAccount, - organizationId: Long, - organizationRoleType: OrganizationRoleType, - ) { - val organization = organizationRepository.findById(organizationId).orElseThrow { NotFoundException() } - - self.grantRoleToUser(user, organization, organizationRoleType = organizationRoleType) - } - fun grantOwnerRoleToUser( user: UserAccount, organization: Organization, From 0d88bda9063fbf0c6da372ee033dedd05b944aa0 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 1 Nov 2024 16:21:34 +0100 Subject: [PATCH 118/162] fix: validate thirdPartyAuth type and handle invalid values as bad request --- .../security/authentication/AuthenticationFilter.kt | 11 ++++++++++- .../io/tolgee/security/service/OAuthServiceEe.kt | 3 ++- .../main/kotlin/io/tolgee/ee/service/OAuthService.kt | 9 ++------- .../main/kotlin/io/tolgee/ee/service/TenantService.kt | 2 +- 4 files changed, 15 insertions(+), 10 deletions(-) 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 445cf224ba..547b952b0f 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 @@ -21,6 +21,8 @@ import io.tolgee.configuration.tolgee.AuthenticationProperties import io.tolgee.constants.Message import io.tolgee.dtos.cacheable.UserAccountDto import io.tolgee.exceptions.AuthenticationException +import io.tolgee.exceptions.BadRequestException +import io.tolgee.model.enums.ThirdPartyAuthType import io.tolgee.security.PAT_PREFIX import io.tolgee.security.ratelimit.RateLimitService import io.tolgee.security.service.OAuthServiceEe @@ -115,11 +117,18 @@ class AuthenticationFilter( } private fun checkIfSsoUserStillExists(userDto: UserAccountDto) { + val thirdPartyAuthType = + userDto.thirdPartyAuth?.let { + runCatching { + ThirdPartyAuthType.valueOf(it.uppercase()) + }.getOrElse { throw BadRequestException(Message.SSO_CANT_VERIFY_USER) } + } + if (!oAuthService.verifyUserSsoAccountAvailable( userDto.ssoDomain, userDto.id, userDto.ssoRefreshToken, - userDto.thirdPartyAuth, + thirdPartyAuthType!!, ) ) { throw AuthenticationException(Message.SSO_CANT_VERIFY_USER) diff --git a/backend/security/src/main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt b/backend/security/src/main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt index ee3274eeeb..e91b485a76 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt @@ -1,5 +1,6 @@ package io.tolgee.security.service +import io.tolgee.model.enums.ThirdPartyAuthType import org.springframework.stereotype.Component @Component @@ -8,6 +9,6 @@ interface OAuthServiceEe { ssoDomain: String?, userId: Long, refreshToken: String?, - thirdPartyAuth: String?, + thirdPartyAuth: ThirdPartyAuthType, ): Boolean } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index 5abb9946d2..421a2e6dcd 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -168,14 +168,9 @@ class OAuthService( ssoDomain: String?, userId: Long, refreshToken: String?, - thirdPartyAuth: String?, + thirdPartyAuth: ThirdPartyAuthType, ): Boolean { - val thirdPartyAuthType = - thirdPartyAuth?.let { - runCatching { ThirdPartyAuthType.valueOf(it.uppercase()) }.getOrNull() - } - - if (thirdPartyAuthType != ThirdPartyAuthType.SSO) { + if (thirdPartyAuth != ThirdPartyAuthType.SSO) { return true } 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 index 02bc939304..ee6318a291 100644 --- 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 @@ -29,7 +29,7 @@ class TenantService( } fun getByDomain(domain: String): SsoTenant = - if (ssoGlobalProperties.enabled) { + if (ssoGlobalProperties.enabled && domain == ssoGlobalProperties.domain) { buildGlobalTenant() } else { tenantRepository.findByDomain(domain) ?: throw NotFoundException() From 26df30ac1dfdd231726ec493f68111d271ce4aae Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 1 Nov 2024 18:19:45 +0100 Subject: [PATCH 119/162] fix: use cacheable user entity to validate sso user instead of cache with expiration manager --- .../security/thirdParty/OAuthUserHandler.kt | 36 +++++++++++-------- .../tolgee/dtos/cacheable/UserAccountDto.kt | 2 ++ .../kotlin/io/tolgee/model/UserAccount.kt | 2 ++ .../kotlin/io/tolgee/security/constants.kt | 1 + .../main/resources/db/changelog/schema.xml | 5 +++ .../tolgee/security/service/OAuthServiceEe.kt | 2 ++ .../io/tolgee/ee/service/OAuthService.kt | 19 ++++------ .../src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 32 ++++++++--------- 8 files changed, 57 insertions(+), 42 deletions(-) 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 index e5ae21a7cd..42bb14b615 100644 --- a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt @@ -1,16 +1,18 @@ package io.tolgee.security.thirdParty -import io.tolgee.component.cacheWithExpiration.CacheWithExpirationManager -import io.tolgee.constants.Caches +import io.tolgee.component.CurrentDateProvider +import io.tolgee.configuration.tolgee.AuthenticationProperties import io.tolgee.constants.Message import io.tolgee.exceptions.AuthenticationException import io.tolgee.model.UserAccount import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.model.enums.ThirdPartyAuthType +import io.tolgee.security.SSO_SESSION_EXPIRATION_MINUTES 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 @Component @@ -18,7 +20,8 @@ class OAuthUserHandler( private val signUpService: SignUpService, private val organizationRoleService: OrganizationRoleService, private val userAccountService: UserAccountService, - private val cacheWithExpirationManager: CacheWithExpirationManager, + private val currentDateProvider: CurrentDateProvider, + private val authenticationProperties: AuthenticationProperties ) { fun findOrCreateUser( userResponse: OAuthUserDetails, @@ -36,7 +39,7 @@ class OAuthUserHandler( if (userAccountOptional.isPresent && thirdPartyAuthType == ThirdPartyAuthType.SSO) { updateRefreshToken(userAccountOptional.get(), userResponse.refreshToken) - cacheSsoUser(userAccountOptional.get().id, thirdPartyAuthType) + updateSsoSessionExpiry(userAccountOptional.get()) } return userAccountOptional.orElseGet { @@ -44,6 +47,11 @@ class OAuthUserHandler( throw AuthenticationException(Message.USERNAME_ALREADY_EXISTS) } + // do not create new user if user is not invited and GLOBAL sso is enabled + if(invitationCode == null && thirdPartyAuthType == ThirdPartyAuthType.SSO && authenticationProperties.sso.enabled) { + throw AuthenticationException(Message.SSO_USER_NOT_INVITED) + } + val newUserAccount = UserAccount() newUserAccount.username = userResponse.email @@ -63,7 +71,7 @@ class OAuthUserHandler( newUserAccount.thirdPartyAuthType = thirdPartyAuthType newUserAccount.ssoRefreshToken = userResponse.refreshToken newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY - + newUserAccount.ssoSessionExpiry = currentDateProvider.date.addMinutes(SSO_SESSION_EXPIRATION_MINUTES) signUpService.signUp(newUserAccount, invitationCode, null) // grant role to user only if request is not from oauth2 delegate @@ -79,8 +87,6 @@ class OAuthUserHandler( ) } - cacheSsoUser(newUserAccount.id, thirdPartyAuthType) - newUserAccount } } @@ -107,12 +113,14 @@ class OAuthUserHandler( } } - private fun cacheSsoUser( - userId: Long, - thirdPartyAuthType: ThirdPartyAuthType, - ) { - if (thirdPartyAuthType == ThirdPartyAuthType.SSO) { - cacheWithExpirationManager.putCache(Caches.IS_SSO_USER_VALID, userId, true) - } + fun updateSsoSessionExpiry(user: UserAccount) { + user.ssoSessionExpiry = currentDateProvider.date.addMinutes(SSO_SESSION_EXPIRATION_MINUTES) + userAccountService.save(user) + } + + fun updateSsoSessionExpiry(userAccountId: Long) { + val user = userAccountService.get(userAccountId) + user.ssoSessionExpiry = currentDateProvider.date.addMinutes(SSO_SESSION_EXPIRATION_MINUTES) + userAccountService.save(user) } } 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 57b5c6bfce..9bc9ffffe7 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 @@ -17,6 +17,7 @@ data class UserAccountDto( val thirdPartyAuth: String?, val ssoRefreshToken: String?, val ssoDomain: String?, + val ssoSessionExpiry: Date?, ) : Serializable { companion object { fun fromEntity(entity: UserAccount) = @@ -33,6 +34,7 @@ data class UserAccountDto( thirdPartyAuth = entity.thirdPartyAuthType?.code(), ssoRefreshToken = entity.ssoRefreshToken, ssoDomain = entity.ssoTenant?.domain, + ssoSessionExpiry = entity.ssoSessionExpiry, ) } 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 54a3a56561..545b6104d7 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt @@ -61,6 +61,8 @@ data class UserAccount( @Column(name = "reset_password_code") var resetPasswordCode: String? = null + var ssoSessionExpiry: Date? = null + @OrderBy("id ASC") @OneToMany(mappedBy = "user", orphanRemoval = true) var organizationRoles: MutableList = mutableListOf() diff --git a/backend/data/src/main/kotlin/io/tolgee/security/constants.kt b/backend/data/src/main/kotlin/io/tolgee/security/constants.kt index 5564fe4dd9..72855d1075 100644 --- a/backend/data/src/main/kotlin/io/tolgee/security/constants.kt +++ b/backend/data/src/main/kotlin/io/tolgee/security/constants.kt @@ -2,3 +2,4 @@ package io.tolgee.security const val PROJECT_API_KEY_PREFIX = "tgpak_" const val PAT_PREFIX = "tgpat_" +const val SSO_SESSION_EXPIRATION_MINUTES = 10 diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index f4d8636b18..8361afdfdc 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -3562,4 +3562,9 @@ + + + + + \ No newline at end of file diff --git a/backend/security/src/main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt b/backend/security/src/main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt index e91b485a76..78b96eb7bd 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt @@ -2,6 +2,7 @@ package io.tolgee.security.service import io.tolgee.model.enums.ThirdPartyAuthType import org.springframework.stereotype.Component +import java.util.* @Component interface OAuthServiceEe { @@ -10,5 +11,6 @@ interface OAuthServiceEe { userId: Long, refreshToken: String?, thirdPartyAuth: ThirdPartyAuthType, + ssoSessionExpiry: Date?, ): Boolean } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index 421a2e6dcd..c50265a64f 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -8,8 +8,7 @@ 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.cacheWithExpiration.CacheWithExpirationManager -import io.tolgee.constants.Caches +import io.tolgee.component.CurrentDateProvider import io.tolgee.constants.Message import io.tolgee.ee.data.GenericUserResponse import io.tolgee.ee.data.OAuth2TokenResponse @@ -40,7 +39,7 @@ class OAuthService( private val jwtProcessor: ConfigurableJWTProcessor, private val tenantService: TenantService, private val oAuthUserHandler: OAuthUserHandler, - private val cacheWithExpirationManager: CacheWithExpirationManager, + private val currentDateProvider: CurrentDateProvider, ) : OAuthServiceEe, Logging { fun handleOAuthCallback( @@ -169,6 +168,7 @@ class OAuthService( userId: Long, refreshToken: String?, thirdPartyAuth: ThirdPartyAuthType, + ssoSessionExpiry: Date?, ): Boolean { if (thirdPartyAuth != ThirdPartyAuthType.SSO) { return true @@ -178,14 +178,7 @@ class OAuthService( throw AuthenticationException(Message.SSO_CANT_VERIFY_USER) } - val isValid = - cacheWithExpirationManager - .getCache( - Caches.IS_SSO_USER_VALID, - )?.getWrapper(userId) - ?.get() as? Boolean - - if (isValid == true) { + if (ssoSessionExpiry != null && isSsoUserValid(ssoSessionExpiry)) { return true } @@ -212,7 +205,7 @@ class OAuthService( OAuth2TokenResponse::class.java, ) if (response.body?.refresh_token != null) { - cacheWithExpirationManager.putCache(Caches.IS_SSO_USER_VALID, userId, true) + oAuthUserHandler.updateSsoSessionExpiry(userId) oAuthUserHandler.updateRefreshToken(userId, response.body?.refresh_token) return true } @@ -222,4 +215,6 @@ class OAuthService( false } } + + private fun isSsoUserValid(ssoSessionExpiry: Date): Boolean = ssoSessionExpiry.after(currentDateProvider.date) } 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 index 0b7671bed6..2a97eca699 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -6,7 +6,6 @@ import com.nimbusds.jwt.JWTClaimsSet import com.nimbusds.jwt.proc.ConfigurableJWTProcessor import io.tolgee.component.cacheWithExpiration.CacheWithExpirationManager import io.tolgee.configuration.tolgee.SsoGlobalProperties -import io.tolgee.constants.Caches import io.tolgee.constants.Message import io.tolgee.development.testDataBuilder.data.OAuthTestData import io.tolgee.dtos.request.organization.OrganizationDto @@ -84,8 +83,10 @@ class OAuthTest : AuthorizedControllerTest() { testDataService.saveTestData(testData.root) } - private fun addTenant(): SsoTenant = - tenantService.save( + private fun addTenant(): SsoTenant { + + return tenantService.findTenant(testData.organization.id) + ?: SsoTenant().apply { name = "tenant1" domain = "registrationId" @@ -95,8 +96,9 @@ class OAuthTest : AuthorizedControllerTest() { 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`() { @@ -182,14 +184,6 @@ class OAuthTest : AuthorizedControllerTest() { assertThat(user.ssoRefreshToken).isNotNull assertThat(user.ssoTenant).isNotNull assertThat(user.thirdPartyAuthType?.code()).isEqualTo("sso") - val isValid = - cacheWithExpirationManager - .getCache( - Caches.IS_SSO_USER_VALID, - )?.getWrapper(user.id) - ?.get() as? Boolean - - assertThat(isValid).isTrue } @Test @@ -202,7 +196,8 @@ class OAuthTest : AuthorizedControllerTest() { user.ssoTenant?.domain, user.id, user.ssoRefreshToken, - user.thirdPartyAuthType?.code(), + user.thirdPartyAuthType!!, + user.ssoSessionExpiry, ), ).isTrue } @@ -221,7 +216,8 @@ class OAuthTest : AuthorizedControllerTest() { user.ssoTenant?.domain, user.id, user.ssoRefreshToken, - user.thirdPartyAuthType?.code(), + user.thirdPartyAuthType!!, + user.ssoSessionExpiry, ), ).isTrue @@ -243,7 +239,11 @@ class OAuthTest : AuthorizedControllerTest() { ssoGlobalProperties.authorizationUrl = "authorizationUri" ssoGlobalProperties.tokenUrl = "http://tokenUri" ssoGlobalProperties.jwkSetUri = "http://jwkSetUri" - oAuthMultiTenantsMocks.authorize("registrationId") + 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() = From 1c631191a5cb242679080ebdd9ba00a77e5822e6 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 1 Nov 2024 18:20:15 +0100 Subject: [PATCH 120/162] fix: save global tenant in db --- .../io/tolgee/ee/service/TenantService.kt | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) 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 index ee6318a291..439394969d 100644 --- 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 @@ -22,7 +22,7 @@ class TenantService( fun getById(id: Long): SsoTenant = tenantRepository.findById(id).orElseThrow { NotFoundException() } fun getEnabledByDomain(domain: String): SsoTenant = - if (ssoGlobalProperties.enabled) { + if (ssoGlobalProperties.enabled && domain == ssoGlobalProperties.domain) { buildGlobalTenant() } else { tenantRepository.findEnabledByDomain(domain) ?: throw NotFoundException(Message.SSO_DOMAIN_NOT_FOUND_OR_DISABLED) @@ -35,8 +35,20 @@ class TenantService( tenantRepository.findByDomain(domain) ?: throw NotFoundException() } - private fun buildGlobalTenant(): SsoTenant = - SsoTenant().apply { + private fun buildGlobalTenant(): SsoTenant { + val domain = validateProperty(ssoGlobalProperties.domain, "domain") + val tenant = tenantRepository.findByDomain(domain) ?: SsoTenant().apply { + this.domain = domain + } + + applyGlobalPropertiesToTenant(tenant) + + return tenantRepository.save(tenant) + } + + // set or update global properties to tenant + private fun applyGlobalPropertiesToTenant(tenant: SsoTenant) { + tenant.apply { enabled = validateProperty(ssoGlobalProperties.enabled.toString(), "enabled").toBoolean() domain = validateProperty(ssoGlobalProperties.domain, "domain") clientId = validateProperty(ssoGlobalProperties.clientId, "clientId") @@ -45,6 +57,7 @@ class TenantService( tokenUri = validateProperty(ssoGlobalProperties.tokenUrl, "tokenUrl") jwkSetUri = validateProperty(ssoGlobalProperties.jwkSetUri, "jwkSetUri") } + } private fun validateProperty( property: String?, From 4e99ce067620031a0a5226bab5553b3de2dc616a Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 1 Nov 2024 18:20:50 +0100 Subject: [PATCH 121/162] feat: dont auth user if it's global sso and there is no invite code --- backend/data/src/main/kotlin/io/tolgee/constants/Message.kt | 1 + 1 file changed, 1 insertion(+) 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 5a5b932cee..ac44a31196 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -251,6 +251,7 @@ enum class Message { SSO_GLOBAL_CONFIG_MISSING_PROPERTIES, SSO_DOMAIN_NOT_FOUND_OR_DISABLED, NATIVE_AUTHENTICATION_DISABLED, + SSO_USER_NOT_INVITED, ; val code: String From ba4eccf1a0a232aa5bdc1fbf3e70e950878e101b Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 1 Nov 2024 18:20:59 +0100 Subject: [PATCH 122/162] fix: add validation --- .../io/tolgee/security/authentication/AuthenticationFilter.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 547b952b0f..60d218eea7 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 @@ -117,6 +117,9 @@ class AuthenticationFilter( } private fun checkIfSsoUserStillExists(userDto: UserAccountDto) { + if(userDto.thirdPartyAuth == null) { + return + } val thirdPartyAuthType = userDto.thirdPartyAuth?.let { runCatching { @@ -129,6 +132,7 @@ class AuthenticationFilter( userDto.id, userDto.ssoRefreshToken, thirdPartyAuthType!!, + userDto.ssoSessionExpiry ) ) { throw AuthenticationException(Message.SSO_CANT_VERIFY_USER) From ce3bf2b5551e464bb401581d939a660f84daa779 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 1 Nov 2024 18:25:25 +0100 Subject: [PATCH 123/162] fix: ktlint check --- .../io/tolgee/controllers/PublicController.kt | 8 ++--- .../security/thirdParty/OAuthUserHandler.kt | 7 ++-- .../organization/OrganizationService.kt | 5 ++- .../authentication/AuthenticationFilter.kt | 4 +-- .../tolgee/ee/repository/TenantRepository.kt | 2 +- .../io/tolgee/ee/service/TenantService.kt | 9 ++--- .../src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 33 ++++++++----------- 7 files changed, 33 insertions(+), 35 deletions(-) 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 bc3ef39eb0..1bca20fcda 100644 --- a/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt @@ -51,7 +51,7 @@ class PublicController( private val signUpService: SignUpService, private val mfaService: MfaService, private val userCredentialsService: UserCredentialsService, - private val authProperties: AuthenticationProperties + private val authProperties: AuthenticationProperties, ) { @Operation(summary = "Generate JWT token") @PostMapping("/generatetoken") @@ -60,7 +60,7 @@ class PublicController( @RequestBody @Valid loginRequest: LoginRequest, ): JwtAuthenticationResponse { - if(!authProperties.nativeEnabled) { + if (!authProperties.nativeEnabled) { throw AuthenticationException(Message.NATIVE_AUTHENTICATION_DISABLED) } @@ -168,9 +168,7 @@ class PublicController( @OpenApiHideFromPublicDocs fun validateEmail( @RequestBody email: TextNode, - ): Boolean { - return userAccountService.findActive(email.asText()) == null - } + ): Boolean = userAccountService.findActive(email.asText()) == null @GetMapping("/authorize_oauth/{serviceType}") @Operation( 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 index 42bb14b615..3642b9dfbd 100644 --- a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt @@ -21,7 +21,7 @@ class OAuthUserHandler( private val organizationRoleService: OrganizationRoleService, private val userAccountService: UserAccountService, private val currentDateProvider: CurrentDateProvider, - private val authenticationProperties: AuthenticationProperties + private val authenticationProperties: AuthenticationProperties, ) { fun findOrCreateUser( userResponse: OAuthUserDetails, @@ -48,7 +48,10 @@ class OAuthUserHandler( } // do not create new user if user is not invited and GLOBAL sso is enabled - if(invitationCode == null && thirdPartyAuthType == ThirdPartyAuthType.SSO && authenticationProperties.sso.enabled) { + if (invitationCode == null && + thirdPartyAuthType == ThirdPartyAuthType.SSO && + authenticationProperties.sso.enabled + ) { throw AuthenticationException(Message.SSO_USER_NOT_INVITED) } 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 acbd381d0d..6ff2215e88 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 @@ -374,7 +374,10 @@ class OrganizationService( currentUserId: Long, ): OrganizationView? = organizationRepository.findView(id, currentUserId) - fun updateSsoProvider(organizationId: Long, tenant: SsoTenant) { + fun updateSsoProvider( + organizationId: Long, + tenant: SsoTenant, + ) { val organization = get(organizationId) organization.ssoTenant = tenant save(organization) 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 60d218eea7..1ad3ebea54 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 @@ -117,7 +117,7 @@ class AuthenticationFilter( } private fun checkIfSsoUserStillExists(userDto: UserAccountDto) { - if(userDto.thirdPartyAuth == null) { + if (userDto.thirdPartyAuth == null) { return } val thirdPartyAuthType = @@ -132,7 +132,7 @@ class AuthenticationFilter( userDto.id, userDto.ssoRefreshToken, thirdPartyAuthType!!, - userDto.ssoSessionExpiry + userDto.ssoSessionExpiry, ) ) { throw AuthenticationException(Message.SSO_CANT_VERIFY_USER) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt index 0bd2cc5110..e648941c26 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt @@ -12,7 +12,7 @@ interface TenantRepository : JpaRepository { fun findByOrganizationId(id: Long): SsoTenant? @Query( - "SELECT t FROM SsoTenant t WHERE t.enabled = true AND t.domain = :domain" + "SELECT t FROM SsoTenant t WHERE t.enabled = true AND t.domain = :domain", ) fun findEnabledByDomain(domain: String): SsoTenant? } 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 index 439394969d..e10fde52cd 100644 --- 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 @@ -17,7 +17,7 @@ import java.net.URISyntaxException class TenantService( private val tenantRepository: TenantRepository, private val ssoGlobalProperties: SsoGlobalProperties, - private val organizationService: OrganizationService + private val organizationService: OrganizationService, ) { fun getById(id: Long): SsoTenant = tenantRepository.findById(id).orElseThrow { NotFoundException() } @@ -37,9 +37,10 @@ class TenantService( private fun buildGlobalTenant(): SsoTenant { val domain = validateProperty(ssoGlobalProperties.domain, "domain") - val tenant = tenantRepository.findByDomain(domain) ?: SsoTenant().apply { - this.domain = domain - } + val tenant = + tenantRepository.findByDomain(domain) ?: SsoTenant().apply { + this.domain = domain + } applyGlobalPropertiesToTenant(tenant) 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 index 2a97eca699..47fdf17364 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -4,7 +4,6 @@ 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.component.cacheWithExpiration.CacheWithExpirationManager import io.tolgee.configuration.tolgee.SsoGlobalProperties import io.tolgee.constants.Message import io.tolgee.development.testDataBuilder.data.OAuthTestData @@ -72,9 +71,6 @@ class OAuthTest : AuthorizedControllerTest() { OAuthMultiTenantsMocks(authMvc, restTemplate, tenantService, jwtProcessor) } - @Autowired - private lateinit var cacheWithExpirationManager: CacheWithExpirationManager - @BeforeEach fun setup() { currentDateProvider.forcedDate = currentDateProvider.date @@ -83,22 +79,19 @@ class OAuthTest : AuthorizedControllerTest() { testDataService.saveTestData(testData.root) } - private fun addTenant(): SsoTenant { - - return 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) } - } - + 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`() { From 593f7465c0630f3b8a82200a7299d8a153d19843 Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 1 Nov 2024 18:38:26 +0100 Subject: [PATCH 124/162] fix: ktlint check --- .../src/main/kotlin/io/tolgee/controllers/PublicController.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 1bca20fcda..7d82bfff79 100644 --- a/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt @@ -168,7 +168,9 @@ class PublicController( @OpenApiHideFromPublicDocs fun validateEmail( @RequestBody email: TextNode, - ): Boolean = userAccountService.findActive(email.asText()) == null + ): Boolean { + return userAccountService.findActive(email.asText()) == null + } @GetMapping("/authorize_oauth/{serviceType}") @Operation( From 22bd59b4b0db4aa7a7ec057a5159e881570df7ce Mon Sep 17 00:00:00 2001 From: Ivan Manzhosov Date: Fri, 1 Nov 2024 18:55:08 +0100 Subject: [PATCH 125/162] chore: update SsoProviderControllerTest --- .../io/tolgee/ee/api/v2/controllers/SsoProviderControllerTest.kt | 1 - 1 file changed, 1 deletion(-) 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 index 774e5edd3f..daabac3258 100644 --- 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 @@ -40,7 +40,6 @@ class SsoProviderControllerTest : AuthorizedControllerTest() { node("clientId").isEqualTo("clientId") node("clientSecret").isEqualTo("clientSecret") node("authorizationUri").isEqualTo("authorization") - node("redirectUri").isEqualTo("redirectUri") node("tokenUri").isEqualTo("tokenUri") node("jwkSetUri").isEqualTo("jwkSetUri") node("isEnabled").isEqualTo(true) From bfabcaf27a62cb8f21100a1445ce681eaa52bc9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Fri, 1 Nov 2024 13:15:35 +0100 Subject: [PATCH 126/162] feat: use managed accounts for sso instead of handling sso as a separate thing --- .../kotlin/io/tolgee/controllers/PublicController.kt | 6 ++++++ .../io/tolgee/security/thirdParty/OAuth2Delegate.kt | 9 ++++++++- .../io/tolgee/security/thirdParty/OAuthUserHandler.kt | 4 +++- .../data/src/main/kotlin/io/tolgee/constants/Message.kt | 1 - .../io/tolgee/service/security/UserAccountService.kt | 4 ---- .../io/tolgee/service/security/UserCredentialsService.kt | 5 ----- .../src/main/kotlin/io/tolgee/ee/service/OAuthService.kt | 9 ++++++++- 7 files changed, 25 insertions(+), 13 deletions(-) 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 7d82bfff79..4ff9b18d44 100644 --- a/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt @@ -80,6 +80,9 @@ class PublicController( request: ResetPasswordRequest, ) { val userAccount = userAccountService.findActive(request.email!!) ?: return + if (userAccount.accountType === UserAccount.AccountType.MANAGED) { + throw BadRequestException(Message.OPERATION_UNAVAILABLE_FOR_ACCOUNT_TYPE) + } val code = RandomStringUtils.randomAlphabetic(50) userAccountService.setResetPasswordCode(userAccount, code) @@ -124,6 +127,9 @@ class PublicController( request: ResetPassword, ) { val userAccount = validateEmailCode(request.code!!, request.email!!) + if (userAccount.accountType === UserAccount.AccountType.MANAGED) { + throw BadRequestException(Message.OPERATION_UNAVAILABLE_FOR_ACCOUNT_TYPE) + } if (userAccount.accountType === UserAccount.AccountType.THIRD_PARTY) { userAccountService.setAccountType(userAccount, UserAccount.AccountType.LOCAL) } 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 3daeb993a4..ab3d210332 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 @@ -4,6 +4,7 @@ import io.tolgee.configuration.tolgee.OAuth2AuthenticationProperties 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 @@ -96,7 +97,13 @@ class OAuth2Delegate( familyName = userResponse.family_name, email = email, ) - val user = oAuthUserHandler.findOrCreateUser(userData, invitationCode, ThirdPartyAuthType.OAUTH2) + val user = + oAuthUserHandler.findOrCreateUser( + userData, + invitationCode, + ThirdPartyAuthType.OAUTH2, + UserAccount.AccountType.THIRD_PARTY, + ) 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 index 3642b9dfbd..a78faf9dd8 100644 --- a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt @@ -27,6 +27,7 @@ class OAuthUserHandler( userResponse: OAuthUserDetails, invitationCode: String?, thirdPartyAuthType: ThirdPartyAuthType, + accountType: UserAccount.AccountType, ): UserAccount { val tenant = userResponse.tenant @@ -73,8 +74,9 @@ class OAuthUserHandler( } newUserAccount.thirdPartyAuthType = thirdPartyAuthType newUserAccount.ssoRefreshToken = userResponse.refreshToken - newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY + newUserAccount.accountType = accountType newUserAccount.ssoSessionExpiry = currentDateProvider.date.addMinutes(SSO_SESSION_EXPIRATION_MINUTES) + signUpService.signUp(newUserAccount, invitationCode, null) // grant role to user only if request is not from oauth2 delegate 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 ac44a31196..d45435213d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -247,7 +247,6 @@ enum class Message { SSO_USER_CANNOT_CREATE_ORGANIZATION, SSO_CANT_VERIFY_USER, SSO_USER_CANT_LOGIN_WITH_NATIVE, - SSO_USER_OPERATION_UNAVAILABLE, SSO_GLOBAL_CONFIG_MISSING_PROPERTIES, SSO_DOMAIN_NOT_FOUND_OR_DISABLED, NATIVE_AUTHENTICATION_DISABLED, 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 64cdf42dd8..25ae6c6651 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 @@ -400,10 +400,6 @@ class UserAccountService( throw BadRequestException(Message.OPERATION_UNAVAILABLE_FOR_ACCOUNT_TYPE) } - if (userAccount.thirdPartyAuthType == ThirdPartyAuthType.SSO) { - throw BadRequestException(Message.SSO_USER_OPERATION_UNAVAILABLE) - } - val matches = passwordEncoder.matches(dto.currentPassword, userAccount.password) if (!matches) throw PermissionException(Message.WRONG_CURRENT_PASSWORD) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/UserCredentialsService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/UserCredentialsService.kt index 2bf719cf35..678938895e 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/UserCredentialsService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/UserCredentialsService.kt @@ -3,7 +3,6 @@ package io.tolgee.service.security import io.tolgee.constants.Message import io.tolgee.exceptions.AuthenticationException import io.tolgee.model.UserAccount -import io.tolgee.model.enums.ThirdPartyAuthType import org.springframework.beans.factory.annotation.Autowired import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service @@ -27,10 +26,6 @@ class UserCredentialsService( throw AuthenticationException(Message.OPERATION_UNAVAILABLE_FOR_ACCOUNT_TYPE) } - if (userAccount.thirdPartyAuthType == ThirdPartyAuthType.SSO) { - throw AuthenticationException(Message.SSO_USER_OPERATION_UNAVAILABLE) - } - checkNativeUserCredentials(userAccount, password) return userAccount } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index c50265a64f..242be5e34e 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -15,6 +15,7 @@ import io.tolgee.ee.data.OAuth2TokenResponse import io.tolgee.ee.exceptions.OAuthAuthorizationException import io.tolgee.exceptions.AuthenticationException import io.tolgee.model.SsoTenant +import io.tolgee.model.UserAccount import io.tolgee.model.enums.ThirdPartyAuthType import io.tolgee.security.authentication.JwtService import io.tolgee.security.payload.JwtAuthenticationResponse @@ -158,7 +159,13 @@ class OAuthService( refreshToken = refreshToken, tenant = tenant, ) - val user = oAuthUserHandler.findOrCreateUser(userData, invitationCode, ThirdPartyAuthType.SSO) + val user = + oAuthUserHandler.findOrCreateUser( + userData, + invitationCode, + ThirdPartyAuthType.SSO, + UserAccount.AccountType.MANAGED, + ) val jwt = jwtService.emitToken(user.id) return JwtAuthenticationResponse(jwt) } From 6816c471806e5be1d57a896b7919ac6580af11c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Fri, 1 Nov 2024 14:32:58 +0100 Subject: [PATCH 127/162] fix: sso license check handling --- .../ee/api/v2/controllers/OAuth2CallbackController.kt | 11 +++++++++++ .../main/kotlin/io/tolgee/ee/service/OAuthService.kt | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index df119f5cfb..7dea316081 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -1,6 +1,9 @@ package io.tolgee.ee.api.v2.controllers import io.tolgee.component.FrontendUrlProvider +import io.tolgee.component.enabledFeaturesProvider.EnabledFeaturesProvider +import io.tolgee.constants.Feature +import io.tolgee.constants.Message import io.tolgee.ee.data.DomainRequest import io.tolgee.ee.data.SsoUrlResponse import io.tolgee.ee.service.OAuthService @@ -21,6 +24,7 @@ class OAuth2CallbackController( private val userAccountService: UserAccountService, private val jwtService: JwtService, private val frontendUrlProvider: FrontendUrlProvider, + private val enabledFeaturesProvider: EnabledFeaturesProvider, ) { @PostMapping("/get-authentication-url") fun getAuthenticationUrl( @@ -28,6 +32,13 @@ class OAuth2CallbackController( ): SsoUrlResponse { val registrationId = request.domain val tenant = tenantService.getEnabledByDomain(registrationId) + enabledFeaturesProvider.checkFeatureEnabled( + organizationId = tenant.organization.id, + Feature.SSO, + ) + if (!tenant.isEnabledForThisOrganization) { + throw OAuthAuthorizationException(Message.SSO_DOMAIN_NOT_ENABLED, "Domain is not enabled for this organization") + } val redirectUrl = buildAuthUrl(tenant, state = request.state) return SsoUrlResponse(redirectUrl) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index 242be5e34e..510f009377 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -9,6 +9,7 @@ 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.constants.Message import io.tolgee.ee.data.GenericUserResponse import io.tolgee.ee.data.OAuth2TokenResponse @@ -41,6 +42,7 @@ class OAuthService( private val tenantService: TenantService, private val oAuthUserHandler: OAuthUserHandler, private val currentDateProvider: CurrentDateProvider, + private val enabledFeaturesProvider: EnabledFeaturesProvider, ) : OAuthServiceEe, Logging { fun handleOAuthCallback( @@ -60,6 +62,10 @@ class OAuthService( } val tenant = tenantService.getEnabledByDomain(registrationId) + enabledFeaturesProvider.checkFeatureEnabled( + organizationId = tenant.organization.id, + Feature.SSO, + ) val tokenResponse = exchangeCodeForToken(tenant, code, redirectUrl) @@ -190,6 +196,10 @@ class OAuthService( } val tenant = tenantService.getEnabledByDomain(ssoDomain) + enabledFeaturesProvider.checkFeatureEnabled( + organizationId = tenant.organization.id, + Feature.SSO, + ) val headers = HttpHeaders().apply { contentType = MediaType.APPLICATION_FORM_URLENCODED From ccec98b15aa509e545ea956df753295078c81434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Fri, 1 Nov 2024 15:40:36 +0100 Subject: [PATCH 128/162] feat: better handling of reset passowrd request for sso accounts --- .../io/tolgee/controllers/PublicController.kt | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) 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 4ff9b18d44..fc149d495b 100644 --- a/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt @@ -81,8 +81,28 @@ class PublicController( ) { val userAccount = userAccountService.findActive(request.email!!) ?: return if (userAccount.accountType === UserAccount.AccountType.MANAGED) { - throw BadRequestException(Message.OPERATION_UNAVAILABLE_FOR_ACCOUNT_TYPE) + 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) From 680576d271c912db2f755bc5685c91a49f4e95ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Fri, 1 Nov 2024 15:42:14 +0100 Subject: [PATCH 129/162] fix: mark some functions private --- .../app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index 510f009377..7a6eb0f5e0 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -78,7 +78,7 @@ class OAuthService( return register(userInfo, tenant, invitationCode, tokenResponse.refresh_token) } - fun exchangeCodeForToken( + private fun exchangeCodeForToken( tenant: SsoTenant, code: String, redirectUrl: String, @@ -112,7 +112,7 @@ class OAuthService( } } - fun verifyAndDecodeIdToken( + private fun verifyAndDecodeIdToken( idToken: String, jwkSetUri: String, ): GenericUserResponse { From bbebb2863618ebeb2e6f01dd32a4947224ebd593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Fri, 1 Nov 2024 15:50:45 +0100 Subject: [PATCH 130/162] fix: lint --- .../src/main/kotlin/io/tolgee/controllers/PublicController.kt | 2 -- 1 file changed, 2 deletions(-) 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 fc149d495b..e1261dd9a2 100644 --- a/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt @@ -97,8 +97,6 @@ class PublicController( """.trimIndent(), ) - - tolgeeEmailSender.sendEmail(params) return } From 1668ecad548806201867256a07db9c0951ce0d81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Fri, 1 Nov 2024 17:38:51 +0100 Subject: [PATCH 131/162] fix: catch correct exception --- .../src/main/kotlin/io/tolgee/ee/service/OAuthService.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index 7a6eb0f5e0..66d1f52620 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -29,7 +29,7 @@ import org.springframework.http.* import org.springframework.stereotype.Service import org.springframework.util.LinkedMultiValueMap import org.springframework.util.MultiValueMap -import org.springframework.web.client.HttpClientErrorException +import org.springframework.web.client.RestClientException import org.springframework.web.client.RestTemplate import java.net.URL import java.util.* @@ -106,7 +106,7 @@ class OAuthService( OAuth2TokenResponse::class.java, ) response.body - } catch (e: HttpClientErrorException) { + } catch (e: RestClientException) { logger.info("Failed to exchange code for token: ${e.message}") null } @@ -227,7 +227,7 @@ class OAuthService( return true } false - } catch (e: HttpClientErrorException) { + } catch (e: RestClientException) { logger.info("Failed to refresh token: ${e.message}") false } From 563a09d5d518279b827c35d9948c08b51301bd8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Mon, 4 Nov 2024 18:07:33 +0100 Subject: [PATCH 132/162] fix: build issues after rebase --- .../tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt | 5 +---- .../app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt | 5 +++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index 7dea316081..4356809ac8 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -33,12 +33,9 @@ class OAuth2CallbackController( val registrationId = request.domain val tenant = tenantService.getEnabledByDomain(registrationId) enabledFeaturesProvider.checkFeatureEnabled( - organizationId = tenant.organization.id, + organizationId = tenant.organization?.id, Feature.SSO, ) - if (!tenant.isEnabledForThisOrganization) { - throw OAuthAuthorizationException(Message.SSO_DOMAIN_NOT_ENABLED, "Domain is not enabled for this organization") - } val redirectUrl = buildAuthUrl(tenant, state = request.state) return SsoUrlResponse(redirectUrl) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt index 66d1f52620..ae8bae703f 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt @@ -10,6 +10,7 @@ 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.constants.Feature import io.tolgee.constants.Message import io.tolgee.ee.data.GenericUserResponse import io.tolgee.ee.data.OAuth2TokenResponse @@ -63,7 +64,7 @@ class OAuthService( val tenant = tenantService.getEnabledByDomain(registrationId) enabledFeaturesProvider.checkFeatureEnabled( - organizationId = tenant.organization.id, + organizationId = tenant.organization?.id, Feature.SSO, ) @@ -197,7 +198,7 @@ class OAuthService( val tenant = tenantService.getEnabledByDomain(ssoDomain) enabledFeaturesProvider.checkFeatureEnabled( - organizationId = tenant.organization.id, + organizationId = tenant.organization?.id, Feature.SSO, ) val headers = From e56d99c5aea41209a46ed6e072f1b05b08e25db1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Wed, 6 Nov 2024 15:34:43 +0100 Subject: [PATCH 133/162] fix: initial refractoring and structure finalization --- .../configuration/PublicConfigurationDTO.kt | 47 +- .../io/tolgee/controllers/PublicController.kt | 23 +- .../thirdParty/GithubOAuthDelegate.kt | 13 +- .../security/thirdParty/OAuth2Delegate.kt | 10 +- .../security/thirdParty/OAuthUserHandler.kt | 24 +- .../security/thirdParty/SsoTenantConfig.kt | 63 ++ .../thirdParty/data/OAuthUserDetails.kt | 4 +- .../tolgee/configuration/WebSecurityConfig.kt | 11 +- .../app/src/test/kotlin/io/tolgee/AuthTest.kt | 57 +- .../component/ThirdPartyAuthTypeConverter.kt | 2 +- .../CacheWithExpirationManager.kt | 14 +- .../tolgee/AuthenticationProperties.kt | 8 +- .../tolgee/SsoGlobalProperties.kt | 4 +- .../tolgee/dtos/cacheable/UserAccountDto.kt | 5 +- .../tolgee/exceptions/AuthExpiredException.kt | 11 + .../exceptions/AuthenticationException.kt | 3 +- .../DisabledFunctionalityException.kt | 11 + .../kotlin/io/tolgee/model/Organization.kt | 12 +- .../main/kotlin/io/tolgee/model/SsoTenant.kt | 8 +- .../kotlin/io/tolgee/model/UserAccount.kt | 25 +- .../io/tolgee}/repository/TenantRepository.kt | 2 +- .../repository/UserAccountRepository.kt | 23 +- .../security/authentication/JwtService.kt | 3 +- .../kotlin/io/tolgee/security/constants.kt | 2 +- .../organization/OrganizationRoleService.kt | 1 + .../organization/OrganizationService.kt | 84 +- .../tolgee/service/security/SignUpService.kt | 22 +- .../service/security/UserAccountService.kt | 80 +- .../authentication/AuthenticationFilter.kt | 26 +- .../AuthenticationInterceptor.kt | 2 + .../tolgee/security/service/OAuthServiceEe.kt | 16 - .../service/thirdParty/SsoDelegate.kt | 24 + .../AuthenticationFilterTest.kt | 27 +- .../controllers/OAuth2CallbackController.kt | 59 +- .../v2/controllers/SsoProviderController.kt | 2 +- .../PublicEnabledFeaturesProvider.kt | 2 +- .../tolgee/ee/data/CreateProviderRequest.kt | 1 + .../kotlin/io/tolgee/ee/data/DomainRequest.kt | 2 +- .../exceptions/OAuthAuthorizationException.kt | 9 - .../exceptions/SsoAuthorizationException.kt | 6 + .../thirdParty/SsoDelegateEe.kt} | 73 +- .../io/tolgee/ee/service/TenantService.kt | 128 --- .../io/tolgee/ee/service/sso/TenantService.kt | 71 ++ .../src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 10 +- .../tolgee/ee/utils/OAuthMultiTenantsMocks.kt | 4 +- .../security/Login/LoginCredentialsForm.tsx | 101 +- .../component/security/Login/LoginView.tsx | 2 +- .../security/Sso/SsoRedirectionHandler.tsx | 2 +- webapp/src/component/security/SsoService.tsx | 18 +- .../src/constants/GlobalValidationSchema.tsx | 12 +- webapp/src/globalContext/useAuthService.tsx | 1 - webapp/src/service/apiSchema.generated.ts | 988 +++++++++++++----- .../translationTools/useErrorTranslation.ts | 2 +- .../sso/CreateProviderSsoForm.tsx | 17 +- .../organizations/sso/OrganizationSsoView.tsx | 3 - 55 files changed, 1358 insertions(+), 822 deletions(-) create mode 100644 backend/api/src/main/kotlin/io/tolgee/security/thirdParty/SsoTenantConfig.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/exceptions/AuthExpiredException.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/exceptions/DisabledFunctionalityException.kt rename {ee/backend/app/src/main/kotlin/io/tolgee/ee => backend/data/src/main/kotlin/io/tolgee}/repository/TenantRepository.kt (94%) delete mode 100644 backend/security/src/main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt create mode 100644 backend/security/src/main/kotlin/io/tolgee/security/service/thirdParty/SsoDelegate.kt delete mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/exceptions/OAuthAuthorizationException.kt create mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/exceptions/SsoAuthorizationException.kt rename ee/backend/app/src/main/kotlin/io/tolgee/ee/{service/OAuthService.kt => security/thirdParty/SsoDelegateEe.kt} (80%) delete mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TenantService.kt create mode 100644 ee/backend/app/src/main/kotlin/io/tolgee/ee/service/sso/TenantService.kt 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 7ec5bc8e97..d812dedcd5 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,12 @@ 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() + + // TODO: check if the sso feature is really enabled (has a license) and show info if not + val globalSsoAuthentication: Boolean = properties.authentication.sso.enabled + 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 @@ -46,6 +50,24 @@ 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, + ), + ) + } + } + class AuthMethodsDTO( val github: OAuthPublicConfigDTO, val google: OAuthPublicConfigDTO, @@ -80,23 +102,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 e1261dd9a2..02c4eae7d7 100644 --- a/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt @@ -14,6 +14,7 @@ import io.tolgee.dtos.request.auth.SignUpDto import io.tolgee.dtos.security.LoginRequest import io.tolgee.exceptions.AuthenticationException import io.tolgee.exceptions.BadRequestException +import io.tolgee.exceptions.DisabledFunctionalityException import io.tolgee.exceptions.NotFoundException import io.tolgee.model.UserAccount import io.tolgee.openApiDocs.OpenApiHideFromPublicDocs @@ -21,6 +22,7 @@ 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 @@ -43,6 +45,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, @@ -79,6 +82,10 @@ 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 = @@ -134,6 +141,9 @@ class PublicController( @PathVariable("code") code: String, @PathVariable("email") email: String, ) { + if (!authProperties.nativeEnabled) { + throw DisabledFunctionalityException(Message.NATIVE_AUTHENTICATION_DISABLED) + } validateEmailCode(code, email) } @@ -144,9 +154,12 @@ 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 BadRequestException(Message.OPERATION_UNAVAILABLE_FOR_ACCOUNT_TYPE) + throw AuthenticationException(Message.OPERATION_UNAVAILABLE_FOR_ACCOUNT_TYPE) } if (userAccount.accountType === UserAccount.AccountType.THIRD_PARTY) { userAccountService.setAccountType(userAccount, UserAccount.AccountType.LOCAL) @@ -169,6 +182,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) } @@ -207,6 +223,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() @@ -225,6 +242,10 @@ class PublicController( oauth2Delegate.getTokenResponse(code, invitationCode, redirectUri) } + "sso" -> { + ssoDelegate.getTokenResponse(code, invitationCode, redirectUri, domain) + } + else -> { throw NotFoundException(Message.SERVICE_NOT_FOUND) } 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 d9880f7a09..69811a754a 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 @@ -58,13 +58,12 @@ class GithubOAuthDelegate( // get github user emails val emails = - restTemplate - .exchange( - githubConfigurationProperties.userUrl + "/emails", - HttpMethod.GET, - entity, - Array::class.java, - ).body + restTemplate.exchange( + githubConfigurationProperties.userUrl + "/emails", + HttpMethod.GET, + entity, + Array::class.java, + ).body ?: throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL) val verifiedEmails = Arrays.stream(emails).filter { it.verified }.collect(Collectors.toList()) 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 ab3d210332..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 @@ -9,10 +9,12 @@ import io.tolgee.model.enums.ThirdPartyAuthType import io.tolgee.security.authentication.JwtService import io.tolgee.security.payload.JwtAuthenticationResponse import io.tolgee.security.thirdParty.data.OAuthUserDetails -import io.tolgee.service.security.SignUpService -import io.tolgee.service.security.UserAccountService import org.slf4j.LoggerFactory -import org.springframework.http.* +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType import org.springframework.stereotype.Component import org.springframework.util.LinkedMultiValueMap import org.springframework.util.MultiValueMap @@ -22,10 +24,8 @@ 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 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 index a78faf9dd8..4eaffb16cd 100644 --- a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt @@ -1,7 +1,6 @@ package io.tolgee.security.thirdParty import io.tolgee.component.CurrentDateProvider -import io.tolgee.configuration.tolgee.AuthenticationProperties import io.tolgee.constants.Message import io.tolgee.exceptions.AuthenticationException import io.tolgee.model.UserAccount @@ -21,7 +20,6 @@ class OAuthUserHandler( private val organizationRoleService: OrganizationRoleService, private val userAccountService: UserAccountService, private val currentDateProvider: CurrentDateProvider, - private val authenticationProperties: AuthenticationProperties, ) { fun findOrCreateUser( userResponse: OAuthUserDetails, @@ -32,8 +30,8 @@ class OAuthUserHandler( val tenant = userResponse.tenant val userAccountOptional = - if (thirdPartyAuthType == ThirdPartyAuthType.SSO && tenant?.domain != null) { - userAccountService.findByDomainSso(tenant.domain, userResponse.sub!!) + if (thirdPartyAuthType == ThirdPartyAuthType.SSO && tenant != null) { + userAccountService.findBySsoTenantId(tenant.entity?.id, userResponse.sub!!) } else { userAccountService.findByThirdParty(thirdPartyAuthType, userResponse.sub!!) } @@ -48,14 +46,6 @@ class OAuthUserHandler( throw AuthenticationException(Message.USERNAME_ALREADY_EXISTS) } - // do not create new user if user is not invited and GLOBAL sso is enabled - if (invitationCode == null && - thirdPartyAuthType == ThirdPartyAuthType.SSO && - authenticationProperties.sso.enabled - ) { - throw AuthenticationException(Message.SSO_USER_NOT_INVITED) - } - val newUserAccount = UserAccount() newUserAccount.username = userResponse.email @@ -69,8 +59,8 @@ class OAuthUserHandler( } newUserAccount.name = name newUserAccount.thirdPartyAuthId = userResponse.sub - if (tenant?.domain != null) { - newUserAccount.ssoTenant = tenant + if (tenant?.entity != null) { + newUserAccount.ssoTenant = tenant.entity } newUserAccount.thirdPartyAuthType = thirdPartyAuthType newUserAccount.ssoRefreshToken = userResponse.refreshToken @@ -111,11 +101,7 @@ class OAuthUserHandler( refreshToken: String?, ) { val userAccount = userAccountService.get(userAccountId) - - if (userAccount.ssoRefreshToken != refreshToken) { - userAccount.ssoRefreshToken = refreshToken - userAccountService.save(userAccount) - } + updateRefreshToken(userAccount, refreshToken) } fun updateSsoSessionExpiry(user: UserAccount) { 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..aaa848f476 --- /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 organization: Organization? = null, + val entity: SsoTenant? = 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, + organization = organization, + entity = this, + ) + } + + 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(), + ) + } + + // TODO: specific message "$name is missing in global SSO configuration", + 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/data/OAuthUserDetails.kt b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/data/OAuthUserDetails.kt index 9fd9d5ba9a..746e890920 100644 --- 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 @@ -1,6 +1,6 @@ package io.tolgee.security.thirdParty.data -import io.tolgee.model.SsoTenant +import io.tolgee.security.thirdParty.SsoTenantConfig data class OAuthUserDetails( var sub: String? = null, @@ -9,5 +9,5 @@ data class OAuthUserDetails( var familyName: String? = null, var email: String = "", val refreshToken: String? = null, - val tenant: SsoTenant? = null, + val tenant: SsoTenantConfig? = null, ) diff --git a/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt b/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt index 621fad3dfc..8778a3856c 100644 --- a/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt +++ b/backend/app/src/main/kotlin/io/tolgee/configuration/WebSecurityConfig.kt @@ -103,24 +103,23 @@ class WebSecurityConfig( @Bean @Order(10) @ConditionalOnProperty(value = ["tolgee.internal.controller-enabled"], havingValue = "false", matchIfMissing = true) - fun internalSecurityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain = - httpSecurity + fun internalSecurityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain { + return httpSecurity .securityMatcher("/internal/**") .authorizeRequests() .anyRequest() .denyAll() .and() .build() + } override fun addInterceptors(registry: InterceptorRegistry) { registry.addInterceptor(rateLimitInterceptor) registry.addInterceptor(authenticationInterceptor) - registry - .addInterceptor(organizationAuthorizationInterceptor) + registry.addInterceptor(organizationAuthorizationInterceptor) .addPathPatterns("/v2/organizations/**") - registry - .addInterceptor(projectAuthorizationInterceptor) + registry.addInterceptor(projectAuthorizationInterceptor) .addPathPatterns("/v2/projects/**", "/api/project/**", "/api/repository/**") } diff --git a/backend/app/src/test/kotlin/io/tolgee/AuthTest.kt b/backend/app/src/test/kotlin/io/tolgee/AuthTest.kt index 4e432f5179..aec0e7ac01 100644 --- a/backend/app/src/test/kotlin/io/tolgee/AuthTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/AuthTest.kt @@ -4,7 +4,11 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import io.tolgee.constants.Message import io.tolgee.controllers.PublicController -import io.tolgee.fixtures.* +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsForbidden +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 @@ -81,18 +85,16 @@ class AuthTest : AbstractControllerTest() { fun userWithTokenHasAccess() { val response = doAuthentication(initialUsername, initialPassword) - .andReturn() - .response.contentAsString + .andReturn().response.contentAsString val token = mapper.readValue(response, HashMap::class.java)["accessToken"] as String? val mvcResult = - mvc - .perform( - MockMvcRequestBuilders - .get("/api/projects") - .accept(MediaType.ALL) - .header("Authorization", String.format("Bearer %s", token)) - .contentType(MediaType.APPLICATION_JSON), - ).andReturn() + mvc.perform( + MockMvcRequestBuilders.get("/api/projects") + .accept(MediaType.ALL) + .header("Authorization", String.format("Bearer %s", token)) + .contentType(MediaType.APPLICATION_JSON), + ) + .andReturn() assertThat(mvcResult.response.status).isEqualTo(200) } @@ -108,14 +110,12 @@ class AuthTest : AbstractControllerTest() { currentDateProvider.forcedDate = baseline val mvcResult = - mvc - .perform( - MockMvcRequestBuilders - .get("/api/projects") - .accept(MediaType.ALL) - .header("Authorization", String.format("Bearer %s", token)) - .contentType(MediaType.APPLICATION_JSON), - ).andReturn() + mvc.perform( + MockMvcRequestBuilders.get("/api/projects") + .accept(MediaType.ALL) + .header("Authorization", String.format("Bearer %s", token)) + .contentType(MediaType.APPLICATION_JSON), + ).andReturn() assertThat(mvcResult.response.status).isEqualTo(401) assertThat(mvcResult.response.contentAsString).contains(Message.EXPIRED_JWT_TOKEN.code) @@ -281,16 +281,13 @@ class AuthTest : AbstractControllerTest() { } private fun assertExpired(token: String) { - mvc - .perform( - MockMvcRequestBuilders - .put("/v2/projects/${project.id}/users/${project.id}/revoke-access") - .accept(MediaType.ALL) - .header("Authorization", String.format("Bearer %s", token)) - .contentType(MediaType.APPLICATION_JSON), - ).andIsForbidden - .andAssertThatJson { - node("code").isEqualTo(Message.EXPIRED_SUPER_JWT_TOKEN.code) - } + mvc.perform( + MockMvcRequestBuilders.put("/v2/projects/${project.id}/users/${project.id}/revoke-access") + .accept(MediaType.ALL) + .header("Authorization", String.format("Bearer %s", token)) + .contentType(MediaType.APPLICATION_JSON), + ).andIsForbidden.andAssertThatJson { + node("code").isEqualTo(Message.EXPIRED_SUPER_JWT_TOKEN.code) + } } } diff --git a/backend/data/src/main/kotlin/io/tolgee/component/ThirdPartyAuthTypeConverter.kt b/backend/data/src/main/kotlin/io/tolgee/component/ThirdPartyAuthTypeConverter.kt index 087e0622d6..0647144931 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/ThirdPartyAuthTypeConverter.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/ThirdPartyAuthTypeConverter.kt @@ -4,7 +4,7 @@ import io.tolgee.model.enums.ThirdPartyAuthType import jakarta.persistence.AttributeConverter import jakarta.persistence.Converter -@Converter(autoApply = true) +@Converter() class ThirdPartyAuthTypeConverter : AttributeConverter { override fun convertToDatabaseColumn(attribute: ThirdPartyAuthType?): String? { return attribute?.code() diff --git a/backend/data/src/main/kotlin/io/tolgee/component/cacheWithExpiration/CacheWithExpirationManager.kt b/backend/data/src/main/kotlin/io/tolgee/component/cacheWithExpiration/CacheWithExpirationManager.kt index e5e01e68a3..74617aa5cb 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/cacheWithExpiration/CacheWithExpirationManager.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/cacheWithExpiration/CacheWithExpirationManager.kt @@ -3,23 +3,13 @@ package io.tolgee.component.cacheWithExpiration import io.tolgee.component.CurrentDateProvider import org.springframework.cache.CacheManager import org.springframework.stereotype.Component -import java.time.Duration @Component class CacheWithExpirationManager( private val cacheManager: CacheManager, private val currentDateProvider: CurrentDateProvider, ) { - fun getCache(name: String): CacheWithExpiration? = - cacheManager.getCache(name)?.let { CacheWithExpiration(it, currentDateProvider) } - - fun putCache( - cacheName: String, - userId: Long, - isUserStillValid: Boolean, - expiration: Duration = Duration.ofMinutes(10), - ) { - val cache = getCache(cacheName) - cache?.put(userId, isUserStillValid, expiration) + fun getCache(name: String): CacheWithExpiration? { + return cacheManager.getCache(name)?.let { CacheWithExpiration(it, currentDateProvider) } } } 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 c19d25f6e4..351e2235aa 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,7 +139,9 @@ 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(), 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 index a106e28f7b..61a7d033e6 100644 --- a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoGlobalProperties.kt +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoGlobalProperties.kt @@ -20,10 +20,10 @@ class SsoGlobalProperties { var clientSecret: String? = null @DocProperty(description = "URL to redirect users for authentication") - var authorizationUrl: String? = null + var authorizationUri: String? = null @DocProperty(description = "URL for exchanging authorization code for tokens") - var tokenUrl: String? = null + var tokenUri: String? = null @DocProperty(description = "Used to identify the organization on login page") var domain: String? = 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 9bc9ffffe7..8c82003df9 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 @@ -21,6 +21,7 @@ data class UserAccountDto( ) : Serializable { companion object { fun fromEntity(entity: UserAccount) = + // FIXME: handle ssoDomain for global sso properly UserAccountDto( name = entity.name, username = entity.username, @@ -38,5 +39,7 @@ data class UserAccountDto( ) } - override fun toString(): String = username + override fun toString(): String { + return username + } } 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..c77b4df82f 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,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) -class AuthenticationException(message: io.tolgee.constants.Message) : ErrorException(message) { +open class AuthenticationException(message: Message) : ErrorException(message) { override val httpStatus: HttpStatus get() = HttpStatus.UNAUTHORIZED } diff --git a/backend/data/src/main/kotlin/io/tolgee/exceptions/DisabledFunctionalityException.kt b/backend/data/src/main/kotlin/io/tolgee/exceptions/DisabledFunctionalityException.kt new file mode 100644 index 0000000000..12f0cb13be --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/exceptions/DisabledFunctionalityException.kt @@ -0,0 +1,11 @@ +package io.tolgee.exceptions + +import io.tolgee.constants.Message +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.ResponseStatus + +@ResponseStatus(HttpStatus.CONFLICT) +class DisabledFunctionalityException(message: Message) : ErrorException(message) { + override val httpStatus: HttpStatus + get() = HttpStatus.CONFLICT +} diff --git a/backend/data/src/main/kotlin/io/tolgee/model/Organization.kt b/backend/data/src/main/kotlin/io/tolgee/model/Organization.kt index 9600bdfa87..283169f8d0 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/Organization.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/Organization.kt @@ -2,7 +2,17 @@ package io.tolgee.model import com.fasterxml.jackson.annotation.JsonIgnore import io.tolgee.model.slackIntegration.OrganizationSlackWorkspace -import jakarta.persistence.* +import jakarta.persistence.CascadeType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.OneToMany +import jakarta.persistence.OneToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Pattern import jakarta.validation.constraints.Size diff --git a/backend/data/src/main/kotlin/io/tolgee/model/SsoTenant.kt b/backend/data/src/main/kotlin/io/tolgee/model/SsoTenant.kt index ecffb45e1f..8947cc2c58 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/SsoTenant.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/SsoTenant.kt @@ -1,25 +1,27 @@ 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 ssoProvider: 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) - @JoinColumn(name = "organization_id") - var organization: Organization? = null + lateinit var organization: Organization @OneToMany(fetch = FetchType.LAZY, mappedBy = "ssoTenant") var userAccounts: MutableSet = mutableSetOf() 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 545b6104d7..093b5ee2ce 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt @@ -6,7 +6,20 @@ import io.tolgee.component.ThirdPartyAuthTypeConverter import io.tolgee.model.enums.ThirdPartyAuthType import io.tolgee.model.slackIntegration.SlackConfig import io.tolgee.model.slackIntegration.SlackUserConnection -import jakarta.persistence.* +import jakarta.persistence.CascadeType +import jakarta.persistence.Column +import jakarta.persistence.Convert +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.ManyToOne +import jakarta.persistence.OneToMany +import jakarta.persistence.OneToOne +import jakarta.persistence.OrderBy import jakarta.validation.constraints.NotBlank import org.hibernate.annotations.ColumnDefault import org.hibernate.annotations.Type @@ -26,9 +39,7 @@ data class UserAccount( @Enumerated(EnumType.STRING) @Column(name = "account_type") override var accountType: AccountType? = AccountType.LOCAL, -) : AuditModel(), - ModelWithAvatar, - IUserAccount { +) : AuditModel(), ModelWithAvatar, IUserAccount { @Column(name = "totp_key", columnDefinition = "bytea") override var totpKey: ByteArray? = null @@ -49,11 +60,11 @@ data class UserAccount( @Convert(converter = ThirdPartyAuthTypeConverter::class) var thirdPartyAuthType: ThirdPartyAuthType? = null - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) var ssoTenant: SsoTenant? = null @Column(name = "sso_refresh_token", columnDefinition = "TEXT") - var ssoRefreshToken: String? = null + var ssoRefreshToken: String? = null // TODO: to jwt token @Column(name = "third_party_auth_id") var thirdPartyAuthId: String? = null @@ -61,7 +72,7 @@ data class UserAccount( @Column(name = "reset_password_code") var resetPasswordCode: String? = null - var ssoSessionExpiry: Date? = null + var ssoSessionExpiry: Date? = null // TODO: to jwt token @OrderBy("id ASC") @OneToMany(mappedBy = "user", orphanRemoval = true) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/TenantRepository.kt similarity index 94% rename from ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt rename to backend/data/src/main/kotlin/io/tolgee/repository/TenantRepository.kt index e648941c26..fa866292e9 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/TenantRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/TenantRepository.kt @@ -1,4 +1,4 @@ -package io.tolgee.ee.repository +package io.tolgee.repository import io.tolgee.model.SsoTenant import org.springframework.data.jpa.repository.JpaRepository 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 03abfaa0df..ed6b52b33e 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt @@ -84,14 +84,33 @@ interface UserAccountRepository : JpaRepository { ): Optional @Query( - "SELECT u FROM UserAccount u JOIN u.ssoTenant s" + - " WHERE s.domain = :domain AND u.thirdPartyAuthId = :thirdPartyAuthId", + """ + from UserAccount ua + where ua.thirdPartyAuthId = :thirdPartyAuthId + and ua.ssoTenant.domain = :domain + and ua.deletedAt is null + and ua.disabledAt is null + """, ) fun findBySsoDomain( thirdPartyAuthId: String, domain: String, ): Optional + @Query( + """ + from UserAccount ua + where ua.thirdPartyAuthId = :thirdPartyAuthId + and ((:ssoTenantId is null and ua.ssoTenant is null) or ua.ssoTenant.id = :ssoTenantId) + and ua.deletedAt is null + and ua.disabledAt is null + """, + ) + fun findBySsoTenantId( + thirdPartyAuthId: String, + ssoTenantId: Long?, + ): Optional + @Query( """ select ua.id as id, ua.name as name, ua.username as username, mr.type as organizationRole, ua.avatarHash as avatarHash 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/security/constants.kt b/backend/data/src/main/kotlin/io/tolgee/security/constants.kt index 72855d1075..1efaa1b443 100644 --- a/backend/data/src/main/kotlin/io/tolgee/security/constants.kt +++ b/backend/data/src/main/kotlin/io/tolgee/security/constants.kt @@ -2,4 +2,4 @@ package io.tolgee.security const val PROJECT_API_KEY_PREFIX = "tgpak_" const val PAT_PREFIX = "tgpat_" -const val SSO_SESSION_EXPIRATION_MINUTES = 10 +const val SSO_SESSION_EXPIRATION_MINUTES = 10 // TODO: configurable? 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 929c28691b..3dcfa7f0e9 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 @@ -211,6 +211,7 @@ class OrganizationRoleService( organizationId: Long, organizationRoleType: OrganizationRoleType, ) { + // TODO: check if we can pass org as obj instead of id val organization = organizationRepository.findById(organizationId).orElseThrow { NotFoundException() } self.grantRoleToUser(user, organization, organizationRoleType = organizationRoleType) 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 6ff2215e88..5765f0c08d 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 @@ -69,7 +69,9 @@ class OrganizationService( lateinit var projectService: ProjectService @Transactional - fun create(createDto: OrganizationDto): Organization = create(createDto, authenticationFacade.authenticatedUserEntity) + fun create(createDto: OrganizationDto): Organization { + return create(createDto, authenticationFacade.authenticatedUserEntity) + } @Transactional fun create( @@ -134,14 +136,13 @@ class OrganizationService( fun findPreferred( userAccountId: Long, exceptOrganizationId: Long = 0, - ): Organization? = - organizationRepository - .findPreferred( - userId = userAccountId, - exceptOrganizationId, - PageRequest.of(0, 1), - ).content - .firstOrNull() + ): Organization? { + return organizationRepository.findPreferred( + userId = userAccountId, + exceptOrganizationId, + PageRequest.of(0, 1), + ).content.firstOrNull() + } /** * Returns existing or created organization which seems to be potentially preferred. @@ -162,42 +163,55 @@ class OrganizationService( pageable: Pageable, requestParamsDto: OrganizationRequestParamsDto, exceptOrganizationId: Long? = null, - ): Page = - findPermittedPaged( + ): Page { + return findPermittedPaged( pageable, requestParamsDto.filterCurrentUserOwner, requestParamsDto.search, exceptOrganizationId, ) + } fun findPermittedPaged( pageable: Pageable, filterCurrentUserOwner: Boolean = false, search: String? = null, exceptOrganizationId: Long? = null, - ): Page = - organizationRepository.findAllPermitted( + ): Page { + return organizationRepository.findAllPermitted( userId = authenticationFacade.authenticatedUser.id, pageable = pageable, roleType = if (filterCurrentUserOwner) OrganizationRoleType.OWNER else null, search = search, exceptOrganizationId = exceptOrganizationId, ) + } - fun get(id: Long): Organization = - organizationRepository.find(id) ?: throw NotFoundException(Message.ORGANIZATION_NOT_FOUND) + fun get(id: Long): Organization { + return organizationRepository.find(id) ?: throw NotFoundException(Message.ORGANIZATION_NOT_FOUND) + } - fun find(id: Long): Organization? = organizationRepository.find(id) + fun find(id: Long): Organization? { + return organizationRepository.find(id) + } - fun get(slug: String): Organization = find(slug) ?: throw NotFoundException(Message.ORGANIZATION_NOT_FOUND) + fun get(slug: String): Organization { + return find(slug) ?: throw NotFoundException(Message.ORGANIZATION_NOT_FOUND) + } - fun find(slug: String): Organization? = organizationRepository.findBySlug(slug) + fun find(slug: String): Organization? { + return organizationRepository.findBySlug(slug) + } @Cacheable(cacheNames = [Caches.ORGANIZATIONS], key = "{'id', #id}") - fun findDto(id: Long): CachedOrganizationDto? = find(id)?.let { CachedOrganizationDto.fromEntity(it) } + fun findDto(id: Long): CachedOrganizationDto? { + return find(id)?.let { CachedOrganizationDto.fromEntity(it) } + } @Cacheable(cacheNames = [Caches.ORGANIZATIONS], key = "{'slug', #slug}") - fun findDto(slug: String): CachedOrganizationDto? = find(slug)?.let { CachedOrganizationDto.fromEntity(it) } + fun findDto(slug: String): CachedOrganizationDto? { + return find(slug)?.let { CachedOrganizationDto.fromEntity(it) } + } @CacheEvict(cacheNames = [Caches.ORGANIZATIONS], key = "{'id', #id}") fun edit( @@ -301,9 +315,13 @@ class OrganizationService( * Checks slug uniqueness * @return Returns true if valid */ - fun validateSlugUniqueness(slug: String): Boolean = !organizationRepository.organizationWithSlugExists(slug) + fun validateSlugUniqueness(slug: String): Boolean { + return !organizationRepository.organizationWithSlugExists(slug) + } - fun isThereAnotherOwner(id: Long): Boolean = organizationRoleService.isAnotherOwnerInOrganization(id) + fun isThereAnotherOwner(id: Long): Boolean { + return organizationRoleService.isAnotherOwnerInOrganization(id) + } fun generateSlug( name: String, @@ -342,11 +360,17 @@ class OrganizationService( pageable: Pageable, search: String?, userId: Long, - ): Page = organizationRepository.findAllViews(pageable, search, userId) + ): Page { + return organizationRepository.findAllViews(pageable, search, userId) + } - fun findAllByName(name: String): List = organizationRepository.findAllByName(name) + fun findAllByName(name: String): List { + return organizationRepository.findAllByName(name) + } - fun getProjectOwner(projectId: Long): Organization = organizationRepository.getProjectOwner(projectId) + fun getProjectOwner(projectId: Long): Organization { + return organizationRepository.getProjectOwner(projectId) + } fun setBasePermission( organizationId: Long, @@ -363,21 +387,25 @@ class OrganizationService( fun findPrivateView( id: Long, currentUserId: Long, - ): PrivateOrganizationView? = - findView(id, currentUserId)?.let { + ): PrivateOrganizationView? { + return findView(id, currentUserId)?.let { val quickStart = quickStartService.findView(currentUserId, id) PrivateOrganizationView(it, quickStart) } + } fun findView( id: Long, currentUserId: Long, - ): OrganizationView? = organizationRepository.findView(id, currentUserId) + ): OrganizationView? { + return organizationRepository.findView(id, currentUserId) + } fun updateSsoProvider( organizationId: Long, tenant: SsoTenant, ) { + // FIXME: shouldn't be needed - org doesn't own the tenant relation and ref should update automatically val organization = get(organizationId) organization.ssoTenant = tenant save(organization) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/SignUpService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/SignUpService.kt index 2c5e922ed9..44d91621b1 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,7 +5,6 @@ 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.UserAccount import io.tolgee.model.enums.ThirdPartyAuthType import io.tolgee.security.authentication.JwtService @@ -53,13 +52,21 @@ class SignUpService( organizationName: String?, userSource: String? = 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) { invitationService.accept(invitation.code, user) } if (user.thirdPartyAuthType == ThirdPartyAuthType.SSO) { + // No organization is created for SSO user return user } @@ -76,15 +83,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 25ae6c6651..402bb82423 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 @@ -71,28 +71,40 @@ class UserAccountService( private val emailValidator = EmailValidator() - fun findActive(username: String): UserAccount? = userAccountRepository.findActive(username) + fun findActive(username: String): UserAccount? { + return userAccountRepository.findActive(username) + } - operator fun get(username: String): UserAccount = - this.findActive(username) ?: throw NotFoundException(Message.USER_NOT_FOUND) + operator fun get(username: String): UserAccount { + return this.findActive(username) ?: throw NotFoundException(Message.USER_NOT_FOUND) + } @Transactional - fun findActive(id: Long): UserAccount? = userAccountRepository.findActive(id) + fun findActive(id: Long): UserAccount? { + return userAccountRepository.findActive(id) + } @Transactional - fun findInitialUser(): UserAccount? = userAccountRepository.findInitialUser() + fun findInitialUser(): UserAccount? { + return userAccountRepository.findInitialUser() + } - fun get(id: Long): UserAccount = this.findActive(id) ?: throw NotFoundException(Message.USER_NOT_FOUND) + fun get(id: Long): UserAccount { + return this.findActive(id) ?: throw NotFoundException(Message.USER_NOT_FOUND) + } @Cacheable(cacheNames = [Caches.USER_ACCOUNTS], key = "#id") @Transactional - fun findDto(id: Long): UserAccountDto? = - userAccountRepository.findActive(id)?.let { + fun findDto(id: Long): UserAccountDto? { + return userAccountRepository.findActive(id)?.let { UserAccountDto.fromEntity(it) } + } @Transactional - fun getDto(id: Long): UserAccountDto = self.findDto(id) ?: throw NotFoundException(Message.USER_NOT_FOUND) + fun getDto(id: Long): UserAccountDto { + return self.findDto(id) ?: throw NotFoundException(Message.USER_NOT_FOUND) + } fun getOrCreateDemoUsers(demoUsers: List): Map { val usernames = demoUsers.map { it.username } @@ -211,13 +223,20 @@ class UserAccountService( fun findByThirdParty( type: ThirdPartyAuthType, id: String, - ): Optional = userAccountRepository.findThirdByThirdParty(id, type) + ): Optional { + return userAccountRepository.findThirdByThirdParty(id, type) + } - fun findByDomainSso( + 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( @@ -253,7 +272,9 @@ class UserAccountService( fun isResetCodeValid( userAccount: UserAccount, code: String?, - ): Boolean = passwordEncoder.matches(code, userAccount.resetPasswordCode) + ): Boolean { + return passwordEncoder.matches(code, userAccount.resetPasswordCode) + } @Transactional @CacheEvict(cacheNames = [Caches.USER_ACCOUNTS], key = "#result.id") @@ -326,16 +347,18 @@ class UserAccountService( organizationId: Long, pageable: Pageable, search: String?, - ): Page = - userAccountRepository.getAllInOrganization(organizationId, pageable, search = search ?: "") + ): Page { + return userAccountRepository.getAllInOrganization(organizationId, pageable, search = search ?: "") + } fun getAllInProject( projectId: Long, pageable: Pageable, search: String?, exceptUserId: Long? = null, - ): Page = - userAccountRepository.getAllInProject(projectId, pageable, search = search, exceptUserId) + ): Page { + return userAccountRepository.getAllInProject(projectId, pageable, search = search, exceptUserId) + } fun getAllInProjectWithPermittedLanguages( projectId: Long, @@ -441,22 +464,33 @@ class UserAccountService( userAccountRepository.saveAll(userAccounts) @CacheEvict(cacheNames = [Caches.USER_ACCOUNTS], key = "#result.id") - fun save(user: UserAccount): UserAccount = userAccountRepository.save(user) + fun save(user: UserAccount): UserAccount { + return userAccountRepository.save(user) + } @CacheEvict(cacheNames = [Caches.USER_ACCOUNTS], key = "#result.id") - fun saveAndFlush(user: UserAccount): UserAccount = userAccountRepository.saveAndFlush(user) + fun saveAndFlush(user: UserAccount): UserAccount { + return userAccountRepository.saveAndFlush(user) + } - fun getAllByIdsIncludingDeleted(ids: Set): MutableList = - userAccountRepository.getAllByIdsIncludingDeleted(ids) + fun getAllByIdsIncludingDeleted(ids: Set): MutableList { + return userAccountRepository.getAllByIdsIncludingDeleted(ids) + } fun findAllWithDisabledPaged( pageable: Pageable, search: String?, - ): Page = userAccountRepository.findAllWithDisabledPaged(search, pageable) + ): Page { + return userAccountRepository.findAllWithDisabledPaged(search, pageable) + } - fun countAll(): Long = userAccountRepository.count() + fun countAll(): Long { + return userAccountRepository.count() + } - fun countAllEnabled(): Long = userAccountRepository.countAllEnabled() + fun countAllEnabled(): Long { + return userAccountRepository.countAllEnabled() + } @Transactional @CacheEvict(cacheNames = [Caches.USER_ACCOUNTS], key = "#userId") 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 1ad3ebea54..73beddeb68 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,12 +20,12 @@ 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.exceptions.BadRequestException import io.tolgee.model.enums.ThirdPartyAuthType import io.tolgee.security.PAT_PREFIX import io.tolgee.security.ratelimit.RateLimitService -import io.tolgee.security.service.OAuthServiceEe +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 @@ -54,7 +54,7 @@ class AuthenticationFilter( @Lazy private val patService: PatService, @Lazy - private val oAuthService: OAuthServiceEe, + private val ssoDelegate: SsoDelegate, ) : OncePerRequestFilter() { override fun doFilterInternal( request: HttpServletRequest, @@ -75,7 +75,9 @@ class AuthenticationFilter( filterChain.doFilter(request, response) } - override fun shouldNotFilter(request: HttpServletRequest): Boolean = request.method == "OPTIONS" + override fun shouldNotFilter(request: HttpServletRequest): Boolean { + return request.method == "OPTIONS" + } private fun doAuthenticate(request: HttpServletRequest) { val authorization = request.getHeader("Authorization") @@ -117,25 +119,21 @@ class AuthenticationFilter( } private fun checkIfSsoUserStillExists(userDto: UserAccountDto) { - if (userDto.thirdPartyAuth == null) { + val authTypeStr = userDto.thirdPartyAuth + if (authTypeStr == null) { return } - val thirdPartyAuthType = - userDto.thirdPartyAuth?.let { - runCatching { - ThirdPartyAuthType.valueOf(it.uppercase()) - }.getOrElse { throw BadRequestException(Message.SSO_CANT_VERIFY_USER) } - } + val thirdPartyAuthType = ThirdPartyAuthType.valueOf(authTypeStr.uppercase()) - if (!oAuthService.verifyUserSsoAccountAvailable( + if (!ssoDelegate.verifyUserSsoAccountAvailable( userDto.ssoDomain, userDto.id, userDto.ssoRefreshToken, - thirdPartyAuthType!!, + thirdPartyAuthType, userDto.ssoSessionExpiry, ) ) { - throw AuthenticationException(Message.SSO_CANT_VERIFY_USER) + throw AuthExpiredException(Message.SSO_CANT_VERIFY_USER) } } 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 217f929b0b..ac57e0e6ce 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 @@ -69,6 +69,8 @@ class AuthenticationInterceptor( requiresSuperAuth && authenticationFacade.authenticatedUser.needsSuperJwt && !authenticationFacade.isUserSuperAuthenticated + // TODO: && authentication.nativeEnabled ?? or how do we know if user can use password? (we can't just check if user has password since it can be set before native auth was disabled) + // NOTE: two-factor authentication can still be used ) { throw PermissionException(Message.EXPIRED_SUPER_JWT_TOKEN) } diff --git a/backend/security/src/main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt b/backend/security/src/main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt deleted file mode 100644 index 78b96eb7bd..0000000000 --- a/backend/security/src/main/kotlin/io/tolgee/security/service/OAuthServiceEe.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.tolgee.security.service - -import io.tolgee.model.enums.ThirdPartyAuthType -import org.springframework.stereotype.Component -import java.util.* - -@Component -interface OAuthServiceEe { - fun verifyUserSsoAccountAvailable( - ssoDomain: String?, - userId: Long, - refreshToken: String?, - thirdPartyAuth: ThirdPartyAuthType, - ssoSessionExpiry: Date?, - ): Boolean -} 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..52f62b7b78 --- /dev/null +++ b/backend/security/src/main/kotlin/io/tolgee/security/service/thirdParty/SsoDelegate.kt @@ -0,0 +1,24 @@ +package io.tolgee.security.service.thirdParty + +import io.tolgee.model.enums.ThirdPartyAuthType +import io.tolgee.security.payload.JwtAuthenticationResponse +import org.springframework.stereotype.Component +import java.util.Date + +@Component +interface SsoDelegate { + fun getTokenResponse( + receivedCode: String?, + invitationCode: String?, + redirectUri: String?, + domain: String?, + ): JwtAuthenticationResponse + + fun verifyUserSsoAccountAvailable( + ssoDomain: String?, + userId: Long, + refreshToken: String?, + thirdPartyAuth: ThirdPartyAuthType, + ssoSessionExpiry: Date?, + ): Boolean +} 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 5830141b13..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,12 +27,16 @@ 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.OAuthServiceEe +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 import io.tolgee.testing.assertions.Assertions.assertThat -import org.junit.jupiter.api.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows import org.mockito.Mockito import org.mockito.kotlin.any import org.springframework.mock.web.MockFilterChain @@ -77,7 +81,7 @@ class AuthenticationFilterTest { private val userAccount = Mockito.mock(UserAccount::class.java, Mockito.RETURNS_DEFAULTS) - private val oAuthServiceEe = Mockito.mock(OAuthServiceEe::class.java) + private val ssoDelegate = Mockito.mock(SsoDelegate::class.java) private val authenticationFilter = AuthenticationFilter( @@ -88,7 +92,7 @@ class AuthenticationFilterTest { userAccountService, pakService, patService, - oAuthServiceEe, + ssoDelegate, ) private val authenticationFacade = @@ -105,21 +109,18 @@ class AuthenticationFilterTest { Mockito.`when`(authProperties.enabled).thenReturn(true) - Mockito - .`when`(rateLimitService.getIpAuthRateLimitPolicy(any())) + Mockito.`when`(rateLimitService.getIpAuthRateLimitPolicy(any())) .thenReturn( RateLimitPolicy("test policy", 5, Duration.ofSeconds(1), true), ) - Mockito - .`when`(rateLimitService.consumeBucketUnless(any(), any())) + Mockito.`when`(rateLimitService.consumeBucketUnless(any(), any())) .then { val fn = it.getArgument<() -> Boolean>(1) fn() } - Mockito - .`when`(jwtService.validateToken(TEST_VALID_TOKEN)) + Mockito.`when`(jwtService.validateToken(TEST_VALID_TOKEN)) .thenReturn( TolgeeAuthentication( "uwu", @@ -128,8 +129,7 @@ class AuthenticationFilterTest { ), ) - Mockito - .`when`(jwtService.validateToken(TEST_INVALID_TOKEN)) + Mockito.`when`(jwtService.validateToken(TEST_INVALID_TOKEN)) .thenThrow(AuthenticationException(Message.INVALID_JWT_TOKEN)) Mockito.`when`(pakService.parseApiKey(TEST_VALID_PAK)).thenReturn(TEST_VALID_PAK) @@ -297,8 +297,7 @@ class AuthenticationFilterTest { val res = MockHttpServletResponse() val chain = MockFilterChain() - Mockito - .`when`(rateLimitService.consumeBucketUnless(any(), any())) + Mockito.`when`(rateLimitService.consumeBucketUnless(any(), any())) .thenThrow(RateLimitedException(1000L, true)) req.addHeader("Authorization", "Bearer $TEST_VALID_TOKEN") diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index 4356809ac8..13c445552a 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -3,26 +3,18 @@ package io.tolgee.ee.api.v2.controllers import io.tolgee.component.FrontendUrlProvider import io.tolgee.component.enabledFeaturesProvider.EnabledFeaturesProvider import io.tolgee.constants.Feature -import io.tolgee.constants.Message import io.tolgee.ee.data.DomainRequest import io.tolgee.ee.data.SsoUrlResponse -import io.tolgee.ee.service.OAuthService -import io.tolgee.ee.service.TenantService -import io.tolgee.model.SsoTenant -import io.tolgee.model.UserAccount -import io.tolgee.security.authentication.JwtService -import io.tolgee.security.payload.JwtAuthenticationResponse -import io.tolgee.service.security.UserAccountService -import jakarta.servlet.http.HttpServletResponse +import io.tolgee.ee.service.sso.TenantService +import io.tolgee.security.thirdParty.SsoTenantConfig import org.springframework.web.bind.annotation.* +// TODO: Move all the logic from this class to PublicController + @RestController @RequestMapping("v2/public/oauth2/callback/") class OAuth2CallbackController( - private val oauthService: OAuthService, private val tenantService: TenantService, - private val userAccountService: UserAccountService, - private val jwtService: JwtService, private val frontendUrlProvider: FrontendUrlProvider, private val enabledFeaturesProvider: EnabledFeaturesProvider, ) { @@ -31,7 +23,7 @@ class OAuth2CallbackController( @RequestBody request: DomainRequest, ): SsoUrlResponse { val registrationId = request.domain - val tenant = tenantService.getEnabledByDomain(registrationId) + val tenant = tenantService.getEnabledConfigByDomain(registrationId) enabledFeaturesProvider.checkFeatureEnabled( organizationId = tenant.organization?.id, Feature.SSO, @@ -42,7 +34,7 @@ class OAuth2CallbackController( } private fun buildAuthUrl( - tenant: SsoTenant, + tenant: SsoTenantConfig, state: String, ): String = "${tenant.authorizationUri}?" + @@ -51,43 +43,4 @@ class OAuth2CallbackController( "response_type=code&" + "scope=openid profile email offline_access&" + "state=$state" - - @GetMapping("/{registrationId}") - fun handleCallback( - @RequestParam(value = "code", required = true) code: String, - @RequestParam(value = "redirect_uri", required = true) redirectUrl: String, - @RequestParam(defaultValue = "") error: String, - @RequestParam(defaultValue = "") error_description: String, - @RequestParam(value = "invitationCode", required = false) invitationCode: String?, - response: HttpServletResponse, - @PathVariable registrationId: String, - ): JwtAuthenticationResponse? { - if (code == "this_is_dummy_code") { - val user = getFakeUser() - return JwtAuthenticationResponse(jwtService.emitToken(user.id)) - } - - return oauthService.handleOAuthCallback( - registrationId = registrationId, - code = code, - redirectUrl = redirectUrl, - error = error, - errorDescription = error_description, - invitationCode = invitationCode, - ) - } - - private fun getFakeUser(): UserAccount { - val username = "johndoe@doe.com" - val user = - userAccountService.findActive(username) ?: let { - UserAccount().apply { - this.username = username - name = "john" - accountType = UserAccount.AccountType.THIRD_PARTY - userAccountService.save(this) - } - } - return user - } } 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 index 13bb963ca4..3c4d97446a 100644 --- 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 @@ -5,7 +5,7 @@ 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.TenantService +import io.tolgee.ee.service.sso.TenantService import io.tolgee.exceptions.NotFoundException import io.tolgee.hateoas.ee.SsoTenantModel import io.tolgee.model.enums.OrganizationRoleType diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/PublicEnabledFeaturesProvider.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/PublicEnabledFeaturesProvider.kt index 14334bf192..0892044a8e 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/PublicEnabledFeaturesProvider.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/PublicEnabledFeaturesProvider.kt @@ -14,7 +14,7 @@ import org.springframework.stereotype.Component class PublicEnabledFeaturesProvider( private val eeSubscriptionService: EeSubscriptionServiceImpl, ) : EnabledFeaturesProvider { - var forceEnabled: Set? = null + var forceEnabled: Set? = setOf(Feature.SSO) override fun get(organizationId: Long?): Array = forceEnabled?.toTypedArray() ?: eeSubscriptionService.findSubscriptionEntity()?.enabledFeatures ?: emptyArray() 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 index 4b1eb8eed4..0da95b2eee 100644 --- 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 @@ -3,6 +3,7 @@ package io.tolgee.ee.data import jakarta.validation.constraints.NotEmpty import org.springframework.validation.annotation.Validated +// TODO: check how validation between backend and frontend is handled @Validated data class CreateProviderRequest( val name: 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 index 506e7dae0d..f488a7492a 100644 --- 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 @@ -1,6 +1,6 @@ package io.tolgee.ee.data data class DomainRequest( - val domain: String, + val domain: String?, val state: String, ) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/exceptions/OAuthAuthorizationException.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/exceptions/OAuthAuthorizationException.kt deleted file mode 100644 index 5ca8ba1133..0000000000 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/exceptions/OAuthAuthorizationException.kt +++ /dev/null @@ -1,9 +0,0 @@ -package io.tolgee.ee.exceptions - -import io.tolgee.constants.Message -import io.tolgee.exceptions.BadRequestException - -data class OAuthAuthorizationException( - val msg: Message, - val details: String? = null, -) : BadRequestException("${msg.code}: $details") 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/service/OAuthService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/security/thirdParty/SsoDelegateEe.kt similarity index 80% rename from ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt rename to ee/backend/app/src/main/kotlin/io/tolgee/ee/security/thirdParty/SsoDelegateEe.kt index ae8bae703f..4bb95586b9 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/OAuthService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/security/thirdParty/SsoDelegateEe.kt @@ -1,4 +1,4 @@ -package io.tolgee.ee.service +package io.tolgee.ee.security.thirdParty import com.nimbusds.jose.JWSAlgorithm import com.nimbusds.jose.jwk.source.JWKSource @@ -14,20 +14,21 @@ import io.tolgee.constants.Feature import io.tolgee.constants.Message import io.tolgee.ee.data.GenericUserResponse import io.tolgee.ee.data.OAuth2TokenResponse -import io.tolgee.ee.exceptions.OAuthAuthorizationException +import io.tolgee.ee.exceptions.SsoAuthorizationException +import io.tolgee.ee.service.sso.TenantService import io.tolgee.exceptions.AuthenticationException -import io.tolgee.model.SsoTenant 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.OAuthServiceEe +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.util.Logging import io.tolgee.util.logger import org.springframework.http.* -import org.springframework.stereotype.Service +import org.springframework.stereotype.Component import org.springframework.util.LinkedMultiValueMap import org.springframework.util.MultiValueMap import org.springframework.web.client.RestClientException @@ -35,8 +36,8 @@ import org.springframework.web.client.RestTemplate import java.net.URL import java.util.* -@Service -class OAuthService( +@Component +class SsoDelegateEe( private val jwtService: JwtService, private val restTemplate: RestTemplate, private val jwtProcessor: ConfigurableJWTProcessor, @@ -44,45 +45,31 @@ class OAuthService( private val oAuthUserHandler: OAuthUserHandler, private val currentDateProvider: CurrentDateProvider, private val enabledFeaturesProvider: EnabledFeaturesProvider, -) : OAuthServiceEe, - Logging { - fun handleOAuthCallback( - registrationId: String, - code: String, - redirectUrl: String, - error: String, - errorDescription: String, +) : SsoDelegate, Logging { + override fun getTokenResponse( + code: String?, invitationCode: String?, - ): JwtAuthenticationResponse? { - if (error.isNotBlank()) { - logger.info("Third party auth failed: $errorDescription $error") - throw OAuthAuthorizationException( - Message.SSO_THIRD_PARTY_AUTH_FAILED, - "$errorDescription $error", - ) - } - - val tenant = tenantService.getEnabledByDomain(registrationId) + redirectUri: String?, + domain: String?, + ): JwtAuthenticationResponse { + val tenant = tenantService.getEnabledConfigByDomain(domain) enabledFeaturesProvider.checkFeatureEnabled( organizationId = tenant.organization?.id, Feature.SSO, ) - val tokenResponse = - exchangeCodeForToken(tenant, code, redirectUrl) - ?: throw OAuthAuthorizationException( - Message.SSO_TOKEN_EXCHANGE_FAILED, - null, - ) + val token = + fetchToken(tenant, code, redirectUri) + ?: throw SsoAuthorizationException(Message.SSO_TOKEN_EXCHANGE_FAILED) - val userInfo = verifyAndDecodeIdToken(tokenResponse.id_token, tenant.jwkSetUri) - return register(userInfo, tenant, invitationCode, tokenResponse.refresh_token) + val userInfo = decodeIdToken(token.id_token, tenant.jwkSetUri) + return getTokenResponseForUser(userInfo, tenant, invitationCode, token.refresh_token) } - private fun exchangeCodeForToken( - tenant: SsoTenant, - code: String, - redirectUrl: String, + private fun fetchToken( + tenant: SsoTenantConfig, + code: String?, + redirectUrl: String?, ): OAuth2TokenResponse? { val headers = HttpHeaders().apply { @@ -113,7 +100,7 @@ class OAuthService( } } - private fun verifyAndDecodeIdToken( + private fun decodeIdToken( idToken: String, jwkSetUri: String, ): GenericUserResponse { @@ -129,7 +116,7 @@ class OAuthService( val expirationTime: Date = jwtClaimsSet.expirationTime if (expirationTime.before(Date())) { - throw OAuthAuthorizationException(Message.SSO_ID_TOKEN_EXPIRED, null) + throw SsoAuthorizationException(Message.SSO_ID_TOKEN_EXPIRED) } return GenericUserResponse().apply { @@ -141,13 +128,13 @@ class OAuthService( } } catch (e: Exception) { logger.info(e.stackTraceToString()) - throw OAuthAuthorizationException(Message.SSO_USER_INFO_RETRIEVAL_FAILED, null) + throw SsoAuthorizationException(Message.SSO_USER_INFO_RETRIEVAL_FAILED) } } - private fun register( + private fun getTokenResponseForUser( userResponse: GenericUserResponse, - tenant: SsoTenant, + tenant: SsoTenantConfig, invitationCode: String?, refreshToken: String, ): JwtAuthenticationResponse { @@ -196,7 +183,7 @@ class OAuthService( return true } - val tenant = tenantService.getEnabledByDomain(ssoDomain) + val tenant = tenantService.getEnabledConfigByDomain(ssoDomain) enabledFeaturesProvider.checkFeatureEnabled( organizationId = tenant.organization?.id, Feature.SSO, 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 deleted file mode 100644 index e10fde52cd..0000000000 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/TenantService.kt +++ /dev/null @@ -1,128 +0,0 @@ -package io.tolgee.ee.service - -import io.tolgee.configuration.tolgee.SsoGlobalProperties -import io.tolgee.constants.Message -import io.tolgee.ee.data.CreateProviderRequest -import io.tolgee.ee.exceptions.OAuthAuthorizationException -import io.tolgee.ee.repository.TenantRepository -import io.tolgee.exceptions.BadRequestException -import io.tolgee.exceptions.NotFoundException -import io.tolgee.model.SsoTenant -import io.tolgee.service.organization.OrganizationService -import org.springframework.stereotype.Service -import java.net.URI -import java.net.URISyntaxException - -@Service -class TenantService( - private val tenantRepository: TenantRepository, - private val ssoGlobalProperties: SsoGlobalProperties, - private val organizationService: OrganizationService, -) { - fun getById(id: Long): SsoTenant = tenantRepository.findById(id).orElseThrow { NotFoundException() } - - fun getEnabledByDomain(domain: String): SsoTenant = - if (ssoGlobalProperties.enabled && domain == ssoGlobalProperties.domain) { - buildGlobalTenant() - } else { - tenantRepository.findEnabledByDomain(domain) ?: throw NotFoundException(Message.SSO_DOMAIN_NOT_FOUND_OR_DISABLED) - } - - fun getByDomain(domain: String): SsoTenant = - if (ssoGlobalProperties.enabled && domain == ssoGlobalProperties.domain) { - buildGlobalTenant() - } else { - tenantRepository.findByDomain(domain) ?: throw NotFoundException() - } - - private fun buildGlobalTenant(): SsoTenant { - val domain = validateProperty(ssoGlobalProperties.domain, "domain") - val tenant = - tenantRepository.findByDomain(domain) ?: SsoTenant().apply { - this.domain = domain - } - - applyGlobalPropertiesToTenant(tenant) - - return tenantRepository.save(tenant) - } - - // set or update global properties to tenant - private fun applyGlobalPropertiesToTenant(tenant: SsoTenant) { - tenant.apply { - enabled = validateProperty(ssoGlobalProperties.enabled.toString(), "enabled").toBoolean() - domain = validateProperty(ssoGlobalProperties.domain, "domain") - clientId = validateProperty(ssoGlobalProperties.clientId, "clientId") - clientSecret = validateProperty(ssoGlobalProperties.clientSecret, "clientSecret") - authorizationUri = validateProperty(ssoGlobalProperties.authorizationUrl, "authorizationUrl") - tokenUri = validateProperty(ssoGlobalProperties.tokenUrl, "tokenUrl") - jwkSetUri = validateProperty(ssoGlobalProperties.jwkSetUri, "jwkSetUri") - } - } - - private fun validateProperty( - property: String?, - propertyName: String, - ): String = - property ?: throw OAuthAuthorizationException( - Message.SSO_GLOBAL_CONFIG_MISSING_PROPERTIES, - "$propertyName is missing in global SSO configuration", - ) - - fun save(tenant: SsoTenant): SsoTenant = tenantRepository.save(tenant) - - fun findAll(): List = tenantRepository.findAll() - - private fun extractDomain(authorizationUri: String): String = - try { - val uri = URI(authorizationUri) - val domain = uri.host - val port = uri.port - - val domainWithPort = - if (port != -1) { - "$domain:$port" - } else { - domain - } - - if (domainWithPort.startsWith("www.")) { - domainWithPort.substring(4) - } else { - domainWithPort - } - } catch (e: URISyntaxException) { - throw BadRequestException("Invalid authorization uri") - } - - fun findTenant(organizationId: Long): SsoTenant? = tenantRepository.findByOrganizationId(organizationId) - - fun getTenant(organizationId: Long): SsoTenant = findTenant(organizationId) ?: throw NotFoundException() - - fun saveOrUpdate( - request: CreateProviderRequest, - organizationId: Long, - ): SsoTenant { - val tenant = findTenant(organizationId) ?: SsoTenant() - return setAndSaveTenantsFields(tenant, request, organizationId) - } - - private fun setAndSaveTenantsFields( - tenant: SsoTenant, - dto: CreateProviderRequest, - organizationId: Long, - ): SsoTenant { - tenant.name = dto.name ?: "" - tenant.organization = organizationService.get(organizationId) - 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 - val saved = save(tenant) - organizationService.updateSsoProvider(organizationId, saved) - return saved - } -} 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..a2169b86bb --- /dev/null +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/sso/TenantService.kt @@ -0,0 +1,71 @@ +package io.tolgee.ee.service.sso + +import io.tolgee.configuration.tolgee.SsoGlobalProperties +import io.tolgee.constants.Message +import io.tolgee.ee.data.CreateProviderRequest +import io.tolgee.exceptions.NotFoundException +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.organization.OrganizationService +import org.springframework.stereotype.Service + +@Service +class TenantService( + private val tenantRepository: TenantRepository, + private val ssoGlobalProperties: SsoGlobalProperties, + private val organizationService: OrganizationService, +) { + fun getById(id: Long): SsoTenant = tenantRepository.findById(id).orElseThrow { NotFoundException() } + + fun getByDomain(domain: String): SsoTenant { + return tenantRepository.findByDomain(domain) ?: throw NotFoundException() + } + + fun getEnabledConfigByDomain(domain: String?): SsoTenantConfig { + return ssoGlobalProperties + .takeIf { it.enabled } + ?.takeIf { domain == null || domain == "" || domain == it.domain } + ?.toConfig() + ?: domain?.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, + organizationId: Long, + ): SsoTenant { + // TODO: pass organization directly + val tenant = findTenant(organizationId) ?: SsoTenant() + return setAndSaveTenantsFields(tenant, request, organizationId) + } + + private fun setAndSaveTenantsFields( + tenant: SsoTenant, + dto: CreateProviderRequest, + organizationId: Long, + ): SsoTenant { + tenant.name = dto.name ?: "" + tenant.organization = organizationService.get(organizationId) + 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 + val saved = save(tenant) + // TODO: don't update organization like this + organizationService.updateSsoProvider(organizationId, saved) + return saved + } +} 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 index 47fdf17364..48df2e1f3f 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -9,8 +9,8 @@ import io.tolgee.constants.Message import io.tolgee.development.testDataBuilder.data.OAuthTestData import io.tolgee.dtos.request.organization.OrganizationDto import io.tolgee.ee.data.OAuth2TokenResponse -import io.tolgee.ee.service.OAuthService -import io.tolgee.ee.service.TenantService +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 @@ -59,7 +59,7 @@ class OAuthTest : AuthorizedControllerTest() { private val jwtProcessor: ConfigurableJWTProcessor? = null @Autowired - private lateinit var oAuthService: OAuthService + private lateinit var ssoDelegate: SsoDelegateEe @Autowired private lateinit var tenantService: TenantService @@ -185,7 +185,7 @@ class OAuthTest : AuthorizedControllerTest() { val userName = jwtClaimsSet.getStringClaim("email") val user = userAccountService.get(userName) assertThat( - oAuthService.verifyUserSsoAccountAvailable( + ssoDelegate.verifyUserSsoAccountAvailable( user.ssoTenant?.domain, user.id, user.ssoRefreshToken, @@ -205,7 +205,7 @@ class OAuthTest : AuthorizedControllerTest() { oAuthMultiTenantsMocks.mockTokenExchange("http://tokenUri") assertThat( - oAuthService.verifyUserSsoAccountAvailable( + ssoDelegate.verifyUserSsoAccountAvailable( user.ssoTenant?.domain, user.id, user.ssoRefreshToken, 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 index 6254c39f1f..8c498116a1 100644 --- 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 @@ -8,7 +8,7 @@ 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.TenantService +import io.tolgee.ee.service.sso.TenantService import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.isNull @@ -93,7 +93,7 @@ class OAuthMultiTenantsMocks( jwtClaims: JWTClaimsSet = jwtClaimsSet, ): MvcResult { val receivedCode = "fake_access_token" - val tenant = tenantService?.getByDomain(registrationId)!! + val tenant = tenantService?.getEnabledConfigByDomain(registrationId)!! // mock token exchange whenever( restTemplate?.exchange( diff --git a/webapp/src/component/security/Login/LoginCredentialsForm.tsx b/webapp/src/component/security/Login/LoginCredentialsForm.tsx index 40c794b0ac..201609dd5d 100644 --- a/webapp/src/component/security/Login/LoginCredentialsForm.tsx +++ b/webapp/src/component/security/Login/LoginCredentialsForm.tsx @@ -1,18 +1,23 @@ -import React, {RefObject} from 'react'; -import {Button, Link as MuiLink, styled, Typography} from '@mui/material'; +import React, { RefObject } from 'react'; +import { Button, Link as MuiLink, Typography, styled } from '@mui/material'; import Box from '@mui/material/Box'; -import {T, useTranslate} from '@tolgee/react'; -import {Link} from 'react-router-dom'; +import { T } from '@tolgee/react'; +import { Link } from 'react-router-dom'; import LoginIcon from '@mui/icons-material/Login'; +import { v4 as uuidv4 } from 'uuid'; -import {LINKS} from 'tg.constants/links'; -import {useConfig} from 'tg.globalContext/helpers'; +import { LINKS } from 'tg.constants/links'; +import { useConfig } from 'tg.globalContext/helpers'; import LoadingButton from 'tg.component/common/form/LoadingButton'; -import {StandardForm} from 'tg.component/common/form/StandardForm'; -import {TextField} from 'tg.component/common/form/fields/TextField'; -import {useOAuthServices} from 'tg.hooks/useOAuthServices'; -import {useGlobalActions, useGlobalContext,} from 'tg.globalContext/GlobalContext'; -import {ApiError} from 'tg.service/http/ApiError'; +import { StandardForm } from 'tg.component/common/form/StandardForm'; +import { TextField } from 'tg.component/common/form/fields/TextField'; +import { useOAuthServices } from 'tg.hooks/useOAuthServices'; +import { + useGlobalActions, + useGlobalContext, +} from 'tg.globalContext/GlobalContext'; +import { ApiError } from 'tg.service/http/ApiError'; +import { useSsoService } from 'tg.component/security/SsoService'; const StyledInputFields = styled('div')` display: grid; @@ -27,13 +32,17 @@ type LoginViewCredentialsProps = { onMfaEnabled: () => void; }; +const LOCAL_STORAGE_STATE_KEY = 'oauth2State'; + export function LoginCredentialsForm(props: LoginViewCredentialsProps) { const remoteConfig = useConfig(); const { login } = useGlobalActions(); - const t = useTranslate(); const isLoading = useGlobalContext((c) => c.auth.loginLoadable.isLoading); + const oAuthServices = useOAuthServices(); + const { getSsoAuthLinkByDomain } = useSsoService(); + return ( + Custom Logo ) : ( - + ) } variant="outlined" @@ -86,27 +95,51 @@ export function LoginCredentialsForm(props: LoginViewCredentialsProps) { color="inherit" > {remoteConfig.customLoginText ? ( - {remoteConfig.customLoginText} + {remoteConfig.customLoginText} ) : ( - + )} )} - {} - {remoteConfig.nativeEnabled && ( - - )} + { + /*TODO: only show when there is SSO or oauth*/ + } + {remoteConfig.nativeEnabled && + !remoteConfig.globalSsoAuthentication && ( + + )} + {remoteConfig.nativeEnabled && + remoteConfig.globalSsoAuthentication && ( + + )} {oAuthServices.map((provider) => ( diff --git a/webapp/src/component/security/Login/LoginView.tsx b/webapp/src/component/security/Login/LoginView.tsx index ef6778eb24..0fa3e49ca4 100644 --- a/webapp/src/component/security/Login/LoginView.tsx +++ b/webapp/src/component/security/Login/LoginView.tsx @@ -1,6 +1,6 @@ import { FunctionComponent, useRef, useState } from 'react'; import { T, useTranslate } from '@tolgee/react'; -import { Alert, Link as MuiLink, useMediaQuery } from '@mui/material'; +import { Alert, useMediaQuery, Link as MuiLink } from '@mui/material'; import { Link } from 'react-router-dom'; import { LINKS } from 'tg.constants/links'; diff --git a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx index 8296e9c822..5a13adf1a5 100644 --- a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx +++ b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx @@ -32,7 +32,7 @@ export const SsoRedirectionHandler: FunctionComponent< localStorage.removeItem(LOCAL_STORAGE_STATE_KEY); } - if (code && !allowPrivate && storedDomain) { + if (code && !allowPrivate && storedDomain !== null) { loginWithOAuthCodeOpenId(storedDomain, code); } }, [allowPrivate]); diff --git a/webapp/src/component/security/SsoService.tsx b/webapp/src/component/security/SsoService.tsx index c40a35296b..ea78c09139 100644 --- a/webapp/src/component/security/SsoService.tsx +++ b/webapp/src/component/security/SsoService.tsx @@ -18,8 +18,12 @@ export const useSsoService = () => { key: INVITATION_CODE_STORAGE_KEY, }); - const authorizeOpenIdLoadable = useApiMutation({ - url: '/v2/public/oauth2/callback/{registrationId}', + // const authorizeOpenIdLoadable = useApiMutation({ + // url: '/v2/public/oauth2/callback/{registrationId}', + // method: 'get', + // }); + const authorizeOAuthLoadable = useApiMutation({ + url: '/api/public/authorize_oauth/{serviceType}', method: 'get', }); @@ -31,13 +35,14 @@ export const useSsoService = () => { return { async loginWithOAuthCodeOpenId(registrationId: string, code: string) { const redirectUri = LINKS.OPENID_RESPONSE.buildWithOrigin({}); - const response = await authorizeOpenIdLoadable.mutateAsync( + const response = await authorizeOAuthLoadable.mutateAsync( { - path: { registrationId: registrationId }, + path: { serviceType: 'sso' }, query: { code, redirect_uri: redirectUri, invitationCode: invitationCode, + domain: registrationId, }, }, { @@ -45,6 +50,7 @@ export const useSsoService = () => { if (error.code === 'invitation_code_does_not_exist_or_expired') { setInvitationCode(undefined); } + // TODO: this handling should no longer be necessary - code should be fine let errorCode = error.code; if (errorCode && errorCode.endsWith(': null')) { errorCode = errorCode.replace(': null', ''); @@ -57,7 +63,7 @@ export const useSsoService = () => { await handleAfterLogin(response!); }, - async getSsoAuthLinkByDomain(domain: string, state: string) { + async getSsoAuthLinkByDomain(domain: string | null, state: string) { return await openIdAuthUrlLoadable.mutateAsync( { content: { 'application/json': { domain, state } }, @@ -68,7 +74,7 @@ export const useSsoService = () => { }, onSuccess: (response) => { if (response.redirectUrl) { - localStorage.setItem(LOCAL_STORAGE_DOMAIN_KEY, domain); + localStorage.setItem(LOCAL_STORAGE_DOMAIN_KEY, domain || ''); } }, } diff --git a/webapp/src/constants/GlobalValidationSchema.tsx b/webapp/src/constants/GlobalValidationSchema.tsx index e74e3f7e62..495d69f4f4 100644 --- a/webapp/src/constants/GlobalValidationSchema.tsx +++ b/webapp/src/constants/GlobalValidationSchema.tsx @@ -1,11 +1,11 @@ -import {DefaultParamType, T, TFnType, TranslationKey} from '@tolgee/react'; +import { DefaultParamType, T, TFnType, TranslationKey } from '@tolgee/react'; import * as Yup from 'yup'; -import {components} from 'tg.service/apiSchema.generated'; -import {organizationService} from '../service/OrganizationService'; -import {signUpService} from '../service/SignUpService'; -import {checkParamNameIsValid} from '@tginternal/editor'; -import {validateObject} from 'tg.fixtures/validateObject'; +import { components } from 'tg.service/apiSchema.generated'; +import { organizationService } from '../service/OrganizationService'; +import { signUpService } from '../service/SignUpService'; +import { checkParamNameIsValid } from '@tginternal/editor'; +import { validateObject } from 'tg.fixtures/validateObject'; type TFunType = TFnType; diff --git a/webapp/src/globalContext/useAuthService.tsx b/webapp/src/globalContext/useAuthService.tsx index d0612f4260..4956850053 100644 --- a/webapp/src/globalContext/useAuthService.tsx +++ b/webapp/src/globalContext/useAuthService.tsx @@ -186,7 +186,6 @@ export const useAuthService = ( setInvitationCode(undefined); await handleAfterLogin(response!); }, - async signUp(data: Omit) { signupLoadable.mutate( { diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 85427336ef..021496e973 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -322,6 +322,12 @@ export interface paths { /** Pairs user account with slack account. */ post: operations["userLogin"]; }; + "/v2/public/translator/translate": { + post: operations["translate"]; + }; + "/v2/public/telemetry/report": { + post: operations["report"]; + }; "/v2/public/slack": { post: operations["slackCommand"]; }; @@ -340,8 +346,26 @@ export interface paths { "/v2/public/oauth2/callback/get-authentication-url": { post: operations["getAuthenticationUrl"]; }; + "/v2/public/licensing/subscription": { + post: operations["getMySubscription"]; + }; + "/v2/public/licensing/set-key": { + post: operations["onLicenceSetKey"]; + }; + "/v2/public/licensing/report-usage": { + post: operations["reportUsage"]; + }; + "/v2/public/licensing/report-error": { + post: operations["reportError"]; + }; + "/v2/public/licensing/release-key": { + post: operations["releaseKey"]; + }; + "/v2/public/licensing/prepare-set-key": { + post: operations["prepareSetLicenseKey"]; + }; "/v2/public/business-events/report": { - post: operations["report"]; + post: operations["report_1"]; }; "/v2/public/business-events/identify": { post: operations["identify"]; @@ -410,7 +434,7 @@ export interface paths { }; "/v2/projects/{projectId}/start-batch-job/pre-translate-by-tm": { /** Pre-translate provided keys to provided languages by TM. */ - post: operations["translate"]; + post: operations["translate_1"]; }; "/v2/projects/{projectId}/start-batch-job/machine-translate": { /** Translate provided keys to provided languages through primary MT provider. */ @@ -496,7 +520,7 @@ export interface paths { }; "/v2/ee-license/prepare-set-license-key": { /** Get info about the upcoming EE subscription. This will show, how much the subscription will cost when key is applied. */ - post: operations["prepareSetLicenseKey"]; + post: operations["prepareSetLicenseKey_1"]; }; "/v2/api-keys": { get: operations["allByUser"]; @@ -553,9 +577,6 @@ export interface paths { "/v2/public/scope-info/hierarchy": { get: operations["getHierarchy"]; }; - "/v2/public/oauth2/callback/{registrationId}": { - get: operations["handleCallback"]; - }; "/v2/public/machine-translation-providers": { /** Get machine translation providers */ get: operations["getInfo_4"]; @@ -1062,8 +1083,10 @@ export interface components { | "sso_user_cannot_create_organization" | "sso_cant_verify_user" | "sso_user_cant_login_with_native" - | "sso_user_operation_unavailable" - | "sso_global_config_missing_properties"; + | "sso_global_config_missing_properties" + | "sso_domain_not_found_or_disabled" + | "native_authentication_disabled" + | "sso_user_not_invited"; params?: { [key: string]: unknown }[]; }; ErrorResponseBody: { @@ -1154,6 +1177,11 @@ export interface components { * @example 200001,200004 */ stateChangeLanguageIds?: number[]; + /** + * @description List of languages user can view. If null, all languages view is permitted. + * @example 200001,200004 + */ + viewLanguageIds?: number[]; /** * @description Granted scopes to the user. When user has type permissions, this field contains permission scopes of the type. * @example KEYS_EDIT,TRANSLATIONS_VIEW @@ -1186,11 +1214,6 @@ export interface components { | "content-delivery.publish" | "webhooks.manage" )[]; - /** - * @description List of languages user can view. If null, all languages view is permitted. - * @example 200001,200004 - */ - viewLanguageIds?: number[]; }; LanguageModel: { /** Format: int64 */ @@ -1719,8 +1742,8 @@ export interface components { secretKey?: string; endpoint: string; signingRegion: string; - enabled?: boolean; contentStorageType?: "S3" | "AZURE"; + enabled?: boolean; }; AzureContentStorageConfigModel: { containerName?: string; @@ -1992,10 +2015,10 @@ export interface components { createNewKeys: boolean; }; ImportSettingsModel: { - /** @description If true, key descriptions will be overridden by the import */ - overrideKeyDescriptions: boolean; /** @description If true, placeholders from other formats will be converted to ICU when possible */ convertPlaceholdersToIcu: boolean; + /** @description If true, key descriptions will be overridden by the import */ + overrideKeyDescriptions: boolean; /** @description If false, only updates keys, skipping the creation of new keys */ createNewKeys: boolean; }; @@ -2163,13 +2186,13 @@ export interface components { }; RevealedPatModel: { token: string; - description: string; /** Format: int64 */ id: number; /** Format: int64 */ createdAt: number; /** Format: int64 */ updatedAt: number; + description: string; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ @@ -2329,19 +2352,19 @@ export interface components { RevealedApiKeyModel: { /** @description Resulting user's api key */ key: string; - description: string; /** Format: int64 */ id: number; + userFullName?: string; projectName: string; username?: string; - scopes: string[]; - /** Format: int64 */ - projectId: number; + description: string; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; - userFullName?: string; + /** Format: int64 */ + projectId: number; + scopes: string[]; }; SuperTokenRequest: { /** @description Has to be provided when TOTP enabled */ @@ -2353,6 +2376,49 @@ export interface components { name: string; oldSlug?: string; }; + ExampleItem: { + source: string; + target: string; + key: string; + keyNamespace?: string; + }; + Metadata: { + examples: components["schemas"]["ExampleItem"][]; + closeItems: components["schemas"]["ExampleItem"][]; + keyDescription?: string; + projectDescription?: string; + languageDescription?: string; + }; + TolgeeTranslateParams: { + text: string; + keyName?: string; + sourceTag: string; + targetTag: string; + metadata?: components["schemas"]["Metadata"]; + formality?: "FORMAL" | "INFORMAL" | "DEFAULT"; + isBatch: boolean; + pluralForms?: { [key: string]: string }; + pluralFormExamples?: { [key: string]: string }; + }; + MtResult: { + translated?: string; + /** Format: int32 */ + price: number; + contextDescription?: string; + }; + TelemetryReportRequest: { + instanceId: string; + /** Format: int64 */ + projectsCount: number; + /** Format: int64 */ + translationsCount: number; + /** Format: int64 */ + languagesCount: number; + /** Format: int64 */ + distinctLanguagesCount: number; + /** Format: int64 */ + usersCount: number; + }; SlackCommandDto: { token?: string; team_id: string; @@ -2366,12 +2432,133 @@ export interface components { team_domain: string; }; DomainRequest: { - domain: string; + domain?: string; state: string; }; SsoUrlResponse: { redirectUrl: string; }; + GetMySubscriptionDto: { + licenseKey: string; + instanceId: string; + }; + PlanIncludedUsageModel: { + /** Format: int64 */ + seats: number; + /** Format: int64 */ + translationSlots: number; + /** Format: int64 */ + translations: number; + /** Format: int64 */ + mtCredits: number; + }; + PlanPricesModel: { + perSeat: number; + perThousandTranslations?: number; + perThousandMtCredits?: number; + subscriptionMonthly: number; + subscriptionYearly: number; + }; + SelfHostedEePlanModel: { + /** Format: int64 */ + id: number; + name: string; + public: boolean; + enabledFeatures: ( + | "GRANULAR_PERMISSIONS" + | "PRIORITIZED_FEATURE_REQUESTS" + | "PREMIUM_SUPPORT" + | "DEDICATED_SLACK_CHANNEL" + | "ASSISTED_UPDATES" + | "DEPLOYMENT_ASSISTANCE" + | "BACKUP_CONFIGURATION" + | "TEAM_TRAINING" + | "ACCOUNT_MANAGER" + | "STANDARD_SUPPORT" + | "PROJECT_LEVEL_CONTENT_STORAGES" + | "WEBHOOKS" + | "MULTIPLE_CONTENT_DELIVERY_CONFIGS" + | "AI_PROMPT_CUSTOMIZATION" + | "SLACK_INTEGRATION" + | "SSO" + )[]; + prices: components["schemas"]["PlanPricesModel"]; + includedUsage: components["schemas"]["PlanIncludedUsageModel"]; + hasYearlyPrice: boolean; + free: boolean; + }; + SelfHostedEeSubscriptionModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + currentPeriodStart?: number; + /** Format: int64 */ + currentPeriodEnd?: number; + currentBillingPeriod: "MONTHLY" | "YEARLY"; + /** Format: int64 */ + createdAt: number; + plan: components["schemas"]["SelfHostedEePlanModel"]; + status: + | "ACTIVE" + | "CANCELED" + | "PAST_DUE" + | "UNPAID" + | "ERROR" + | "KEY_USED_BY_ANOTHER_INSTANCE"; + licenseKey?: string; + estimatedCosts?: number; + }; + SetLicenseKeyLicensingDto: { + licenseKey: string; + /** Format: int64 */ + seats: number; + instanceId: string; + }; + ReportUsageDto: { + licenseKey: string; + /** Format: int64 */ + seats: number; + }; + ReportErrorDto: { + stackTrace: string; + licenseKey: string; + }; + ReleaseKeyDto: { + licenseKey: string; + }; + PrepareSetLicenseKeyDto: { + licenseKey: string; + /** Format: int64 */ + seats: number; + }; + AverageProportionalUsageItemModel: { + total: number; + unusedQuantity: number; + usedQuantity: number; + usedQuantityOverPlan: number; + }; + PrepareSetEeLicenceKeyModel: { + plan: components["schemas"]["SelfHostedEePlanModel"]; + usage: components["schemas"]["UsageModel"]; + }; + SumUsageItemModel: { + total: number; + /** Format: int64 */ + unusedQuantity: number; + /** Format: int64 */ + usedQuantity: number; + /** Format: int64 */ + usedQuantityOverPlan: number; + }; + UsageModel: { + subscriptionPrice?: number; + /** @description Relevant for invoices only. When there are applied stripe credits, we need to reduce the total price by this amount. */ + appliedStripeCredits?: number; + seats: components["schemas"]["AverageProportionalUsageItemModel"]; + translations: components["schemas"]["AverageProportionalUsageItemModel"]; + credits?: components["schemas"]["SumUsageItemModel"]; + total: number; + }; BusinessEventReportRequest: { eventName: string; anonymousUserId?: string; @@ -2750,8 +2937,10 @@ export interface components { | "sso_user_cannot_create_organization" | "sso_cant_verify_user" | "sso_user_cant_login_with_native" - | "sso_user_operation_unavailable" - | "sso_global_config_missing_properties"; + | "sso_global_config_missing_properties" + | "sso_domain_not_found_or_disabled" + | "native_authentication_disabled" + | "sso_user_not_invited"; params?: { [key: string]: unknown }[]; }; UntagKeysRequest: { @@ -3192,79 +3381,6 @@ export interface components { createdAt: string; location?: string; }; - AverageProportionalUsageItemModel: { - total: number; - unusedQuantity: number; - usedQuantity: number; - usedQuantityOverPlan: number; - }; - PlanIncludedUsageModel: { - /** Format: int64 */ - seats: number; - /** Format: int64 */ - translationSlots: number; - /** Format: int64 */ - translations: number; - /** Format: int64 */ - mtCredits: number; - }; - PlanPricesModel: { - perSeat: number; - perThousandTranslations?: number; - perThousandMtCredits?: number; - subscriptionMonthly: number; - subscriptionYearly: number; - }; - PrepareSetEeLicenceKeyModel: { - plan: components["schemas"]["SelfHostedEePlanModel"]; - usage: components["schemas"]["UsageModel"]; - }; - SelfHostedEePlanModel: { - /** Format: int64 */ - id: number; - name: string; - public: boolean; - enabledFeatures: ( - | "GRANULAR_PERMISSIONS" - | "PRIORITIZED_FEATURE_REQUESTS" - | "PREMIUM_SUPPORT" - | "DEDICATED_SLACK_CHANNEL" - | "ASSISTED_UPDATES" - | "DEPLOYMENT_ASSISTANCE" - | "BACKUP_CONFIGURATION" - | "TEAM_TRAINING" - | "ACCOUNT_MANAGER" - | "STANDARD_SUPPORT" - | "PROJECT_LEVEL_CONTENT_STORAGES" - | "WEBHOOKS" - | "MULTIPLE_CONTENT_DELIVERY_CONFIGS" - | "AI_PROMPT_CUSTOMIZATION" - | "SLACK_INTEGRATION" - | "SSO" - )[]; - prices: components["schemas"]["PlanPricesModel"]; - includedUsage: components["schemas"]["PlanIncludedUsageModel"]; - hasYearlyPrice: boolean; - free: boolean; - }; - SumUsageItemModel: { - total: number; - /** Format: int64 */ - unusedQuantity: number; - /** Format: int64 */ - usedQuantity: number; - /** Format: int64 */ - usedQuantityOverPlan: number; - }; - UsageModel: { - subscriptionPrice?: number; - /** @description Relevant for invoices only. When there are applied stripe credits, we need to reduce the total price by this amount. */ - appliedStripeCredits?: number; - seats: components["schemas"]["AverageProportionalUsageItemModel"]; - translations: components["schemas"]["AverageProportionalUsageItemModel"]; - credits?: components["schemas"]["SumUsageItemModel"]; - total: number; - }; CreateApiKeyDto: { /** Format: int64 */ projectId: number; @@ -3430,15 +3546,10 @@ export interface components { | "SSO" )[]; quickStart?: components["schemas"]["QuickStartModel"]; - /** @example This is a beautiful organization full of beautiful and clever people */ - description?: string; /** @example Beautiful organization */ name: string; /** Format: int64 */ id: number; - /** @example btforg */ - slug: string; - avatar?: components["schemas"]["Avatar"]; basePermissions: components["schemas"]["PermissionModel"]; /** * @description The role of currently authorized user. @@ -3446,6 +3557,11 @@ export interface components { * Can be null when user has direct access to one of the projects owned by the organization. */ currentUserRole?: "MEMBER" | "OWNER"; + /** @example This is a beautiful organization full of beautiful and clever people */ + description?: string; + avatar?: components["schemas"]["Avatar"]; + /** @example btforg */ + slug: string; }; PublicBillingConfigurationDTO: { enabled: boolean; @@ -3456,6 +3572,7 @@ export interface components { version: string; authentication: boolean; authMethods?: components["schemas"]["AuthMethodsDTO"]; + globalSsoAuthentication: boolean; passwordResettable: boolean; allowRegistrations: boolean; screenshotsUrl: string; @@ -3510,9 +3627,9 @@ export interface components { defaultFileStructureTemplate: string; }; DocItem: { + name: string; displayName?: string; description?: string; - name: string; }; PagedModelProjectModel: { _embedded?: { @@ -3583,23 +3700,23 @@ export interface components { formalitySupported: boolean; }; KeySearchResultView: { - description?: string; name: string; /** Format: int64 */ id: number; - translation?: string; - namespace?: string; baseTranslation?: string; - }; + namespace?: string; + description?: string; + translation?: string; + }; KeySearchSearchResultModel: { view?: components["schemas"]["KeySearchResultView"]; - description?: string; name: string; /** Format: int64 */ id: number; - translation?: string; - namespace?: string; baseTranslation?: string; + namespace?: string; + description?: string; + translation?: string; }; PagedModelKeySearchSearchResultModel: { _embedded?: { @@ -4143,13 +4260,13 @@ export interface components { }; PatWithUserModel: { user: components["schemas"]["SimpleUserAccountModel"]; - description: string; /** Format: int64 */ id: number; /** Format: int64 */ createdAt: number; /** Format: int64 */ updatedAt: number; + description: string; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ @@ -4280,19 +4397,19 @@ export interface components { * @description Languages for which user has translate permission. */ permittedLanguageIds?: number[]; - description: string; /** Format: int64 */ id: number; + userFullName?: string; projectName: string; username?: string; - scopes: string[]; - /** Format: int64 */ - projectId: number; + description: string; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; - userFullName?: string; + /** Format: int64 */ + projectId: number; + scopes: string[]; }; PagedModelUserAccountModel: { _embedded?: { @@ -9195,7 +9312,369 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["ApiKeyModel"]; + "application/json": components["schemas"]["ApiKeyModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["V2EditApiKeyDto"]; + }; + }; + }; + delete_13: { + parameters: { + path: { + apiKeyId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + regenerate_1: { + parameters: { + path: { + apiKeyId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["RevealedApiKeyModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RegenerateApiKeyDto"]; + }; + }; + }; + /** Enables previously disabled user. */ + enableUser: { + parameters: { + path: { + userId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Disables user account. User will not be able to log in, but their user data will be preserved, so you can enable the user later using the `enable` endpoint. */ + disableUser: { + parameters: { + path: { + userId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Set's the global role on the Tolgee Platform server. */ + setRole: { + parameters: { + path: { + userId: number; + role: "USER" | "ADMIN"; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Resends email verification email to currently authenticated user. */ + sendEmailVerification: { + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Generates new JWT token permitted to sensitive operations */ + getSuperToken: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["JwtAuthenticationResponse"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SuperTokenRequest"]; + }; + }; + }; + generateProjectSlug: { + responses: { + /** OK */ + 200: { + content: { + "application/json": string; }; }; /** Bad Request */ @@ -9233,19 +9712,18 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["V2EditApiKeyDto"]; + "application/json": components["schemas"]["GenerateSlugDto"]; }; }; }; - delete_13: { - parameters: { - path: { - apiKeyId: number; - }; - }; + generateOrganizationSlug: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": string; + }; + }; /** Bad Request */ 400: { content: { @@ -9279,20 +9757,23 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["GenerateSlugDto"]; + }; + }; }; - regenerate_1: { + /** Pairs user account with slack account. */ + userLogin: { parameters: { - path: { - apiKeyId: number; + query: { + /** The encrypted data about the desired connection between Slack account and Tolgee account */ + data: string; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["RevealedApiKeyModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -9326,22 +9807,15 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["RegenerateApiKeyDto"]; - }; - }; }; - /** Enables previously disabled user. */ - enableUser: { - parameters: { - path: { - userId: number; - }; - }; + translate: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["MtResult"]; + }; + }; /** Bad Request */ 400: { content: { @@ -9375,14 +9849,13 @@ export interface operations { }; }; }; - }; - /** Disables user account. User will not be able to log in, but their user data will be preserved, so you can enable the user later using the `enable` endpoint. */ - disableUser: { - parameters: { - path: { - userId: number; + requestBody: { + content: { + "application/json": components["schemas"]["TolgeeTranslateParams"]; }; }; + }; + report: { responses: { /** OK */ 200: unknown; @@ -9419,18 +9892,26 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["TelemetryReportRequest"]; + }; + }; }; - /** Set's the global role on the Tolgee Platform server. */ - setRole: { + slackCommand: { parameters: { - path: { - userId: number; - role: "USER" | "ADMIN"; + header: { + "X-Slack-Signature": string; + "X-Slack-Request-Timestamp": string; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": string; + }; + }; /** Bad Request */ 400: { content: { @@ -9464,9 +9945,23 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": { + payload?: components["schemas"]["SlackCommandDto"]; + body?: string; + }; + }; + }; }; - /** Resends email verification email to currently authenticated user. */ - sendEmailVerification: { + /** This is triggered when interactivity event is triggered. E.g., when user clicks button provided in previous messages. */ + onInteractivityEvent: { + parameters: { + header: { + "X-Slack-Signature": string; + "X-Slack-Request-Timestamp": string; + }; + }; responses: { /** OK */ 200: unknown; @@ -9503,14 +9998,29 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": string; + }; + }; }; - /** Generates new JWT token permitted to sensitive operations */ - getSuperToken: { + /** + * This is triggered when bot event is triggered. E.g., when app is uninstalled from workspace. + * + * Heads up! The events have to be configured via Slack App configuration in Event Subscription section. + */ + fetchBotEvent: { + parameters: { + header: { + "X-Slack-Signature": string; + "X-Slack-Request-Timestamp": string; + }; + }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["JwtAuthenticationResponse"]; + "application/json": { [key: string]: unknown }; }; }; /** Bad Request */ @@ -9548,16 +10058,16 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["SuperTokenRequest"]; + "application/json": string; }; }; }; - generateProjectSlug: { + getAuthenticationUrl: { responses: { /** OK */ 200: { content: { - "application/json": string; + "application/json": components["schemas"]["SsoUrlResponse"]; }; }; /** Bad Request */ @@ -9595,16 +10105,16 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["GenerateSlugDto"]; + "application/json": components["schemas"]["DomainRequest"]; }; }; }; - generateOrganizationSlug: { + getMySubscription: { responses: { /** OK */ 200: { content: { - "application/json": string; + "application/json": components["schemas"]["SelfHostedEeSubscriptionModel"]; }; }; /** Bad Request */ @@ -9642,21 +10152,18 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["GenerateSlugDto"]; + "application/json": components["schemas"]["GetMySubscriptionDto"]; }; }; }; - /** Pairs user account with slack account. */ - userLogin: { - parameters: { - query: { - /** The encrypted data about the desired connection between Slack account and Tolgee account */ - data: string; - }; - }; + onLicenceSetKey: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["SelfHostedEeSubscriptionModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -9690,21 +10197,16 @@ export interface operations { }; }; }; - }; - slackCommand: { - parameters: { - header: { - "X-Slack-Signature": string; - "X-Slack-Request-Timestamp": string; + requestBody: { + content: { + "application/json": components["schemas"]["SetLicenseKeyLicensingDto"]; }; }; + }; + reportUsage: { responses: { /** OK */ - 200: { - content: { - "application/json": string; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -9740,21 +10242,11 @@ export interface operations { }; requestBody: { content: { - "application/json": { - payload?: components["schemas"]["SlackCommandDto"]; - body?: string; - }; + "application/json": components["schemas"]["ReportUsageDto"]; }; }; }; - /** This is triggered when interactivity event is triggered. E.g., when user clicks button provided in previous messages. */ - onInteractivityEvent: { - parameters: { - header: { - "X-Slack-Signature": string; - "X-Slack-Request-Timestamp": string; - }; - }; + reportError: { responses: { /** OK */ 200: unknown; @@ -9793,29 +10285,14 @@ export interface operations { }; requestBody: { content: { - "application/json": string; + "application/json": components["schemas"]["ReportErrorDto"]; }; }; }; - /** - * This is triggered when bot event is triggered. E.g., when app is uninstalled from workspace. - * - * Heads up! The events have to be configured via Slack App configuration in Event Subscription section. - */ - fetchBotEvent: { - parameters: { - header: { - "X-Slack-Signature": string; - "X-Slack-Request-Timestamp": string; - }; - }; + releaseKey: { responses: { /** OK */ - 200: { - content: { - "application/json": { [key: string]: unknown }; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -9851,16 +10328,16 @@ export interface operations { }; requestBody: { content: { - "application/json": string; + "application/json": components["schemas"]["ReleaseKeyDto"]; }; }; }; - getAuthenticationUrl: { + prepareSetLicenseKey: { responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["SsoUrlResponse"]; + "application/json": components["schemas"]["PrepareSetEeLicenceKeyModel"]; }; }; /** Bad Request */ @@ -9898,11 +10375,11 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["DomainRequest"]; + "application/json": components["schemas"]["PrepareSetLicenseKeyDto"]; }; }; }; - report: { + report_1: { responses: { /** OK */ 200: unknown; @@ -11138,7 +11615,7 @@ export interface operations { }; }; /** Pre-translate provided keys to provided languages by TM. */ - translate: { + translate_1: { parameters: { path: { projectId: number; @@ -12651,7 +13128,7 @@ export interface operations { }; }; /** Get info about the upcoming EE subscription. This will show, how much the subscription will cost when key is applied. */ - prepareSetLicenseKey: { + prepareSetLicenseKey_1: { responses: { /** OK */ 200: { @@ -13414,60 +13891,6 @@ export interface operations { }; }; }; - handleCallback: { - parameters: { - query: { - code: string; - redirect_uri: string; - error?: string; - error_description?: string; - invitationCode?: string; - }; - path: { - registrationId: string; - }; - }; - responses: { - /** OK */ - 200: { - content: { - "application/json": components["schemas"]["JwtAuthenticationResponse"]; - }; - }; - /** Bad Request */ - 400: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Unauthorized */ - 401: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Forbidden */ - 403: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - /** Not Found */ - 404: { - content: { - "application/json": - | components["schemas"]["ErrorResponseTyped"] - | components["schemas"]["ErrorResponseBody"]; - }; - }; - }; - }; /** Get machine translation providers */ getInfo_4: { responses: { @@ -16720,6 +17143,7 @@ export interface operations { code?: string; redirect_uri?: string; invitationCode?: string; + domain?: string; }; }; responses: { diff --git a/webapp/src/translationTools/useErrorTranslation.ts b/webapp/src/translationTools/useErrorTranslation.ts index 50468fc859..3fc1f72e42 100644 --- a/webapp/src/translationTools/useErrorTranslation.ts +++ b/webapp/src/translationTools/useErrorTranslation.ts @@ -1,4 +1,4 @@ -import {useTranslate} from '@tolgee/react'; +import { useTranslate } from '@tolgee/react'; export function useErrorTranslation() { const { t } = useTranslate(); diff --git a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx index 5b879a0401..4b6c134aa8 100644 --- a/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx +++ b/webapp/src/views/organizations/sso/CreateProviderSsoForm.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import {styled} from '@mui/material'; -import {T, useTranslate} from '@tolgee/react'; -import {StandardForm} from 'tg.component/common/form/StandardForm'; -import {TextField} from 'tg.component/common/form/fields/TextField'; -import {useApiMutation} from 'tg.service/http/useQueryApi'; -import {messageService} from 'tg.service/MessageService'; -import {useOrganization} from 'tg.views/organizations/useOrganization'; -import {Validation} from 'tg.constants/GlobalValidationSchema'; +import { styled } from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; +import { StandardForm } from 'tg.component/common/form/StandardForm'; +import { TextField } from 'tg.component/common/form/fields/TextField'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { messageService } from 'tg.service/MessageService'; +import { useOrganization } from 'tg.views/organizations/useOrganization'; +import { Validation } from 'tg.constants/GlobalValidationSchema'; const StyledInputFields = styled('div')` display: grid; @@ -84,7 +84,6 @@ export function CreateProviderSsoForm({ data, disabled }) { label={} minHeight={false} helperText={} - />
diff --git a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx index 9724e8a948..55c2bbb8ed 100644 --- a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx +++ b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx @@ -10,9 +10,6 @@ import Box from '@mui/material/Box'; import { useEnabledFeatures } from 'tg.globalContext/helpers'; import { PaidFeatureBanner } from 'tg.ee/common/PaidFeatureBanner'; -const StyledContainer = styled('div')` - background: ${({ theme }) => theme.palette.background.paper}; -`; export const OrganizationSsoView: FunctionComponent = () => { const organization = useOrganization(); const { isEnabled } = useEnabledFeatures(); From 62674084c68a74626cdc2e1b0f6f1bd4f9743073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Wed, 6 Nov 2024 17:43:32 +0100 Subject: [PATCH 134/162] fix: configuration handling improvements --- .../configuration/PublicConfigurationDTO.kt | 23 ++++-- .../security/thirdParty/SsoTenantConfig.kt | 2 +- .../tolgee/SsoGlobalProperties.kt | 16 ++-- e2e/cypress/common/apiCalls/common.ts | 8 +- .../controllers/OAuth2CallbackController.kt | 2 - .../kotlin/io/tolgee/ee/data/DomainRequest.kt | 2 +- .../ee/security/thirdParty/SsoDelegateEe.kt | 17 +++- .../io/tolgee/ee/service/sso/TenantService.kt | 7 +- .../security/Login/LoginCredentialsForm.tsx | 34 ++++---- .../component/security/Sso/LoginSsoForm.tsx | 9 +-- webapp/src/component/security/SsoService.tsx | 54 ++++++------- webapp/src/service/apiSchema.generated.ts | 78 ++++++++++--------- 12 files changed, 140 insertions(+), 112 deletions(-) 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 d812dedcd5..0501dab594 100644 --- a/backend/api/src/main/kotlin/io/tolgee/configuration/PublicConfigurationDTO.kt +++ b/backend/api/src/main/kotlin/io/tolgee/configuration/PublicConfigurationDTO.kt @@ -16,9 +16,6 @@ class PublicConfigurationDTO( ) { val authentication: Boolean = properties.authentication.enabled val authMethods: AuthMethodsDTO? = properties.authentication.asAuthMethodsDTO() - - // TODO: check if the sso feature is really enabled (has a license) and show info if not - val globalSsoAuthentication: Boolean = properties.authentication.sso.enabled val passwordResettable: Boolean = properties.authentication.nativeEnabled val allowRegistrations: Boolean = properties.authentication.registrationsAllowed val screenshotsUrl = properties.fileStorageUrl + "/" + FileStoragePath.SCREENSHOTS @@ -33,8 +30,6 @@ class PublicConfigurationDTO( val recaptchaSiteKey = properties.recaptcha.siteKey val chatwootToken = properties.chatwootToken val nativeEnabled = properties.authentication.nativeEnabled - val customLoginLogo = properties.authentication.sso.customLogoUrl - val customLoginText = properties.authentication.sso.customButtonText val capterraTracker = properties.capterraTracker val ga4Tag = properties.ga4Tag val postHogApiKey: String? = properties.postHog.apiKey @@ -64,6 +59,14 @@ class PublicConfigurationDTO( oauth2.authorizationUrl, oauth2.scopes, ), + SsoPublicConfigDTO( + sso.enabled, + sso.globalEnabled, + sso.clientId, + sso.domain, + sso.customLogoUrl, + sso.customLoginText, + ), ) } } @@ -72,6 +75,7 @@ class PublicConfigurationDTO( val github: OAuthPublicConfigDTO, val google: OAuthPublicConfigDTO, val oauth2: OAuthPublicExtendsConfigDTO, + val sso: SsoPublicConfigDTO, ) data class OAuthPublicConfigDTO( @@ -88,6 +92,15 @@ class PublicConfigurationDTO( val enabled: Boolean = !clientId.isNullOrEmpty() } + data class SsoPublicConfigDTO( + val enabled: Boolean, + val globalEnabled: Boolean, + val clientId: String?, + val domain: String?, + val customLogoUrl: String?, + val customLoginText: String?, + ) + data class MtServicesDTO( val defaultPrimaryService: MtServiceType?, val services: Map, 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 index aaa848f476..e56456351c 100644 --- a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/SsoTenantConfig.kt +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/SsoTenantConfig.kt @@ -38,7 +38,7 @@ data class SsoTenantConfig( } fun SsoGlobalProperties.toConfig(): SsoTenantConfig? { - if (!enabled) { + if (!globalEnabled) { return null } 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 index 61a7d033e6..57ee0c936e 100644 --- a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoGlobalProperties.kt +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoGlobalProperties.kt @@ -7,12 +7,19 @@ import org.springframework.boot.context.properties.ConfigurationProperties @DocProperty( description = "Single sign-on (SSO) is an authentication process that allows a user to" + - " access multiple applications with one set of login credentials.", + " access multiple applications with one set of login credentials. To use SSO" + + " in Tolgee, can either configure global SSO settings in this section or" + + " just enable SSO and configure separately for each organization in the" + + " organization settings.", displayName = "Single Sign-On", ) class SsoGlobalProperties { + @DocProperty(description = "Enables SSO authentication") var enabled: Boolean = false + val globalEnabled: Boolean + get() = enabled && !domain.isNullOrEmpty() + @DocProperty(description = "Unique identifier for an application") var clientId: String? = null @@ -33,15 +40,14 @@ class SsoGlobalProperties { @DocProperty( description = - "Custom logo URL to be displayed on the login screen. Can be set only when `nativeEnabled` is `false`" + + "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 (the default logo is sso_login.svg," + " which is stored in the webapp/public directory).", ) var customLogoUrl: String? = null @DocProperty( - description = "Custom text for the login button.", - defaultExplanation = "Defaults to 'SSO Login' if not set.", + description = "Custom text for the SSO login page. Can be set only when `nativeEnabled` is `false`.", ) - var customButtonText: String? = null + var customLoginText: String? = null } diff --git a/e2e/cypress/common/apiCalls/common.ts b/e2e/cypress/common/apiCalls/common.ts index 16aa5a9440..b9fe91f078 100644 --- a/e2e/cypress/common/apiCalls/common.ts +++ b/e2e/cypress/common/apiCalls/common.ts @@ -196,11 +196,11 @@ export const setTranslations = ( }); export const setSsoProvider = () => { - const sql = `insert into ee.tenant (id, organization_id, domain, client_id, client_secret, authorization_uri, - jwk_set_uri, token_uri, redirect_uri_base, is_enabled_for_this_organization, + 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', '${HOST}', true, 'name', 'sso', CURRENT_TIMESTAMP, + 'http://jwkSetUri', 'http://tokenUri', true, 'name', 'sso', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`; internalFetch(`sql/execute`, { method: 'POST', body: sql }); }; @@ -208,7 +208,7 @@ export const setSsoProvider = () => { export const deleteSso = () => { const sql = ` delete - from ee.tenant + from public.tenant where organization_id = 1 `; diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index 13c445552a..6d807323ac 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -9,8 +9,6 @@ import io.tolgee.ee.service.sso.TenantService import io.tolgee.security.thirdParty.SsoTenantConfig import org.springframework.web.bind.annotation.* -// TODO: Move all the logic from this class to PublicController - @RestController @RequestMapping("v2/public/oauth2/callback/") class OAuth2CallbackController( 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 index f488a7492a..506e7dae0d 100644 --- 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 @@ -1,6 +1,6 @@ package io.tolgee.ee.data data class DomainRequest( - val domain: String?, + val domain: String, val state: String, ) 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 index 4bb95586b9..33c8c19c3b 100644 --- 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 @@ -10,6 +10,7 @@ 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.ee.data.GenericUserResponse @@ -17,6 +18,7 @@ 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 @@ -41,6 +43,7 @@ class SsoDelegateEe( private val jwtService: JwtService, private val restTemplate: RestTemplate, private val jwtProcessor: ConfigurableJWTProcessor, + private val ssoGlobalProperties: SsoGlobalProperties, private val tenantService: TenantService, private val oAuthUserHandler: OAuthUserHandler, private val currentDateProvider: CurrentDateProvider, @@ -52,6 +55,11 @@ class SsoDelegateEe( redirectUri: String?, domain: String?, ): JwtAuthenticationResponse { + if (domain == null) { + // TODO: specific message "Missing parameter: domain" + throw BadRequestException("Missing parameter: domain") + } + val tenant = tenantService.getEnabledConfigByDomain(domain) enabledFeaturesProvider.checkFeatureEnabled( organizationId = tenant.organization?.id, @@ -175,15 +183,18 @@ class SsoDelegateEe( return true } - if (ssoDomain == null || refreshToken == null) { + var domain = ssoDomain + if (domain == null) { + domain = ssoGlobalProperties.domain + } + if (domain == null || refreshToken == null) { throw AuthenticationException(Message.SSO_CANT_VERIFY_USER) } - if (ssoSessionExpiry != null && isSsoUserValid(ssoSessionExpiry)) { return true } - val tenant = tenantService.getEnabledConfigByDomain(ssoDomain) + val tenant = tenantService.getEnabledConfigByDomain(domain) enabledFeaturesProvider.checkFeatureEnabled( organizationId = tenant.organization?.id, Feature.SSO, 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 index a2169b86bb..a0a96bd5c2 100644 --- 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 @@ -23,12 +23,11 @@ class TenantService( return tenantRepository.findByDomain(domain) ?: throw NotFoundException() } - fun getEnabledConfigByDomain(domain: String?): SsoTenantConfig { + fun getEnabledConfigByDomain(domain: String): SsoTenantConfig { return ssoGlobalProperties - .takeIf { it.enabled } - ?.takeIf { domain == null || domain == "" || domain == it.domain } + .takeIf { it.globalEnabled && domain == it.domain } ?.toConfig() - ?: domain?.let { tenantRepository.findEnabledByDomain(it)?.toConfig() } + ?: domain.let { tenantRepository.findEnabledByDomain(it)?.toConfig() } ?: throw NotFoundException(Message.SSO_DOMAIN_NOT_FOUND_OR_DISABLED) } diff --git a/webapp/src/component/security/Login/LoginCredentialsForm.tsx b/webapp/src/component/security/Login/LoginCredentialsForm.tsx index 201609dd5d..125fdd0d88 100644 --- a/webapp/src/component/security/Login/LoginCredentialsForm.tsx +++ b/webapp/src/component/security/Login/LoginCredentialsForm.tsx @@ -4,7 +4,6 @@ import Box from '@mui/material/Box'; import { T } from '@tolgee/react'; import { Link } from 'react-router-dom'; import LoginIcon from '@mui/icons-material/Login'; -import { v4 as uuidv4 } from 'uuid'; import { LINKS } from 'tg.constants/links'; import { useConfig } from 'tg.globalContext/helpers'; @@ -32,16 +31,14 @@ type LoginViewCredentialsProps = { onMfaEnabled: () => void; }; -const LOCAL_STORAGE_STATE_KEY = 'oauth2State'; - export function LoginCredentialsForm(props: LoginViewCredentialsProps) { const remoteConfig = useConfig(); const { login } = useGlobalActions(); const isLoading = useGlobalContext((c) => c.auth.loginLoadable.isLoading); const oAuthServices = useOAuthServices(); - - const { getSsoAuthLinkByDomain } = useSsoService(); + // TODO: do I need to do something to monitor the state of the redirectLoadable? + const { handleRedirect, redirectLoadable } = useSsoService(); return ( @@ -94,8 +91,8 @@ export function LoginCredentialsForm(props: LoginViewCredentialsProps) { style={{ marginBottom: '0.5rem' }} color="inherit" > - {remoteConfig.customLoginText ? ( - {remoteConfig.customLoginText} + {remoteConfig.authMethods?.sso.customLoginText ? ( + {remoteConfig.authMethods?.sso.customLoginText} ) : ( )} @@ -103,13 +100,14 @@ export function LoginCredentialsForm(props: LoginViewCredentialsProps) { )} { + (oAuthServices.length > 0 || remoteConfig.authMethods?.sso.globalEnabled || remoteConfig.authMethods?.sso.enabled) && /*TODO: only show when there is SSO or oauth*/ + /> } {remoteConfig.nativeEnabled && - !remoteConfig.globalSsoAuthentication && ( + !remoteConfig.authMethods?.sso.globalEnabled && ( + )} {oAuthServices.map((provider) => ( diff --git a/webapp/src/component/security/Sso/LoginSsoForm.tsx b/webapp/src/component/security/Sso/LoginSsoForm.tsx index 7eea1d3637..8b65f565b9 100644 --- a/webapp/src/component/security/Sso/LoginSsoForm.tsx +++ b/webapp/src/component/security/Sso/LoginSsoForm.tsx @@ -9,7 +9,6 @@ import LoadingButton from 'tg.component/common/form/LoadingButton'; import { StandardForm } from 'tg.component/common/form/StandardForm'; import { TextField } from 'tg.component/common/form/fields/TextField'; import { useGlobalContext } from 'tg.globalContext/GlobalContext'; -import { v4 as uuidv4 } from 'uuid'; import { useSsoService } from 'tg.component/security/SsoService'; const StyledInputFields = styled('div')` @@ -23,10 +22,9 @@ type Credentials = { domain: string }; type LoginViewCredentialsProps = { credentialsRef: RefObject; }; -const LOCAL_STORAGE_STATE_KEY = 'oauth2State'; export function LoginSsoForm(props: LoginViewCredentialsProps) { - const { getSsoAuthLinkByDomain } = useSsoService(); + const { handleRedirect } = useSsoService(); const isLoading = useGlobalContext((c) => c.auth.loginLoadable.isLoading); return ( @@ -56,10 +54,7 @@ export function LoginSsoForm(props: LoginViewCredentialsProps) { } onSubmit={async (data) => { - const state = uuidv4(); - localStorage.setItem(LOCAL_STORAGE_STATE_KEY, state); - const response = await getSsoAuthLinkByDomain(data.domain, state); - window.location.href = response.redirectUrl; + await handleRedirect(data.domain); }} > diff --git a/webapp/src/component/security/SsoService.tsx b/webapp/src/component/security/SsoService.tsx index ea78c09139..494098ca4d 100644 --- a/webapp/src/component/security/SsoService.tsx +++ b/webapp/src/component/security/SsoService.tsx @@ -1,3 +1,4 @@ +import { v4 as uuidv4 } from 'uuid'; import { LINKS } from 'tg.constants/links'; import { messageService } from 'tg.service/MessageService'; import { TranslatedError } from 'tg.translationTools/TranslatedError'; @@ -6,6 +7,7 @@ import { useApiMutation } from 'tg.service/http/useQueryApi'; import { useLocalStorageState } from 'tg.hooks/useLocalStorageState'; import { INVITATION_CODE_STORAGE_KEY } from 'tg.service/InvitationCodeService'; +const LOCAL_STORAGE_STATE_KEY = 'oauth2State'; const LOCAL_STORAGE_DOMAIN_KEY = 'oauth2Domain'; export const useSsoService = () => { @@ -18,10 +20,6 @@ export const useSsoService = () => { key: INVITATION_CODE_STORAGE_KEY, }); - // const authorizeOpenIdLoadable = useApiMutation({ - // url: '/v2/public/oauth2/callback/{registrationId}', - // method: 'get', - // }); const authorizeOAuthLoadable = useApiMutation({ url: '/api/public/authorize_oauth/{serviceType}', method: 'get', @@ -32,6 +30,24 @@ export const useSsoService = () => { method: 'post', }); + const getSsoAuthLinkByDomain = async (domain: string, state: string) => { + return await openIdAuthUrlLoadable.mutateAsync( + { + content: { 'application/json': { domain, state } }, + }, + { + onError: (error) => { + messageService.error(); + }, + onSuccess: (response) => { + if (response.redirectUrl) { + localStorage.setItem(LOCAL_STORAGE_DOMAIN_KEY, domain || ''); + } + }, + } + ); + }; + return { async loginWithOAuthCodeOpenId(registrationId: string, code: string) { const redirectUri = LINKS.OPENID_RESPONSE.buildWithOrigin({}); @@ -50,12 +66,7 @@ export const useSsoService = () => { if (error.code === 'invitation_code_does_not_exist_or_expired') { setInvitationCode(undefined); } - // TODO: this handling should no longer be necessary - code should be fine - let errorCode = error.code; - if (errorCode && errorCode.endsWith(': null')) { - errorCode = errorCode.replace(': null', ''); - } - messageService.error(); + messageService.error(); }, } ); @@ -63,22 +74,13 @@ export const useSsoService = () => { await handleAfterLogin(response!); }, - async getSsoAuthLinkByDomain(domain: string | null, state: string) { - return await openIdAuthUrlLoadable.mutateAsync( - { - content: { 'application/json': { domain, state } }, - }, - { - onError: (error) => { - messageService.error(); - }, - onSuccess: (response) => { - if (response.redirectUrl) { - localStorage.setItem(LOCAL_STORAGE_DOMAIN_KEY, domain || ''); - } - }, - } - ); + async handleRedirect(domain: string) { + const state = uuidv4(); + localStorage.setItem(LOCAL_STORAGE_STATE_KEY, state); + const response = await getSsoAuthLinkByDomain(domain, state); + window.location.href = response.redirectUrl; }, + + redirectLoadable: openIdAuthUrlLoadable, }; }; diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 021496e973..cefb2dcb58 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -1159,24 +1159,6 @@ export interface components { | "SERVER_ADMIN"; /** @description The user's permission type. This field is null if uses granular permissions */ type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; - /** - * @deprecated - * @description Deprecated (use translateLanguageIds). - * - * List of languages current user has TRANSLATE permission to. If null, all languages edition is permitted. - * @example 200001,200004 - */ - permittedLanguageIds?: number[]; - /** - * @description List of languages user can translate to. If null, all languages editing is permitted. - * @example 200001,200004 - */ - translateLanguageIds?: number[]; - /** - * @description List of languages user can change state to. If null, changing state of all language values is permitted. - * @example 200001,200004 - */ - stateChangeLanguageIds?: number[]; /** * @description List of languages user can view. If null, all languages view is permitted. * @example 200001,200004 @@ -1214,6 +1196,24 @@ export interface components { | "content-delivery.publish" | "webhooks.manage" )[]; + /** + * @description List of languages user can translate to. If null, all languages editing is permitted. + * @example 200001,200004 + */ + translateLanguageIds?: number[]; + /** + * @description List of languages user can change state to. If null, changing state of all language values is permitted. + * @example 200001,200004 + */ + stateChangeLanguageIds?: number[]; + /** + * @deprecated + * @description Deprecated (use translateLanguageIds). + * + * List of languages current user has TRANSLATE permission to. If null, all languages edition is permitted. + * @example 200001,200004 + */ + permittedLanguageIds?: number[]; }; LanguageModel: { /** Format: int64 */ @@ -2015,12 +2015,12 @@ export interface components { createNewKeys: boolean; }; ImportSettingsModel: { + /** @description If false, only updates keys, skipping the creation of new keys */ + createNewKeys: boolean; /** @description If true, placeholders from other formats will be converted to ICU when possible */ convertPlaceholdersToIcu: boolean; /** @description If true, key descriptions will be overridden by the import */ overrideKeyDescriptions: boolean; - /** @description If false, only updates keys, skipping the creation of new keys */ - createNewKeys: boolean; }; /** @description User who created the comment */ SimpleUserAccountModel: { @@ -2188,11 +2188,11 @@ export interface components { token: string; /** Format: int64 */ id: number; + description: string; /** Format: int64 */ createdAt: number; /** Format: int64 */ updatedAt: number; - description: string; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ @@ -2355,16 +2355,16 @@ export interface components { /** Format: int64 */ id: number; userFullName?: string; - projectName: string; username?: string; description: string; + scopes: string[]; + /** Format: int64 */ + projectId: number; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; - /** Format: int64 */ - projectId: number; - scopes: string[]; + projectName: string; }; SuperTokenRequest: { /** @description Has to be provided when TOTP enabled */ @@ -2432,7 +2432,7 @@ export interface components { team_domain: string; }; DomainRequest: { - domain?: string; + domain: string; state: string; }; SsoUrlResponse: { @@ -3491,6 +3491,7 @@ export interface components { github: components["schemas"]["OAuthPublicConfigDTO"]; google: components["schemas"]["OAuthPublicConfigDTO"]; oauth2: components["schemas"]["OAuthPublicExtendsConfigDTO"]; + sso: components["schemas"]["SsoPublicConfigDTO"]; }; InitialDataModel: { serverConfiguration: components["schemas"]["PublicConfigurationDTO"]; @@ -3550,18 +3551,18 @@ export interface components { name: string; /** Format: int64 */ id: number; - basePermissions: components["schemas"]["PermissionModel"]; /** * @description The role of currently authorized user. * * Can be null when user has direct access to one of the projects owned by the organization. */ currentUserRole?: "MEMBER" | "OWNER"; + basePermissions: components["schemas"]["PermissionModel"]; /** @example This is a beautiful organization full of beautiful and clever people */ description?: string; - avatar?: components["schemas"]["Avatar"]; /** @example btforg */ slug: string; + avatar?: components["schemas"]["Avatar"]; }; PublicBillingConfigurationDTO: { enabled: boolean; @@ -3572,7 +3573,6 @@ export interface components { version: string; authentication: boolean; authMethods?: components["schemas"]["AuthMethodsDTO"]; - globalSsoAuthentication: boolean; passwordResettable: boolean; allowRegistrations: boolean; screenshotsUrl: string; @@ -3589,8 +3589,6 @@ export interface components { recaptchaSiteKey?: string; chatwootToken?: string; nativeEnabled: boolean; - customLoginLogo?: string; - customLoginText?: string; capterraTracker?: string; ga4Tag?: string; postHogApiKey?: string; @@ -3603,6 +3601,14 @@ export interface components { enabled: boolean; connected: boolean; }; + SsoPublicConfigDTO: { + enabled: boolean; + globalEnabled: boolean; + clientId?: string; + domain?: string; + customLogoUrl?: string; + customLoginText?: string; + }; CollectionModelExportFormatModel: { _embedded?: { exportFormats?: components["schemas"]["ExportFormatModel"][]; @@ -4262,11 +4268,11 @@ export interface components { user: components["schemas"]["SimpleUserAccountModel"]; /** Format: int64 */ id: number; + description: string; /** Format: int64 */ createdAt: number; /** Format: int64 */ updatedAt: number; - description: string; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ @@ -4400,16 +4406,16 @@ export interface components { /** Format: int64 */ id: number; userFullName?: string; - projectName: string; username?: string; description: string; + scopes: string[]; + /** Format: int64 */ + projectId: number; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; - /** Format: int64 */ - projectId: number; - scopes: string[]; + projectName: string; }; PagedModelUserAccountModel: { _embedded?: { From 5f02cc108c929135755df9456c5d30a2a29b67e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Wed, 6 Nov 2024 17:44:43 +0100 Subject: [PATCH 135/162] fix: test build --- ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 48df2e1f3f..1ee02e4612 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -229,8 +229,8 @@ class OAuthTest : AuthorizedControllerTest() { ssoGlobalProperties.domain = "registrationId" ssoGlobalProperties.clientId = "clientId" ssoGlobalProperties.clientSecret = "clientSecret" - ssoGlobalProperties.authorizationUrl = "authorizationUri" - ssoGlobalProperties.tokenUrl = "http://tokenUri" + ssoGlobalProperties.authorizationUri = "authorizationUri" + ssoGlobalProperties.tokenUri = "http://tokenUri" ssoGlobalProperties.jwkSetUri = "http://jwkSetUri" val response = oAuthMultiTenantsMocks.authorize("registrationId") From ea07d4f522583fcd7594c59a7432a39fd4071fc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Thu, 7 Nov 2024 11:14:21 +0100 Subject: [PATCH 136/162] fix: frontend lint --- .../security/Login/LoginCredentialsForm.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/webapp/src/component/security/Login/LoginCredentialsForm.tsx b/webapp/src/component/security/Login/LoginCredentialsForm.tsx index 125fdd0d88..3009341361 100644 --- a/webapp/src/component/security/Login/LoginCredentialsForm.tsx +++ b/webapp/src/component/security/Login/LoginCredentialsForm.tsx @@ -99,13 +99,11 @@ export function LoginCredentialsForm(props: LoginViewCredentialsProps) { )} - { - (oAuthServices.length > 0 || remoteConfig.authMethods?.sso.globalEnabled || remoteConfig.authMethods?.sso.enabled) && - - } + {(oAuthServices.length > 0 || + remoteConfig.authMethods?.sso.globalEnabled || + remoteConfig.authMethods?.sso.enabled) && ( + + )} {remoteConfig.nativeEnabled && !remoteConfig.authMethods?.sso.globalEnabled && ( + + + + + + + + + + )} - {(oAuthServices.length > 0 || - remoteConfig.authMethods?.sso.globalEnabled || - remoteConfig.authMethods?.sso.enabled) && ( + {nativeEnabled && hasNonNativeAuthMethods && ( )} - {remoteConfig.nativeEnabled && - !remoteConfig.authMethods?.sso.globalEnabled && ( - - )} - {remoteConfig.nativeEnabled && - remoteConfig.authMethods?.sso.globalEnabled && ( - } - variant="outlined" - style={{ marginBottom: '0.5rem', marginTop: '1rem' }} - color="inherit" - onClick={async (data) => { - await handleRedirect( - remoteConfig.authMethods?.sso.domain as string - ); - }} - > - - - )} + + {ssoEnabled && ( + + {!globalSsoEnabled && ( + + )} + {globalSsoEnabled && ( + + {loginText} + + )} + + )} {oAuthServices.map((provider) => ( @@ -167,7 +175,7 @@ export function LoginCredentialsForm(props: LoginViewCredentialsProps) { } }} > - {remoteConfig.nativeEnabled && ( + {nativeEnabled && ( c.auth.loginLoadable.isLoading); return ( @@ -54,7 +54,7 @@ export function LoginSsoForm(props: LoginViewCredentialsProps) { } onSubmit={async (data) => { - await handleRedirect(data.domain); + await loginRedirect(data.domain); }} > diff --git a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx index 5a13adf1a5..03c8fd193e 100644 --- a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx +++ b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx @@ -1,39 +1,25 @@ import { FunctionComponent, useEffect } from 'react'; -import { useHistory } from 'react-router-dom'; - -import { LINKS } from 'tg.constants/links'; import { useGlobalContext } from 'tg.globalContext/GlobalContext'; import { FullPageLoading } from 'tg.component/common/FullPageLoading'; import { useSsoService } from 'tg.component/security/SsoService'; interface SsoRedirectionHandlerProps {} -const LOCAL_STORAGE_STATE_KEY = 'oauth2State'; -const LOCAL_STORAGE_DOMAIN_KEY = 'oauth2Domain'; export const SsoRedirectionHandler: FunctionComponent< SsoRedirectionHandlerProps > = () => { const allowPrivate = useGlobalContext((c) => c.auth.allowPrivate); - const { loginWithOAuthCodeOpenId } = useSsoService(); - const history = useHistory(); + const { login } = useSsoService(); useEffect(() => { const searchParam = new URLSearchParams(window.location.search); const code = searchParam.get('code'); const state = searchParam.get('state'); - const storedState = localStorage.getItem(LOCAL_STORAGE_STATE_KEY); - const storedDomain = localStorage.getItem(LOCAL_STORAGE_DOMAIN_KEY); - if (storedState !== state) { - history.replace(LINKS.LOGIN.build()); - } else { - localStorage.removeItem(LOCAL_STORAGE_STATE_KEY); - } - - if (code && !allowPrivate && storedDomain !== null) { - loginWithOAuthCodeOpenId(storedDomain, code); + if (code && state && !allowPrivate) { + login(state, code); } }, [allowPrivate]); diff --git a/webapp/src/component/security/SsoService.tsx b/webapp/src/component/security/SsoService.tsx index 494098ca4d..53a4dbe34b 100644 --- a/webapp/src/component/security/SsoService.tsx +++ b/webapp/src/component/security/SsoService.tsx @@ -1,4 +1,5 @@ import { v4 as uuidv4 } from 'uuid'; +import { useHistory } from 'react-router-dom'; import { LINKS } from 'tg.constants/links'; import { messageService } from 'tg.service/MessageService'; import { TranslatedError } from 'tg.translationTools/TranslatedError'; @@ -20,6 +21,8 @@ export const useSsoService = () => { key: INVITATION_CODE_STORAGE_KEY, }); + const history = useHistory(); + const authorizeOAuthLoadable = useApiMutation({ url: '/api/public/authorize_oauth/{serviceType}', method: 'get', @@ -49,7 +52,16 @@ export const useSsoService = () => { }; return { - async loginWithOAuthCodeOpenId(registrationId: string, code: string) { + async login(state: string, code: string) { + const storedState = localStorage.getItem(LOCAL_STORAGE_STATE_KEY); + const storedDomain = localStorage.getItem(LOCAL_STORAGE_DOMAIN_KEY); + if (storedState !== state || storedDomain === null) { + history.replace(LINKS.LOGIN.build()); + return; + } + + localStorage.removeItem(LOCAL_STORAGE_STATE_KEY); + const redirectUri = LINKS.OPENID_RESPONSE.buildWithOrigin({}); const response = await authorizeOAuthLoadable.mutateAsync( { @@ -58,7 +70,7 @@ export const useSsoService = () => { code, redirect_uri: redirectUri, invitationCode: invitationCode, - domain: registrationId, + domain: storedDomain, }, }, { @@ -74,7 +86,7 @@ export const useSsoService = () => { await handleAfterLogin(response!); }, - async handleRedirect(domain: string) { + async loginRedirect(domain: string) { const state = uuidv4(); localStorage.setItem(LOCAL_STORAGE_STATE_KEY, state); const response = await getSsoAuthLinkByDomain(domain, state); diff --git a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx index 55c2bbb8ed..20ffba8fbd 100644 --- a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx +++ b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx @@ -5,7 +5,7 @@ import { LINKS, PARAMS } from 'tg.constants/links'; import { useOrganization } from '../useOrganization'; import { CreateProviderSsoForm } from 'tg.views/organizations/sso/CreateProviderSsoForm'; import { useApiQuery } from 'tg.service/http/useQueryApi'; -import { FormControlLabel, styled, Switch } from '@mui/material'; +import { FormControlLabel, Switch } from '@mui/material'; import Box from '@mui/material/Box'; import { useEnabledFeatures } from 'tg.globalContext/helpers'; import { PaidFeatureBanner } from 'tg.ee/common/PaidFeatureBanner'; From 6d8dd2c3ead6678c458779e37b79489102116ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Thu, 7 Nov 2024 14:32:19 +0100 Subject: [PATCH 139/162] fix: revert unwanted change --- .../io/tolgee/ee/component/PublicEnabledFeaturesProvider.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/PublicEnabledFeaturesProvider.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/PublicEnabledFeaturesProvider.kt index 0892044a8e..14334bf192 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/PublicEnabledFeaturesProvider.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/component/PublicEnabledFeaturesProvider.kt @@ -14,7 +14,7 @@ import org.springframework.stereotype.Component class PublicEnabledFeaturesProvider( private val eeSubscriptionService: EeSubscriptionServiceImpl, ) : EnabledFeaturesProvider { - var forceEnabled: Set? = setOf(Feature.SSO) + var forceEnabled: Set? = null override fun get(organizationId: Long?): Array = forceEnabled?.toTypedArray() ?: eeSubscriptionService.findSubscriptionEntity()?.enabledFeatures ?: emptyArray() From e72767022eae88f37f94651450bd08d39da9247b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Thu, 7 Nov 2024 15:17:49 +0100 Subject: [PATCH 140/162] fix: merge sso service and auth service on FE side + use proper loadables --- e2e/cypress/common/apiCalls/common.ts | 2 +- webapp/public/sso_login.svg | 1 - .../security/Login/LoginCredentialsForm.tsx | 17 ++-- .../component/security/Login/LoginView.tsx | 11 +-- .../component/security/Sso/LoginSsoForm.tsx | 14 ++- .../component/security/Sso/SsoLoginView.tsx | 6 +- .../security/Sso/SsoRedirectionHandler.tsx | 11 ++- webapp/src/component/security/SsoService.tsx | 98 ------------------- webapp/src/globalContext/useAuthService.tsx | 68 +++++++++++++ 9 files changed, 101 insertions(+), 127 deletions(-) delete mode 100644 webapp/public/sso_login.svg delete mode 100644 webapp/src/component/security/SsoService.tsx diff --git a/e2e/cypress/common/apiCalls/common.ts b/e2e/cypress/common/apiCalls/common.ts index be03d8e96f..a11a2343b1 100644 --- a/e2e/cypress/common/apiCalls/common.ts +++ b/e2e/cypress/common/apiCalls/common.ts @@ -1,4 +1,4 @@ -import { API_URL, HOST, PASSWORD, USERNAME } from '../constants'; +import { API_URL, PASSWORD, USERNAME } from '../constants'; import { ArgumentTypes, Scope } from '../types'; import { components } from '../../../../webapp/src/service/apiSchema.generated'; import bcrypt = require('bcryptjs'); diff --git a/webapp/public/sso_login.svg b/webapp/public/sso_login.svg deleted file mode 100644 index ce351e096e..0000000000 --- a/webapp/public/sso_login.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/webapp/src/component/security/Login/LoginCredentialsForm.tsx b/webapp/src/component/security/Login/LoginCredentialsForm.tsx index 786cd3d600..e8c3dd41cf 100644 --- a/webapp/src/component/security/Login/LoginCredentialsForm.tsx +++ b/webapp/src/component/security/Login/LoginCredentialsForm.tsx @@ -3,7 +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 as LoginIcon } from '@untitled-ui/icons-react'; +import { LogIn01 } from '@untitled-ui/icons-react'; import { LINKS } from 'tg.constants/links'; import { useConfig } from 'tg.globalContext/helpers'; @@ -16,7 +16,6 @@ import { useGlobalContext, } from 'tg.globalContext/GlobalContext'; import { ApiError } from 'tg.service/http/ApiError'; -import { useSsoService } from 'tg.component/security/SsoService'; const StyledInputFields = styled('div')` display: grid; @@ -33,11 +32,13 @@ 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 { loginRedirect, redirectLoadable } = useSsoService(); const nativeEnabled = remoteConfig.nativeEnabled; const ssoEnabled = remoteConfig.authMethods?.sso.enabled ?? false; @@ -55,7 +56,7 @@ export function LoginCredentialsForm(props: LoginViewCredentialsProps) { style={{ width: 24, height: 24 }} /> ) : ( - + ); const customLoginText = remoteConfig.authMethods?.sso.customLoginText; const loginText = customLoginText ? ( @@ -65,7 +66,7 @@ export function LoginCredentialsForm(props: LoginViewCredentialsProps) { ); function globalSsoLogin() { - loginRedirect(remoteConfig.authMethods?.sso.domain as string); + loginRedirectSso(remoteConfig.authMethods?.sso.domain as string); } return ( @@ -131,7 +132,7 @@ export function LoginCredentialsForm(props: LoginViewCredentialsProps) { )} {globalSsoEnabled && ( { const registrationAllowed = useGlobalContext( (c) => - c.initialData.serverConfiguration.allowRegistrations || - c.auth.allowRegistration - ); - - const nativeEnabled = useGlobalContext( - (c) => c.initialData.serverConfiguration.nativeEnabled + (c.initialData.serverConfiguration.allowRegistrations || + c.auth.allowRegistration) && + c.initialData.serverConfiguration.nativeEnabled ); useReportOnce('LOGIN_PAGE_OPENED'); @@ -58,7 +55,7 @@ export const LoginView: FunctionComponent = () => { windowTitle={t('login_title')} title={t('login_title')} subtitle={ - registrationAllowed && nativeEnabled ? ( + registrationAllowed ? ( c.auth.loginLoadable.isLoading); + const { loginRedirectSso } = useGlobalActions(); + const isLoading = useGlobalContext( + (c) => c.auth.redirectSsoUrlLoadable.isLoading + ); return ( } onSubmit={async (data) => { - await loginRedirect(data.domain); + await loginRedirectSso(data.domain); }} > diff --git a/webapp/src/component/security/Sso/SsoLoginView.tsx b/webapp/src/component/security/Sso/SsoLoginView.tsx index e418125ea5..3d89538eac 100644 --- a/webapp/src/component/security/Sso/SsoLoginView.tsx +++ b/webapp/src/component/security/Sso/SsoLoginView.tsx @@ -16,8 +16,10 @@ export const SsoLoginView: FunctionComponent = () => { const { t } = useTranslate(); const credentialsRef = useRef({ domain: '' }); - const error = useGlobalContext((c) => c.auth.loginLoadable.error); - const isLoading = useGlobalContext((c) => c.auth.loginLoadable.isLoading); + const error = useGlobalContext((c) => c.auth.authorizeOAuthLoadable.error); + const isLoading = useGlobalContext( + (c) => c.auth.authorizeOAuthLoadable.isLoading + ); const isSmall = useMediaQuery(SPLIT_CONTENT_BREAK_POINT); diff --git a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx index 03c8fd193e..013a5f565c 100644 --- a/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx +++ b/webapp/src/component/security/Sso/SsoRedirectionHandler.tsx @@ -1,8 +1,10 @@ import { FunctionComponent, useEffect } from 'react'; -import { useGlobalContext } from 'tg.globalContext/GlobalContext'; +import { + useGlobalActions, + useGlobalContext, +} from 'tg.globalContext/GlobalContext'; import { FullPageLoading } from 'tg.component/common/FullPageLoading'; -import { useSsoService } from 'tg.component/security/SsoService'; interface SsoRedirectionHandlerProps {} @@ -10,8 +12,7 @@ export const SsoRedirectionHandler: FunctionComponent< SsoRedirectionHandlerProps > = () => { const allowPrivate = useGlobalContext((c) => c.auth.allowPrivate); - - const { login } = useSsoService(); + const { loginWithOAuthCodeSso } = useGlobalActions(); useEffect(() => { const searchParam = new URLSearchParams(window.location.search); @@ -19,7 +20,7 @@ export const SsoRedirectionHandler: FunctionComponent< const state = searchParam.get('state'); if (code && state && !allowPrivate) { - login(state, code); + loginWithOAuthCodeSso(state, code); } }, [allowPrivate]); diff --git a/webapp/src/component/security/SsoService.tsx b/webapp/src/component/security/SsoService.tsx deleted file mode 100644 index 53a4dbe34b..0000000000 --- a/webapp/src/component/security/SsoService.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { v4 as uuidv4 } from 'uuid'; -import { useHistory } from 'react-router-dom'; -import { LINKS } from 'tg.constants/links'; -import { messageService } from 'tg.service/MessageService'; -import { TranslatedError } from 'tg.translationTools/TranslatedError'; -import { useGlobalActions } from 'tg.globalContext/GlobalContext'; -import { useApiMutation } from 'tg.service/http/useQueryApi'; -import { useLocalStorageState } from 'tg.hooks/useLocalStorageState'; -import { INVITATION_CODE_STORAGE_KEY } from 'tg.service/InvitationCodeService'; - -const LOCAL_STORAGE_STATE_KEY = 'oauth2State'; -const LOCAL_STORAGE_DOMAIN_KEY = 'oauth2Domain'; - -export const useSsoService = () => { - const { handleAfterLogin, setInvitationCode } = useGlobalActions(); - - const [invitationCode, _setInvitationCode] = useLocalStorageState< - string | undefined - >({ - initial: undefined, - key: INVITATION_CODE_STORAGE_KEY, - }); - - const history = useHistory(); - - const authorizeOAuthLoadable = useApiMutation({ - url: '/api/public/authorize_oauth/{serviceType}', - method: 'get', - }); - - const openIdAuthUrlLoadable = useApiMutation({ - url: '/v2/public/oauth2/callback/get-authentication-url', - method: 'post', - }); - - const getSsoAuthLinkByDomain = async (domain: string, state: string) => { - return await openIdAuthUrlLoadable.mutateAsync( - { - content: { 'application/json': { domain, state } }, - }, - { - onError: (error) => { - messageService.error(); - }, - onSuccess: (response) => { - if (response.redirectUrl) { - localStorage.setItem(LOCAL_STORAGE_DOMAIN_KEY, domain || ''); - } - }, - } - ); - }; - - return { - async login(state: string, code: string) { - const storedState = localStorage.getItem(LOCAL_STORAGE_STATE_KEY); - const storedDomain = localStorage.getItem(LOCAL_STORAGE_DOMAIN_KEY); - if (storedState !== state || storedDomain === null) { - history.replace(LINKS.LOGIN.build()); - return; - } - - localStorage.removeItem(LOCAL_STORAGE_STATE_KEY); - - const redirectUri = LINKS.OPENID_RESPONSE.buildWithOrigin({}); - const response = await authorizeOAuthLoadable.mutateAsync( - { - path: { serviceType: 'sso' }, - query: { - code, - redirect_uri: redirectUri, - invitationCode: invitationCode, - domain: storedDomain, - }, - }, - { - onError: (error) => { - if (error.code === 'invitation_code_does_not_exist_or_expired') { - setInvitationCode(undefined); - } - messageService.error(); - }, - } - ); - localStorage.removeItem(LOCAL_STORAGE_DOMAIN_KEY); - await handleAfterLogin(response!); - }, - - async loginRedirect(domain: string) { - const state = uuidv4(); - localStorage.setItem(LOCAL_STORAGE_STATE_KEY, state); - const response = await getSsoAuthLinkByDomain(domain, state); - window.location.href = response.redirectUrl; - }, - - redirectLoadable: openIdAuthUrlLoadable, - }; -}; diff --git a/webapp/src/globalContext/useAuthService.tsx b/webapp/src/globalContext/useAuthService.tsx index 4956850053..3b4b746f1c 100644 --- a/webapp/src/globalContext/useAuthService.tsx +++ b/webapp/src/globalContext/useAuthService.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { T } from '@tolgee/react'; import { useHistory } from 'react-router-dom'; +import { v4 as uuidv4 } from 'uuid'; import { securityService } from 'tg.service/SecurityService'; import { @@ -25,6 +26,9 @@ type JwtAuthenticationResponse = type SignUpDto = components['schemas']['SignUpDto']; type SuperTokenAction = { onCancel: () => void; onSuccess: () => void }; +const LOCAL_STORAGE_STATE_KEY = 'oauth2State'; +const LOCAL_STORAGE_DOMAIN_KEY = 'oauth2Domain'; + export function getRedirectUrl(userId?: number) { const link = securityService.getAfterLoginLink(); if (link?.url && (link.userId === undefined || link.userId === userId)) { @@ -54,6 +58,11 @@ export const useAuthService = ( method: 'get', }); + const redirectSsoUrlLoadable = useApiMutation({ + url: '/v2/public/oauth2/callback/get-authentication-url', + method: 'post', + }); + const acceptInvitationLoadable = useApiMutation({ url: '/v2/invitations/{code}/accept', method: 'get', @@ -102,6 +111,24 @@ export const useAuthService = ( const history = useHistory(); + async function getSsoAuthLinkByDomain(domain: string, state: string) { + return await redirectSsoUrlLoadable.mutateAsync( + { + content: { 'application/json': { domain, state } }, + }, + { + onError: (error) => { + messageService.error(); + }, + onSuccess: (response) => { + if (response.redirectUrl) { + localStorage.setItem(LOCAL_STORAGE_DOMAIN_KEY, domain || ''); + } + }, + } + ); + } + async function setJwtToken(token: string | undefined) { _setJwtToken(token); if (token) { @@ -150,6 +177,7 @@ export const useAuthService = ( loginLoadable, signupLoadable, authorizeOAuthLoadable, + redirectSsoUrlLoadable, allowRegistration, }; @@ -186,6 +214,46 @@ export const useAuthService = ( setInvitationCode(undefined); await handleAfterLogin(response!); }, + async loginWithOAuthCodeSso(state: string, code: string) { + const storedState = localStorage.getItem(LOCAL_STORAGE_STATE_KEY); + const storedDomain = localStorage.getItem(LOCAL_STORAGE_DOMAIN_KEY); + if (storedState !== state || storedDomain === null) { + history.replace(LINKS.LOGIN.build()); + return; + } + + localStorage.removeItem(LOCAL_STORAGE_STATE_KEY); + + const redirectUri = LINKS.OPENID_RESPONSE.buildWithOrigin({}); + const response = await authorizeOAuthLoadable.mutateAsync( + { + path: { serviceType: 'sso' }, + query: { + code, + redirect_uri: redirectUri, + invitationCode: invitationCode, + domain: storedDomain, + }, + }, + { + onError: (error) => { + if (error.code === 'invitation_code_does_not_exist_or_expired') { + setInvitationCode(undefined); + } + messageService.error(); + }, + } + ); + setInvitationCode(undefined); + localStorage.removeItem(LOCAL_STORAGE_DOMAIN_KEY); + await handleAfterLogin(response!); + }, + async loginRedirectSso(domain: string) { + const state = uuidv4(); + localStorage.setItem(LOCAL_STORAGE_STATE_KEY, state); + const response = await getSsoAuthLinkByDomain(domain, state); + window.location.href = response.redirectUrl; + }, async signUp(data: Omit) { signupLoadable.mutate( { From b738129928b2db9ce0062ac869e3fd3ef450b37a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Thu, 7 Nov 2024 17:05:22 +0100 Subject: [PATCH 141/162] fix: small fixes and notes --- .../configuration/PublicConfigurationDTO.kt | 4 +- .../tolgee/SsoGlobalProperties.kt | 12 +- .../main/kotlin/io/tolgee/constants/Caches.kt | 2 - .../kotlin/io/tolgee/constants/Message.kt | 1 + .../kotlin/io/tolgee/model/UserAccount.kt | 1 + .../controllers/OAuth2CallbackController.kt | 1 + .../main/resources/db/changelog/ee-schema.xml | 34 - .../tolgee/ee/utils/OAuthMultiTenantsMocks.kt | 2 +- webapp/src/service/apiSchema.generated.ts | 1895 ++++++++++++++--- .../translationTools/useErrorTranslation.ts | 1 + .../organizations/sso/OrganizationSsoView.tsx | 2 + 11 files changed, 1618 insertions(+), 337 deletions(-) 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 f723f5a352..a000ba2e20 100644 --- a/backend/api/src/main/kotlin/io/tolgee/configuration/PublicConfigurationDTO.kt +++ b/backend/api/src/main/kotlin/io/tolgee/configuration/PublicConfigurationDTO.kt @@ -87,9 +87,7 @@ class PublicConfigurationDTO( val sso: SsoPublicConfigDTO, ) - data class OAuthPublicConfigDTO( - val clientId: String?, - ) { + data class OAuthPublicConfigDTO(val clientId: String?) { val enabled: Boolean = clientId != null && clientId.isNotEmpty() } 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 index dd9cd2a05b..d4a1f63bad 100644 --- a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoGlobalProperties.kt +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoGlobalProperties.kt @@ -3,14 +3,22 @@ package io.tolgee.configuration.tolgee import io.tolgee.configuration.annotations.DocProperty import org.springframework.boot.context.properties.ConfigurationProperties +// TODO: Should we allow "Global SSO" users to manage/create their own organizations? +// Basically the global SSO would become a separate login method similar to other oAuth providers. @ConfigurationProperties(prefix = "tolgee.authentication.sso") @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" + - " just enable SSO and configure separately for each organization in the" + - " organization settings.", + " just set the `enable` to `true` and configure 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. In both cases though, the SSO user has no" + + " rights to create or manage organizations. Global SSO users has to be invited to organizations they should" + + " have access to. Per organization SSO users are automatically added to the organization they belong to.", displayName = "Single Sign-On", ) class SsoGlobalProperties { diff --git a/backend/data/src/main/kotlin/io/tolgee/constants/Caches.kt b/backend/data/src/main/kotlin/io/tolgee/constants/Caches.kt index aa1589c7ca..4a2908ef63 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Caches.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Caches.kt @@ -19,7 +19,6 @@ interface Caches { const val EE_SUBSCRIPTION = "eeSubscription" const val LANGUAGES = "languages" const val ORGANIZATION_ROLES = "organizationRoles" - const val IS_SSO_USER_VALID = "ssoUserValid" val caches = listOf( @@ -38,7 +37,6 @@ interface Caches { EE_SUBSCRIPTION, LANGUAGES, ORGANIZATION_ROLES, - IS_SSO_USER_VALID, ) } } 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 6a41e7744a..f6268736c2 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -254,6 +254,7 @@ enum class Message { SSO_DOMAIN_NOT_FOUND_OR_DISABLED, NATIVE_AUTHENTICATION_DISABLED, SSO_USER_NOT_INVITED, + // TODO: remove unused ; val code: String 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 a54ed1e55c..08c3c9cc6b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt @@ -64,6 +64,7 @@ data class UserAccount( @Convert(converter = ThirdPartyAuthTypeConverter::class) var thirdPartyAuthType: ThirdPartyAuthType? = null + // TODO: replace with link from OrganizationRole @ManyToOne(fetch = FetchType.LAZY) var ssoTenant: SsoTenant? = null diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt index 6d807323ac..91d2aa69e3 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt @@ -16,6 +16,7 @@ class OAuth2CallbackController( private val frontendUrlProvider: FrontendUrlProvider, private val enabledFeaturesProvider: EnabledFeaturesProvider, ) { + // TODO: Move to PublicController? @PostMapping("/get-authentication-url") fun getAuthenticationUrl( @RequestBody request: DomainRequest, diff --git a/ee/backend/app/src/main/resources/db/changelog/ee-schema.xml b/ee/backend/app/src/main/resources/db/changelog/ee-schema.xml index f6ad3be104..27dec578c5 100644 --- a/ee/backend/app/src/main/resources/db/changelog/ee-schema.xml +++ b/ee/backend/app/src/main/resources/db/changelog/ee-schema.xml @@ -60,38 +60,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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 index 8c498116a1..25173cf793 100644 --- 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 @@ -110,7 +110,7 @@ class OAuthMultiTenantsMocks( return authMvc!! .perform( MockMvcRequestBuilders.get( - "/v2/public/oauth2/callback/$registrationId?code=$receivedCode&redirect_uri=redirect_uri", + "/api/public/authorize_oauth/sso?domain=$registrationId&code=$receivedCode&redirect_uri=redirect_uri", ), ).andReturn() } diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index cefb2dcb58..63ae7b7f96 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -72,6 +72,27 @@ export interface paths { "/v2/projects/{projectId}/users/{userId}/revoke-access": { put: operations["revokePermission"]; }; + "/v2/projects/{projectId}/tasks/{taskNumber}/reopen": { + put: operations["reopenTask"]; + }; + "/v2/projects/{projectId}/tasks/{taskNumber}/keys/{keyId}": { + /** Mark key as done, which updates task progress. */ + put: operations["updateTaskKey"]; + }; + "/v2/projects/{projectId}/tasks/{taskNumber}/keys": { + get: operations["getTaskKeys"]; + put: operations["updateTaskKeys"]; + }; + "/v2/projects/{projectId}/tasks/{taskNumber}/finish": { + put: operations["finishTask"]; + }; + "/v2/projects/{projectId}/tasks/{taskNumber}/close": { + put: operations["closeTask"]; + }; + "/v2/projects/{projectId}/tasks/{taskNumber}": { + get: operations["getTask"]; + put: operations["updateTask"]; + }; "/v2/projects/{projectId}/per-language-auto-translation-settings": { get: operations["getPerLanguageAutoTranslationSettings"]; put: operations["setPerLanguageAutoTranslationSettings"]; @@ -384,6 +405,16 @@ export interface paths { /** Sends a test request to the webhook */ post: operations["test"]; }; + "/v2/projects/{projectId}/tasks/create-multiple-tasks": { + post: operations["createTasks"]; + }; + "/v2/projects/{projectId}/tasks/calculate-scope": { + post: operations["calculateScope"]; + }; + "/v2/projects/{projectId}/tasks": { + get: operations["getTasks_1"]; + post: operations["createTask"]; + }; "/v2/projects/{projectId}/keys/info": { /** Returns information about keys. (KeyData, Screenshots, Translation in specified language)If key is not found, it's not included in the response. */ post: operations["getInfo"]; @@ -552,6 +583,9 @@ export interface paths { /** Returns all organizations owned only by current user */ get: operations["getAllSingleOwnedOrganizations"]; }; + "/v2/user-tasks": { + get: operations["getTasks"]; + }; "/v2/user-preferences": { get: operations["get"]; }; @@ -600,6 +634,21 @@ export interface paths { /** Returns all used project namespaces. Response contains default (null) namespace if used. */ get: operations["getUsedNamespaces"]; }; + "/v2/projects/{projectId}/tasks/{taskNumber}/xlsx-report": { + /** Detailed statistics about the task results */ + get: operations["getXlsxReport"]; + }; + "/v2/projects/{projectId}/tasks/{taskNumber}/per-user-report": { + /** Detailed statistics for every assignee */ + get: operations["getPerUserReport"]; + }; + "/v2/projects/{projectId}/tasks/{taskNumber}/blocking-tasks": { + /** If the tasks is blocked by other tasks, it returns numbers of these tasks. */ + get: operations["getBlockingTasks"]; + }; + "/v2/projects/{projectId}/tasks/possible-assignees": { + get: operations["getPossibleAssignees"]; + }; "/v2/projects/{projectId}/namespaces": { get: operations["getAllNamespaces"]; }; @@ -612,12 +661,24 @@ export interface paths { get: operations["getMachineTranslationLanguageInfo"]; }; "/v2/projects/{projectId}/keys/search": { - /** This endpoint helps you to find desired key by keyName, base translation or translation in specified language. */ + /** + * This endpoint helps you to find desired key by keyName, base translation or translation in specified language. + * + * Sort is ignored for this request. + */ get: operations["searchForKey"]; }; "/v2/projects/{projectId}/all-keys": { get: operations["getAllKeys"]; }; + "/v2/projects/{projectId}/all-keys-with-disabled-languages": { + /** + * Returns all project key with any disabled language. + * + * If key has no disabled language, it is not returned. + */ + get: operations["getDisabledLanguages_2"]; + }; "/v2/projects/{projectId}/activity/revisions/{revisionId}/modified-entities": { get: operations["getModifiedEntitiesByRevision"]; }; @@ -1080,6 +1141,9 @@ export interface components { | "plan_auto_assignment_only_for_free_plans" | "plan_auto_assignment_only_for_private_plans" | "plan_auto_assignment_organization_ids_not_in_for_organization_ids" + | "task_not_found" + | "task_not_finished" + | "task_not_open" | "sso_user_cannot_create_organization" | "sso_cant_verify_user" | "sso_user_cant_login_with_native" @@ -1159,6 +1223,24 @@ export interface components { | "SERVER_ADMIN"; /** @description The user's permission type. This field is null if uses granular permissions */ type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; + /** + * @description List of languages user can translate to. If null, all languages editing is permitted. + * @example 200001,200004 + */ + translateLanguageIds?: number[]; + /** + * @description List of languages user can change state to. If null, changing state of all language values is permitted. + * @example 200001,200004 + */ + stateChangeLanguageIds?: number[]; + /** + * @deprecated + * @description Deprecated (use translateLanguageIds). + * + * List of languages current user has TRANSLATE permission to. If null, all languages edition is permitted. + * @example 200001,200004 + */ + permittedLanguageIds?: number[]; /** * @description List of languages user can view. If null, all languages view is permitted. * @example 200001,200004 @@ -1195,25 +1277,9 @@ export interface components { | "content-delivery.manage" | "content-delivery.publish" | "webhooks.manage" + | "tasks.view" + | "tasks.edit" )[]; - /** - * @description List of languages user can translate to. If null, all languages editing is permitted. - * @example 200001,200004 - */ - translateLanguageIds?: number[]; - /** - * @description List of languages user can change state to. If null, changing state of all language values is permitted. - * @example 200001,200004 - */ - stateChangeLanguageIds?: number[]; - /** - * @deprecated - * @description Deprecated (use translateLanguageIds). - * - * List of languages current user has TRANSLATE permission to. If null, all languages edition is permitted. - * @example 200001,200004 - */ - permittedLanguageIds?: number[]; }; LanguageModel: { /** Format: int64 */ @@ -1287,6 +1353,8 @@ export interface components { | "content-delivery.manage" | "content-delivery.publish" | "webhooks.manage" + | "tasks.view" + | "tasks.edit" )[]; /** @description The user's permission type. This field is null if uses granular permissions */ type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; @@ -1361,6 +1429,65 @@ export interface components { */ lastExecuted?: number; }; + SimpleUserAccountModel: { + /** Format: int64 */ + id: number; + username: string; + name?: string; + avatar?: components["schemas"]["Avatar"]; + deleted: boolean; + }; + TaskModel: { + /** Format: int64 */ + number: number; + name: string; + description: string; + type: "TRANSLATE" | "REVIEW"; + language: components["schemas"]["LanguageModel"]; + /** Format: int64 */ + dueDate?: number; + assignees: components["schemas"]["SimpleUserAccountModel"][]; + /** Format: int64 */ + totalItems: number; + /** Format: int64 */ + doneItems: number; + /** Format: int64 */ + baseWordCount: number; + /** Format: int64 */ + baseCharacterCount: number; + author?: components["schemas"]["SimpleUserAccountModel"]; + /** Format: int64 */ + createdAt?: number; + /** Format: int64 */ + closedAt?: number; + state: "NEW" | "IN_PROGRESS" | "DONE" | "CLOSED"; + }; + UpdateTaskKeyRequest: { + done: boolean; + }; + UpdateTaskKeyResponse: { + /** @description Task key is marked as done */ + done: boolean; + /** @description Task progress is 100% */ + taskFinished: boolean; + }; + UpdateTaskKeysRequest: { + /** @description Keys to add to task */ + addKeys?: number[]; + /** @description Keys to remove from task */ + removeKeys?: number[]; + }; + UpdateTaskRequest: { + name: string; + description: string; + /** + * Format: int64 + * @description Due to date in epoch format (milliseconds). + * @example 1661172869000 + */ + dueDate?: number; + assignees: number[]; + }; AutoTranslationSettingsDto: { /** Format: int64 */ languageId?: number; @@ -1795,7 +1922,6 @@ export interface components { format: | "JSON" | "JSON_TOLGEE" - | "JSON_I18NEXT" | "XLIFF" | "PO" | "APPLE_STRINGS_STRINGSDICT" @@ -1804,7 +1930,9 @@ export interface components { | "FLUTTER_ARB" | "PROPERTIES" | "YAML_RUBY" - | "YAML"; + | "YAML" + | "JSON_I18NEXT" + | "CSV"; /** * @description Delimiter to structure file content. * @@ -1894,7 +2022,6 @@ export interface components { format: | "JSON" | "JSON_TOLGEE" - | "JSON_I18NEXT" | "XLIFF" | "PO" | "APPLE_STRINGS_STRINGSDICT" @@ -1903,7 +2030,9 @@ export interface components { | "FLUTTER_ARB" | "PROPERTIES" | "YAML_RUBY" - | "YAML"; + | "YAML" + | "JSON_I18NEXT" + | "CSV"; /** * @description Delimiter to structure file content. * @@ -2015,21 +2144,12 @@ export interface components { createNewKeys: boolean; }; ImportSettingsModel: { - /** @description If false, only updates keys, skipping the creation of new keys */ - createNewKeys: boolean; /** @description If true, placeholders from other formats will be converted to ICU when possible */ convertPlaceholdersToIcu: boolean; /** @description If true, key descriptions will be overridden by the import */ overrideKeyDescriptions: boolean; - }; - /** @description User who created the comment */ - SimpleUserAccountModel: { - /** Format: int64 */ - id: number; - username: string; - name?: string; - avatar?: components["schemas"]["Avatar"]; - deleted: boolean; + /** @description If false, only updates keys, skipping the creation of new keys */ + createNewKeys: boolean; }; TranslationCommentModel: { /** @@ -2190,13 +2310,13 @@ export interface components { id: number; description: string; /** Format: int64 */ - createdAt: number; - /** Format: int64 */ - updatedAt: number; - /** Format: int64 */ expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; + /** Format: int64 */ + createdAt: number; + /** Format: int64 */ + updatedAt: number; }; SetOrganizationRoleDto: { roleType: "MEMBER" | "OWNER"; @@ -2285,6 +2405,7 @@ export interface components { | "MULTIPLE_CONTENT_DELIVERY_CONFIGS" | "AI_PROMPT_CUSTOMIZATION" | "SLACK_INTEGRATION" + | "TASKS" | "SSO" )[]; /** Format: int64 */ @@ -2357,11 +2478,11 @@ export interface components { userFullName?: string; username?: string; description: string; - scopes: string[]; /** Format: int64 */ projectId: number; /** Format: int64 */ expiresAt?: number; + scopes: string[]; /** Format: int64 */ lastUsedAt?: number; projectName: string; @@ -2480,6 +2601,7 @@ export interface components { | "MULTIPLE_CONTENT_DELIVERY_CONFIGS" | "AI_PROMPT_CUSTOMIZATION" | "SLACK_INTEGRATION" + | "TASKS" | "SSO" )[]; prices: components["schemas"]["PlanPricesModel"]; @@ -2589,6 +2711,44 @@ export interface components { WebhookTestResponse: { success: boolean; }; + CreateMultipleTasksRequest: { + tasks: components["schemas"]["CreateTaskRequest"][]; + }; + CreateTaskRequest: { + name: string; + description: string; + type: "TRANSLATE" | "REVIEW"; + /** + * Format: int64 + * @description Due to date in epoch format (milliseconds). + * @example 1661172869000 + */ + dueDate?: number; + /** + * Format: int64 + * @description Id of language, this task is attached to. + * @example 1 + */ + languageId: number; + assignees: number[]; + keys: number[]; + }; + CalculateScopeRequest: { + /** Format: int64 */ + languageId: number; + type: "TRANSLATE" | "REVIEW"; + keys: number[]; + }; + KeysScopeView: { + /** Format: int64 */ + keyCount: number; + /** Format: int64 */ + characterCount: number; + /** Format: int64 */ + wordCount: number; + /** Format: int64 */ + keyCountIncludingConflicts: number; + }; GetKeysRequestDto: { keys: components["schemas"]["KeyDefinitionDto"][]; /** @description Tags to return language translations in */ @@ -2632,11 +2792,12 @@ export interface components { * * - KEEP: Translation is not changed * - OVERRIDE: Translation is overridden - * - NEW: New translation is created) + * - NEW: New translation is created + * - FORCE_OVERRIDE: Translation is updated, created or kept. * * @example OVERRIDE */ - resolution: "KEEP" | "OVERRIDE" | "NEW"; + resolution: "KEEP" | "OVERRIDE" | "NEW" | "FORCE_OVERRIDE"; }; KeyImportResolvableResultModel: { /** @description List of keys */ @@ -2934,6 +3095,9 @@ export interface components { | "plan_auto_assignment_only_for_free_plans" | "plan_auto_assignment_only_for_private_plans" | "plan_auto_assignment_organization_ids_not_in_for_organization_ids" + | "task_not_found" + | "task_not_finished" + | "task_not_open" | "sso_user_cannot_create_organization" | "sso_cant_verify_user" | "sso_user_cant_login_with_native" @@ -3049,6 +3213,10 @@ export interface components { * It is recommended to provide these values to prevent any issues with format detection. */ format?: + | "CSV_ICU" + | "CSV_JAVA" + | "CSV_PHP" + | "CSV_RUBY" | "JSON_I18NEXT" | "JSON_ICU" | "JSON_JAVA" @@ -3200,7 +3368,6 @@ export interface components { format: | "JSON" | "JSON_TOLGEE" - | "JSON_I18NEXT" | "XLIFF" | "PO" | "APPLE_STRINGS_STRINGSDICT" @@ -3209,7 +3376,9 @@ export interface components { | "FLUTTER_ARB" | "PROPERTIES" | "YAML_RUBY" - | "YAML"; + | "YAML" + | "JSON_I18NEXT" + | "CSV"; /** * @description Delimiter to structure file content. * @@ -3425,6 +3594,48 @@ export interface components { organizations?: components["schemas"]["SimpleOrganizationModel"][]; }; }; + PagedModelTaskWithProjectModel: { + _embedded?: { + tasks?: components["schemas"]["TaskWithProjectModel"][]; + }; + page?: components["schemas"]["PageMetadata"]; + }; + SimpleProjectModel: { + /** Format: int64 */ + id: number; + name: string; + description?: string; + slug?: string; + avatar?: components["schemas"]["Avatar"]; + baseLanguage?: components["schemas"]["LanguageModel"]; + icuPlaceholders: boolean; + }; + TaskWithProjectModel: { + /** Format: int64 */ + number: number; + name: string; + description: string; + type: "TRANSLATE" | "REVIEW"; + language: components["schemas"]["LanguageModel"]; + /** Format: int64 */ + dueDate?: number; + assignees: components["schemas"]["SimpleUserAccountModel"][]; + /** Format: int64 */ + totalItems: number; + /** Format: int64 */ + doneItems: number; + /** Format: int64 */ + baseWordCount: number; + /** Format: int64 */ + baseCharacterCount: number; + author?: components["schemas"]["SimpleUserAccountModel"]; + /** Format: int64 */ + createdAt?: number; + /** Format: int64 */ + closedAt?: number; + state: "NEW" | "IN_PROGRESS" | "DONE" | "CLOSED"; + project: components["schemas"]["SimpleProjectModel"]; + }; UserPreferencesModel: { language?: string; /** Format: int64 */ @@ -3464,7 +3675,9 @@ export interface components { | "translations.batch-machine" | "content-delivery.manage" | "content-delivery.publish" - | "webhooks.manage"; + | "webhooks.manage" + | "tasks.view" + | "tasks.edit"; requires: components["schemas"]["HierarchyItem"][]; }; MachineTranslationProviderModel: { @@ -3485,7 +3698,8 @@ export interface components { | "NEW_PRICING" | "FEATURE_AI_CUSTOMIZATION" | "FEATURE_VISUAL_EDITOR" - | "FEATURE_CLI_2"; + | "FEATURE_CLI_2" + | "FEATURE_TASKS"; }; AuthMethodsDTO: { github: components["schemas"]["OAuthPublicConfigDTO"]; @@ -3525,6 +3739,11 @@ export interface components { scopes?: string[]; enabled: boolean; }; + PlausibleDto: { + domain?: string; + url: string; + scriptUrl: string; + }; PrivateOrganizationModel: { organizationModel?: components["schemas"]["OrganizationModel"]; /** @example Features organization has enabled */ @@ -3544,6 +3763,7 @@ export interface components { | "MULTIPLE_CONTENT_DELIVERY_CONFIGS" | "AI_PROMPT_CUSTOMIZATION" | "SLACK_INTEGRATION" + | "TASKS" | "SSO" )[]; quickStart?: components["schemas"]["QuickStartModel"]; @@ -3560,9 +3780,9 @@ export interface components { basePermissions: components["schemas"]["PermissionModel"]; /** @example This is a beautiful organization full of beautiful and clever people */ description?: string; + avatar?: components["schemas"]["Avatar"]; /** @example btforg */ slug: string; - avatar?: components["schemas"]["Avatar"]; }; PublicBillingConfigurationDTO: { enabled: boolean; @@ -3595,6 +3815,7 @@ export interface components { postHogHost?: string; contentDeliveryConfigured: boolean; userSourceField: boolean; + plausible: components["schemas"]["PlausibleDto"]; slack: components["schemas"]["SlackDTO"]; }; SlackDTO: { @@ -3618,7 +3839,6 @@ export interface components { format: | "JSON" | "JSON_TOLGEE" - | "JSON_I18NEXT" | "XLIFF" | "PO" | "APPLE_STRINGS_STRINGSDICT" @@ -3627,7 +3847,9 @@ export interface components { | "FLUTTER_ARB" | "PROPERTIES" | "YAML_RUBY" - | "YAML"; + | "YAML" + | "JSON_I18NEXT" + | "CSV"; extension: string; mediaType: string; defaultFileStructureTemplate: string; @@ -3684,6 +3906,30 @@ export interface components { */ name?: string; }; + TaskPerUserReportModel: { + user: components["schemas"]["SimpleUserAccountModel"]; + /** Format: int64 */ + doneItems: number; + /** Format: int64 */ + baseCharacterCount: number; + /** Format: int64 */ + baseWordCount: number; + }; + TaskKeysResponse: { + keys: number[]; + }; + PagedModelSimpleUserAccountModel: { + _embedded?: { + users?: components["schemas"]["SimpleUserAccountModel"][]; + }; + page?: components["schemas"]["PageMetadata"]; + }; + PagedModelTaskModel: { + _embedded?: { + tasks?: components["schemas"]["TaskModel"][]; + }; + page?: components["schemas"]["PageMetadata"]; + }; PagedModelNamespaceModel: { _embedded?: { namespaces?: components["schemas"]["NamespaceModel"][]; @@ -3753,6 +3999,36 @@ export interface components { keys?: components["schemas"]["KeyModel"][]; }; }; + CollectionModelKeyDisabledLanguagesModel: { + _embedded?: { + keys?: components["schemas"]["KeyDisabledLanguagesModel"][]; + }; + }; + /** @description Disabled languages */ + KeyDisabledLanguageModel: { + /** Format: int64 */ + id: number; + tag: string; + }; + KeyDisabledLanguagesModel: { + /** + * Format: int64 + * @description Id of key record + */ + id: number; + /** + * @description Name of key + * @example this_is_super_key + */ + name: string; + /** + * @description Namespace of key + * @example homepage + */ + namespace?: string; + /** @description Disabled languages */ + disabledLanguages: components["schemas"]["KeyDisabledLanguageModel"][]; + }; EntityDescriptionWithRelations: { entityClass: string; /** Format: int64 */ @@ -3849,7 +4125,15 @@ export interface components { | "WEBHOOK_CONFIG_CREATE" | "WEBHOOK_CONFIG_UPDATE" | "WEBHOOK_CONFIG_DELETE" - | "COMPLEX_TAG_OPERATION"; + | "COMPLEX_TAG_OPERATION" + | "TASKS_CREATE" + | "TASK_CREATE" + | "TASK_UPDATE" + | "TASK_KEYS_UPDATE" + | "TASK_FINISH" + | "TASK_CLOSE" + | "TASK_REOPEN" + | "TASK_KEY_UPDATE"; author?: components["schemas"]["ProjectActivityAuthorModel"]; modifiedEntities?: { [key: string]: components["schemas"]["ModifiedEntityModel"][]; @@ -3948,7 +4232,8 @@ export interface components { | "TRANSLATION_TOO_LONG" | "KEY_IS_BLANK" | "TRANSLATION_DEFINED_IN_ANOTHER_FILE" - | "INVALID_CUSTOM_VALUES"; + | "INVALID_CUSTOM_VALUES" + | "DESCRIPTION_TOO_LONG"; params: components["schemas"]["ImportFileIssueParamModel"][]; }; ImportFileIssueParamModel: { @@ -4017,6 +4302,17 @@ export interface components { SelectAllResponse: { ids: number[]; }; + /** @description Tasks related to this key */ + KeyTaskViewModel: { + /** Format: int64 */ + number: number; + /** Format: int64 */ + languageId: number; + languageTag: string; + done: boolean; + userAssigned: boolean; + type: "TRANSLATE" | "REVIEW"; + }; KeyWithTranslationsModel: { /** * Format: int64 @@ -4081,6 +4377,8 @@ export interface components { translations: { [key: string]: components["schemas"]["TranslationViewModel"]; }; + /** @description Tasks related to this key */ + tasks?: components["schemas"]["KeyTaskViewModel"][]; }; KeysWithTranslationsPageModel: { _embedded?: { @@ -4181,6 +4479,8 @@ export interface components { /** Format: int64 */ keyCount: number; /** Format: int64 */ + taskCount: number; + /** Format: int64 */ baseWordsCount: number; /** Format: double */ translatedPercentage: number; @@ -4270,13 +4570,13 @@ export interface components { id: number; description: string; /** Format: int64 */ - createdAt: number; - /** Format: int64 */ - updatedAt: number; - /** Format: int64 */ expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; + /** Format: int64 */ + createdAt: number; + /** Format: int64 */ + updatedAt: number; }; PagedModelOrganizationModel: { _embedded?: { @@ -4378,16 +4678,6 @@ export interface components { }; page?: components["schemas"]["PageMetadata"]; }; - SimpleProjectModel: { - /** Format: int64 */ - id: number; - name: string; - description?: string; - slug?: string; - avatar?: components["schemas"]["Avatar"]; - baseLanguage?: components["schemas"]["LanguageModel"]; - icuPlaceholders: boolean; - }; UserAccountWithOrganizationRoleModel: { /** Format: int64 */ id: number; @@ -4408,11 +4698,11 @@ export interface components { userFullName?: string; username?: string; description: string; - scopes: string[]; /** Format: int64 */ projectId: number; /** Format: int64 */ expiresAt?: number; + scopes: string[]; /** Format: int64 */ lastUsedAt?: number; projectName: string; @@ -5621,9 +5911,10 @@ export interface operations { }; }; }; - getPerLanguageAutoTranslationSettings: { + reopenTask: { parameters: { path: { + taskNumber: number; projectId: number; }; }; @@ -5631,7 +5922,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["CollectionModelAutoTranslationConfigModel"]; + "application/json": components["schemas"]["TaskModel"]; }; }; /** Bad Request */ @@ -5668,9 +5959,12 @@ export interface operations { }; }; }; - setPerLanguageAutoTranslationSettings: { + /** Mark key as done, which updates task progress. */ + updateTaskKey: { parameters: { path: { + taskNumber: number; + keyId: number; projectId: number; }; }; @@ -5678,7 +5972,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["CollectionModelAutoTranslationConfigModel"]; + "application/json": components["schemas"]["UpdateTaskKeyResponse"]; }; }; /** Bad Request */ @@ -5716,14 +6010,14 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AutoTranslationSettingsDto"][]; + "application/json": components["schemas"]["UpdateTaskKeyRequest"]; }; }; }; - update_1: { + getTaskKeys: { parameters: { path: { - id: number; + taskNumber: number; projectId: number; }; }; @@ -5731,7 +6025,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["NamespaceModel"]; + "application/json": components["schemas"]["TaskKeysResponse"]; }; }; /** Bad Request */ @@ -5767,25 +6061,17 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateNamespaceDto"]; - }; - }; }; - getMachineTranslationSettings: { + updateTaskKeys: { parameters: { path: { + taskNumber: number; projectId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["CollectionModelLanguageConfigItemModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -5819,10 +6105,16 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateTaskKeysRequest"]; + }; + }; }; - setMachineTranslationSettings: { + finishTask: { parameters: { path: { + taskNumber: number; projectId: number; }; }; @@ -5830,7 +6122,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["CollectionModelLanguageConfigItemModel"]; + "application/json": components["schemas"]["TaskModel"]; }; }; /** Bad Request */ @@ -5866,17 +6158,11 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["SetMachineTranslationSettingsDto"]; - }; - }; }; - /** Returns languages, in which key is disabled */ - getDisabledLanguages: { + closeTask: { parameters: { path: { - id: number; + taskNumber: number; projectId: number; }; }; @@ -5884,7 +6170,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["CollectionModelLanguageModel"]; + "application/json": components["schemas"]["TaskModel"]; }; }; /** Bad Request */ @@ -5921,11 +6207,10 @@ export interface operations { }; }; }; - /** Sets languages, in which key is disabled */ - setDisabledLanguages: { + getTask: { parameters: { path: { - id: number; + taskNumber: number; projectId: number; }; }; @@ -5933,7 +6218,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["CollectionModelLanguageModel"]; + "application/json": components["schemas"]["TaskModel"]; }; }; /** Bad Request */ @@ -5969,17 +6254,11 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["SetDisabledLanguagesRequest"]; - }; - }; }; - /** Edits key name, translations, tags, screenshots, and other data */ - complexEdit: { + updateTask: { parameters: { path: { - id: number; + taskNumber: number; projectId: number; }; }; @@ -5987,7 +6266,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["KeyWithDataModel"]; + "application/json": components["schemas"]["TaskModel"]; }; }; /** Bad Request */ @@ -6025,14 +6304,13 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ComplexEditKeyDto"]; + "application/json": components["schemas"]["UpdateTaskRequest"]; }; }; }; - get_6: { + getPerLanguageAutoTranslationSettings: { parameters: { path: { - id: number; projectId: number; }; }; @@ -6040,7 +6318,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["KeyModel"]; + "application/json": components["schemas"]["CollectionModelAutoTranslationConfigModel"]; }; }; /** Bad Request */ @@ -6077,10 +6355,9 @@ export interface operations { }; }; }; - edit: { + setPerLanguageAutoTranslationSettings: { parameters: { path: { - id: number; projectId: number; }; }; @@ -6088,7 +6365,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["KeyModel"]; + "application/json": components["schemas"]["CollectionModelAutoTranslationConfigModel"]; }; }; /** Bad Request */ @@ -6126,13 +6403,14 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["EditKeyDto"]; + "application/json": components["schemas"]["AutoTranslationSettingsDto"][]; }; }; }; - inviteUser: { + update_1: { parameters: { path: { + id: number; projectId: number; }; }; @@ -6140,7 +6418,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["ProjectInvitationModel"]; + "application/json": components["schemas"]["NamespaceModel"]; }; }; /** Bad Request */ @@ -6178,14 +6456,13 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ProjectInviteUserDto"]; + "application/json": components["schemas"]["UpdateNamespaceDto"]; }; }; }; - get_8: { + getMachineTranslationSettings: { parameters: { path: { - contentStorageId: number; projectId: number; }; }; @@ -6193,7 +6470,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["ContentStorageModel"]; + "application/json": components["schemas"]["CollectionModelLanguageConfigItemModel"]; }; }; /** Bad Request */ @@ -6230,10 +6507,9 @@ export interface operations { }; }; }; - update_3: { + setMachineTranslationSettings: { parameters: { path: { - contentStorageId: number; projectId: number; }; }; @@ -6241,7 +6517,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["ContentStorageModel"]; + "application/json": components["schemas"]["CollectionModelLanguageConfigItemModel"]; }; }; /** Bad Request */ @@ -6279,20 +6555,25 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ContentStorageRequest"]; + "application/json": components["schemas"]["SetMachineTranslationSettingsDto"]; }; }; }; - delete_6: { + /** Returns languages, in which key is disabled */ + getDisabledLanguages: { parameters: { path: { - contentStorageId: number; + id: number; projectId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["CollectionModelLanguageModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -6327,7 +6608,8 @@ export interface operations { }; }; }; - get_9: { + /** Sets languages, in which key is disabled */ + setDisabledLanguages: { parameters: { path: { id: number; @@ -6338,7 +6620,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["ContentDeliveryConfigModel"]; + "application/json": components["schemas"]["CollectionModelLanguageModel"]; }; }; /** Bad Request */ @@ -6374,8 +6656,14 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["SetDisabledLanguagesRequest"]; + }; + }; }; - update_4: { + /** Edits key name, translations, tags, screenshots, and other data */ + complexEdit: { parameters: { path: { id: number; @@ -6386,7 +6674,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["ContentDeliveryConfigModel"]; + "application/json": components["schemas"]["KeyWithDataModel"]; }; }; /** Bad Request */ @@ -6424,12 +6712,11 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ContentDeliveryConfigRequest"]; + "application/json": components["schemas"]["ComplexEditKeyDto"]; }; }; }; - /** Immediately publishes content to the configured Content Delivery */ - post: { + get_6: { parameters: { path: { id: number; @@ -6438,7 +6725,11 @@ export interface operations { }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["KeyModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -6473,7 +6764,7 @@ export interface operations { }; }; }; - delete_7: { + edit: { parameters: { path: { id: number; @@ -6482,7 +6773,11 @@ export interface operations { }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["KeyModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -6516,9 +6811,13 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["EditKeyDto"]; + }; + }; }; - /** Returns default auto translation settings for project (deprecated: use per language config with null language id) */ - getAutoTranslationSettings: { + inviteUser: { parameters: { path: { projectId: number; @@ -6528,7 +6827,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["AutoTranslationConfigModel"]; + "application/json": components["schemas"]["ProjectInvitationModel"]; }; }; /** Bad Request */ @@ -6564,11 +6863,16 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["ProjectInviteUserDto"]; + }; + }; }; - /** Sets default auto-translation settings for project (deprecated: use per language config with null language id) */ - setAutoTranslationSettings: { + get_8: { parameters: { path: { + contentStorageId: number; projectId: number; }; }; @@ -6576,7 +6880,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["AutoTranslationConfigModel"]; + "application/json": components["schemas"]["ContentStorageModel"]; }; }; /** Bad Request */ @@ -6612,21 +6916,21 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["AutoTranslationSettingsDto"]; - }; - }; }; - executeComplexTagOperation: { + update_3: { parameters: { path: { + contentStorageId: number; projectId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["ContentStorageModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -6662,25 +6966,20 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ComplexTagKeysRequest"]; + "application/json": components["schemas"]["ContentStorageRequest"]; }; }; }; - /** Tags a key with tag. If tag with provided name doesn't exist, it is created */ - tagKey: { + delete_6: { parameters: { path: { - keyId: number; + contentStorageId: number; projectId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["TagModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -6714,24 +7013,21 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["TagKeyDto"]; - }; - }; }; - /** Resolves translation conflict. The old translation will be overridden. */ - resolveTranslationSetOverride: { + get_9: { parameters: { path: { - languageId: number; - translationId: number; + id: number; projectId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["ContentDeliveryConfigModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -6766,18 +7062,20 @@ export interface operations { }; }; }; - /** Resolves translation conflict. The old translation will be kept. */ - resolveTranslationSetKeepExisting: { + update_4: { parameters: { path: { - languageId: number; - translationId: number; + id: number; projectId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["ContentDeliveryConfigModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -6811,12 +7109,17 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["ContentDeliveryConfigRequest"]; + }; + }; }; - /** Resolves all translation conflicts for provided language. The old translations will be overridden. */ - resolveTranslationSetOverride_2: { + /** Immediately publishes content to the configured Content Delivery */ + post: { parameters: { path: { - languageId: number; + id: number; projectId: number; }; }; @@ -6857,11 +7160,10 @@ export interface operations { }; }; }; - /** Resolves all translation conflicts for provided language. The old translations will be kept. */ - resolveTranslationSetKeepExisting_2: { + delete_7: { parameters: { path: { - languageId: number; + id: number; projectId: number; }; }; @@ -6902,18 +7204,20 @@ export interface operations { }; }; }; - /** Sets existing language to pair with language to import. Data will be imported to selected existing language when applied. */ - selectExistingLanguage: { + /** Returns default auto translation settings for project (deprecated: use per language config with null language id) */ + getAutoTranslationSettings: { parameters: { path: { - importLanguageId: number; - existingLanguageId: number; projectId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["AutoTranslationConfigModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -6948,17 +7252,20 @@ export interface operations { }; }; }; - /** Resets existing language paired with language to import. */ - resetExistingLanguage: { + /** Sets default auto-translation settings for project (deprecated: use per language config with null language id) */ + setAutoTranslationSettings: { parameters: { path: { - importLanguageId: number; projectId: number; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["AutoTranslationConfigModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -6992,12 +7299,15 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["AutoTranslationSettingsDto"]; + }; + }; }; - /** Sets namespace for file to import. */ - selectNamespace: { + executeComplexTagOperation: { parameters: { path: { - fileId: number; projectId: number; }; }; @@ -7039,18 +7349,15 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["SetFileNamespaceRequest"]; + "application/json": components["schemas"]["ComplexTagKeysRequest"]; }; }; }; - /** Imports the data prepared in previous step. Streams current status. */ - applyImportStreaming: { + /** Tags a key with tag. If tag with provided name doesn't exist, it is created */ + tagKey: { parameters: { - query: { - /** Whether override or keep all translations with unresolved conflicts */ - forceMode?: "OVERRIDE" | "KEEP" | "NO_FORCE"; - }; path: { + keyId: number; projectId: number; }; }; @@ -7058,7 +7365,7 @@ export interface operations { /** OK */ 200: { content: { - "application/x-ndjson": components["schemas"]["StreamingResponseBody"]; + "application/json": components["schemas"]["TagModel"]; }; }; /** Bad Request */ @@ -7094,11 +7401,391 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["TagKeyDto"]; + }; + }; }; - /** Imports the data prepared in previous step */ - applyImport: { + /** Resolves translation conflict. The old translation will be overridden. */ + resolveTranslationSetOverride: { parameters: { - query: { + path: { + languageId: number; + translationId: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Resolves translation conflict. The old translation will be kept. */ + resolveTranslationSetKeepExisting: { + parameters: { + path: { + languageId: number; + translationId: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Resolves all translation conflicts for provided language. The old translations will be overridden. */ + resolveTranslationSetOverride_2: { + parameters: { + path: { + languageId: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Resolves all translation conflicts for provided language. The old translations will be kept. */ + resolveTranslationSetKeepExisting_2: { + parameters: { + path: { + languageId: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Sets existing language to pair with language to import. Data will be imported to selected existing language when applied. */ + selectExistingLanguage: { + parameters: { + path: { + importLanguageId: number; + existingLanguageId: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Resets existing language paired with language to import. */ + resetExistingLanguage: { + parameters: { + path: { + importLanguageId: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Sets namespace for file to import. */ + selectNamespace: { + parameters: { + path: { + fileId: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetFileNamespaceRequest"]; + }; + }; + }; + /** Imports the data prepared in previous step. Streams current status. */ + applyImportStreaming: { + parameters: { + query: { + /** Whether override or keep all translations with unresolved conflicts */ + forceMode?: "OVERRIDE" | "KEEP" | "NO_FORCE"; + }; + path: { + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/x-ndjson": components["schemas"]["StreamingResponseBody"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Imports the data prepared in previous step */ + applyImport: { + parameters: { + query: { /** Whether override or keep all translations with unresolved conflicts */ forceMode?: "OVERRIDE" | "KEEP" | "NO_FORCE"; }; @@ -7688,6 +8375,8 @@ export interface operations { filterRevisionId?: number[]; /** Select only keys which were not successfully translated by batch job with provided id */ filterFailedKeysOfJob?: number; + /** Select only keys which are in specified task */ + filterTaskNumber?: number[]; /** Zero-based page index (0..N) */ page?: number; /** The size of the page to be returned */ @@ -10024,11 +10713,195 @@ export interface operations { }; responses: { /** OK */ - 200: { - content: { - "application/json": { [key: string]: unknown }; - }; - }; + 200: { + content: { + "application/json": { [key: string]: unknown }; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": string; + }; + }; + }; + getAuthenticationUrl: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["SsoUrlResponse"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DomainRequest"]; + }; + }; + }; + getMySubscription: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["SelfHostedEeSubscriptionModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GetMySubscriptionDto"]; + }; + }; + }; + onLicenceSetKey: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["SelfHostedEeSubscriptionModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetLicenseKeyLicensingDto"]; + }; + }; + }; + reportUsage: { + responses: { + /** OK */ + 200: unknown; /** Bad Request */ 400: { content: { @@ -10064,18 +10937,14 @@ export interface operations { }; requestBody: { content: { - "application/json": string; + "application/json": components["schemas"]["ReportUsageDto"]; }; }; }; - getAuthenticationUrl: { + reportError: { responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["SsoUrlResponse"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -10111,18 +10980,14 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["DomainRequest"]; + "application/json": components["schemas"]["ReportErrorDto"]; }; }; }; - getMySubscription: { + releaseKey: { responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["SelfHostedEeSubscriptionModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -10158,16 +11023,16 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["GetMySubscriptionDto"]; + "application/json": components["schemas"]["ReleaseKeyDto"]; }; }; }; - onLicenceSetKey: { + prepareSetLicenseKey: { responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["SelfHostedEeSubscriptionModel"]; + "application/json": components["schemas"]["PrepareSetEeLicenceKeyModel"]; }; }; /** Bad Request */ @@ -10205,11 +11070,11 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["SetLicenseKeyLicensingDto"]; + "application/json": components["schemas"]["PrepareSetLicenseKeyDto"]; }; }; }; - reportUsage: { + report_1: { responses: { /** OK */ 200: unknown; @@ -10248,11 +11113,11 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ReportUsageDto"]; + "application/json": components["schemas"]["BusinessEventReportRequest"]; }; }; }; - reportError: { + identify: { responses: { /** OK */ 200: unknown; @@ -10291,14 +11156,34 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ReportErrorDto"]; + "application/json": components["schemas"]["IdentifyRequest"]; }; }; }; - releaseKey: { + /** Returns all projects where current user has any permission */ + getAll: { + parameters: { + query: { + /** Filter projects by id */ + filterId?: number[]; + /** Filter projects without id */ + filterNotId?: number[]; + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + search?: string; + }; + }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/hal+json": components["schemas"]["PagedModelProjectModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -10332,18 +11217,14 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["ReleaseKeyDto"]; - }; - }; }; - prepareSetLicenseKey: { + /** Creates a new project with languages and initial settings. */ + createProject: { responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["PrepareSetEeLicenceKeyModel"]; + "application/json": components["schemas"]["ProjectModel"]; }; }; /** Bad Request */ @@ -10381,14 +11262,31 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["PrepareSetLicenseKeyDto"]; + "application/json": components["schemas"]["CreateProjectRequest"]; }; }; }; - report_1: { + list: { + parameters: { + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + }; + path: { + projectId: number; + }; + }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["PagedModelWebhookConfigModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -10422,16 +11320,20 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["BusinessEventReportRequest"]; + }; + create: { + parameters: { + path: { + projectId: number; }; }; - }; - identify: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["WebhookConfigModel"]; + }; + }; /** Bad Request */ 400: { content: { @@ -10467,28 +11369,23 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["IdentifyRequest"]; + "application/json": components["schemas"]["WebhookConfigRequest"]; }; }; }; - /** Returns all projects where current user has any permission */ - getAll: { + /** Sends a test request to the webhook */ + test: { parameters: { - query: { - /** Zero-based page index (0..N) */ - page?: number; - /** The size of the page to be returned */ - size?: number; - /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ - sort?: string[]; - search?: string; + path: { + id: number; + projectId: number; }; }; responses: { /** OK */ 200: { content: { - "application/hal+json": components["schemas"]["PagedModelProjectModel"]; + "application/json": components["schemas"]["WebhookTestResponse"]; }; }; /** Bad Request */ @@ -10525,15 +11422,24 @@ export interface operations { }; }; }; - /** Creates a new project with languages and initial settings. */ - createProject: { + createTasks: { + parameters: { + query: { + filterState?: ( + | "UNTRANSLATED" + | "TRANSLATED" + | "REVIEWED" + | "DISABLED" + )[]; + filterOutdated?: boolean; + }; + path: { + projectId: number; + }; + }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["ProjectModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -10569,19 +11475,20 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CreateProjectRequest"]; + "application/json": components["schemas"]["CreateMultipleTasksRequest"]; }; }; }; - list: { + calculateScope: { parameters: { query: { - /** Zero-based page index (0..N) */ - page?: number; - /** The size of the page to be returned */ - size?: number; - /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ - sort?: string[]; + filterState?: ( + | "UNTRANSLATED" + | "TRANSLATED" + | "REVIEWED" + | "DISABLED" + )[]; + filterOutdated?: boolean; }; path: { projectId: number; @@ -10591,7 +11498,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["PagedModelWebhookConfigModel"]; + "application/json": components["schemas"]["KeysScopeView"]; }; }; /** Bad Request */ @@ -10627,9 +11534,45 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["CalculateScopeRequest"]; + }; + }; }; - create: { + getTasks_1: { parameters: { + query: { + /** Filter tasks by state */ + filterState?: ("NEW" | "IN_PROGRESS" | "DONE" | "CLOSED")[]; + /** Filter tasks without state */ + filterNotState?: ("NEW" | "IN_PROGRESS" | "DONE" | "CLOSED")[]; + /** Filter tasks by assignee */ + filterAssignee?: number[]; + /** Filter tasks by type */ + filterType?: ("TRANSLATE" | "REVIEW")[]; + /** Filter tasks by id */ + filterId?: number[]; + /** Filter tasks without id */ + filterNotId?: number[]; + /** Filter tasks by project */ + filterProject?: number[]; + /** Filter tasks without project */ + filterNotProject?: number[]; + /** Filter tasks by language */ + filterLanguage?: number[]; + /** Filter tasks by key */ + filterKey?: number[]; + /** Exclude "done" tasks which are older than specified timestamp */ + filterDoneMinClosedAt?: number; + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + search?: string; + }; path: { projectId: number; }; @@ -10638,7 +11581,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["WebhookConfigModel"]; + "application/json": components["schemas"]["PagedModelTaskModel"]; }; }; /** Bad Request */ @@ -10674,17 +11617,19 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["WebhookConfigRequest"]; - }; - }; }; - /** Sends a test request to the webhook */ - test: { + createTask: { parameters: { + query: { + filterState?: ( + | "UNTRANSLATED" + | "TRANSLATED" + | "REVIEWED" + | "DISABLED" + )[]; + filterOutdated?: boolean; + }; path: { - id: number; projectId: number; }; }; @@ -10692,7 +11637,7 @@ export interface operations { /** OK */ 200: { content: { - "application/json": components["schemas"]["WebhookTestResponse"]; + "application/json": components["schemas"]["TaskModel"]; }; }; /** Bad Request */ @@ -10728,6 +11673,11 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateTaskRequest"]; + }; + }; }; /** Returns information about keys. (KeyData, Screenshots, Translation in specified language)If key is not found, it's not included in the response. */ getInfo: { @@ -12052,7 +13002,6 @@ export interface operations { format?: | "JSON" | "JSON_TOLGEE" - | "JSON_I18NEXT" | "XLIFF" | "PO" | "APPLE_STRINGS_STRINGSDICT" @@ -12061,7 +13010,9 @@ export interface operations { | "FLUTTER_ARB" | "PROPERTIES" | "YAML_RUBY" - | "YAML"; + | "YAML" + | "JSON_I18NEXT" + | "CSV"; /** * Delimiter to structure file content. * @@ -12629,6 +13580,10 @@ export interface operations { size?: number; /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ sort?: string[]; + /** Filter languages by id */ + filterId?: number[]; + /** Filter languages without id */ + filterNotId?: number[]; }; }; responses: { @@ -13545,13 +14500,88 @@ export interface operations { }; }; }; - /** Returns all organizations owned only by current user */ - getAllSingleOwnedOrganizations: { + /** Returns all organizations owned only by current user */ + getAllSingleOwnedOrganizations: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["CollectionModelSimpleOrganizationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + getTasks: { + parameters: { + query: { + /** Filter tasks by state */ + filterState?: ("NEW" | "IN_PROGRESS" | "DONE" | "CLOSED")[]; + /** Filter tasks without state */ + filterNotState?: ("NEW" | "IN_PROGRESS" | "DONE" | "CLOSED")[]; + /** Filter tasks by assignee */ + filterAssignee?: number[]; + /** Filter tasks by type */ + filterType?: ("TRANSLATE" | "REVIEW")[]; + /** Filter tasks by id */ + filterId?: number[]; + /** Filter tasks without id */ + filterNotId?: number[]; + /** Filter tasks by project */ + filterProject?: number[]; + /** Filter tasks without project */ + filterNotProject?: number[]; + /** Filter tasks by language */ + filterLanguage?: number[]; + /** Filter tasks by key */ + filterKey?: number[]; + /** Exclude "done" tasks which are older than specified timestamp */ + filterDoneMinClosedAt?: number; + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + search?: string; + }; + }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["CollectionModelSimpleOrganizationModel"]; + "application/json": components["schemas"]["PagedModelTaskWithProjectModel"]; }; }; /** Bad Request */ @@ -13812,6 +14842,8 @@ export interface operations { | "content-delivery.manage" | "content-delivery.publish" | "webhooks.manage" + | "tasks.view" + | "tasks.edit" )[]; }; }; @@ -14177,6 +15209,219 @@ export interface operations { }; }; }; + /** Detailed statistics about the task results */ + getXlsxReport: { + parameters: { + path: { + taskNumber: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": string; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** Detailed statistics for every assignee */ + getPerUserReport: { + parameters: { + path: { + taskNumber: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["TaskPerUserReportModel"][]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** If the tasks is blocked by other tasks, it returns numbers of these tasks. */ + getBlockingTasks: { + parameters: { + path: { + taskNumber: number; + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": number[]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + getPossibleAssignees: { + parameters: { + query: { + /** Filter users by id */ + filterId?: number[]; + /** Filter only users that have at least following scopes */ + filterMinimalScope?: string; + /** Filter only users that can view language */ + filterViewLanguageId?: number; + /** Filter only users that can edit language */ + filterEditLanguageId?: number; + /** Filter only users that can edit state of language */ + filterStateLanguageId?: number; + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + search?: string; + }; + path: { + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PagedModelSimpleUserAccountModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; getAllNamespaces: { parameters: { query: { @@ -14329,7 +15574,11 @@ export interface operations { }; }; }; - /** This endpoint helps you to find desired key by keyName, base translation or translation in specified language. */ + /** + * This endpoint helps you to find desired key by keyName, base translation or translation in specified language. + * + * Sort is ignored for this request. + */ searchForKey: { parameters: { query: { @@ -14436,6 +15685,58 @@ export interface operations { }; }; }; + /** + * Returns all project key with any disabled language. + * + * If key has no disabled language, it is not returned. + */ + getDisabledLanguages_2: { + parameters: { + path: { + projectId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["CollectionModelKeyDisabledLanguagesModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; getModifiedEntitiesByRevision: { parameters: { query: { @@ -15445,6 +16746,8 @@ export interface operations { filterRevisionId?: number[]; /** Select only keys which were not successfully translated by batch job with provided id */ filterFailedKeysOfJob?: number; + /** Select only keys which are in specified task */ + filterTaskNumber?: number[]; }; path: { projectId: number; @@ -15543,6 +16846,8 @@ export interface operations { filterRevisionId?: number[]; /** Select only keys which were not successfully translated by batch job with provided id */ filterFailedKeysOfJob?: number; + /** Select only keys which are in specified task */ + filterTaskNumber?: number[]; }; path: { projectId: number; diff --git a/webapp/src/translationTools/useErrorTranslation.ts b/webapp/src/translationTools/useErrorTranslation.ts index 3fc1f72e42..d243a222c3 100644 --- a/webapp/src/translationTools/useErrorTranslation.ts +++ b/webapp/src/translationTools/useErrorTranslation.ts @@ -129,6 +129,7 @@ export function useErrorTranslation() { return t('verify_email_verification_code_not_valid'); case 'user_is_subscribed_to_paid_plan': return t('user_is_subscribed_to_paid_plan'); + // TODO: check and remove unused case 'sso_token_exchange_failed': return t('sso_token_exchange_failed'); case 'sso_id_token_expired': diff --git a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx index 20ffba8fbd..c9979a3393 100644 --- a/webapp/src/views/organizations/sso/OrganizationSsoView.tsx +++ b/webapp/src/views/organizations/sso/OrganizationSsoView.tsx @@ -38,6 +38,8 @@ export const OrganizationSsoView: FunctionComponent = () => { setToggleFormState(event.target.checked); }; + // TODO: Show info when SSO is disabled in configuration + return ( Date: Mon, 11 Nov 2024 18:23:43 +0100 Subject: [PATCH 142/162] feat: wip - sso finalizace --- .../configuration/PublicConfigurationDTO.kt | 26 ++- .../security/thirdParty/OAuthUserHandler.kt | 116 ++++++----- .../security/thirdParty/SsoTenantConfig.kt | 8 +- .../main/kotlin/io/tolgee/api/IUserAccount.kt | 2 +- .../tolgee/AuthenticationProperties.kt | 3 +- .../tolgee/SsoGlobalProperties.kt | 24 ++- .../tolgee/SsoLocalProperties.kt | 33 +++ .../kotlin/io/tolgee/constants/Message.kt | 14 +- .../tolgee/dtos/cacheable/UserAccountDto.kt | 8 +- .../io/tolgee/model/OrganizationRole.kt | 6 + .../main/kotlin/io/tolgee/model/SsoTenant.kt | 3 - .../kotlin/io/tolgee/model/UserAccount.kt | 12 +- .../tolgee/model/enums/ThirdPartyAuthType.kt | 1 + .../repository/OrganizationRoleRepository.kt | 2 + .../repository/UserAccountRepository.kt | 10 +- .../kotlin/io/tolgee/security/constants.kt | 1 - .../organization/OrganizationRoleService.kt | 36 +++- .../organization/OrganizationService.kt | 11 - .../tolgee/service/security/SignUpService.kt | 27 ++- .../main/resources/db/changelog/schema.xml | 28 +-- .../authentication/AuthenticationFilter.kt | 16 +- .../AuthenticationInterceptor.kt | 5 +- .../service/thirdParty/SsoDelegate.kt | 11 +- ...backController.kt => SsoAuthController.kt} | 9 +- .../v2/controllers/SsoProviderController.kt | 13 +- .../tolgee/ee/data/CreateProviderRequest.kt | 15 +- .../ee/security/thirdParty/SsoDelegateEe.kt | 54 ++--- .../io/tolgee/ee/service/sso/TenantService.kt | 27 ++- .../src/test/kotlin/io/tolgee/ee/OAuthTest.kt | 18 +- .../tolgee/ee/utils/OAuthMultiTenantsMocks.kt | 2 +- .../security/Login/LoginCredentialsForm.tsx | 19 +- webapp/src/globalContext/useAuthService.tsx | 2 +- webapp/src/service/apiSchema.generated.ts | 193 +++++++++--------- .../translationTools/useErrorTranslation.ts | 21 +- .../organizations/sso/OrganizationSsoView.tsx | 95 ++++++--- 35 files changed, 484 insertions(+), 387 deletions(-) create mode 100644 backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoLocalProperties.kt rename ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/{OAuth2CallbackController.kt => SsoAuthController.kt} (88%) 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 a000ba2e20..772148011d 100644 --- a/backend/api/src/main/kotlin/io/tolgee/configuration/PublicConfigurationDTO.kt +++ b/backend/api/src/main/kotlin/io/tolgee/configuration/PublicConfigurationDTO.kt @@ -68,13 +68,15 @@ class PublicConfigurationDTO( oauth2.authorizationUrl, oauth2.scopes, ), - SsoPublicConfigDTO( - sso.enabled, - sso.globalEnabled, - sso.clientId, - sso.domain, - sso.customLogoUrl, - sso.customLoginText, + SsoGlobalPublicConfigDTO( + ssoGlobal.enabled, + ssoGlobal.clientId, + ssoGlobal.domain, + ssoGlobal.customLogoUrl, + ssoGlobal.customLoginText, + ), + SsoOrganizationsPublicConfigDTO( + ssoOrganizations.enabled, ), ) } @@ -84,7 +86,8 @@ class PublicConfigurationDTO( val github: OAuthPublicConfigDTO, val google: OAuthPublicConfigDTO, val oauth2: OAuthPublicExtendsConfigDTO, - val sso: SsoPublicConfigDTO, + val ssoGlobal: SsoGlobalPublicConfigDTO, + val ssoOrganizations: SsoOrganizationsPublicConfigDTO, ) data class OAuthPublicConfigDTO(val clientId: String?) { @@ -99,15 +102,18 @@ class PublicConfigurationDTO( val enabled: Boolean = !clientId.isNullOrEmpty() } - data class SsoPublicConfigDTO( + data class SsoGlobalPublicConfigDTO( val enabled: Boolean, - val globalEnabled: Boolean, val clientId: String?, val domain: String?, val customLogoUrl: String?, val customLoginText: String?, ) + data class SsoOrganizationsPublicConfigDTO( + val enabled: Boolean, + ) + data class MtServicesDTO( val defaultPrimaryService: MtServiceType?, val services: Map, diff --git a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt index 4eaffb16cd..8c2d4a5ce6 100644 --- a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/OAuthUserHandler.kt @@ -1,23 +1,26 @@ 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.OrganizationRoleType import io.tolgee.model.enums.ThirdPartyAuthType -import io.tolgee.security.SSO_SESSION_EXPIRATION_MINUTES 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.Date @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, ) { @@ -27,63 +30,69 @@ class OAuthUserHandler( thirdPartyAuthType: ThirdPartyAuthType, accountType: UserAccount.AccountType, ): UserAccount { - val tenant = userResponse.tenant - val userAccountOptional = - if (thirdPartyAuthType == ThirdPartyAuthType.SSO && tenant != null) { - userAccountService.findBySsoTenantId(tenant.entity?.id, userResponse.sub!!) + 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!!) } - if (userAccountOptional.isPresent && thirdPartyAuthType == ThirdPartyAuthType.SSO) { - updateRefreshToken(userAccountOptional.get(), userResponse.refreshToken) - updateSsoSessionExpiry(userAccountOptional.get()) + userAccountOptional.ifPresent { + if ( + thirdPartyAuthType == ThirdPartyAuthType.SSO || + thirdPartyAuthType == ThirdPartyAuthType.SSO_GLOBAL + ) { + updateRefreshToken(it, userResponse.refreshToken) + resetSsoSessionExpiry(it) + } } return userAccountOptional.orElseGet { - userAccountService.findActive(userResponse.email)?.let { - throw AuthenticationException(Message.USERNAME_ALREADY_EXISTS) - } + createUser(userResponse, invitationCode, thirdPartyAuthType, accountType) + } + } - val newUserAccount = UserAccount() - newUserAccount.username = userResponse.email + 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] - } + 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 - if (tenant?.entity != null) { - newUserAccount.ssoTenant = tenant.entity } - newUserAccount.thirdPartyAuthType = thirdPartyAuthType - newUserAccount.ssoRefreshToken = userResponse.refreshToken - newUserAccount.accountType = accountType - newUserAccount.ssoSessionExpiry = currentDateProvider.date.addMinutes(SSO_SESSION_EXPIRATION_MINUTES) + newUserAccount.name = name + newUserAccount.thirdPartyAuthId = userResponse.sub + newUserAccount.thirdPartyAuthType = thirdPartyAuthType + newUserAccount.ssoRefreshToken = userResponse.refreshToken + newUserAccount.accountType = accountType + newUserAccount.ssoSessionExpiry = ssoCurrentExpiration(thirdPartyAuthType) - signUpService.signUp(newUserAccount, invitationCode, null) - - // grant role to user only if request is not from oauth2 delegate - val organization = tenant?.organization - if (organization?.id != null && - thirdPartyAuthType != ThirdPartyAuthType.OAUTH2 && - invitationCode == null - ) { - organizationRoleService.grantRoleToUser( - newUserAccount, - organization.id, - OrganizationRoleType.MEMBER, - ) - } + val organization = userResponse.tenant?.organization + signUpService.signUp(newUserAccount, invitationCode, null, organizationForced = organization) - newUserAccount + if (organization != null) { + organizationRoleService.setManaged(newUserAccount, organization, true) } + + return newUserAccount } fun updateRefreshToken( @@ -104,14 +113,23 @@ class OAuthUserHandler( updateRefreshToken(userAccount, refreshToken) } - fun updateSsoSessionExpiry(user: UserAccount) { - user.ssoSessionExpiry = currentDateProvider.date.addMinutes(SSO_SESSION_EXPIRATION_MINUTES) + fun resetSsoSessionExpiry(user: UserAccount) { + user.ssoSessionExpiry = ssoCurrentExpiration(user.thirdPartyAuthType) userAccountService.save(user) } - fun updateSsoSessionExpiry(userAccountId: Long) { + fun resetSsoSessionExpiry(userAccountId: Long) { val user = userAccountService.get(userAccountId) - user.ssoSessionExpiry = currentDateProvider.date.addMinutes(SSO_SESSION_EXPIRATION_MINUTES) - userAccountService.save(user) + 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 index e56456351c..2dfc438a55 100644 --- a/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/SsoTenantConfig.kt +++ b/backend/api/src/main/kotlin/io/tolgee/security/thirdParty/SsoTenantConfig.kt @@ -15,8 +15,8 @@ data class SsoTenantConfig( val domain: String, val jwkSetUri: String, val tokenUri: String, + val global: Boolean, val organization: Organization? = null, - val entity: SsoTenant? = null, ) { companion object { fun SsoTenant.toConfig(): SsoTenantConfig? { @@ -32,13 +32,13 @@ data class SsoTenantConfig( domain = domain, jwkSetUri = jwkSetUri, tokenUri = tokenUri, + global = false, organization = organization, - entity = this, ) } fun SsoGlobalProperties.toConfig(): SsoTenantConfig? { - if (!globalEnabled) { + if (!enabled) { return null } @@ -50,10 +50,10 @@ data class SsoTenantConfig( domain = ::domain.validate(), jwkSetUri = ::jwkSetUri.validate(), tokenUri = ::tokenUri.validate(), + global = true, ) } - // TODO: specific message "$name is missing in global SSO configuration", private fun KProperty0.validate(): T = this.get() ?: throw BadRequestException( Message.SSO_GLOBAL_CONFIG_MISSING_PROPERTIES, 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/configuration/tolgee/AuthenticationProperties.kt b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/AuthenticationProperties.kt index 351e2235aa..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 @@ -147,7 +147,8 @@ class AuthenticationProperties( var github: GithubAuthenticationProperties = GithubAuthenticationProperties(), var google: GoogleAuthenticationProperties = GoogleAuthenticationProperties(), var oauth2: OAuth2AuthenticationProperties = OAuth2AuthenticationProperties(), - var sso: SsoGlobalProperties = SsoGlobalProperties(), + 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 index d4a1f63bad..bbbf4c69b0 100644 --- a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoGlobalProperties.kt +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/SsoGlobalProperties.kt @@ -3,31 +3,25 @@ package io.tolgee.configuration.tolgee import io.tolgee.configuration.annotations.DocProperty import org.springframework.boot.context.properties.ConfigurationProperties -// TODO: Should we allow "Global SSO" users to manage/create their own organizations? -// Basically the global SSO would become a separate login method similar to other oAuth providers. -@ConfigurationProperties(prefix = "tolgee.authentication.sso") +@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" + - " just set the `enable` to `true` and configure it separately for each organization in the" + - " organization settings.\n\n" + + " 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. In both cases though, the SSO user has no" + - " rights to create or manage organizations. Global SSO users has to be invited to organizations they should" + + " 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") + @DocProperty(description = "Enables SSO authentication on global level - as a login method for the whole server") var enabled: Boolean = false - val globalEnabled: Boolean - get() = enabled && !domain.isNullOrEmpty() - @DocProperty(description = "Unique identifier for an application") var clientId: String? = null @@ -46,6 +40,14 @@ class SsoGlobalProperties { @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`. " + 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/Message.kt b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt index f6268736c2..79d1e64fd9 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -235,11 +235,6 @@ enum class Message { SLACK_WORKSPACE_ALREADY_CONNECTED, SLACK_CONNECTION_ERROR, EMAIL_VERIFICATION_CODE_NOT_VALID, - SSO_THIRD_PARTY_AUTH_FAILED, - SSO_TOKEN_EXCHANGE_FAILED, - SSO_USER_INFO_RETRIEVAL_FAILED, - SSO_ID_TOKEN_EXPIRED, - SSO_DOMAIN_NOT_ENABLED, CANNOT_SUBSCRIBE_TO_FREE_PLAN, PLAN_AUTO_ASSIGNMENT_ONLY_FOR_FREE_PLANS, PLAN_AUTO_ASSIGNMENT_ONLY_FOR_PRIVATE_PLANS, @@ -247,14 +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_USER_CANT_LOGIN_WITH_NATIVE, SSO_GLOBAL_CONFIG_MISSING_PROPERTIES, + SSO_AUTH_MISSING_DOMAIN, SSO_DOMAIN_NOT_FOUND_OR_DISABLED, NATIVE_AUTHENTICATION_DISABLED, - SSO_USER_NOT_INVITED, - // TODO: remove unused + INVITATION_ORGANIZATION_MISMATCH, + USER_IS_MANAGED_BY_ORGANIZATION, ; val code: String 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 8c82003df9..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,14 +15,12 @@ data class UserAccountDto( val deleted: Boolean, val tokensValidNotBefore: Date?, val emailVerified: Boolean, - val thirdPartyAuth: String?, + val thirdPartyAuth: ThirdPartyAuthType?, val ssoRefreshToken: String?, - val ssoDomain: String?, val ssoSessionExpiry: Date?, ) : Serializable { companion object { fun fromEntity(entity: UserAccount) = - // FIXME: handle ssoDomain for global sso properly UserAccountDto( name = entity.name, username = entity.username, @@ -32,9 +31,8 @@ data class UserAccountDto( deleted = entity.deletedAt != null, tokensValidNotBefore = entity.tokensValidNotBefore, emailVerified = entity.emailVerification == null, - thirdPartyAuth = entity.thirdPartyAuthType?.code(), + thirdPartyAuth = entity.thirdPartyAuthType, ssoRefreshToken = entity.ssoRefreshToken, - ssoDomain = entity.ssoTenant?.domain, ssoSessionExpiry = entity.ssoSessionExpiry, ) } 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 index 8947cc2c58..dbc08bbf34 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/SsoTenant.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/SsoTenant.kt @@ -23,9 +23,6 @@ class SsoTenant : StandardAuditModel() { @OneToOne(fetch = FetchType.LAZY) lateinit var organization: Organization - @OneToMany(fetch = FetchType.LAZY, mappedBy = "ssoTenant") - var userAccounts: MutableSet = mutableSetOf() - @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 08c3c9cc6b..732875fdb7 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/UserAccount.kt @@ -19,7 +19,6 @@ import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.ManyToMany -import jakarta.persistence.ManyToOne import jakarta.persistence.OneToMany import jakarta.persistence.OneToOne import jakarta.persistence.OrderBy @@ -64,12 +63,11 @@ data class UserAccount( @Convert(converter = ThirdPartyAuthTypeConverter::class) var thirdPartyAuthType: ThirdPartyAuthType? = null - // TODO: replace with link from OrganizationRole - @ManyToOne(fetch = FetchType.LAZY) - var ssoTenant: SsoTenant? = null - @Column(name = "sso_refresh_token", columnDefinition = "TEXT") - var ssoRefreshToken: String? = null // TODO: to jwt token + var ssoRefreshToken: String? = null + + @Column(name = "sso_session_expiry") + var ssoSessionExpiry: Date? = null @Column(name = "third_party_auth_id") var thirdPartyAuthId: String? = null @@ -77,8 +75,6 @@ data class UserAccount( @Column(name = "reset_password_code") var resetPasswordCode: String? = null - var ssoSessionExpiry: Date? = null // TODO: to jwt token - @OrderBy("id ASC") @OneToMany(mappedBy = "user", orphanRemoval = true) var organizationRoles: MutableList = mutableListOf() 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 index 2a80773d87..cf4aea8787 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/enums/ThirdPartyAuthType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/enums/ThirdPartyAuthType.kt @@ -5,6 +5,7 @@ enum class ThirdPartyAuthType { GITHUB, OAUTH2, SSO, + SSO_GLOBAL, ; fun code(): String = name.lowercase() diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRoleRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRoleRepository.kt index bca49eee78..eb2146e186 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRoleRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRoleRepository.kt @@ -15,6 +15,8 @@ interface OrganizationRoleRepository : JpaRepository { organizationId: Long, ): OrganizationRole? + fun findOneByUserIdAndManagedIsTrue(userId: Long): OrganizationRole? + fun countAllByOrganizationIdAndTypeAndUserIdNot( id: Long, owner: OrganizationRoleType, diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt index b20f1147c5..b925d89bca 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt @@ -166,8 +166,11 @@ interface UserAccountRepository : JpaRepository { @Query( """ from UserAccount ua + join OrganizationRole orl on orl.user = ua + join Organization o on orl.organization = o where ua.thirdPartyAuthId = :thirdPartyAuthId - and ua.ssoTenant.domain = :domain + and orl.managed = true + and o.ssoTenant.domain = :domain and ua.deletedAt is null and ua.disabledAt is null """, @@ -180,8 +183,11 @@ interface UserAccountRepository : JpaRepository { @Query( """ from UserAccount ua + join OrganizationRole orl on orl.user = ua + join Organization o on orl.organization = o where ua.thirdPartyAuthId = :thirdPartyAuthId - and ((:ssoTenantId is null and ua.ssoTenant is null) or ua.ssoTenant.id = :ssoTenantId) + 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 """, diff --git a/backend/data/src/main/kotlin/io/tolgee/security/constants.kt b/backend/data/src/main/kotlin/io/tolgee/security/constants.kt index 1efaa1b443..5564fe4dd9 100644 --- a/backend/data/src/main/kotlin/io/tolgee/security/constants.kt +++ b/backend/data/src/main/kotlin/io/tolgee/security/constants.kt @@ -2,4 +2,3 @@ package io.tolgee.security const val PROJECT_API_KEY_PREFIX = "tgpak_" const val PAT_PREFIX = "tgpat_" -const val SSO_SESSION_EXPIRATION_MINUTES = 10 // TODO: configurable? 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 3dcfa7f0e9..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) @@ -206,17 +227,6 @@ class OrganizationRoleService( } } - fun grantRoleToUser( - user: UserAccount, - organizationId: Long, - organizationRoleType: OrganizationRoleType, - ) { - // TODO: check if we can pass org as obj instead of id - val organization = organizationRepository.findById(organizationId).orElseThrow { NotFoundException() } - - self.grantRoleToUser(user, organization, organizationRoleType = organizationRoleType) - } - fun leave(organizationId: Long) { this.removeUser(organizationId, authenticationFacade.authenticatedUser.id) } @@ -225,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 5b1f570dac..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 @@ -13,7 +13,6 @@ import io.tolgee.events.BeforeOrganizationDeleteEvent import io.tolgee.exceptions.NotFoundException import io.tolgee.model.Organization import io.tolgee.model.Permission -import io.tolgee.model.SsoTenant import io.tolgee.model.UserAccount import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.model.enums.ProjectPermissionType @@ -401,14 +400,4 @@ class OrganizationService( ): OrganizationView? { return organizationRepository.findView(id, currentUserId) } - - fun updateSsoProvider( - organizationId: Long, - tenant: SsoTenant, - ) { - // FIXME: shouldn't be needed - org doesn't own the tenant relation and ref should update automatically - val organization = get(organizationId) - organization.ssoTenant = tenant - save(organization) - } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/SignUpService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/SignUpService.kt index 44d91621b1..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.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, ) { @@ -51,6 +55,7 @@ class SignUpService( invitationCode: String?, organizationName: String?, userSource: String? = null, + organizationForced: Organization? = null, ): UserAccount { if (invitationCode == null && entity.accountType != UserAccount.AccountType.MANAGED && @@ -62,16 +67,24 @@ class SignUpService( 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, + ) } - if (user.thirdPartyAuthType == ThirdPartyAuthType.SSO) { - // No organization is created for SSO user - return user - } - - 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) diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index 31083597c1..ba18279603 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -3878,7 +3878,7 @@ - + @@ -3904,31 +3904,33 @@ - - - + + + + + - + - + - + - + - + - + - - + + - + 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 73beddeb68..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 @@ -22,7 +22,6 @@ import io.tolgee.constants.Message import io.tolgee.dtos.cacheable.UserAccountDto import io.tolgee.exceptions.AuthExpiredException import io.tolgee.exceptions.AuthenticationException -import io.tolgee.model.enums.ThirdPartyAuthType import io.tolgee.security.PAT_PREFIX import io.tolgee.security.ratelimit.RateLimitService import io.tolgee.security.service.thirdParty.SsoDelegate @@ -119,20 +118,7 @@ class AuthenticationFilter( } private fun checkIfSsoUserStillExists(userDto: UserAccountDto) { - val authTypeStr = userDto.thirdPartyAuth - if (authTypeStr == null) { - return - } - val thirdPartyAuthType = ThirdPartyAuthType.valueOf(authTypeStr.uppercase()) - - if (!ssoDelegate.verifyUserSsoAccountAvailable( - userDto.ssoDomain, - userDto.id, - userDto.ssoRefreshToken, - thirdPartyAuthType, - userDto.ssoSessionExpiry, - ) - ) { + if (!ssoDelegate.verifyUserSsoAccountAvailable(userDto)) { throw AuthExpiredException(Message.SSO_CANT_VERIFY_USER) } } 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 be7ecb5718..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,8 +72,9 @@ class AuthenticationInterceptor( authenticationProperties.enabled && authenticationFacade.authenticatedUser.needsSuperJwt && !authenticationFacade.isUserSuperAuthenticated - // TODO: && authentication.nativeEnabled ?? or how do we know if user can use password? (we can't just check if user has password since it can be set before native auth was disabled) - // NOTE: two-factor authentication can still be used + // 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 index 52f62b7b78..5ae0c68a6b 100644 --- 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 @@ -1,9 +1,8 @@ package io.tolgee.security.service.thirdParty -import io.tolgee.model.enums.ThirdPartyAuthType +import io.tolgee.dtos.cacheable.UserAccountDto import io.tolgee.security.payload.JwtAuthenticationResponse import org.springframework.stereotype.Component -import java.util.Date @Component interface SsoDelegate { @@ -14,11 +13,5 @@ interface SsoDelegate { domain: String?, ): JwtAuthenticationResponse - fun verifyUserSsoAccountAvailable( - ssoDomain: String?, - userId: Long, - refreshToken: String?, - thirdPartyAuth: ThirdPartyAuthType, - ssoSessionExpiry: Date?, - ): Boolean + fun verifyUserSsoAccountAvailable(user: UserAccountDto): Boolean } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoAuthController.kt similarity index 88% rename from ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt rename to ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoAuthController.kt index 91d2aa69e3..c559519b65 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/OAuth2CallbackController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/SsoAuthController.kt @@ -1,5 +1,6 @@ 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 @@ -10,14 +11,14 @@ import io.tolgee.security.thirdParty.SsoTenantConfig import org.springframework.web.bind.annotation.* @RestController -@RequestMapping("v2/public/oauth2/callback/") -class OAuth2CallbackController( +@RequestMapping("/api/public") +@Tag(name = "Authentication") +class SsoAuthController( private val tenantService: TenantService, private val frontendUrlProvider: FrontendUrlProvider, private val enabledFeaturesProvider: EnabledFeaturesProvider, ) { - // TODO: Move to PublicController? - @PostMapping("/get-authentication-url") + @PostMapping("/authorize_oauth/sso/authentication-url") fun getAuthenticationUrl( @RequestBody request: DomainRequest, ): SsoUrlResponse { 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 index 3c4d97446a..75a4010b9a 100644 --- 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 @@ -9,9 +9,9 @@ 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.OrganizationHolder 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.* @@ -22,7 +22,7 @@ class SsoProviderController( private val tenantService: TenantService, private val ssoTenantAssembler: SsoTenantAssembler, private val enabledFeaturesProvider: EnabledFeaturesProvider, - private val organizationHolder: OrganizationHolder, + private val organizationService: OrganizationService, ) { @RequiresOrganizationRole(role = OrganizationRoleType.OWNER) @PutMapping("") @@ -32,12 +32,12 @@ class SsoProviderController( @PathVariable organizationId: Long, ): SsoTenantModel { enabledFeaturesProvider.checkFeatureEnabled( - organizationId = - organizationHolder.organization.id, + organizationId = organizationId, Feature.SSO, ) - return ssoTenantAssembler.toModel(tenantService.saveOrUpdate(request, organizationId).toDto()) + val organization = organizationService.get(organizationId) + return ssoTenantAssembler.toModel(tenantService.saveOrUpdate(request, organization).toDto()) } @RequiresOrganizationRole(role = OrganizationRoleType.OWNER) @@ -48,8 +48,7 @@ class SsoProviderController( ): SsoTenantModel? = try { enabledFeaturesProvider.checkFeatureEnabled( - organizationId = - organizationHolder.organization.id, + organizationId = organizationId, Feature.SSO, ) 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 index 0da95b2eee..e5e72630ab 100644 --- 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 @@ -1,23 +1,22 @@ package io.tolgee.ee.data -import jakarta.validation.constraints.NotEmpty +import jakarta.validation.constraints.NotNull import org.springframework.validation.annotation.Validated -// TODO: check how validation between backend and frontend is handled @Validated data class CreateProviderRequest( val name: String?, - @field:NotEmpty + @field:NotNull val clientId: String, - @field:NotEmpty + @field:NotNull val clientSecret: String, - @field:NotEmpty + @field:NotNull val authorizationUri: String, - @field:NotEmpty + @field:NotNull val tokenUri: String, - @field:NotEmpty + @field:NotNull val jwkSetUri: String, val isEnabled: Boolean, - @field:NotEmpty + @field:NotNull val domainName: String, ) 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 index 33c8c19c3b..6494c64964 100644 --- 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 @@ -13,6 +13,7 @@ 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 @@ -27,6 +28,7 @@ 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.* @@ -44,6 +46,7 @@ class SsoDelegateEe( 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, @@ -55,9 +58,8 @@ class SsoDelegateEe( redirectUri: String?, domain: String?, ): JwtAuthenticationResponse { - if (domain == null) { - // TODO: specific message "Missing parameter: domain" - throw BadRequestException("Missing parameter: domain") + if (domain.isNullOrEmpty()) { + throw BadRequestException(Message.SSO_AUTH_MISSING_DOMAIN) } val tenant = tenantService.getEnabledConfigByDomain(domain) @@ -115,6 +117,7 @@ class SsoDelegateEe( try { val signedJWT = SignedJWT.parse(idToken) + // TODO: Why is this deprecated? val jwkSource: JWKSource = RemoteJWKSet(URL(jwkSetUri)) val keySelector = JWSAlgorithmFamilyJWSKeySelector(JWSAlgorithm.Family.RSA, jwkSource) @@ -165,32 +168,35 @@ class SsoDelegateEe( oAuthUserHandler.findOrCreateUser( userData, invitationCode, - ThirdPartyAuthType.SSO, + if (tenant.global) ThirdPartyAuthType.SSO_GLOBAL else ThirdPartyAuthType.SSO, UserAccount.AccountType.MANAGED, ) val jwt = jwtService.emitToken(user.id) return JwtAuthenticationResponse(jwt) } - override fun verifyUserSsoAccountAvailable( - ssoDomain: String?, - userId: Long, - refreshToken: String?, - thirdPartyAuth: ThirdPartyAuthType, - ssoSessionExpiry: Date?, - ): Boolean { - if (thirdPartyAuth != ThirdPartyAuthType.SSO) { - return true - } + fun fetchLocalSsoDomainFor(userId: Long): String? { + // TODO: Cache this? + val organization = organizationRoleService.getManagedBy(userId) ?: return null + val tenant = tenantService.findTenant(organization.id) + return tenant?.domain + } - var domain = ssoDomain - if (domain == null) { - domain = ssoGlobalProperties.domain - } - if (domain == null || refreshToken == null) { + 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 (ssoSessionExpiry != null && isSsoUserValid(ssoSessionExpiry)) { + if (user.ssoSessionExpiry?.after(currentDateProvider.date) == true) { return true } @@ -209,7 +215,7 @@ class SsoDelegateEe( body.add("client_id", tenant.clientId) body.add("client_secret", tenant.clientSecret) body.add("scope", "offline_access openid") - body.add("refresh_token", refreshToken) + body.add("refresh_token", user.ssoRefreshToken) val request = HttpEntity(body, headers) return try { @@ -221,8 +227,8 @@ class SsoDelegateEe( OAuth2TokenResponse::class.java, ) if (response.body?.refresh_token != null) { - oAuthUserHandler.updateSsoSessionExpiry(userId) - oAuthUserHandler.updateRefreshToken(userId, response.body?.refresh_token) + oAuthUserHandler.resetSsoSessionExpiry(user.id) + oAuthUserHandler.updateRefreshToken(user.id, response.body?.refresh_token) return true } false @@ -231,6 +237,4 @@ class SsoDelegateEe( false } } - - private fun isSsoUserValid(ssoSessionExpiry: Date): Boolean = ssoSessionExpiry.after(currentDateProvider.date) } 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 index a0a96bd5c2..68b4354a38 100644 --- 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 @@ -1,21 +1,22 @@ 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.organization.OrganizationService import org.springframework.stereotype.Service @Service class TenantService( private val tenantRepository: TenantRepository, private val ssoGlobalProperties: SsoGlobalProperties, - private val organizationService: OrganizationService, + private val ssoLocalProperties: SsoLocalProperties, ) { fun getById(id: Long): SsoTenant = tenantRepository.findById(id).orElseThrow { NotFoundException() } @@ -25,9 +26,11 @@ class TenantService( fun getEnabledConfigByDomain(domain: String): SsoTenantConfig { return ssoGlobalProperties - .takeIf { it.globalEnabled && domain == it.domain } + .takeIf { it.enabled && domain == it.domain } ?.toConfig() - ?: domain.let { tenantRepository.findEnabledByDomain(it)?.toConfig() } + ?: domain + .takeIf { ssoLocalProperties.enabled } + ?.let { tenantRepository.findEnabledByDomain(it)?.toConfig() } ?: throw NotFoundException(Message.SSO_DOMAIN_NOT_FOUND_OR_DISABLED) } @@ -41,20 +44,19 @@ class TenantService( fun saveOrUpdate( request: CreateProviderRequest, - organizationId: Long, + organization: Organization, ): SsoTenant { - // TODO: pass organization directly - val tenant = findTenant(organizationId) ?: SsoTenant() - return setAndSaveTenantsFields(tenant, request, organizationId) + val tenant = findTenant(organization.id) ?: SsoTenant() + return setAndSaveTenantsFields(tenant, request, organization) } private fun setAndSaveTenantsFields( tenant: SsoTenant, dto: CreateProviderRequest, - organizationId: Long, + organization: Organization, ): SsoTenant { tenant.name = dto.name ?: "" - tenant.organization = organizationService.get(organizationId) + tenant.organization = organization tenant.domain = dto.domainName tenant.clientId = dto.clientId tenant.clientSecret = dto.clientSecret @@ -62,9 +64,6 @@ class TenantService( tenant.tokenUri = dto.tokenUri tenant.jwkSetUri = dto.jwkSetUri tenant.enabled = dto.isEnabled - val saved = save(tenant) - // TODO: don't update organization like this - organizationService.updateSsoProvider(organizationId, saved) - return saved + return save(tenant) } } 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 index 1ee02e4612..8c7ccdebe3 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/OAuthTest.kt @@ -7,6 +7,7 @@ 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 @@ -175,22 +176,19 @@ class OAuthTest : AuthorizedControllerTest() { val userName = jwtClaimsSet.getStringClaim("email") val user = userAccountService.get(userName) assertThat(user.ssoRefreshToken).isNotNull - assertThat(user.ssoTenant).isNotNull + val managedBy = organizationRoleService.getManagedBy(user.id) + assertThat(managedBy).isNotNull assertThat(user.thirdPartyAuthType?.code()).isEqualTo("sso") } @Test - fun `user is employee validation works`() { + fun `user account available validation works`() { loginAsSsoUser() val userName = jwtClaimsSet.getStringClaim("email") val user = userAccountService.get(userName) assertThat( ssoDelegate.verifyUserSsoAccountAvailable( - user.ssoTenant?.domain, - user.id, - user.ssoRefreshToken, - user.thirdPartyAuthType!!, - user.ssoSessionExpiry, + UserAccountDto.fromEntity(user), ), ).isTrue } @@ -206,11 +204,7 @@ class OAuthTest : AuthorizedControllerTest() { oAuthMultiTenantsMocks.mockTokenExchange("http://tokenUri") assertThat( ssoDelegate.verifyUserSsoAccountAvailable( - user.ssoTenant?.domain, - user.id, - user.ssoRefreshToken, - user.thirdPartyAuthType!!, - user.ssoSessionExpiry, + UserAccountDto.fromEntity(user), ), ).isTrue 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 index 25173cf793..8bb66728a7 100644 --- 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 @@ -119,7 +119,7 @@ class OAuthMultiTenantsMocks( authMvc!! .perform( MockMvcRequestBuilders - .post("/v2/public/oauth2/callback/get-authentication-url") + .post("/api/public/authorize_oauth/sso/authentication-url") .contentType(MediaType.APPLICATION_JSON) .content( """ diff --git a/webapp/src/component/security/Login/LoginCredentialsForm.tsx b/webapp/src/component/security/Login/LoginCredentialsForm.tsx index e8c3dd41cf..86c7c8d134 100644 --- a/webapp/src/component/security/Login/LoginCredentialsForm.tsx +++ b/webapp/src/component/security/Login/LoginCredentialsForm.tsx @@ -41,13 +41,14 @@ export function LoginCredentialsForm(props: LoginViewCredentialsProps) { const oAuthServices = useOAuthServices(); const nativeEnabled = remoteConfig.nativeEnabled; - const ssoEnabled = remoteConfig.authMethods?.sso.enabled ?? false; - const globalSsoEnabled = remoteConfig.authMethods?.sso.globalEnabled ?? false; + const localSsoEnabled = + remoteConfig.authMethods?.ssoOrganizations.enabled ?? false; + const globalSsoEnabled = remoteConfig.authMethods?.ssoGlobal.enabled ?? false; const hasNonNativeAuthMethods = - oAuthServices.length > 0 || ssoEnabled || globalSsoEnabled; + oAuthServices.length > 0 || localSsoEnabled || globalSsoEnabled; const noLoginMethods = !nativeEnabled && !hasNonNativeAuthMethods; - const customLogoUrl = remoteConfig.authMethods?.sso.customLogoUrl; + const customLogoUrl = remoteConfig.authMethods?.ssoGlobal.customLogoUrl; // TODO: the custom logo is just displayed on button, is that expected? const logoIcon = customLogoUrl ? ( ); - const customLoginText = remoteConfig.authMethods?.sso.customLoginText; + const customLoginText = remoteConfig.authMethods?.ssoGlobal.customLoginText; const loginText = customLoginText ? ( {customLoginText} ) : ( @@ -66,7 +67,7 @@ export function LoginCredentialsForm(props: LoginViewCredentialsProps) { ); function globalSsoLogin() { - loginRedirectSso(remoteConfig.authMethods?.sso.domain as string); + loginRedirectSso(remoteConfig.authMethods?.ssoGlobal.domain as string); } return ( @@ -115,9 +116,9 @@ export function LoginCredentialsForm(props: LoginViewCredentialsProps) { )} - {ssoEnabled && ( + {(localSsoEnabled || globalSsoEnabled) && ( - {!globalSsoEnabled && ( + {localSsoEnabled && (