Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow switching to SSO provider #2883

Merged
merged 19 commits into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package io.tolgee.controllers

import io.swagger.v3.oas.annotations.Operation
import io.tolgee.dtos.response.AuthProviderDto
import io.tolgee.exceptions.NotFoundException
import io.tolgee.openApiDocs.OpenApiHideFromPublicDocs
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
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@CrossOrigin(origins = ["*"])
@RequestMapping("/v2/auth-provider") // TODO: I should probably use the v2
@AuthenticationTag
@OpenApiHideFromPublicDocs
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")
@AllowApiAccess(AuthTokenType.ONLY_PAT)
fun getCurrentAuthProvider(): AuthProviderDto {
val info = authProviderChangeService.getCurrent(authenticationFacade.authenticatedUserEntity)
return info ?: throw NotFoundException()
}

@GetMapping("/changed")
@Operation(summary = "Get info about authentication provider which can replace the current one")
@AllowApiAccess(AuthTokenType.ONLY_PAT)
fun getChangedAuthProvider(): AuthProviderDto {
val info = authProviderChangeService.getRequestedChange(authenticationFacade.authenticatedUserEntity)
return info ?: throw NotFoundException()
}

@PostMapping("/changed/accept")
@Operation(summary = "Accept change of the third party authentication provider")
@AllowApiAccess(AuthTokenType.ONLY_PAT)
@RequiresSuperAuthentication
@Transactional
fun acceptChangeAuthProvider(): JwtAuthenticationResponse {
val user = authenticationFacade.authenticatedUserEntity
authProviderChangeService.acceptProviderChange(user)
userAccountService.invalidateTokens(user)
return JwtAuthenticationResponse(
jwtService.emitToken(authenticationFacade.authenticatedUser.id, true),
)
}

@PostMapping("/changed/reject")
@Operation(summary = "Reject change of the third party authentication provider")
@AllowApiAccess(AuthTokenType.ONLY_PAT)
@Transactional
fun rejectChangeAuthProvider() {
authProviderChangeService.rejectProviderChange(authenticationFacade.authenticatedUserEntity)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ class SsoTenantModel(
override val clientId: String,
override val clientSecret: String,
override val tokenUri: String,
/**
* When true, users with an email matching the organization's domain must sign in using SSO
*/
override val force: Boolean,
Anty0 marked this conversation as resolved.
Show resolved Hide resolved
override val domain: String,
) : ISsoTenant,
RepresentationModel<SsoTenantModel>(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ package io.tolgee.security.thirdParty
import io.tolgee.configuration.tolgee.GithubAuthenticationProperties
import io.tolgee.configuration.tolgee.TolgeeProperties
import io.tolgee.constants.Message
import io.tolgee.dtos.request.auth.AuthProviderChangeData
import io.tolgee.exceptions.AuthenticationException
import io.tolgee.model.UserAccount
import io.tolgee.model.enums.ThirdPartyAuthType
import io.tolgee.security.authentication.JwtService
import io.tolgee.security.payload.JwtAuthenticationResponse
import io.tolgee.security.service.thirdParty.ThirdPartyAuthDelegate
import io.tolgee.service.TenantService
import io.tolgee.service.security.AuthProviderChangeService
import io.tolgee.service.security.SignUpService
import io.tolgee.service.security.UserAccountService
import org.springframework.http.HttpEntity
Expand All @@ -27,6 +30,8 @@ class GithubOAuthDelegate(
private val restTemplate: RestTemplate,
properties: TolgeeProperties,
private val signUpService: SignUpService,
private val authProviderChangeService: AuthProviderChangeService,
private val tenantService: TenantService,
) : ThirdPartyAuthDelegate {
private val githubConfigurationProperties: GithubAuthenticationProperties = properties.authentication.github

Expand Down Expand Up @@ -80,23 +85,9 @@ class GithubOAuthDelegate(
)?.email
?: throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL)

val userAccount =
userAccountService.findByThirdParty(ThirdPartyAuthType.GITHUB, userResponse!!.id!!) ?: let {
userAccountService.findActive(githubEmail)?.let {
throw AuthenticationException(Message.USERNAME_ALREADY_EXISTS)
}
val userAccount = findOrCreateAccount(githubEmail, userResponse!!, invitationCode)

val newUserAccount = UserAccount()
newUserAccount.username = githubEmail
newUserAccount.name = userResponse.name ?: userResponse.login
newUserAccount.thirdPartyAuthId = userResponse.id
newUserAccount.thirdPartyAuthType = ThirdPartyAuthType.GITHUB
newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY

signUpService.signUp(newUserAccount, invitationCode, null)

newUserAccount
}
tenantService.checkSsoNotRequiredOrAuthProviderChangeActive(userAccount)

val jwt = jwtService.emitToken(userAccount.id)
return JwtAuthenticationResponse(jwt)
Expand All @@ -112,6 +103,39 @@ class GithubOAuthDelegate(
throw AuthenticationException(Message.THIRD_PARTY_AUTH_UNKNOWN_ERROR)
}

fun findOrCreateAccount(
githubEmail: String,
userResponse: GithubUserResponse,
invitationCode: String?,
): UserAccount {
userAccountService.findByThirdParty(ThirdPartyAuthType.GITHUB, userResponse.id!!)?.let {
return it
}

userAccountService.findActive(githubEmail)?.let {
authProviderChangeService.initiateProviderChange(
AuthProviderChangeData(
it,
UserAccount.AccountType.THIRD_PARTY,
ThirdPartyAuthType.GITHUB,
userResponse.id,
),
)
return it
}

val newUserAccount = UserAccount()
newUserAccount.username = githubEmail
newUserAccount.name = userResponse.name ?: userResponse.login
newUserAccount.thirdPartyAuthId = userResponse.id
newUserAccount.thirdPartyAuthType = ThirdPartyAuthType.GITHUB
newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY

signUpService.signUp(newUserAccount, invitationCode, null)

return newUserAccount
}

class GithubEmailResponse {
var email: String? = null
var primary = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ package io.tolgee.security.thirdParty
import io.tolgee.configuration.tolgee.GoogleAuthenticationProperties
import io.tolgee.configuration.tolgee.TolgeeProperties
import io.tolgee.constants.Message
import io.tolgee.dtos.request.auth.AuthProviderChangeData
import io.tolgee.exceptions.AuthenticationException
import io.tolgee.model.UserAccount
import io.tolgee.model.enums.ThirdPartyAuthType
import io.tolgee.security.authentication.JwtService
import io.tolgee.security.payload.JwtAuthenticationResponse
import io.tolgee.security.service.thirdParty.ThirdPartyAuthDelegate
import io.tolgee.service.TenantService
import io.tolgee.service.security.AuthProviderChangeService
import io.tolgee.service.security.SignUpService
import io.tolgee.service.security.UserAccountService
import org.springframework.http.HttpEntity
Expand All @@ -26,6 +29,8 @@ class GoogleOAuthDelegate(
private val restTemplate: RestTemplate,
properties: TolgeeProperties,
private val signUpService: SignUpService,
private val authProviderChangeService: AuthProviderChangeService,
private val tenantService: TenantService,
) : ThirdPartyAuthDelegate {
private val googleConfigurationProperties: GoogleAuthenticationProperties = properties.authentication.google

Expand Down Expand Up @@ -82,25 +87,10 @@ class GoogleOAuthDelegate(
}
}

val googleEmail = userResponse.email ?: throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL)
val userAccount = findOrCreateAccount(userResponse, invitationCode)

val userAccount =
userAccountService.findByThirdParty(ThirdPartyAuthType.GOOGLE, userResponse.sub!!) ?: let {
userAccountService.findActive(googleEmail)?.let {
throw AuthenticationException(Message.USERNAME_ALREADY_EXISTS)
}
tenantService.checkSsoNotRequiredOrAuthProviderChangeActive(userAccount)

val newUserAccount = UserAccount()
newUserAccount.username = userResponse.email
?: throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL)
newUserAccount.name = userResponse.name ?: (userResponse.given_name + " " + userResponse.family_name)
newUserAccount.thirdPartyAuthId = userResponse.sub
newUserAccount.thirdPartyAuthType = ThirdPartyAuthType.GOOGLE
newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY
signUpService.signUp(newUserAccount, invitationCode, null)

newUserAccount
}
val jwt = jwtService.emitToken(userAccount.id)
return JwtAuthenticationResponse(jwt)
}
Expand All @@ -117,6 +107,40 @@ class GoogleOAuthDelegate(
}
}

private fun findOrCreateAccount(
userResponse: GoogleUserResponse,
invitationCode: String?,
): UserAccount {
val googleEmail = userResponse.email ?: throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL)

userAccountService.findByThirdParty(ThirdPartyAuthType.GOOGLE, userResponse.sub!!)?.let {
return it
}

userAccountService.findActive(googleEmail)?.let {
authProviderChangeService.initiateProviderChange(
AuthProviderChangeData(
it,
UserAccount.AccountType.THIRD_PARTY,
ThirdPartyAuthType.GOOGLE,
userResponse.sub,
),
)
return it
}

val newUserAccount = UserAccount()
newUserAccount.username = userResponse.email
?: throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL)
newUserAccount.name = userResponse.name ?: (userResponse.given_name + " " + userResponse.family_name)
newUserAccount.thirdPartyAuthId = userResponse.sub
newUserAccount.thirdPartyAuthType = ThirdPartyAuthType.GOOGLE
newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY
signUpService.signUp(newUserAccount, invitationCode, null)

return newUserAccount
}

@Suppress("PropertyName")
class GoogleUserResponse {
var sub: String? = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import io.tolgee.security.authentication.JwtService
import io.tolgee.security.payload.JwtAuthenticationResponse
import io.tolgee.security.service.thirdParty.ThirdPartyAuthDelegate
import io.tolgee.security.thirdParty.data.OAuthUserDetails
import io.tolgee.service.TenantService
import org.slf4j.LoggerFactory
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
Expand All @@ -28,6 +29,7 @@ class OAuth2Delegate(
private val restTemplate: RestTemplate,
properties: TolgeeProperties,
private val oAuthUserHandler: OAuthUserHandler,
private val tenantService: TenantService,
) : ThirdPartyAuthDelegate {
private val oauth2ConfigurationProperties: OAuth2AuthenticationProperties = properties.authentication.oauth2
private val logger = LoggerFactory.getLogger(this::class.java)
Expand Down Expand Up @@ -110,6 +112,8 @@ class OAuth2Delegate(
UserAccount.AccountType.THIRD_PARTY,
)

tenantService.checkSsoNotRequiredOrAuthProviderChangeActive(user)

val jwt = jwtService.emitToken(user.id)
return JwtAuthenticationResponse(jwt)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package io.tolgee.security.thirdParty

import io.tolgee.constants.Message
import io.tolgee.dtos.request.auth.AuthProviderChangeData
import io.tolgee.exceptions.AuthenticationException
import io.tolgee.model.UserAccount
import io.tolgee.model.enums.ThirdPartyAuthType
import io.tolgee.security.thirdParty.data.OAuthUserDetails
import io.tolgee.service.organization.OrganizationRoleService
import io.tolgee.service.security.AuthProviderChangeService
import io.tolgee.service.security.SignUpService
import io.tolgee.service.security.UserAccountService
import org.springframework.stereotype.Component
Expand All @@ -15,6 +17,7 @@ class OAuthUserHandler(
private val signUpService: SignUpService,
private val organizationRoleService: OrganizationRoleService,
private val userAccountService: UserAccountService,
private val authProviderChangeService: AuthProviderChangeService,
) {
fun findOrCreateUser(
userResponse: OAuthUserDetails,
Expand Down Expand Up @@ -59,8 +62,21 @@ class OAuthUserHandler(
thirdPartyAuthType: ThirdPartyAuthType,
accountType: UserAccount.AccountType,
): UserAccount {
if (userAccountService.findActive(userResponse.email) != null) {
throw AuthenticationException(Message.USERNAME_ALREADY_EXISTS)
var existingUserAccount =
userAccountService.findActive(userResponse.email)
if (existingUserAccount != null) {
authProviderChangeService.initiateProviderChange(
AuthProviderChangeData(
existingUserAccount,
accountType,
thirdPartyAuthType,
userResponse.sub,
ssoDomain = userResponse.tenant?.domain,
ssoRefreshToken = userResponse.refreshToken,
ssoExpiration = userAccountService.getCurrentSsoExpiration(thirdPartyAuthType),
),
)
return existingUserAccount
}

val newUserAccount = UserAccount()
Expand Down
1 change: 1 addition & 0 deletions backend/app/src/main/resources/application-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ tolgee:
user-url: "https://dummy-url.com/userinfo"
sso-organizations:
enabled: false
allowed-domains: "domain.com"
sso-global:
enabled: false
authorization-uri: "https://dummy-url.com"
Expand Down
5 changes: 5 additions & 0 deletions backend/data/src/main/kotlin/io/tolgee/api/ISsoTenant.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,10 @@ interface ISsoTenant {
val authorizationUri: String
val domain: String
val tokenUri: String

/**
* When true, users with an email matching the organization's domain must sign in using SSO
*/
val force: Boolean
val global: Boolean
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ class SsoGlobalProperties : ISsoTenant {
@DocProperty(description = "Enables SSO authentication on global level - as a login method for the whole server")
var enabled: Boolean = false

@DocProperty(description = "When true, users with an email matching the organization's domain must sign in using SSO")
override val force: Boolean = false

@DocProperty(description = "Unique identifier for an application")
override var clientId: String = ""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,19 @@ class SsoOrganizationsProperties {
" able to access the server after the account has been disabled or deleted in the SSO provider.",
)
var sessionExpirationMinutes: Int = 10

@DocProperty(
description =
"Only allow listed domains to be used for SSO configuration.",
)
var allowedDomains: List<String>? = emptyList()

fun isAllowedDomain(domain: String): Boolean {
val allowed = allowedDomains
if (allowed == null) {
return true
}

return domain in allowed
}
}
Loading
Loading