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: sso implementation #2523

Merged
merged 111 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
111 commits
Select commit Hold shift + click to select a range
a52e18e
feat: create dynamic client registration
huglx Sep 2, 2024
278aa62
feat: Add a tenant to configure the dynamic configuration of providers.
huglx Sep 2, 2024
140c6df
feat: Add a controller to fetch callbacks from the provider and retur…
huglx Sep 2, 2024
b5e75b9
feat: Add a controller to dynamically configure providers
huglx Sep 2, 2024
f7b8c0a
feat: add sso login to FE
huglx Sep 2, 2024
4a14242
fix: move auth logic from controller to service
huglx Sep 6, 2024
8302d33
feat: add custom exceptions
huglx Sep 6, 2024
10a70b7
fix: add component annotation
huglx Sep 6, 2024
ac291db
feat: save domain with port if presents
huglx Sep 6, 2024
1143485
fix: change url
huglx Sep 6, 2024
d8dfdb8
fix: add more properties CreateProviderRequest
huglx Sep 6, 2024
e6a8cd3
fix: The function wasn't marked as a transaction, so it caused a data…
huglx Sep 8, 2024
fccbe74
feat: add an ability to accept invitation code to backend part
huglx Sep 11, 2024
de3cd3c
feat: add properties to enable custom logo & button text
huglx Sep 11, 2024
8d285fa
feat: add create sso provider route
huglx Sep 12, 2024
e866ebb
feat: add an ability to set custom login logo to FE
huglx Sep 12, 2024
10f4022
feat: add create sso provider links
huglx Sep 12, 2024
c468f2c
feat: pass invitationCode to BE
huglx Sep 12, 2024
bf60293
feat: display saved provider on FE
huglx Sep 16, 2024
b7297a0
feat: verify id token to improve security
huglx Sep 17, 2024
45acdfe
feat: disable/enable sso provider FE
huglx Sep 24, 2024
f1e4999
feat: also set jwk uri in ClientRegistration
huglx Sep 24, 2024
0477f25
feat: now a new user logged in via sso is a member of the organizatio…
huglx Oct 6, 2024
8a83e52
feat: refactor login form FE
huglx Oct 6, 2024
0cc553d
feat: enable/disable provider
huglx Oct 6, 2024
d21a845
feat: add function to add user to the org.
huglx Oct 6, 2024
c906b4e
feat: throw if sso provider is disabled
huglx Oct 6, 2024
40e444d
fix: code clean up
huglx Oct 6, 2024
e494b5c
chore: test
huglx Oct 6, 2024
fdfa217
feat: configuration to parse jwt token
huglx Oct 6, 2024
d0f9cb1
feat: generate ee db schema
huglx Oct 6, 2024
86a37dc
Merge remote-tracking branch 'origin/main' into ivanmanzhosov/sso-pro…
huglx Oct 6, 2024
ca6836b
feat: generate FE api schema
huglx Oct 6, 2024
31fef0c
feat: save or update provider
huglx Oct 6, 2024
9d832f0
fix: npm run prettier
huglx Oct 6, 2024
be3754c
fix: ktlint
huglx Oct 6, 2024
43b4d4a
fix: npm run prettier
huglx Oct 6, 2024
4311e68
fix: BE build
huglx Oct 6, 2024
56815f3
fix: fet rid of calling static function
huglx Oct 8, 2024
90dfeda
chore: mock everything for tests
huglx Oct 8, 2024
c02388f
fix: rename, add policy to provider's controller
huglx Oct 8, 2024
a2e70e3
fix: rename oauth2 endpoint
huglx Oct 8, 2024
bc14f1b
fix: code clean up
huglx Oct 8, 2024
a63094f
fix: use Model & ModelAssembler approach
huglx Oct 8, 2024
2b1f8c7
fix: add RequiresSuperAuthentication and change url on FE
huglx Oct 11, 2024
e943634
feat: add sso form validation
huglx Oct 11, 2024
a1c92d0
fix: refactor tenant Service
huglx Oct 11, 2024
22e6462
fix: refactor oauth service and delegate
huglx Oct 11, 2024
9069401
fix: move data class to data package
huglx Oct 11, 2024
e39a9e7
fix: delete timestamp file
huglx Oct 11, 2024
b5a7b0d
fix: edit new url on FE
huglx Oct 11, 2024
036be10
fix: npm run prettier
huglx Oct 11, 2024
333739a
fix: move dto to data package
huglx Oct 11, 2024
682a9d6
fix: rename FE link
huglx Oct 11, 2024
8b39bcc
fix: refactor auth service FE
huglx Oct 11, 2024
ccebc56
fix: refactor provider form and view
huglx Oct 11, 2024
156afd3
fix: add sso_domain in user account
huglx Oct 11, 2024
726b5ea
fix: refactor sso provider form
huglx Oct 11, 2024
d36d18c
fix: now user must type his domain
huglx Oct 11, 2024
d7f5ec5
fix: rephrase description in auth props, use local icon instead of re…
huglx Oct 11, 2024
098c602
fix: rename error messages
huglx Oct 11, 2024
2fb5794
fix: code format
huglx Oct 11, 2024
9d14caa
fix: ktlint fix
huglx Oct 11, 2024
be5752d
fix: eslint
huglx Oct 11, 2024
af7cc92
fix: eslint
huglx Oct 11, 2024
53c5735
fix: rename sso provider url
huglx Oct 13, 2024
b64d790
chore: add sso controller tests
huglx Oct 13, 2024
eaaa364
chore: add sso auth tests
huglx Oct 13, 2024
48f1e19
chore: add simple e2e test
huglx Oct 13, 2024
c965f20
fix: regenerate schema
huglx Oct 13, 2024
915a774
fix: FE prettier
huglx Oct 13, 2024
d879d48
fix: FE prettier
huglx Oct 13, 2024
0a4b9e1
fix: BE code format
huglx Oct 13, 2024
1ba0902
fix: rename link
huglx Oct 14, 2024
481d3f6
fix: rename static link
huglx Oct 14, 2024
4e9d32e
fix: rename static link e2e
huglx Oct 14, 2024
ad236eb
fix: remove unused code
huglx Oct 14, 2024
560bd01
fix: decode/encode domain in url
huglx Oct 14, 2024
41e9334
fix: store domain in localstorage instead of passing throw url
huglx Oct 14, 2024
abb886d
fix: remove unused code
huglx Oct 15, 2024
72ba29a
fix: FE prettier
huglx Oct 15, 2024
f537f59
fix: FE prettier
huglx Oct 15, 2024
923cf48
fit: remove score role from auth request
huglx Oct 18, 2024
1493068
feat: do not create user's organization on sso login
huglx Oct 21, 2024
ebec37e
chore: test sso login doesnt create user's organization
huglx Oct 21, 2024
1d7061b
feat: check if organization has sso feature
huglx Oct 21, 2024
642d6fd
feat: show banner if user doesnt have feature enabled
huglx Oct 21, 2024
1dee374
fix: prettier
huglx Oct 21, 2024
a3d655e
fix: add sso feature message
huglx Oct 21, 2024
0672f26
fix: renaming
huglx Oct 21, 2024
72b531e
fix: prettier
huglx Oct 21, 2024
f232ae6
fix: prettier
huglx Oct 21, 2024
61e5fd3
fix: remove scope
huglx Oct 21, 2024
ac207b6
feat: prevent sso user from create organizations
huglx Oct 22, 2024
53df0c0
fix: if it's sso user find by sso domain
huglx Oct 22, 2024
b8a4962
fix: change default sso login logo
huglx Oct 22, 2024
af85aff
feat: add sso valid user cache
huglx Oct 25, 2024
284d7a6
feat: add validation that user is still an employee
huglx Oct 25, 2024
e73c0aa
chore: test new validation
huglx Oct 25, 2024
6c0b685
feat: move ssoDomain from UserAccount to separate entity
huglx Oct 26, 2024
fc83174
chore: update tests
huglx Oct 26, 2024
b69c9b6
fix: use enum as ThirdPartyAuthType instead of string
huglx Oct 26, 2024
ffdcbe4
feat: prevent sso user to login
huglx Oct 26, 2024
c77ee5e
feat: prevent sso user to change password
huglx Oct 26, 2024
370a51e
feat: add global sso config
huglx Oct 27, 2024
b9681d3
chore: test global sso config
huglx Oct 27, 2024
e046416
fix: use frontend url from config, instead of saving it to db
huglx Oct 29, 2024
873324d
fix: use frontend url from config, instead of saving it to db
huglx Oct 29, 2024
a960a6c
chore: update tests
huglx Oct 29, 2024
2397931
chore: update docs property descriptions
huglx Oct 29, 2024
1cfa8df
chore: fix ktlint
huglx Oct 29, 2024
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
Expand Up @@ -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
Anty0 marked this conversation as resolved.
Show resolved Hide resolved
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
Expand All @@ -49,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()
}

Expand Down
18 changes: 18 additions & 0 deletions backend/api/src/main/kotlin/io/tolgee/hateoas/ee/SsoTenantModel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package io.tolgee.hateoas.ee

import org.springframework.hateoas.RepresentationModel
import org.springframework.hateoas.server.core.Relation
import java.io.Serializable

@Suppress("unused")
@Relation(collectionRelation = "ssoTenants", itemRelation = "ssoTenant")
class SsoTenantModel(
val authorizationUri: String,
val clientId: String,
val clientSecret: String,
val tokenUri: String,
val isEnabled: Boolean,
val jwkSetUri: String,
val domainName: String,
) : RepresentationModel<SsoTenantModel>(),
Serializable
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,12 +58,13 @@ class GithubOAuthDelegate(

// get github user emails
val emails =
restTemplate.exchange(
githubConfigurationProperties.userUrl + "/emails",
HttpMethod.GET,
entity,
Array<GithubEmailResponse>::class.java,
).body
restTemplate
.exchange(
githubConfigurationProperties.userUrl + "/emails",
HttpMethod.GET,
entity,
Array<GithubEmailResponse>::class.java,
).body
?: throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL)

val verifiedEmails = Arrays.stream(emails).filter { it.verified }.collect(Collectors.toList())
Expand All @@ -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 {
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,14 @@ 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
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
Expand All @@ -28,6 +25,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)
Expand Down Expand Up @@ -90,33 +88,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, ThirdPartyAuthType.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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
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
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
import org.springframework.stereotype.Component

@Component
class OAuthUserHandler(
private val signUpService: SignUpService,
private val organizationRoleService: OrganizationRoleService,
private val userAccountService: UserAccountService,
private val ssoConfService: SsoConfigService,
private val cacheWithExpirationManager: CacheWithExpirationManager,
) {
fun findOrCreateUser(
userResponse: OAuthUserDetails,
invitationCode: String?,
thirdPartyAuthType: ThirdPartyAuthType,
): UserAccount {
val userAccountOptional =
if (thirdPartyAuthType == ThirdPartyAuthType.SSO && userResponse.domain != null) {
userAccountService.findByDomainSso(userResponse.domain, userResponse.sub!!)
} else {
userAccountService.findByThirdParty(thirdPartyAuthType, userResponse.sub!!)
}

if (userAccountOptional.isPresent && thirdPartyAuthType == 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)
}

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
if (userResponse.domain != null) {
newUserAccount.ssoConfig = ssoConfService.save(newUserAccount, userResponse.domain!!)
}
newUserAccount.thirdPartyAuthType = thirdPartyAuthType
newUserAccount.ssoRefreshToken = userResponse.refreshToken
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 != ThirdPartyAuthType.OAUTH2 &&
invitationCode == null
) {
organizationRoleService.grantRoleToUser(
newUserAccount,
userResponse.organizationId,
OrganizationRoleType.MEMBER,
)
}

cacheSsoUser(newUserAccount.id, thirdPartyAuthType)

newUserAccount
}
}

fun updateRefreshToken(
userAccount: UserAccount,
refreshToken: String?,
) {
if (userAccount.ssoRefreshToken != refreshToken) {
userAccount.ssoRefreshToken = refreshToken
userAccountService.save(userAccount)
}
}

fun updateRefreshToken(
userAccountId: Long,
refreshToken: String?,
) {
val userAccount = userAccountService.get(userAccountId)

if (userAccount.ssoRefreshToken != refreshToken) {
userAccount.ssoRefreshToken = refreshToken
userAccountService.save(userAccount)
}
}

private fun cacheSsoUser(
userId: Long,
thirdPartyAuthType: ThirdPartyAuthType,
) {
if (thirdPartyAuthType == ThirdPartyAuthType.SSO) {
cacheWithExpirationManager.putCache(Caches.IS_SSO_USER_VALID, userId, true)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
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,
val refreshToken: String? = null,
)
1 change: 1 addition & 0 deletions backend/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.security:spring-security-oauth2-client")
implementation "org.springframework.boot:spring-boot-starter-validation"
implementation "org.springframework.boot:spring-boot-starter-hateoas"
implementation "org.springframework.boot:spring-boot-configuration-processor"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,23 +103,24 @@ class WebSecurityConfig(
@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/**")
}

Expand Down
Loading
Loading