Files
hanko/backend/thirdparty/linking.go
2025-09-25 19:15:20 +02:00

329 lines
11 KiB
Go

package thirdparty
import (
"fmt"
"strings"
"time"
"github.com/gobuffalo/pop/v6"
"github.com/gofrs/uuid"
"github.com/teamhanko/hanko/backend/v2/config"
"github.com/teamhanko/hanko/backend/v2/persistence"
"github.com/teamhanko/hanko/backend/v2/persistence/models"
"github.com/teamhanko/hanko/backend/v2/webhooks/events"
)
type AccountLinkingResult struct {
Type models.AuditLogType
User *models.User
WebhookEvent *events.Event
UserCreated bool
}
func LinkAccount(tx *pop.Connection, cfg *config.Config, p persistence.Persister, userData *UserData, providerID string, isSaml bool, samlDomain *string, isFlow bool) (*AccountLinkingResult, error) {
if !isFlow {
if cfg.Email.RequireVerification && !userData.Metadata.EmailVerified {
return nil, ErrorUnverifiedProviderEmail("third party provider email must be verified")
}
}
// Validate userData
if userData == nil {
return nil, ErrorInvalidRequest("user data must be set")
}
// Ensure the email is lowercase to avoid case sensitivity issues
userData.Metadata.Email = strings.ToLower(userData.Metadata.Email)
identity, err := p.GetIdentityPersister().Get(userData.Metadata.Subject, providerID)
if err != nil {
return nil, ErrorServer("could not get identity").WithCause(err)
}
if identity == nil {
user, err := p.GetUserPersisterWithConnection(tx).GetByEmailAddress(userData.Metadata.Email)
if err != nil {
return nil, ErrorServer("could not get email").WithCause(err)
}
if user == nil {
return signUp(tx, cfg, p, userData, providerID, isSaml, samlDomain)
} else {
return link(tx, cfg, p, userData, providerID, user, isSaml, samlDomain)
}
} else {
return signIn(tx, cfg, p, userData, identity)
}
}
func link(tx *pop.Connection, cfg *config.Config, p persistence.Persister, userData *UserData, providerID string, user *models.User, isSaml bool, samlDomain *string) (*AccountLinkingResult, error) {
if !isSaml {
if strings.HasPrefix(providerID, "custom_") {
provider, ok := cfg.ThirdParty.CustomProviders[strings.TrimPrefix(providerID, "custom_")]
if !ok {
return nil, ErrorServer(fmt.Sprintf("unknown provider: %s", providerID))
}
if !provider.AllowLinking {
return nil, ErrorUserConflict("third party account linking for existing user with same email disallowed")
}
} else {
provider := cfg.ThirdParty.Providers.Get(providerID)
if provider == nil {
return nil, fmt.Errorf("unknown provider: %s", providerID)
}
if !provider.AllowLinking {
return nil, ErrorUserConflict("third party account linking for existing user with same email disallowed")
}
}
}
email := user.GetEmailByAddress(userData.Metadata.Email)
userDataMap, err := userData.ToMap()
if err != nil {
return nil, ErrorServer("could not link account").WithCause(err)
}
identity, err := models.NewIdentity(providerID, userDataMap, email.ID)
if err != nil {
return nil, ErrorServer("could not create identity").WithCause(err)
}
err = p.GetIdentityPersisterWithConnection(tx).Create(*identity)
if err != nil {
return nil, ErrorServer("could not create identity").WithCause(err)
}
if isSaml && samlDomain != nil && *samlDomain != "" {
if existingSamlIdentity := email.GetSamlIdentityForDomain(*samlDomain); existingSamlIdentity != nil {
identityToDeleteID := existingSamlIdentity.IdentityID
existingSamlIdentity.IdentityID = identity.ID
err = p.GetSamlIdentityPersisterWithConnection(tx).Update(*existingSamlIdentity)
if err != nil {
return nil, ErrorServer("could update saml identity").WithCause(err)
}
err = p.GetIdentityPersisterWithConnection(tx).Delete(models.Identity{ID: identityToDeleteID})
if err != nil {
return nil, ErrorServer("could not delete identity").WithCause(err)
}
} else {
samlIdentityID, _ := uuid.NewV4()
now := time.Now().UTC()
samlIdentity := &models.SamlIdentity{
ID: samlIdentityID,
IdentityID: identity.ID,
Domain: *samlDomain,
CreatedAt: now,
UpdatedAt: now,
}
err = p.GetSamlIdentityPersisterWithConnection(tx).Create(*samlIdentity)
if err != nil {
return nil, ErrorServer("could not create saml identity").WithCause(err)
}
}
}
u, terr := p.GetUserPersisterWithConnection(tx).Get(*email.UserID)
if terr != nil {
return nil, ErrorServer("could not get user").WithCause(terr)
}
return &AccountLinkingResult{
Type: models.AuditLogThirdPartyLinkingSucceeded,
User: u,
WebhookEvent: nil,
UserCreated: false,
}, nil
}
func signIn(tx *pop.Connection, cfg *config.Config, p persistence.Persister, userData *UserData, identity *models.Identity) (*AccountLinkingResult, error) {
var linkingResult *AccountLinkingResult
var webhookEvent events.Event
userPersister := p.GetUserPersisterWithConnection(tx)
emailPersister := p.GetEmailPersisterWithConnection(tx)
identityPersister := p.GetIdentityPersisterWithConnection(tx)
var terr error
email := identity.Email
if userData.Metadata.Email != identity.Email.Address {
// The primary email address at the provider has changed, check if the new provider email already exists
email, terr = emailPersister.FindByAddress(userData.Metadata.Email)
if terr != nil {
return nil, ErrorServer("could not get email").WithCause(terr)
}
if email != nil {
if email.UserID == nil {
// The email already exists but is unassigned, claim it and associate the identity with it
email.UserID = identity.Email.UserID
email.Verified = userData.Metadata.EmailVerified
terr = emailPersister.Update(*email)
if terr != nil {
return nil, ErrorServer("could not update email").WithCause(terr)
}
identity.EmailID = email.ID
webhookEvent = events.UserUpdate
} else if email.UserID.String() != identity.Email.UserID.String() {
// The email is assigned to a different user, and so the identity is linked to multiple users. There
// is not much we can do here but return an error.
return nil, ErrorMultipleAccounts(fmt.Sprintf("cannot identify associated user: '%s' is used by multiple accounts", email.Address))
} else {
// The email is assigned to the same user. This can happen if the user creates an email with an
// address equal to the new primary provider email prior to changing the primary mail at the
// provider and then doing a sign in with the provider. We need to update the associated email in
// the identity.
identity.EmailID = email.ID
}
} else {
// The email does not exist. Create a new one and associate the identity with it.
emailCount, err := emailPersister.CountByUserId(*identity.Email.UserID)
if err != nil {
return nil, ErrorServer("failed to count user emails").WithCause(err)
}
if emailCount >= cfg.Email.Limit {
return nil, ErrorMaxNumberOfAddresses("max number of email addresses reached")
}
email = models.NewEmail(identity.Email.UserID, userData.Metadata.Email)
email.Verified = userData.Metadata.EmailVerified
terr = emailPersister.Create(*email)
if terr != nil {
return nil, ErrorServer("could not create email").WithCause(terr)
}
identity.EmailID = email.ID
webhookEvent = events.UserEmailCreate
}
}
userDataMap, err := userData.ToMap()
if err != nil {
return nil, ErrorServer("could not link account").WithCause(err)
}
identity.Data = userDataMap
terr = identityPersister.Update(*identity)
if terr != nil {
return nil, ErrorServer("could not update identity").WithCause(terr)
}
user, terr := userPersister.Get(*identity.Email.UserID)
if terr != nil {
return nil, ErrorServer("could not get user").WithCause(terr)
}
linkingResult = &AccountLinkingResult{
Type: models.AuditLogThirdPartySignInSucceeded,
User: user,
WebhookEvent: &webhookEvent,
UserCreated: false,
}
return linkingResult, nil
}
func signUp(tx *pop.Connection, cfg *config.Config, p persistence.Persister, userData *UserData, providerID string, isSaml bool, samlDomain *string) (*AccountLinkingResult, error) {
if !cfg.Account.AllowSignup {
return nil, ErrorSignUpDisabled("account signup is disabled")
}
var linkingResult *AccountLinkingResult
userPersister := p.GetUserPersisterWithConnection(tx)
emailPersister := p.GetEmailPersisterWithConnection(tx)
primaryEmailPersister := p.GetPrimaryEmailPersisterWithConnection(tx)
identityPersister := p.GetIdentityPersisterWithConnection(tx)
email, terr := emailPersister.FindByAddress(userData.Metadata.Email)
if terr != nil {
return nil, ErrorServer("could not get email").WithCause(terr)
}
user := models.NewUser()
terr = userPersister.Create(user)
if terr != nil {
return nil, ErrorServer("could not create user").WithCause(terr)
}
if email != nil && email.UserID == nil {
// There exists an email with the same address as the primary provider address, but it is not assigned
// to any user yet, hence we assign the new user ID to this email.
email.UserID = &user.ID
email.Verified = userData.Metadata.EmailVerified
terr = emailPersister.Update(*email)
if terr != nil {
return nil, ErrorServer("could not update email").WithCause(terr)
}
} else {
// No email exists, create a new one using the provider user data email
email = models.NewEmail(&user.ID, userData.Metadata.Email)
email.Verified = userData.Metadata.EmailVerified
terr = emailPersister.Create(*email)
if terr != nil {
return nil, ErrorServer("failed to store email").WithCause(terr)
}
}
primaryEmail := models.NewPrimaryEmail(email.ID, *email.UserID)
terr = primaryEmailPersister.Create(*primaryEmail)
if terr != nil {
return nil, ErrorServer("failed to store primary email").WithCause(terr)
}
userDataMap, err := userData.ToMap()
if err != nil {
return nil, ErrorServer("could not link account").WithCause(err)
}
identity, terr := models.NewIdentity(providerID, userDataMap, email.ID)
if terr != nil {
return nil, ErrorServer("could not create identity").WithCause(terr)
}
terr = identityPersister.Create(*identity)
if terr != nil {
return nil, ErrorServer("could not store identity").WithCause(terr)
}
if isSaml && samlDomain != nil && *samlDomain != "" {
samlIdentityID, _ := uuid.NewV4()
now := time.Now().UTC()
samlIdentity := &models.SamlIdentity{
ID: samlIdentityID,
IdentityID: identity.ID,
Domain: *samlDomain,
CreatedAt: now,
UpdatedAt: now,
}
err = p.GetSamlIdentityPersisterWithConnection(tx).Create(*samlIdentity)
if err != nil {
return nil, ErrorServer("could not store saml identity").WithCause(err)
}
}
u, terr := userPersister.Get(*email.UserID)
if terr != nil {
return nil, ErrorServer("could not get user").WithCause(terr)
}
evt := events.UserCreate
linkingResult = &AccountLinkingResult{
Type: models.AuditLogThirdPartySignUpSucceeded,
User: u,
WebhookEvent: &evt,
UserCreated: true,
}
return linkingResult, nil
}