From c8a0a2260060f388756b4fa72f7dfab1b9e7596e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Tue, 4 Feb 2025 16:07:28 +0100 Subject: [PATCH] feat: finalize implementation of third-party auth provider change logic --- .../AuthProviderChangeController.kt | 14 ++++- .../request/auth/AuthProviderChangeData.kt | 6 +- .../tolgee/model/AuthProviderChangeRequest.kt | 5 ++ .../AuthProviderChangeRequestRepository.kt | 4 +- .../security/AuthProviderChangeService.kt | 23 +++++-- .../service/security/UserAccountService.kt | 17 ++++-- .../main/resources/db/changelog/schema.xml | 9 +-- e2e/cypress/support/dataCyType.d.ts | 5 ++ .../PendingAuthProviderChangeBanner.tsx | 4 +- .../component/layout/TopBanner/TopBanner.tsx | 29 +++++++-- .../security/AcceptAuthProviderChangeView.tsx | 61 ++++++++++++++----- webapp/src/globalContext/useAuthService.tsx | 17 +++--- webapp/src/service/apiSchema.generated.ts | 6 +- .../translationTools/useErrorTranslation.ts | 2 + 14 files changed, 151 insertions(+), 51 deletions(-) diff --git a/backend/api/src/main/kotlin/io/tolgee/controllers/AuthProviderChangeController.kt b/backend/api/src/main/kotlin/io/tolgee/controllers/AuthProviderChangeController.kt index e578b961a7..957c173273 100644 --- a/backend/api/src/main/kotlin/io/tolgee/controllers/AuthProviderChangeController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/controllers/AuthProviderChangeController.kt @@ -6,8 +6,11 @@ import io.tolgee.exceptions.NotFoundException import io.tolgee.security.authentication.AllowApiAccess import io.tolgee.security.authentication.AuthTokenType import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.security.authentication.JwtService import io.tolgee.security.authentication.RequiresSuperAuthentication +import io.tolgee.security.payload.JwtAuthenticationResponse import io.tolgee.service.security.AuthProviderChangeService +import io.tolgee.service.security.UserAccountService import org.springframework.transaction.annotation.Transactional import org.springframework.web.bind.annotation.CrossOrigin import org.springframework.web.bind.annotation.GetMapping @@ -22,6 +25,8 @@ import org.springframework.web.bind.annotation.RestController class AuthProviderChangeController( private val authenticationFacade: AuthenticationFacade, private val authProviderChangeService: AuthProviderChangeService, + private val userAccountService: UserAccountService, + private val jwtService: JwtService, ) { @GetMapping("/current") @Operation(summary = "Get current third party authentication provider") @@ -44,8 +49,13 @@ class AuthProviderChangeController( @AllowApiAccess(AuthTokenType.ONLY_PAT) @RequiresSuperAuthentication @Transactional - fun acceptChangeAuthProvider() { - authProviderChangeService.acceptProviderChange(authenticationFacade.authenticatedUserEntity) + fun acceptChangeAuthProvider(): JwtAuthenticationResponse { + val user = authenticationFacade.authenticatedUserEntity + authProviderChangeService.acceptProviderChange(user) + userAccountService.invalidateTokens(user) + return JwtAuthenticationResponse( + jwtService.emitToken(authenticationFacade.authenticatedUser.id, true), + ) } @PostMapping("/changed/reject") diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/auth/AuthProviderChangeData.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/auth/AuthProviderChangeData.kt index 97089d0b09..fbf63c84e2 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/auth/AuthProviderChangeData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/auth/AuthProviderChangeData.kt @@ -3,6 +3,8 @@ package io.tolgee.dtos.request.auth import io.tolgee.model.AuthProviderChangeRequest import io.tolgee.model.UserAccount import io.tolgee.model.enums.ThirdPartyAuthType +import org.apache.commons.lang3.time.DateUtils +import java.util.Calendar import java.util.Date data class AuthProviderChangeData( @@ -14,9 +16,11 @@ data class AuthProviderChangeData( var ssoRefreshToken: String? = null, var ssoExpiration: Date? = null, ) { - fun asAuthProviderChangeRequest(): AuthProviderChangeRequest { + fun asAuthProviderChangeRequest(expirationDate: Date): AuthProviderChangeRequest { return AuthProviderChangeRequest().also { it.userAccount = this.userAccount + it.expirationDate = DateUtils.truncate(expirationDate, Calendar.SECOND) + it.accountType = this.accountType it.authType = this.authType it.authId = this.authId it.ssoDomain = this.ssoDomain diff --git a/backend/data/src/main/kotlin/io/tolgee/model/AuthProviderChangeRequest.kt b/backend/data/src/main/kotlin/io/tolgee/model/AuthProviderChangeRequest.kt index beeaab36a3..23228feef0 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/AuthProviderChangeRequest.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/AuthProviderChangeRequest.kt @@ -5,6 +5,8 @@ import io.tolgee.model.enums.ThirdPartyAuthType import jakarta.persistence.Column import jakarta.persistence.Convert import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated import jakarta.persistence.OneToOne import java.util.Date @@ -13,6 +15,9 @@ class AuthProviderChangeRequest : StandardAuditModel() { @OneToOne(optional = false) var userAccount: UserAccount? = null + var expirationDate: Date? = null + + @Enumerated(EnumType.STRING) var accountType: UserAccount.AccountType? = null @Convert(converter = ThirdPartyAuthTypeConverter::class) diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/AuthProviderChangeRequestRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/AuthProviderChangeRequestRepository.kt index af6e20eff0..9a5e8ad1f6 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/AuthProviderChangeRequestRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/AuthProviderChangeRequestRepository.kt @@ -6,7 +6,5 @@ import org.springframework.stereotype.Repository @Repository interface AuthProviderChangeRequestRepository : JpaRepository { - fun findByUserAccountId(id: Long): AuthProviderChangeRequest - - fun deleteByUserAccountId(id: Long): AuthProviderChangeRequest + fun deleteByUserAccountId(id: Long): Int } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/AuthProviderChangeService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/AuthProviderChangeService.kt index 8799c9b0ab..dccf2d1075 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/AuthProviderChangeService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/AuthProviderChangeService.kt @@ -1,16 +1,19 @@ package io.tolgee.service.security +import io.tolgee.component.CurrentDateProvider import io.tolgee.constants.Message import io.tolgee.dtos.request.auth.AuthProviderChangeData import io.tolgee.dtos.response.AuthProviderDto import io.tolgee.dtos.response.AuthProviderDto.Companion.asAuthProviderDto import io.tolgee.exceptions.AuthenticationException import io.tolgee.exceptions.NotFoundException +import io.tolgee.model.AuthProviderChangeRequest import io.tolgee.model.UserAccount import io.tolgee.model.enums.ThirdPartyAuthType import io.tolgee.repository.AuthProviderChangeRequestRepository import io.tolgee.service.TenantService import io.tolgee.service.organization.OrganizationRoleService +import io.tolgee.util.addMinutes import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Propagation import org.springframework.transaction.annotation.Transactional @@ -20,19 +23,21 @@ class AuthProviderChangeService( private val authProviderChangeRequestRepository: AuthProviderChangeRequestRepository, private val tenantService: TenantService, private val organizationRoleService: OrganizationRoleService, + private val currentDateProvider: CurrentDateProvider, ) { fun getCurrent(user: UserAccount): AuthProviderDto? { return user.asAuthProviderDto() } - fun getRequestedChange(user: UserAccount): AuthProviderDto? { - return user.authProviderChangeRequest?.asAuthProviderDto() + fun getRequestedChange(userAccount: UserAccount): AuthProviderDto? { + return getActiveAuthProviderChangeRequest(userAccount)?.asAuthProviderDto() } @Transactional(propagation = Propagation.REQUIRES_NEW) fun initiateProviderChange(data: AuthProviderChangeData) { authProviderChangeRequestRepository.deleteByUserAccountId(data.userAccount.id) - authProviderChangeRequestRepository.save(data.asAuthProviderChangeRequest()) + val expirationDate = currentDateProvider.date.addMinutes(30) + authProviderChangeRequestRepository.save(data.asAuthProviderChangeRequest(expirationDate)) } fun acceptProviderChange(userAccount: UserAccount) { @@ -40,8 +45,9 @@ class AuthProviderChangeService( throw AuthenticationException(Message.OPERATION_UNAVAILABLE_FOR_ACCOUNT_TYPE) } - val req = userAccount.authProviderChangeRequest ?: return + val req = getActiveAuthProviderChangeRequest(userAccount) ?: return userAccount.apply { + accountType = req.accountType thirdPartyAuthType = req.authType thirdPartyAuthId = req.authId ssoRefreshToken = req.ssoRefreshToken @@ -66,4 +72,13 @@ class AuthProviderChangeService( fun rejectProviderChange(userAccount: UserAccount) { authProviderChangeRequestRepository.deleteByUserAccountId(userAccount.id) } + + private fun getActiveAuthProviderChangeRequest(userAccount: UserAccount): AuthProviderChangeRequest? { + val request = userAccount.authProviderChangeRequest ?: return null + val expiry = request.expirationDate ?: return null + if (expiry < currentDateProvider.date) { + return null + } + return request + } } 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 d65db49907..911e88ab8f 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 @@ -266,7 +266,7 @@ class UserAccountService( userAccount: UserAccount, password: String?, ): UserAccount { - userAccount.tokensValidNotBefore = DateUtils.truncate(Date(), Calendar.SECOND) + resetTokensValidNotBefore(userAccount) userAccount.password = passwordEncoder.encode(password) return userAccountRepository.save(userAccount) } @@ -293,7 +293,7 @@ class UserAccountService( key: ByteArray, ): UserAccount { userAccount.totpKey = key - userAccount.tokensValidNotBefore = DateUtils.truncate(Date(), Calendar.SECOND) + resetTokensValidNotBefore(userAccount) return userAccountRepository.save(userAccount) } @@ -303,7 +303,7 @@ class UserAccountService( userAccount.totpKey = null // note: if support for more MFA methods is added, this should be only done if no other MFA method is enabled userAccount.mfaRecoveryCodes = emptyList() - userAccount.tokensValidNotBefore = DateUtils.truncate(Date(), Calendar.SECOND) + resetTokensValidNotBefore(userAccount) return userAccountRepository.save(userAccount) } @@ -458,7 +458,7 @@ class UserAccountService( val matches = passwordEncoder.matches(dto.currentPassword, userAccount.password) if (!matches) throw PermissionException(Message.WRONG_CURRENT_PASSWORD) - userAccount.tokensValidNotBefore = DateUtils.truncate(Date(), Calendar.SECOND) + resetTokensValidNotBefore(userAccount) userAccount.password = passwordEncoder.encode(dto.password) userAccount.passwordChanged = true return userAccountRepository.save(userAccount) @@ -484,6 +484,15 @@ class UserAccountService( } } + fun invalidateTokens(userAccount: UserAccount): UserAccount { + resetTokensValidNotBefore(userAccount) + return userAccountRepository.save(userAccount) + } + + private fun resetTokensValidNotBefore(userAccount: UserAccount) { + userAccount.tokensValidNotBefore = DateUtils.truncate(currentDateProvider.date, Calendar.SECOND) + } + private fun publishUserInfoUpdatedEvent( old: UserAccountDto, userAccount: UserAccount, diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index e628a97768..09ff297ca3 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -4059,7 +4059,7 @@ - + @@ -4070,9 +4070,10 @@ - + + @@ -4081,10 +4082,10 @@ - + - + diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index dad4a6f992..4f30abe46e 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -1,5 +1,8 @@ declare namespace DataCy { export type Value = + "accept-auth-provider-change-accept" | + "accept-auth-provider-change-decline" | + "accept-auth-provider-change-info-text" | "accept-invitation-accept" | "accept-invitation-decline" | "accept-invitation-info-text" | @@ -381,6 +384,8 @@ declare namespace DataCy { "pat-list-item-last-used" | "pat-list-item-new-token-input" | "pat-list-item-regenerate-button" | + "pending-auth-provider-change-banner" | + "pending-auth-provider-change-dismiss" | "pending-invitation-banner" | "pending-invitation-dismiss" | "permissions-advanced-checkbox" | diff --git a/webapp/src/component/layout/TopBanner/PendingAuthProviderChangeBanner.tsx b/webapp/src/component/layout/TopBanner/PendingAuthProviderChangeBanner.tsx index 572696ad2c..0c80998238 100644 --- a/webapp/src/component/layout/TopBanner/PendingAuthProviderChangeBanner.tsx +++ b/webapp/src/component/layout/TopBanner/PendingAuthProviderChangeBanner.tsx @@ -1,6 +1,6 @@ import { T, useTranslate } from '@tolgee/react'; import { Announcement } from './Announcement'; -import { User01 } from '@untitled-ui/icons-react'; +import { LogIn01 } from '@untitled-ui/icons-react'; import { Box, styled } from '@mui/material'; import { useGlobalActions } from 'tg.globalContext/GlobalContext'; @@ -33,7 +33,7 @@ export const PendingAuthProviderChangeBanner = () => { /> } - icon={} + icon={} action={ c.initialData.announcement?.type); const pendingInvitationCode = useGlobalContext((c) => c.auth.invitationCode); - const pendingAuthProviderChange = useGlobalContext((c) => c.auth.authProviderChange); + const pendingAuthProviderChange = useGlobalContext( + (c) => c.auth.authProviderChange + ); + const waitingForLogin = useGlobalContext((c) => !c.auth.allowPrivate); + const showPendingAuthProviderChange = + pendingAuthProviderChange && waitingForLogin; const { setTopBannerHeight, dismissAnnouncement } = useGlobalActions(); const bannerRef = useRef(null); const isAuthenticated = tokenService.getToken() !== undefined; @@ -73,7 +78,9 @@ export function TopBanner() { const announcement = bannerType && getAnnouncement(bannerType); const showCloseButton = - !showEmailVerificationBanner && !pendingInvitationCode; + !showEmailVerificationBanner && + !pendingInvitationCode && + !showPendingAuthProviderChange; useResizeObserver({ ref: bannerRef, @@ -85,9 +92,19 @@ export function TopBanner() { useEffect(() => { const height = bannerRef.current?.offsetHeight; setTopBannerHeight(height ?? 0); - }, [announcement, isEmailVerified, pendingInvitationCode]); + }, [ + announcement, + isEmailVerified, + pendingInvitationCode, + showPendingAuthProviderChange, + ]); - if (!announcement && !pendingInvitationCode && !showEmailVerificationBanner) { + if ( + !announcement && + !showPendingAuthProviderChange && + !pendingInvitationCode && + !showEmailVerificationBanner + ) { return null; } @@ -105,7 +122,7 @@ export function TopBanner() { title={t('verify_email_account_not_verified_title')} icon={} /> - ) : pendingAuthProviderChange ? ( + ) : showPendingAuthProviderChange ? ( ) : pendingInvitationCode ? ( diff --git a/webapp/src/component/security/AcceptAuthProviderChangeView.tsx b/webapp/src/component/security/AcceptAuthProviderChangeView.tsx index 27d97fb961..285e631ab5 100644 --- a/webapp/src/component/security/AcceptAuthProviderChangeView.tsx +++ b/webapp/src/component/security/AcceptAuthProviderChangeView.tsx @@ -5,12 +5,16 @@ import { Box, Button, Link, Paper, styled, Typography } from '@mui/material'; import { LINKS } from 'tg.constants/links'; import { messageService } from 'tg.service/MessageService'; import { useApiMutation, useApiQuery } from 'tg.service/http/useQueryApi'; -import { useGlobalActions } from 'tg.globalContext/GlobalContext'; +import { + useGlobalActions, + useGlobalContext, +} from 'tg.globalContext/GlobalContext'; import { DashboardPage } from 'tg.component/layout/DashboardPage'; import { useWindowTitle } from 'tg.hooks/useWindowTitle'; import LoadingButton from 'tg.component/common/form/LoadingButton'; import { FullPageLoading } from 'tg.component/common/FullPageLoading'; import { TranslatedError } from 'tg.translationTools/TranslatedError'; +import React from 'react'; export const FULL_PAGE_BREAK_POINT = '(max-width: 700px)'; @@ -44,11 +48,28 @@ const AcceptAuthProviderChangeView: React.FC = () => { useWindowTitle(t('accept_auth_provider_change_title')); - const { setAuthProviderChange } = useGlobalActions(); + const { setAuthProviderChange, redirectAfterLogin, handleAfterLogin } = + useGlobalActions(); + const authProviderChange = useGlobalContext((c) => c.auth.authProviderChange); const acceptChange = useApiMutation({ url: '/api/auth_provider/changed/accept', method: 'post', + fetchOptions: { + disableAutoErrorHandle: true, + }, + }); + + const authProviderCurrentInfo = useApiQuery({ + url: '/api/auth_provider/current', + method: 'get', + options: { + onError(e) { + if (e.code && e.code != 'resource_not_found') { + messageService.error(); + } + }, + }, }); const authProviderChangeInfo = useApiQuery({ @@ -57,7 +78,7 @@ const AcceptAuthProviderChangeView: React.FC = () => { options: { onError(e) { setAuthProviderChange(false); - history.replace(LINKS.PROJECT.build()); + history.replace(LINKS.PROJECTS.build()); if (e.code) { messageService.error(); } @@ -66,15 +87,19 @@ const AcceptAuthProviderChangeView: React.FC = () => { }); function handleAccept() { - acceptChange.mutate({ - onSuccess() { - setAuthProviderChange(false); - messageService.success(); - }, - onSettled() { - history.replace(LINKS.PROJECTS.build()); - }, - }); + acceptChange.mutate( + {}, + { + onSuccess(r) { + setAuthProviderChange(false); + handleAfterLogin(r); + messageService.success(); + }, + onSettled() { + history.replace(LINKS.PROJECTS.build()); + }, + } + ); } function handleDecline() { @@ -82,7 +107,7 @@ const AcceptAuthProviderChangeView: React.FC = () => { history.push(LINKS.LOGIN.build()); } - if (!authProviderChangeInfo.data) { + if (!authProviderChangeInfo.data || authProviderCurrentInfo.isLoading) { return ; } @@ -90,14 +115,17 @@ const AcceptAuthProviderChangeView: React.FC = () => { const accountType = authProviderChangeInfo.data.accountType; const authType = authProviderChangeInfo.data.authType; + const authTypeOld = authProviderCurrentInfo.data?.authType ?? 'NONE'; const ssoDomain = authProviderChangeInfo.data.ssoDomain; const params = { + authType, + authTypeOld, ssoDomain, b: , }; if (accountType === 'MANAGED') { - if (authType == 'SSO' && ssoDomain) { + if ((authType == 'SSO' || authType == 'SSO_GLOBAL') && ssoDomain) { infoText = ( { ); } + if (!authProviderChange) { + redirectAfterLogin(); + return null; + } + return ( diff --git a/webapp/src/globalContext/useAuthService.tsx b/webapp/src/globalContext/useAuthService.tsx index 39dbcc7ea1..a6e9873d66 100644 --- a/webapp/src/globalContext/useAuthService.tsx +++ b/webapp/src/globalContext/useAuthService.tsx @@ -23,7 +23,7 @@ type SignUpDto = components['schemas']['SignUpDto']; type SuperTokenAction = { onCancel: () => void; onSuccess: () => void }; export const INVITATION_CODE_STORAGE_KEY = 'invitationCode'; -export const AUTH_PROVIDER_CHANGE_USER_STORAGE_KEY = 'authProviderChangeUser'; +export const AUTH_PROVIDER_CHANGE_STORAGE_KEY = 'authProviderChange'; const LOCAL_STORAGE_STATE_KEY = 'oauth2State'; const LOCAL_STORAGE_DOMAIN_KEY = 'ssoDomain'; @@ -107,15 +107,15 @@ export const useAuthService = ( initial: undefined, key: INVITATION_CODE_STORAGE_KEY, }); + const [ authProviderChangeStr, setAuthProviderChangeStr, getAuthProviderChangeStr, ] = useLocalStorageState({ - initial: undefined, - key: AUTH_PROVIDER_CHANGE_USER_STORAGE_KEY, + initial: 'false', + key: AUTH_PROVIDER_CHANGE_STORAGE_KEY, }); - const authProviderChange = authProviderChangeStr === 'true'; function setAuthProviderChange(value: boolean) { @@ -243,7 +243,6 @@ export const useAuthService = ( if (error.code === 'invitation_code_does_not_exist_or_expired') { setInvitationCode(undefined); } - messageService.error(); }, } ); @@ -286,10 +285,9 @@ export const useAuthService = ( }, handleAfterLogin, redirectAfterLogin() { - if (getAuthProviderChange()) { - history.replace(LINKS.ACCEPT_AUTH_PROVIDER_CHANGE.build()); - } - const link = getRedirectUrl(userId); + const link = getAuthProviderChange() + ? LINKS.ACCEPT_AUTH_PROVIDER_CHANGE.build() + : getRedirectUrl(userId); history.replace(link); securityService.removeAfterLoginLink(); }, @@ -323,7 +321,6 @@ export const useAuthService = ( setAdminToken(undefined); }, setInvitationCode, - authProviderChange, setAuthProviderChange, getAuthProviderChange, redirectTo(url: string) { diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 7e10669e5d..5fb96f6e9c 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -4814,7 +4814,11 @@ export interface operations { acceptChangeAuthProvider: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["JwtAuthenticationResponse"]; + }; + }; /** Bad Request */ 400: { content: { diff --git a/webapp/src/translationTools/useErrorTranslation.ts b/webapp/src/translationTools/useErrorTranslation.ts index e7b1ce8275..567ca1586f 100644 --- a/webapp/src/translationTools/useErrorTranslation.ts +++ b/webapp/src/translationTools/useErrorTranslation.ts @@ -147,6 +147,8 @@ export function useErrorTranslation() { return t('sso_domain_not_found_or_disabled'); case 'native_authentication_disabled': return t('native_authentication_disabled'); + case 'operation_unavailable_for_account_type': + return t('operation_unavailable_for_account_type'); case 'invitation_organization_mismatch': return t('invitation_organization_mismatch'); case 'user_is_managed_by_organization':