mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-04-30 17:06:32 +00:00
feat(auth): sso fallback mapping (#3068)
Reviewed-on: https://kolaente.dev/vikunja/vikunja/pulls/3068 Reviewed-by: konrad <k@knt.li> Co-authored-by: Marc <marc88@free.fr> Co-committed-by: Marc <marc88@free.fr>
This commit is contained in:
@@ -48,16 +48,18 @@ type Callback struct {
|
||||
|
||||
// Provider is the structure of an OpenID Connect provider
|
||||
type Provider struct {
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
OriginalAuthURL string `json:"-"`
|
||||
AuthURL string `json:"auth_url"`
|
||||
LogoutURL string `json:"logout_url"`
|
||||
ClientID string `json:"client_id"`
|
||||
Scope string `json:"scope"`
|
||||
ClientSecret string `json:"-"`
|
||||
openIDProvider *oidc.Provider
|
||||
Oauth2Config *oauth2.Config `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
OriginalAuthURL string `json:"-"`
|
||||
AuthURL string `json:"auth_url"`
|
||||
LogoutURL string `json:"logout_url"`
|
||||
ClientID string `json:"client_id"`
|
||||
Scope string `json:"scope"`
|
||||
EmailFallback bool `json:"email_fallback"`
|
||||
UsernameFallback bool `json:"username_fallback"`
|
||||
ClientSecret string `json:"-"`
|
||||
openIDProvider *oidc.Provider
|
||||
Oauth2Config *oauth2.Config `json:"-"`
|
||||
}
|
||||
type claims struct {
|
||||
Email string `json:"email"`
|
||||
@@ -110,112 +112,29 @@ func (p *Provider) Issuer() (issuerURL string, err error) {
|
||||
// @Failure 500 {object} models.Message "Internal error"
|
||||
// @Router /auth/openid/{provider}/callback [post]
|
||||
func HandleCallback(c echo.Context) error {
|
||||
cb := &Callback{}
|
||||
if err := c.Bind(cb); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, models.Message{Message: "Bad data"})
|
||||
}
|
||||
|
||||
// Check if the provider exists
|
||||
providerKey := c.Param("provider")
|
||||
provider, err := GetProvider(providerKey)
|
||||
provider, oauthToken, idToken, err := getProviderAndOidcTokens(c)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err)
|
||||
}
|
||||
if provider == nil {
|
||||
return c.JSON(http.StatusBadRequest, models.Message{Message: "Provider does not exist"})
|
||||
}
|
||||
|
||||
log.Debugf("Trying to authenticate user using provider: %s", provider.Key)
|
||||
|
||||
provider.Oauth2Config.RedirectURL = cb.RedirectURL
|
||||
|
||||
// Parse the access & ID token
|
||||
oauth2Token, err := provider.Oauth2Config.Exchange(context.Background(), cb.Code)
|
||||
if err != nil {
|
||||
var rerr *oauth2.RetrieveError
|
||||
if errors.As(err, &rerr) {
|
||||
|
||||
details := make(map[string]interface{})
|
||||
if err := json.Unmarshal(rerr.Body, &details); err != nil {
|
||||
log.Errorf("Error unmarshalling token for provider %s: %v", provider.Name, err)
|
||||
return handler.HandleHTTPError(err)
|
||||
}
|
||||
|
||||
log.Error(err)
|
||||
var detailedErr *models.ErrOpenIDBadRequestWithDetails
|
||||
if errors.As(err, &detailedErr) {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{
|
||||
"message": "Could not authenticate against third party.",
|
||||
"details": details,
|
||||
"message": detailedErr.Message,
|
||||
"details": detailedErr.Details,
|
||||
})
|
||||
}
|
||||
|
||||
return handler.HandleHTTPError(err)
|
||||
}
|
||||
|
||||
// Extract the ID Token from OAuth2 token.
|
||||
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, models.Message{Message: "Missing token"})
|
||||
}
|
||||
|
||||
verifier := provider.openIDProvider.Verifier(&oidc.Config{ClientID: provider.ClientID})
|
||||
|
||||
// Parse and verify ID Token payload.
|
||||
idToken, err := verifier.Verify(context.Background(), rawIDToken)
|
||||
cl, err := getClaims(provider, oauthToken, idToken)
|
||||
if err != nil {
|
||||
log.Errorf("Error verifying token for provider %s: %v", provider.Name, err)
|
||||
return handler.HandleHTTPError(err)
|
||||
}
|
||||
|
||||
// Extract custom claims
|
||||
cl := &claims{}
|
||||
|
||||
err = idToken.Claims(cl)
|
||||
if err != nil {
|
||||
log.Errorf("Error getting token claims for provider %s: %v", provider.Name, err)
|
||||
return handler.HandleHTTPError(err)
|
||||
}
|
||||
|
||||
if cl.Email == "" || cl.Name == "" || cl.PreferredUsername == "" {
|
||||
info, err := provider.openIDProvider.UserInfo(context.Background(), provider.Oauth2Config.TokenSource(context.Background(), oauth2Token))
|
||||
if err != nil {
|
||||
log.Errorf("Error getting userinfo for provider %s: %v", provider.Name, err)
|
||||
return handler.HandleHTTPError(err)
|
||||
}
|
||||
|
||||
cl2 := &claims{}
|
||||
err = info.Claims(cl2)
|
||||
if err != nil {
|
||||
log.Errorf("Error parsing userinfo claims for provider %s: %v", provider.Name, err)
|
||||
return handler.HandleHTTPError(err)
|
||||
}
|
||||
|
||||
if cl.Email == "" {
|
||||
cl.Email = cl2.Email
|
||||
}
|
||||
|
||||
if cl.Name == "" {
|
||||
cl.Name = cl2.Name
|
||||
}
|
||||
|
||||
if cl.PreferredUsername == "" {
|
||||
cl.PreferredUsername = cl2.PreferredUsername
|
||||
}
|
||||
|
||||
if cl.PreferredUsername == "" && cl2.Nickname != "" {
|
||||
cl.PreferredUsername = cl2.Nickname
|
||||
}
|
||||
|
||||
if cl.Email == "" {
|
||||
log.Errorf("Claim does not contain an email address for provider %s", provider.Name)
|
||||
return handler.HandleHTTPError(&user.ErrNoOpenIDEmailProvided{})
|
||||
}
|
||||
}
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
// Check if we have seen this user before
|
||||
u, err := getOrCreateUser(s, cl, idToken.Issuer, idToken.Subject)
|
||||
u, err := getOrCreateUser(s, cl, provider, idToken)
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
log.Errorf("Error creating new user for provider %s: %v", provider.Name, err)
|
||||
@@ -403,40 +322,71 @@ func GetOrCreateTeamsByOIDC(s *xorm.Session, teamData []*models.OIDCTeam, u *use
|
||||
return te, err
|
||||
}
|
||||
|
||||
func getOrCreateUser(s *xorm.Session, cl *claims, issuer, subject string) (u *user.User, err error) {
|
||||
func getOrCreateUser(s *xorm.Session, cl *claims, provider *Provider, idToken *oidc.IDToken) (u *user.User, err error) {
|
||||
|
||||
// set defaults
|
||||
fallbackMatchFound := false
|
||||
alreadyCreatedFromIssuer := false
|
||||
|
||||
// first check if the user already signed up using the provider
|
||||
|
||||
// Check if the user exists for that issuer and subject
|
||||
u, err = user.GetUserWithEmail(s, &user.User{
|
||||
Issuer: issuer,
|
||||
Subject: subject,
|
||||
Issuer: idToken.Issuer,
|
||||
Subject: idToken.Subject,
|
||||
})
|
||||
if err != nil && !user.IsErrUserDoesNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
alreadyCreatedFromIssuer = err == nil // found if no error, not found if we reach it here despite an error
|
||||
|
||||
// If no user exists, create one with the preferred username if it is not already taken
|
||||
if user.IsErrUserDoesNotExist(err) {
|
||||
if !alreadyCreatedFromIssuer && (provider.EmailFallback || provider.UsernameFallback) {
|
||||
|
||||
// try finding the user on fallback mappingproperties
|
||||
|
||||
searchUser := &user.User{
|
||||
Issuer: user.IssuerLocal,
|
||||
}
|
||||
if provider.UsernameFallback {
|
||||
// Match oidc subject on username as each is unique identifier in its own referential
|
||||
// Discouraged if multiple account providers are used.
|
||||
searchUser.Username = idToken.Subject
|
||||
}
|
||||
if provider.EmailFallback {
|
||||
// Used alone, allow for someone to connect from various provider to the same account
|
||||
// Discouraged for untrusted provider where someone can set email without verification
|
||||
// Note : mapping on email prevent from auto-updating user email
|
||||
searchUser.Email = cl.Email
|
||||
}
|
||||
|
||||
// Check if the user exists for the given fallback matching options
|
||||
u, err = user.GetUserWithEmail(s, searchUser)
|
||||
if err != nil && !user.IsErrUserDoesNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
fallbackMatchFound = err == nil // found if no error, not found if we reach it here despite an error
|
||||
}
|
||||
|
||||
if !alreadyCreatedFromIssuer && !fallbackMatchFound {
|
||||
|
||||
// If no user exists, create one with the preferred username if it is not already taken
|
||||
uu := &user.User{
|
||||
Username: strings.ReplaceAll(cl.PreferredUsername, " ", "-"),
|
||||
Email: cl.Email,
|
||||
Name: cl.Name,
|
||||
Status: user.StatusActive,
|
||||
Issuer: issuer,
|
||||
Subject: subject,
|
||||
Issuer: idToken.Issuer,
|
||||
Subject: idToken.Subject,
|
||||
}
|
||||
|
||||
return auth.CreateUserWithRandomUsername(s, uu)
|
||||
}
|
||||
} else if alreadyCreatedFromIssuer {
|
||||
|
||||
// If it exists, check if the email address changed and change it if not
|
||||
if cl.Email != u.Email || cl.Name != u.Name {
|
||||
// try updating user.Name and/or user.Email if necessary
|
||||
if cl.Email != u.Email {
|
||||
u.Email = cl.Email
|
||||
}
|
||||
if cl.Name != u.Name {
|
||||
u.Name = cl.Name
|
||||
}
|
||||
|
||||
u, err = user.UpdateUser(s, u, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -445,3 +395,110 @@ func getOrCreateUser(s *xorm.Session, cl *claims, issuer, subject string) (u *us
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func getClaims(provider *Provider, oauth2Token *oauth2.Token, idToken *oidc.IDToken) (*claims, error) {
|
||||
|
||||
cl := &claims{}
|
||||
err := idToken.Claims(cl)
|
||||
if err != nil {
|
||||
log.Errorf("Error getting token claims for provider %s: %v", provider.Name, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cl.Email == "" || cl.Name == "" || cl.PreferredUsername == "" {
|
||||
info, err := provider.openIDProvider.UserInfo(context.Background(), provider.Oauth2Config.TokenSource(context.Background(), oauth2Token))
|
||||
if err != nil {
|
||||
log.Errorf("Error getting userinfo for provider %s: %v", provider.Name, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cl2 := &claims{}
|
||||
err = info.Claims(cl2)
|
||||
if err != nil {
|
||||
log.Errorf("Error parsing userinfo claims for provider %s: %v", provider.Name, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cl.Email == "" {
|
||||
cl.Email = cl2.Email
|
||||
}
|
||||
|
||||
if cl.Name == "" {
|
||||
cl.Name = cl2.Name
|
||||
}
|
||||
|
||||
if cl.PreferredUsername == "" {
|
||||
cl.PreferredUsername = cl2.PreferredUsername
|
||||
}
|
||||
|
||||
if cl.PreferredUsername == "" && cl2.Nickname != "" {
|
||||
cl.PreferredUsername = cl2.Nickname
|
||||
}
|
||||
|
||||
if cl.Email == "" {
|
||||
log.Errorf("Claim does not contain an email address for provider %s", provider.Name)
|
||||
return nil, &user.ErrNoOpenIDEmailProvided{}
|
||||
}
|
||||
}
|
||||
return cl, nil
|
||||
}
|
||||
|
||||
func getProviderAndOidcTokens(c echo.Context) (*Provider, *oauth2.Token, *oidc.IDToken, error) {
|
||||
|
||||
cb := &Callback{}
|
||||
if err := c.Bind(cb); err != nil {
|
||||
return nil, nil, nil, &models.ErrOpenIDBadRequest{Message: "Bad data"}
|
||||
}
|
||||
|
||||
// Check if the provider exists
|
||||
providerKey := c.Param("provider")
|
||||
provider, err := GetProvider(providerKey)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if provider == nil {
|
||||
return nil, nil, nil, &models.ErrOpenIDBadRequest{Message: "Provider does not exist"}
|
||||
}
|
||||
|
||||
log.Debugf("Trying to authenticate user using provider: %s", provider.Key)
|
||||
|
||||
provider.Oauth2Config.RedirectURL = cb.RedirectURL
|
||||
// Parse the access & ID token
|
||||
oauth2Token, err := provider.Oauth2Config.Exchange(context.Background(), cb.Code)
|
||||
if err != nil {
|
||||
var rerr *oauth2.RetrieveError
|
||||
if errors.As(err, &rerr) {
|
||||
|
||||
details := make(map[string]interface{})
|
||||
if err := json.Unmarshal(rerr.Body, &details); err != nil {
|
||||
log.Errorf("Error unmarshalling token for provider %s: %v", provider.Name, err)
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
log.Error(err)
|
||||
return nil, nil, nil, &models.ErrOpenIDBadRequestWithDetails{
|
||||
Message: "Could not authenticate against third party.",
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
// Extract the ID Token from OAuth2 token.
|
||||
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return nil, nil, nil, &models.ErrOpenIDBadRequest{Message: "Missing token"}
|
||||
}
|
||||
|
||||
verifier := provider.openIDProvider.Verifier(&oidc.Config{ClientID: provider.ClientID})
|
||||
|
||||
// Parse and verify ID Token payload.
|
||||
idToken, err := verifier.Verify(context.Background(), rawIDToken)
|
||||
if err != nil {
|
||||
log.Errorf("Error verifying token for provider %s: %v", provider.Name, err)
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
return provider, oauth2Token, idToken, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user