Skip to content

Commit

Permalink
feat: finalize implementation of third-party auth provider change logic
Browse files Browse the repository at this point in the history
  • Loading branch information
Anty0 committed Feb 6, 2025
1 parent 15bd3b5 commit b84e023
Show file tree
Hide file tree
Showing 14 changed files with 151 additions and 51 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,5 @@ import org.springframework.stereotype.Repository

@Repository
interface AuthProviderChangeRequestRepository : JpaRepository<AuthProviderChangeRequest, Long> {
fun findByUserAccountId(id: Long): AuthProviderChangeRequest

fun deleteByUserAccountId(id: Long): AuthProviderChangeRequest
fun deleteByUserAccountId(id: Long): Int
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -20,28 +23,31 @@ 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) {
if (userAccount.accountType === UserAccount.AccountType.MANAGED) {
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
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}

Expand All @@ -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)
}

Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
9 changes: 5 additions & 4 deletions backend/data/src/main/resources/db/changelog/schema.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4059,7 +4059,7 @@
</column>
</addColumn>
</changeSet>
<changeSet author="anty (generated)" id="1738594225631-1">
<changeSet author="anty (generated)" id="1738671416787-1">
<createTable tableName="auth_provider_change_request">
<column name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true" primaryKeyName="auth_provider_change_requestPK"/>
Expand All @@ -4070,9 +4070,10 @@
<column name="updated_at" type="TIMESTAMP(6) WITHOUT TIME ZONE">
<constraints nullable="false"/>
</column>
<column name="account_type" type="SMALLINT"/>
<column name="account_type" type="VARCHAR(255)"/>
<column name="auth_id" type="VARCHAR(255)"/>
<column name="auth_type" type="VARCHAR(255)"/>
<column name="expiration_date" type="TIMESTAMP(6) WITHOUT TIME ZONE"/>
<column name="sso_domain" type="VARCHAR(255)"/>
<column name="sso_expiration" type="TIMESTAMP(6) WITHOUT TIME ZONE"/>
<column name="sso_refresh_token" type="TEXT"/>
Expand All @@ -4081,10 +4082,10 @@
</column>
</createTable>
</changeSet>
<changeSet author="anty (generated)" id="1738594225631-2">
<changeSet author="anty (generated)" id="1738671416787-2">
<addUniqueConstraint columnNames="user_account_id" constraintName="UC_AUTH_PROVIDER_CHANGE_REQUESTUSER_ACCOUNT_ID_COL" tableName="auth_provider_change_request"/>
</changeSet>
<changeSet author="anty (generated)" id="1738594225631-3">
<changeSet author="anty (generated)" id="1738671416787-3">
<addForeignKeyConstraint baseColumnNames="user_account_id" baseTableName="auth_provider_change_request" constraintName="FKo6db96l21g8a86a9c0t50x32x" deferrable="false" initiallyDeferred="false" referencedColumnNames="id" referencedTableName="user_account" validate="true"/>
</changeSet>
</databaseChangeLog>
5 changes: 5 additions & 0 deletions e2e/cypress/support/dataCyType.d.ts
Original file line number Diff line number Diff line change
@@ -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" |
Expand Down Expand Up @@ -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" |
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -33,7 +33,7 @@ export const PendingAuthProviderChangeBanner = () => {
/>
</Box>
}
icon={<User01 />}
icon={<LogIn01 />}
action={
<StyledDismiss
role="button"
Expand Down
29 changes: 23 additions & 6 deletions webapp/src/component/layout/TopBanner/TopBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { tokenService } from 'tg.service/TokenService';
import { PendingInvitationBanner } from './PendingInvitationBanner';
import { useTranslate } from '@tolgee/react';
import { Announcement } from './Announcement';
import {PendingAuthProviderChangeBanner} from "tg.component/layout/TopBanner/PendingAuthProviderChangeBanner";
import { PendingAuthProviderChangeBanner } from 'tg.component/layout/TopBanner/PendingAuthProviderChangeBanner';

const StyledContainer = styled('div')`
position: fixed;
Expand Down Expand Up @@ -61,7 +61,12 @@ export function TopBanner() {
const { t } = useTranslate();
const bannerType = useGlobalContext((c) => 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<HTMLDivElement>(null);
const isAuthenticated = tokenService.getToken() !== undefined;
Expand All @@ -73,7 +78,9 @@ export function TopBanner() {

const announcement = bannerType && getAnnouncement(bannerType);
const showCloseButton =
!showEmailVerificationBanner && !pendingInvitationCode;
!showEmailVerificationBanner &&
!pendingInvitationCode &&
!showPendingAuthProviderChange;

useResizeObserver({
ref: bannerRef,
Expand All @@ -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;
}

Expand All @@ -105,7 +122,7 @@ export function TopBanner() {
title={t('verify_email_account_not_verified_title')}
icon={<Mail01 />}
/>
) : pendingAuthProviderChange ? (
) : showPendingAuthProviderChange ? (
<PendingAuthProviderChangeBanner />
) : pendingInvitationCode ? (
<PendingInvitationBanner code={pendingInvitationCode} />
Expand Down
Loading

0 comments on commit b84e023

Please sign in to comment.