Add Option to synchronize Admin & Restricted states from OIDC/OAuth2 along with Setting Scopes (#16766)

* Add setting to OAuth handlers to override local 2FA settings

This PR adds a setting to OAuth and OpenID login sources to allow the source to
override local 2FA requirements.

Fix #13939

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Fix regression from #16544

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Add scopes settings

Signed-off-by: Andrew Thornton <art27@cantab.net>

* fix trace logging in auth_openid

Signed-off-by: Andrew Thornton <art27@cantab.net>

* add required claim options

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Move UpdateExternalUser to externalaccount

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Allow OAuth2/OIDC to set Admin/Restricted status

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Allow use of the same group claim name for the prohibit login value

Signed-off-by: Andrew Thornton <art27@cantab.net>

* fixup! Move UpdateExternalUser to externalaccount

* as per wxiaoguang

Signed-off-by: Andrew Thornton <art27@cantab.net>

* add label back in

Signed-off-by: Andrew Thornton <art27@cantab.net>

* adjust localisation

Signed-off-by: Andrew Thornton <art27@cantab.net>

* placate lint

Signed-off-by: Andrew Thornton <art27@cantab.net>

Co-authored-by: 6543 <6543@obermui.de>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: techknowlogick <techknowlogick@gitea.io>
This commit is contained in:
zeripath 2021-12-14 08:37:11 +00:00 committed by GitHub
parent b4782e24d2
commit 0981ec30c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 344 additions and 88 deletions

View File

@ -299,6 +299,36 @@ var (
Name: "skip-local-2fa", Name: "skip-local-2fa",
Usage: "Set to true to skip local 2fa for users authenticated by this source", Usage: "Set to true to skip local 2fa for users authenticated by this source",
}, },
cli.StringSliceFlag{
Name: "scopes",
Value: nil,
Usage: "Scopes to request when to authenticate against this OAuth2 source",
},
cli.StringFlag{
Name: "required-claim-name",
Value: "",
Usage: "Claim name that has to be set to allow users to login with this source",
},
cli.StringFlag{
Name: "required-claim-value",
Value: "",
Usage: "Claim value that has to be set to allow users to login with this source",
},
cli.StringFlag{
Name: "group-claim-name",
Value: "",
Usage: "Claim name providing group names for this source",
},
cli.StringFlag{
Name: "admin-group",
Value: "",
Usage: "Group Claim value for administrator users",
},
cli.StringFlag{
Name: "restricted-group",
Value: "",
Usage: "Group Claim value for restricted users",
},
} }
microcmdAuthUpdateOauth = cli.Command{ microcmdAuthUpdateOauth = cli.Command{
@ -649,6 +679,12 @@ func parseOAuth2Config(c *cli.Context) *oauth2.Source {
CustomURLMapping: customURLMapping, CustomURLMapping: customURLMapping,
IconURL: c.String("icon-url"), IconURL: c.String("icon-url"),
SkipLocalTwoFA: c.Bool("skip-local-2fa"), SkipLocalTwoFA: c.Bool("skip-local-2fa"),
Scopes: c.StringSlice("scopes"),
RequiredClaimName: c.String("required-claim-name"),
RequiredClaimValue: c.String("required-claim-value"),
GroupClaimName: c.String("group-claim-name"),
AdminGroup: c.String("admin-group"),
RestrictedGroup: c.String("restricted-group"),
} }
} }
@ -711,6 +747,28 @@ func runUpdateOauth(c *cli.Context) error {
oAuth2Config.IconURL = c.String("icon-url") oAuth2Config.IconURL = c.String("icon-url")
} }
if c.IsSet("scopes") {
oAuth2Config.Scopes = c.StringSlice("scopes")
}
if c.IsSet("required-claim-name") {
oAuth2Config.RequiredClaimName = c.String("required-claim-name")
}
if c.IsSet("required-claim-value") {
oAuth2Config.RequiredClaimValue = c.String("required-claim-value")
}
if c.IsSet("group-claim-name") {
oAuth2Config.GroupClaimName = c.String("group-claim-name")
}
if c.IsSet("admin-group") {
oAuth2Config.AdminGroup = c.String("admin-group")
}
if c.IsSet("restricted-group") {
oAuth2Config.RestrictedGroup = c.String("restricted-group")
}
// update custom URL mapping // update custom URL mapping
var customURLMapping = &oauth2.CustomURLMapping{} var customURLMapping = &oauth2.CustomURLMapping{}

View File

@ -129,6 +129,13 @@ Admin operations:
- `--custom-profile-url`: Use a custom Profile URL (option for GitLab/GitHub). - `--custom-profile-url`: Use a custom Profile URL (option for GitLab/GitHub).
- `--custom-email-url`: Use a custom Email URL (option for GitHub). - `--custom-email-url`: Use a custom Email URL (option for GitHub).
- `--icon-url`: Custom icon URL for OAuth2 login source. - `--icon-url`: Custom icon URL for OAuth2 login source.
- `--override-local-2fa`: Allow source to override local 2fa. (Optional)
- `--scopes`: Addtional scopes to request for this OAuth2 source. (Optional)
- `--required-claim-name`: Claim name that has to be set to allow users to login with this source. (Optional)
- `--required-claim-value`: Claim value that has to be set to allow users to login with this source. (Optional)
- `--group-claim-name`: Claim name providing group names for this source. (Optional)
- `--admin-group`: Group Claim value for administrator users. (Optional)
- `--restricted-group`: Group Claim value for restricted users. (Optional)
- Examples: - Examples:
- `gitea admin auth add-oauth --name external-github --provider github --key OBTAIN_FROM_SOURCE --secret OBTAIN_FROM_SOURCE` - `gitea admin auth add-oauth --name external-github --provider github --key OBTAIN_FROM_SOURCE --secret OBTAIN_FROM_SOURCE`
- `update-oauth`: - `update-oauth`:
@ -145,6 +152,13 @@ Admin operations:
- `--custom-profile-url`: Use a custom Profile URL (option for GitLab/GitHub). - `--custom-profile-url`: Use a custom Profile URL (option for GitLab/GitHub).
- `--custom-email-url`: Use a custom Email URL (option for GitHub). - `--custom-email-url`: Use a custom Email URL (option for GitHub).
- `--icon-url`: Custom icon URL for OAuth2 login source. - `--icon-url`: Custom icon URL for OAuth2 login source.
- `--override-local-2fa`: Allow source to override local 2fa. (Optional)
- `--scopes`: Addtional scopes to request for this OAuth2 source.
- `--required-claim-name`: Claim name that has to be set to allow users to login with this source. (Optional)
- `--required-claim-value`: Claim value that has to be set to allow users to login with this source. (Optional)
- `--group-claim-name`: Claim name providing group names for this source. (Optional)
- `--admin-group`: Group Claim value for administrator users. (Optional)
- `--restricted-group`: Group Claim value for restricted users. (Optional)
- Examples: - Examples:
- `gitea admin auth update-oauth --id 1 --name external-github-updated` - `gitea admin auth update-oauth --id 1 --name external-github-updated`
- `add-ldap`: Add new LDAP (via Bind DN) authentication source - `add-ldap`: Add new LDAP (via Bind DN) authentication source

View File

@ -10,9 +10,7 @@ import (
"time" "time"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/login"
"github.com/markbates/goth"
"xorm.io/builder" "xorm.io/builder"
) )
@ -139,42 +137,18 @@ func GetUserIDByExternalUserID(provider, userID string) (int64, error) {
return id, nil return id, nil
} }
// UpdateExternalUser updates external user's information // UpdateExternalUserByExternalID updates an external user's information
func UpdateExternalUser(user *User, gothUser goth.User) error { func UpdateExternalUserByExternalID(external *ExternalLoginUser) error {
loginSource, err := login.GetActiveOAuth2LoginSourceByName(gothUser.Provider) has, err := db.GetEngine(db.DefaultContext).Where("external_id=? AND login_source_id=?", external.ExternalID, external.LoginSourceID).
if err != nil {
return err
}
externalLoginUser := &ExternalLoginUser{
ExternalID: gothUser.UserID,
UserID: user.ID,
LoginSourceID: loginSource.ID,
RawData: gothUser.RawData,
Provider: gothUser.Provider,
Email: gothUser.Email,
Name: gothUser.Name,
FirstName: gothUser.FirstName,
LastName: gothUser.LastName,
NickName: gothUser.NickName,
Description: gothUser.Description,
AvatarURL: gothUser.AvatarURL,
Location: gothUser.Location,
AccessToken: gothUser.AccessToken,
AccessTokenSecret: gothUser.AccessTokenSecret,
RefreshToken: gothUser.RefreshToken,
ExpiresAt: gothUser.ExpiresAt,
}
has, err := db.GetEngine(db.DefaultContext).Where("external_id=? AND login_source_id=?", gothUser.UserID, loginSource.ID).
NoAutoCondition(). NoAutoCondition().
Exist(externalLoginUser) Exist(external)
if err != nil { if err != nil {
return err return err
} else if !has { } else if !has {
return ErrExternalLoginUserNotExist{user.ID, loginSource.ID} return ErrExternalLoginUserNotExist{external.UserID, external.LoginSourceID}
} }
_, err = db.GetEngine(db.DefaultContext).Where("external_id=? AND login_source_id=?", gothUser.UserID, loginSource.ID).AllCols().Update(externalLoginUser) _, err = db.GetEngine(db.DefaultContext).Where("external_id=? AND login_source_id=?", external.ExternalID, external.LoginSourceID).AllCols().Update(external)
return err return err
} }

View File

@ -377,6 +377,7 @@ func NewFuncMap() []template.FuncMap {
"MermaidMaxSourceCharacters": func() int { "MermaidMaxSourceCharacters": func() int {
return setting.MermaidMaxSourceCharacters return setting.MermaidMaxSourceCharacters
}, },
"Join": strings.Join,
"QueryEscape": url.QueryEscape, "QueryEscape": url.QueryEscape,
}} }}
} }

View File

@ -2521,6 +2521,11 @@ auths.oauth2_emailURL = Email URL
auths.skip_local_two_fa = Skip local 2FA auths.skip_local_two_fa = Skip local 2FA
auths.skip_local_two_fa_helper = Leaving unset means local users with 2FA set will still have to pass 2FA to log on auths.skip_local_two_fa_helper = Leaving unset means local users with 2FA set will still have to pass 2FA to log on
auths.oauth2_tenant = Tenant auths.oauth2_tenant = Tenant
auths.oauth2_scopes = Additional Scopes
auths.oauth2_required_claim_name = Required Claim Name
auths.oauth2_required_claim_name_helper = Set this name to restrict login from this source to users with a claim with this name
auths.oauth2_required_claim_value = Required Claim Value
auths.oauth2_required_claim_value_helper = Set this value to restrict login from this source to users with a claim with this name and value
auths.enable_auto_register = Enable Auto Registration auths.enable_auto_register = Enable Auto Registration
auths.sspi_auto_create_users = Automatically create users auths.sspi_auto_create_users = Automatically create users
auths.sspi_auto_create_users_helper = Allow SSPI auth method to automatically create new accounts for users that login for the first time auths.sspi_auto_create_users_helper = Allow SSPI auth method to automatically create new accounts for users that login for the first time

View File

@ -11,6 +11,7 @@ import (
"net/url" "net/url"
"regexp" "regexp"
"strconv" "strconv"
"strings"
"code.gitea.io/gitea/models/login" "code.gitea.io/gitea/models/login"
"code.gitea.io/gitea/modules/auth/pam" "code.gitea.io/gitea/modules/auth/pam"
@ -187,6 +188,9 @@ func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source {
OpenIDConnectAutoDiscoveryURL: form.OpenIDConnectAutoDiscoveryURL, OpenIDConnectAutoDiscoveryURL: form.OpenIDConnectAutoDiscoveryURL,
CustomURLMapping: customURLMapping, CustomURLMapping: customURLMapping,
IconURL: form.Oauth2IconURL, IconURL: form.Oauth2IconURL,
Scopes: strings.Split(form.Oauth2Scopes, ","),
RequiredClaimName: form.Oauth2RequiredClaimName,
RequiredClaimValue: form.Oauth2RequiredClaimValue,
SkipLocalTwoFA: form.SkipLocalTwoFA, SkipLocalTwoFA: form.SkipLocalTwoFA,
} }
} }
@ -329,8 +333,8 @@ func EditAuthSource(ctx *context.Context) {
break break
} }
} }
} }
ctx.HTML(http.StatusOK, tplAuthEdit) ctx.HTML(http.StatusOK, tplAuthEdit)
} }

View File

@ -320,16 +320,8 @@ func TwoFactorPost(ctx *context.Context) {
} }
if ctx.Session.Get("linkAccount") != nil { if ctx.Session.Get("linkAccount") != nil {
gothUser := ctx.Session.Get("linkAccountGothUser") if err := externalaccount.LinkAccountFromStore(ctx.Session, u); err != nil {
if gothUser == nil {
ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session"))
return
}
err = externalaccount.LinkAccountToUser(u, gothUser.(goth.User))
if err != nil {
ctx.ServerError("UserSignIn", err) ctx.ServerError("UserSignIn", err)
return
} }
} }
@ -506,16 +498,8 @@ func U2FSign(ctx *context.Context) {
} }
if ctx.Session.Get("linkAccount") != nil { if ctx.Session.Get("linkAccount") != nil {
gothUser := ctx.Session.Get("linkAccountGothUser") if err := externalaccount.LinkAccountFromStore(ctx.Session, user); err != nil {
if gothUser == nil {
ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session"))
return
}
err = externalaccount.LinkAccountToUser(user, gothUser.(goth.User))
if err != nil {
ctx.ServerError("UserSignIn", err) ctx.ServerError("UserSignIn", err)
return
} }
} }
redirect := handleSignInFull(ctx, user, remember, false) redirect := handleSignInFull(ctx, user, remember, false)
@ -653,6 +637,13 @@ func SignInOAuthCallback(ctx *context.Context) {
u, gothUser, err := oAuth2UserLoginCallback(loginSource, ctx.Req, ctx.Resp) u, gothUser, err := oAuth2UserLoginCallback(loginSource, ctx.Req, ctx.Resp)
if err != nil { if err != nil {
if user_model.IsErrUserProhibitLogin(err) {
uplerr := err.(*user_model.ErrUserProhibitLogin)
log.Info("Failed authentication attempt for %s from %s: %v", uplerr.Name, ctx.RemoteAddr(), err)
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
return
}
ctx.ServerError("UserSignIn", err) ctx.ServerError("UserSignIn", err)
return return
} }
@ -690,6 +681,8 @@ func SignInOAuthCallback(ctx *context.Context) {
IsRestricted: setting.Service.DefaultUserIsRestricted, IsRestricted: setting.Service.DefaultUserIsRestricted,
} }
setUserGroupClaims(loginSource, u, &gothUser)
if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) { if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) {
// error already handled // error already handled
return return
@ -704,6 +697,53 @@ func SignInOAuthCallback(ctx *context.Context) {
handleOAuth2SignIn(ctx, loginSource, u, gothUser) handleOAuth2SignIn(ctx, loginSource, u, gothUser)
} }
func claimValueToStringSlice(claimValue interface{}) []string {
var groups []string
switch rawGroup := claimValue.(type) {
case []string:
groups = rawGroup
default:
str := fmt.Sprintf("%s", rawGroup)
groups = strings.Split(str, ",")
}
return groups
}
func setUserGroupClaims(loginSource *login.Source, u *user_model.User, gothUser *goth.User) bool {
source := loginSource.Cfg.(*oauth2.Source)
if source.GroupClaimName == "" || (source.AdminGroup == "" && source.RestrictedGroup == "") {
return false
}
groupClaims, has := gothUser.RawData[source.GroupClaimName]
if !has {
return false
}
groups := claimValueToStringSlice(groupClaims)
wasAdmin, wasRestricted := u.IsAdmin, u.IsRestricted
if source.AdminGroup != "" {
u.IsAdmin = false
}
if source.RestrictedGroup != "" {
u.IsRestricted = false
}
for _, g := range groups {
if source.AdminGroup != "" && g == source.AdminGroup {
u.IsAdmin = true
} else if source.RestrictedGroup != "" && g == source.RestrictedGroup {
u.IsRestricted = true
}
}
return wasAdmin != u.IsAdmin || wasRestricted != u.IsRestricted
}
func getUserName(gothUser *goth.User) string { func getUserName(gothUser *goth.User) string {
switch setting.OAuth2Client.Username { switch setting.OAuth2Client.Username {
case setting.OAuth2UsernameEmail: case setting.OAuth2UsernameEmail:
@ -774,13 +814,21 @@ func handleOAuth2SignIn(ctx *context.Context, source *login.Source, u *user_mode
// Register last login // Register last login
u.SetLastLogin() u.SetLastLogin()
if err := user_model.UpdateUserCols(db.DefaultContext, u, "last_login_unix"); err != nil {
// Update GroupClaims
changed := setUserGroupClaims(source, u, &gothUser)
cols := []string{"last_login_unix"}
if changed {
cols = append(cols, "is_admin", "is_restricted")
}
if err := user_model.UpdateUserCols(db.DefaultContext, u, cols...); err != nil {
ctx.ServerError("UpdateUserCols", err) ctx.ServerError("UpdateUserCols", err)
return return
} }
// update external user information // update external user information
if err := user_model.UpdateExternalUser(u, gothUser); err != nil { if err := externalaccount.UpdateExternalUser(u, gothUser); err != nil {
log.Error("UpdateExternalUser failed: %v", err) log.Error("UpdateExternalUser failed: %v", err)
} }
@ -794,6 +842,14 @@ func handleOAuth2SignIn(ctx *context.Context, source *login.Source, u *user_mode
return return
} }
changed := setUserGroupClaims(source, u, &gothUser)
if changed {
if err := user_model.UpdateUserCols(db.DefaultContext, u, "is_admin", "is_restricted"); err != nil {
ctx.ServerError("UpdateUserCols", err)
return
}
}
// User needs to use 2FA, save data and redirect to 2FA page. // User needs to use 2FA, save data and redirect to 2FA page.
if err := ctx.Session.Set("twofaUid", u.ID); err != nil { if err := ctx.Session.Set("twofaUid", u.ID); err != nil {
log.Error("Error setting twofaUid in session: %v", err) log.Error("Error setting twofaUid in session: %v", err)
@ -818,7 +874,9 @@ func handleOAuth2SignIn(ctx *context.Context, source *login.Source, u *user_mode
// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful // OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful
// login the user // login the user
func oAuth2UserLoginCallback(loginSource *login.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) { func oAuth2UserLoginCallback(loginSource *login.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) {
gothUser, err := loginSource.Cfg.(*oauth2.Source).Callback(request, response) oauth2Source := loginSource.Cfg.(*oauth2.Source)
gothUser, err := oauth2Source.Callback(request, response)
if err != nil { if err != nil {
if err.Error() == "securecookie: the value is too long" || strings.Contains(err.Error(), "Data too long") { if err.Error() == "securecookie: the value is too long" || strings.Contains(err.Error(), "Data too long") {
log.Error("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", loginSource.Name, setting.OAuth2.MaxTokenLength) log.Error("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", loginSource.Name, setting.OAuth2.MaxTokenLength)
@ -827,6 +885,27 @@ func oAuth2UserLoginCallback(loginSource *login.Source, request *http.Request, r
return nil, goth.User{}, err return nil, goth.User{}, err
} }
if oauth2Source.RequiredClaimName != "" {
claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName]
if !has {
return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
}
if oauth2Source.RequiredClaimValue != "" {
groups := claimValueToStringSlice(claimInterface)
found := false
for _, group := range groups {
if group == oauth2Source.RequiredClaimValue {
found = true
break
}
}
if !found {
return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
}
}
}
user := &user_model.User{ user := &user_model.User{
LoginName: gothUser.UserID, LoginName: gothUser.UserID,
LoginType: login.OAuth2, LoginType: login.OAuth2,
@ -1354,7 +1433,7 @@ func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.
// update external user information // update external user information
if gothUser != nil { if gothUser != nil {
if err := user_model.UpdateExternalUser(u, *gothUser); err != nil { if err := externalaccount.UpdateExternalUser(u, *gothUser); err != nil {
log.Error("UpdateExternalUser failed: %v", err) log.Error("UpdateExternalUser failed: %v", err)
} }
} }

View File

@ -144,10 +144,10 @@ func SignInOpenIDPost(ctx *context.Context) {
// signInOpenIDVerify handles response from OpenID provider // signInOpenIDVerify handles response from OpenID provider
func signInOpenIDVerify(ctx *context.Context) { func signInOpenIDVerify(ctx *context.Context) {
log.Trace("Incoming call to: " + ctx.Req.URL.String()) log.Trace("Incoming call to: %s", ctx.Req.URL.String())
fullURL := setting.AppURL + ctx.Req.URL.String()[1:] fullURL := setting.AppURL + ctx.Req.URL.String()[1:]
log.Trace("Full URL: " + fullURL) log.Trace("Full URL: %s", fullURL)
var id, err = openid.Verify(fullURL) var id, err = openid.Verify(fullURL)
if err != nil { if err != nil {
@ -157,7 +157,7 @@ func signInOpenIDVerify(ctx *context.Context) {
return return
} }
log.Trace("Verified ID: " + id) log.Trace("Verified ID: %s", id)
/* Now we should seek for the user and log him in, or prompt /* Now we should seek for the user and log him in, or prompt
* to register if not found */ * to register if not found */
@ -180,7 +180,7 @@ func signInOpenIDVerify(ctx *context.Context) {
return return
} }
log.Trace("User with openid " + id + " does not exist, should connect or register") log.Trace("User with openid: %s does not exist, should connect or register", id)
parsedURL, err := url.Parse(fullURL) parsedURL, err := url.Parse(fullURL)
if err != nil { if err != nil {
@ -199,7 +199,7 @@ func signInOpenIDVerify(ctx *context.Context) {
email := values.Get("openid.sreg.email") email := values.Get("openid.sreg.email")
nickname := values.Get("openid.sreg.nickname") nickname := values.Get("openid.sreg.nickname")
log.Trace("User has email=" + email + " and nickname=" + nickname) log.Trace("User has email=%s and nickname=%s", email, nickname)
if email != "" { if email != "" {
u, err = user_model.GetUserByEmail(email) u, err = user_model.GetUserByEmail(email)
@ -213,7 +213,7 @@ func signInOpenIDVerify(ctx *context.Context) {
log.Error("signInOpenIDVerify: %v", err) log.Error("signInOpenIDVerify: %v", err)
} }
if u != nil { if u != nil {
log.Trace("Local user " + u.LowerName + " has OpenID provided email " + email) log.Trace("Local user %s has OpenID provided email %s", u.LowerName, email)
} }
} }
@ -228,7 +228,7 @@ func signInOpenIDVerify(ctx *context.Context) {
} }
} }
if u != nil { if u != nil {
log.Trace("Local user " + u.LowerName + " has OpenID provided nickname " + nickname) log.Trace("Local user %s has OpenID provided nickname %s", u.LowerName, nickname)
} }
} }

View File

@ -17,7 +17,7 @@ import (
) )
// CustomProviderNewFn creates a goth.Provider using a custom url mapping // CustomProviderNewFn creates a goth.Provider using a custom url mapping
type CustomProviderNewFn func(clientID, secret, callbackURL string, custom *CustomURLMapping) (goth.Provider, error) type CustomProviderNewFn func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error)
// CustomProvider is a GothProvider that has CustomURL features // CustomProvider is a GothProvider that has CustomURL features
type CustomProvider struct { type CustomProvider struct {
@ -35,7 +35,7 @@ func (c *CustomProvider) CustomURLSettings() *CustomURLSettings {
func (c *CustomProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) { func (c *CustomProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) {
custom := c.customURLSettings.OverrideWith(source.CustomURLMapping) custom := c.customURLSettings.OverrideWith(source.CustomURLMapping)
return c.newFn(source.ClientID, source.ClientSecret, callbackURL, custom) return c.newFn(source.ClientID, source.ClientSecret, callbackURL, custom, source.Scopes)
} }
// NewCustomProvider is a constructor function for custom providers // NewCustomProvider is a constructor function for custom providers
@ -60,8 +60,7 @@ func init() {
ProfileURL: availableAttribute(github.ProfileURL), ProfileURL: availableAttribute(github.ProfileURL),
EmailURL: availableAttribute(github.EmailURL), EmailURL: availableAttribute(github.EmailURL),
}, },
func(clientID, secret, callbackURL string, custom *CustomURLMapping) (goth.Provider, error) { func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
scopes := []string{}
if setting.OAuth2Client.EnableAutoRegistration { if setting.OAuth2Client.EnableAutoRegistration {
scopes = append(scopes, "user:email") scopes = append(scopes, "user:email")
} }
@ -73,8 +72,9 @@ func init() {
AuthURL: availableAttribute(gitlab.AuthURL), AuthURL: availableAttribute(gitlab.AuthURL),
TokenURL: availableAttribute(gitlab.TokenURL), TokenURL: availableAttribute(gitlab.TokenURL),
ProfileURL: availableAttribute(gitlab.ProfileURL), ProfileURL: availableAttribute(gitlab.ProfileURL),
}, func(clientID, secret, callbackURL string, custom *CustomURLMapping) (goth.Provider, error) { }, func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
return gitlab.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, "read_user"), nil scopes = append(scopes, "read_user")
return gitlab.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil
})) }))
RegisterGothProvider(NewCustomProvider( RegisterGothProvider(NewCustomProvider(
@ -83,8 +83,8 @@ func init() {
AuthURL: requiredAttribute(gitea.AuthURL), AuthURL: requiredAttribute(gitea.AuthURL),
ProfileURL: requiredAttribute(gitea.ProfileURL), ProfileURL: requiredAttribute(gitea.ProfileURL),
}, },
func(clientID, secret, callbackURL string, custom *CustomURLMapping) (goth.Provider, error) { func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
return gitea.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL), nil return gitea.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil
})) }))
RegisterGothProvider(NewCustomProvider( RegisterGothProvider(NewCustomProvider(
@ -93,25 +93,31 @@ func init() {
AuthURL: requiredAttribute(nextcloud.AuthURL), AuthURL: requiredAttribute(nextcloud.AuthURL),
ProfileURL: requiredAttribute(nextcloud.ProfileURL), ProfileURL: requiredAttribute(nextcloud.ProfileURL),
}, },
func(clientID, secret, callbackURL string, custom *CustomURLMapping) (goth.Provider, error) { func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
return nextcloud.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL), nil return nextcloud.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil
})) }))
RegisterGothProvider(NewCustomProvider( RegisterGothProvider(NewCustomProvider(
"mastodon", "Mastodon", &CustomURLSettings{ "mastodon", "Mastodon", &CustomURLSettings{
AuthURL: requiredAttribute(mastodon.InstanceURL), AuthURL: requiredAttribute(mastodon.InstanceURL),
}, },
func(clientID, secret, callbackURL string, custom *CustomURLMapping) (goth.Provider, error) { func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
return mastodon.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL), nil return mastodon.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, scopes...), nil
})) }))
RegisterGothProvider(NewCustomProvider( RegisterGothProvider(NewCustomProvider(
"azureadv2", "Azure AD v2", &CustomURLSettings{ "azureadv2", "Azure AD v2", &CustomURLSettings{
Tenant: requiredAttribute("organizations"), Tenant: requiredAttribute("organizations"),
}, },
func(clientID, secret, callbackURL string, custom *CustomURLMapping) (goth.Provider, error) { func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
azureScopes := make([]azureadv2.ScopeType, len(scopes))
for i, scope := range scopes {
azureScopes[i] = azureadv2.ScopeType(scope)
}
return azureadv2.New(clientID, secret, callbackURL, azureadv2.ProviderOptions{ return azureadv2.New(clientID, secret, callbackURL, azureadv2.ProviderOptions{
Tenant: azureadv2.TenantType(custom.Tenant), Tenant: azureadv2.TenantType(custom.Tenant),
Scopes: azureScopes,
}), nil }), nil
}, },
)) ))

View File

@ -33,7 +33,12 @@ func (o *OpenIDProvider) Image() string {
// CreateGothProvider creates a GothProvider from this Provider // CreateGothProvider creates a GothProvider from this Provider
func (o *OpenIDProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) { func (o *OpenIDProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) {
provider, err := openidConnect.New(source.ClientID, source.ClientSecret, callbackURL, source.OpenIDConnectAutoDiscoveryURL, setting.OAuth2Client.OpenIDConnectScopes...) scopes := setting.OAuth2Client.OpenIDConnectScopes
if len(scopes) == 0 {
scopes = append(scopes, source.Scopes...)
}
provider, err := openidConnect.New(source.ClientID, source.ClientSecret, callbackURL, source.OpenIDConnectAutoDiscoveryURL, scopes...)
if err != nil { if err != nil {
log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, source.OpenIDConnectAutoDiscoveryURL, err) log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, source.OpenIDConnectAutoDiscoveryURL, err)
} }

View File

@ -31,7 +31,10 @@ type SimpleProvider struct {
// CreateGothProvider creates a GothProvider from this Provider // CreateGothProvider creates a GothProvider from this Provider
func (c *SimpleProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) { func (c *SimpleProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) {
return c.newFn(source.ClientID, source.ClientSecret, callbackURL, c.scopes...), nil scopes := make([]string, len(c.scopes)+len(source.Scopes))
copy(scopes, c.scopes)
copy(scopes[len(c.scopes):], source.Scopes)
return c.newFn(source.ClientID, source.ClientSecret, callbackURL, scopes...), nil
} }
// NewSimpleProvider is a constructor function for simple providers // NewSimpleProvider is a constructor function for simple providers

View File

@ -24,7 +24,14 @@ type Source struct {
OpenIDConnectAutoDiscoveryURL string OpenIDConnectAutoDiscoveryURL string
CustomURLMapping *CustomURLMapping CustomURLMapping *CustomURLMapping
IconURL string IconURL string
SkipLocalTwoFA bool `json:",omitempty"`
Scopes []string
RequiredClaimName string
RequiredClaimValue string
GroupClaimName string
AdminGroup string
RestrictedGroup string
SkipLocalTwoFA bool `json:",omitempty"`
// reference to the loginSource // reference to the loginSource
loginSource *login.Source loginSource *login.Source

View File

@ -0,0 +1,29 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package externalaccount
import (
"fmt"
user_model "code.gitea.io/gitea/models/user"
"github.com/markbates/goth"
)
// Store represents a thing that stores things
type Store interface {
Get(interface{}) interface{}
Set(interface{}, interface{}) error
Release() error
}
// LinkAccountFromStore links the provided user with a stored external user
func LinkAccountFromStore(store Store, user *user_model.User) error {
gothUser := store.Get("linkAccountGothUser")
if gothUser == nil {
return fmt.Errorf("not in LinkAccount session")
}
return LinkAccountToUser(user, gothUser.(goth.User))
}

View File

@ -15,14 +15,12 @@ import (
"github.com/markbates/goth" "github.com/markbates/goth"
) )
// LinkAccountToUser link the gothUser to the user func toExternalLoginUser(user *user_model.User, gothUser goth.User) (*user_model.ExternalLoginUser, error) {
func LinkAccountToUser(user *user_model.User, gothUser goth.User) error {
loginSource, err := login.GetActiveOAuth2LoginSourceByName(gothUser.Provider) loginSource, err := login.GetActiveOAuth2LoginSourceByName(gothUser.Provider)
if err != nil { if err != nil {
return err return nil, err
} }
return &user_model.ExternalLoginUser{
externalLoginUser := &user_model.ExternalLoginUser{
ExternalID: gothUser.UserID, ExternalID: gothUser.UserID,
UserID: user.ID, UserID: user.ID,
LoginSourceID: loginSource.ID, LoginSourceID: loginSource.ID,
@ -40,6 +38,14 @@ func LinkAccountToUser(user *user_model.User, gothUser goth.User) error {
AccessTokenSecret: gothUser.AccessTokenSecret, AccessTokenSecret: gothUser.AccessTokenSecret,
RefreshToken: gothUser.RefreshToken, RefreshToken: gothUser.RefreshToken,
ExpiresAt: gothUser.ExpiresAt, ExpiresAt: gothUser.ExpiresAt,
}, nil
}
// LinkAccountToUser link the gothUser to the user
func LinkAccountToUser(user *user_model.User, gothUser goth.User) error {
externalLoginUser, err := toExternalLoginUser(user, gothUser)
if err != nil {
return err
} }
if err := user_model.LinkExternalToUser(user, externalLoginUser); err != nil { if err := user_model.LinkExternalToUser(user, externalLoginUser); err != nil {
@ -62,3 +68,13 @@ func LinkAccountToUser(user *user_model.User, gothUser goth.User) error {
return nil return nil
} }
// UpdateExternalUser updates external user's information
func UpdateExternalUser(user *user_model.User, gothUser goth.User) error {
externalLoginUser, err := toExternalLoginUser(user, gothUser)
if err != nil {
return err
}
return user_model.UpdateExternalUserByExternalID(externalLoginUser)
}

View File

@ -67,6 +67,12 @@ type AuthenticationForm struct {
Oauth2EmailURL string Oauth2EmailURL string
Oauth2IconURL string Oauth2IconURL string
Oauth2Tenant string Oauth2Tenant string
Oauth2Scopes string
Oauth2RequiredClaimName string
Oauth2RequiredClaimValue string
Oauth2GroupClaimName string
Oauth2AdminGroup string
Oauth2RestrictedGroup string
SkipLocalTwoFA bool SkipLocalTwoFA bool
SSPIAutoCreateUsers bool SSPIAutoCreateUsers bool
SSPIAutoActivateUsers bool SSPIAutoActivateUsers bool

View File

@ -286,11 +286,6 @@
<input id="skip_local_two_fa" name="skip_local_two_fa" type="checkbox" {{if $cfg.SkipLocalTwoFA}}checked{{end}}> <input id="skip_local_two_fa" name="skip_local_two_fa" type="checkbox" {{if $cfg.SkipLocalTwoFA}}checked{{end}}>
<p class="help">{{.i18n.Tr "admin.auths.skip_local_two_fa_helper"}}</p> <p class="help">{{.i18n.Tr "admin.auths.skip_local_two_fa_helper"}}</p>
</div> </div>
</div>
<div class="oauth2_use_custom_url inline field">
<div class="ui checkbox">
<label><strong>{{.i18n.Tr "admin.auths.oauth2_use_custom_url"}}</strong></label>
<input id="oauth2_use_custom_url" name="oauth2_use_custom_url" type="checkbox" {{if $cfg.CustomURLMapping}}checked{{end}}> <input id="oauth2_use_custom_url" name="oauth2_use_custom_url" type="checkbox" {{if $cfg.CustomURLMapping}}checked{{end}}>
</div> </div>
</div> </div>
@ -323,6 +318,33 @@
<input id="{{.Name}}_email_url" value="{{.CustomURLSettings.EmailURL.Value}}" data-available="{{.CustomURLSettings.EmailURL.Available}}" data-required="{{.CustomURLSettings.EmailURL.Required}}" type="hidden" /> <input id="{{.Name}}_email_url" value="{{.CustomURLSettings.EmailURL.Value}}" data-available="{{.CustomURLSettings.EmailURL.Available}}" data-required="{{.CustomURLSettings.EmailURL.Required}}" type="hidden" />
<input id="{{.Name}}_tenant" value="{{.CustomURLSettings.Tenant.Value}}" data-available="{{.CustomURLSettings.Tenant.Available}}" data-required="{{.CustomURLSettings.Tenant.Required}}" type="hidden" /> <input id="{{.Name}}_tenant" value="{{.CustomURLSettings.Tenant.Value}}" data-available="{{.CustomURLSettings.Tenant.Available}}" data-required="{{.CustomURLSettings.Tenant.Required}}" type="hidden" />
{{end}}{{end}} {{end}}{{end}}
<div class="field">
<label for="oauth2_scopes">{{.i18n.Tr "admin.auths.oauth2_scopes"}}</label>
<input id="oauth2_scopes" name="oauth2_scopes" value="{{if $cfg.Scopes}}{{Join $cfg.Scopes "," }}{{end}}">
</div>
<div class="field">
<label for="oauth2_required_claim_name">{{.i18n.Tr "admin.auths.oauth2_required_claim_name"}}</label>
<input id="oauth2_required_claim_name" name="oauth2_required_claim_name" values="{{$cfg.RequiredClaimName}}">
<p class="help">{{.i18n.Tr "admin.auths.oauth2_required_claim_name_helper"}}</p>
</div>
<div class="field">
<label for="oauth2_required_claim_value">{{.i18n.Tr "admin.auths.oauth2_required_claim_value"}}</label>
<input id="oauth2_required_claim_value" name="oauth2_required_claim_value" values="{{$cfg.RequiredClaimValue}}">
<p class="help">{{.i18n.Tr "admin.auths.oauth2_required_claim_value_helper"}}</p>
</div>
<div class="field">
<label for="oauth2_group_claim_name">{{.i18n.Tr "admin.auths.oauth2_group_claim_name"}}</label>
<input id="oauth2_group_claim_name" name="oauth2_group_claim_name" value="{{$cfg.GroupClaimName}}">
</div>
<div class="field">
<label for="oauth2_admin_group">{{.i18n.Tr "admin.auths.oauth2_admin_group"}}</label>
<input id="oauth2_admin_group" name="oauth2_admin_group" value="{{$cfg.AdminGroup}}">
</div>
<div class="field">
<label for="oauth2_restricted_group">{{.i18n.Tr "admin.auths.oauth2_restricted_group"}}</label>
<input id="oauth2_restricted_group" name="oauth2_restricted_group" value="{{$cfg.RestrictedGroup}}">
</div>
{{end}} {{end}}
<!-- SSPI --> <!-- SSPI -->

View File

@ -71,4 +71,31 @@
<input id="{{.Name}}_email_url" value="{{.CustomURLSettings.EmailURL.Value}}" data-available="{{.CustomURLSettings.EmailURL.Available}}" data-required="{{.CustomURLSettings.EmailURL.Required}}" type="hidden" /> <input id="{{.Name}}_email_url" value="{{.CustomURLSettings.EmailURL.Value}}" data-available="{{.CustomURLSettings.EmailURL.Available}}" data-required="{{.CustomURLSettings.EmailURL.Required}}" type="hidden" />
<input id="{{.Name}}_tenant" value="{{.CustomURLSettings.Tenant.Value}}" data-available="{{.CustomURLSettings.Tenant.Available}}" data-required="{{.CustomURLSettings.Tenant.Required}}" type="hidden" /> <input id="{{.Name}}_tenant" value="{{.CustomURLSettings.Tenant.Value}}" data-available="{{.CustomURLSettings.Tenant.Available}}" data-required="{{.CustomURLSettings.Tenant.Required}}" type="hidden" />
{{end}}{{end}} {{end}}{{end}}
<div class="field">
<label for="oauth2_scopes">{{.i18n.Tr "admin.auths.oauth2_scopes"}}</label>
<input id="oauth2_scopes" name="oauth2_scopes" values="{{.oauth2_scopes}}">
</div>
<div class="field">
<label for="oauth2_required_claim_name">{{.i18n.Tr "admin.auths.oauth2_required_claim_name"}}</label>
<input id="oauth2_required_claim_name" name="oauth2_required_claim_name" values="{{.oauth2_required_claim_name}}">
<p class="help">{{.i18n.Tr "admin.auths.oauth2_required_claim_name_helper"}}</p>
</div>
<div class="field">
<label for="oauth2_required_claim_value">{{.i18n.Tr "admin.auths.oauth2_required_claim_value"}}</label>
<input id="oauth2_required_claim_value" name="oauth2_required_claim_value" values="{{.oauth2_required_claim_value}}">
<p class="help">{{.i18n.Tr "admin.auths.oauth2_required_claim_value_helper"}}</p>
</div>
<div class="field">
<label for="oauth2_group_claim_name">{{.i18n.Tr "admin.auths.oauth2_group_claim_name"}}</label>
<input id="oauth2_group_claim_name" name="oauth2_group_claim_name" value="{{.oauth2_group_claim_name}}">
</div>
<div class="field">
<label for="oauth2_admin_group">{{.i18n.Tr "admin.auths.oauth2_admin_group"}}</label>
<input id="oauth2_admin_group" name="oauth2_admin_group" value="{{.oauth2_group_claim_name}}">
</div>
<div class="field">
<label for="oauth2_restricted_group">{{.i18n.Tr "admin.auths.oauth2_restricted_group"}}</label>
<input id="oauth2_restricted_group" name="oauth2_restricted_group" value="{{.oauth2_group_claim_name}}">
</div>
</div> </div>