Refactor OpenIDConnect to support SSH/FullName sync (#34978)

* Fix #26585
* Fix #28327
* Fix #34932
This commit is contained in:
wxiaoguang 2025-07-11 02:35:59 +08:00 committed by GitHub
parent 6ab6d4e17f
commit a5a3d9b101
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 459 additions and 206 deletions

View File

@ -87,6 +87,14 @@ func oauthCLIFlags() []cli.Flag {
Value: nil, Value: nil,
Usage: "Scopes to request when to authenticate against this OAuth2 source", Usage: "Scopes to request when to authenticate against this OAuth2 source",
}, },
&cli.StringFlag{
Name: "ssh-public-key-claim-name",
Usage: "Claim name that provides SSH public keys",
},
&cli.StringFlag{
Name: "full-name-claim-name",
Usage: "Claim name that provides user's full name",
},
&cli.StringFlag{ &cli.StringFlag{
Name: "required-claim-name", Name: "required-claim-name",
Value: "", Value: "",
@ -177,6 +185,8 @@ func parseOAuth2Config(c *cli.Command) *oauth2.Source {
RestrictedGroup: c.String("restricted-group"), RestrictedGroup: c.String("restricted-group"),
GroupTeamMap: c.String("group-team-map"), GroupTeamMap: c.String("group-team-map"),
GroupTeamMapRemoval: c.Bool("group-team-map-removal"), GroupTeamMapRemoval: c.Bool("group-team-map-removal"),
SSHPublicKeyClaimName: c.String("ssh-public-key-claim-name"),
FullNameClaimName: c.String("full-name-claim-name"),
} }
} }
@ -268,6 +278,12 @@ func (a *authService) runUpdateOauth(ctx context.Context, c *cli.Command) error
if c.IsSet("group-team-map-removal") { if c.IsSet("group-team-map-removal") {
oAuth2Config.GroupTeamMapRemoval = c.Bool("group-team-map-removal") oAuth2Config.GroupTeamMapRemoval = c.Bool("group-team-map-removal")
} }
if c.IsSet("ssh-public-key-claim-name") {
oAuth2Config.SSHPublicKeyClaimName = c.String("ssh-public-key-claim-name")
}
if c.IsSet("full-name-claim-name") {
oAuth2Config.FullNameClaimName = c.String("full-name-claim-name")
}
// update custom URL mapping // update custom URL mapping
customURLMapping := &oauth2.CustomURLMapping{} customURLMapping := &oauth2.CustomURLMapping{}

View File

@ -88,6 +88,8 @@ func TestAddOauth(t *testing.T) {
"--restricted-group", "restricted", "--restricted-group", "restricted",
"--group-team-map", `{"group1": [1,2]}`, "--group-team-map", `{"group1": [1,2]}`,
"--group-team-map-removal=true", "--group-team-map-removal=true",
"--ssh-public-key-claim-name", "attr_ssh_pub_key",
"--full-name-claim-name", "attr_full_name",
}, },
source: &auth_model.Source{ source: &auth_model.Source{
Type: auth_model.OAuth2, Type: auth_model.OAuth2,
@ -104,15 +106,17 @@ func TestAddOauth(t *testing.T) {
EmailURL: "https://example.com/email", EmailURL: "https://example.com/email",
Tenant: "some_tenant", Tenant: "some_tenant",
}, },
IconURL: "https://example.com/icon", IconURL: "https://example.com/icon",
Scopes: []string{"scope1", "scope2"}, Scopes: []string{"scope1", "scope2"},
RequiredClaimName: "claim_name", RequiredClaimName: "claim_name",
RequiredClaimValue: "claim_value", RequiredClaimValue: "claim_value",
GroupClaimName: "group_name", GroupClaimName: "group_name",
AdminGroup: "admin", AdminGroup: "admin",
RestrictedGroup: "restricted", RestrictedGroup: "restricted",
GroupTeamMap: `{"group1": [1,2]}`, GroupTeamMap: `{"group1": [1,2]}`,
GroupTeamMapRemoval: true, GroupTeamMapRemoval: true,
SSHPublicKeyClaimName: "attr_ssh_pub_key",
FullNameClaimName: "attr_full_name",
}, },
TwoFactorPolicy: "skip", TwoFactorPolicy: "skip",
}, },
@ -223,15 +227,17 @@ func TestUpdateOauth(t *testing.T) {
EmailURL: "https://old.example.com/email", EmailURL: "https://old.example.com/email",
Tenant: "old_tenant", Tenant: "old_tenant",
}, },
IconURL: "https://old.example.com/icon", IconURL: "https://old.example.com/icon",
Scopes: []string{"old_scope1", "old_scope2"}, Scopes: []string{"old_scope1", "old_scope2"},
RequiredClaimName: "old_claim_name", RequiredClaimName: "old_claim_name",
RequiredClaimValue: "old_claim_value", RequiredClaimValue: "old_claim_value",
GroupClaimName: "old_group_name", GroupClaimName: "old_group_name",
AdminGroup: "old_admin", AdminGroup: "old_admin",
RestrictedGroup: "old_restricted", RestrictedGroup: "old_restricted",
GroupTeamMap: `{"old_group1": [1,2]}`, GroupTeamMap: `{"old_group1": [1,2]}`,
GroupTeamMapRemoval: true, GroupTeamMapRemoval: true,
SSHPublicKeyClaimName: "old_ssh_pub_key",
FullNameClaimName: "old_full_name",
}, },
TwoFactorPolicy: "", TwoFactorPolicy: "",
}, },
@ -257,6 +263,8 @@ func TestUpdateOauth(t *testing.T) {
"--restricted-group", "restricted", "--restricted-group", "restricted",
"--group-team-map", `{"group1": [1,2]}`, "--group-team-map", `{"group1": [1,2]}`,
"--group-team-map-removal=false", "--group-team-map-removal=false",
"--ssh-public-key-claim-name", "new_ssh_pub_key",
"--full-name-claim-name", "new_full_name",
}, },
authSource: &auth_model.Source{ authSource: &auth_model.Source{
ID: 1, ID: 1,
@ -274,15 +282,17 @@ func TestUpdateOauth(t *testing.T) {
EmailURL: "https://example.com/email", EmailURL: "https://example.com/email",
Tenant: "new_tenant", Tenant: "new_tenant",
}, },
IconURL: "https://example.com/icon", IconURL: "https://example.com/icon",
Scopes: []string{"scope1", "scope2"}, Scopes: []string{"scope1", "scope2"},
RequiredClaimName: "claim_name", RequiredClaimName: "claim_name",
RequiredClaimValue: "claim_value", RequiredClaimValue: "claim_value",
GroupClaimName: "group_name", GroupClaimName: "group_name",
AdminGroup: "admin", AdminGroup: "admin",
RestrictedGroup: "restricted", RestrictedGroup: "restricted",
GroupTeamMap: `{"group1": [1,2]}`, GroupTeamMap: `{"group1": [1,2]}`,
GroupTeamMapRemoval: false, GroupTeamMapRemoval: false,
SSHPublicKeyClaimName: "new_ssh_pub_key",
FullNameClaimName: "new_full_name",
}, },
TwoFactorPolicy: "skip", TwoFactorPolicy: "skip",
}, },

View File

@ -355,13 +355,13 @@ func AddPublicKeysBySource(ctx context.Context, usr *user_model.User, s *auth.So
return sshKeysNeedUpdate return sshKeysNeedUpdate
} }
// SynchronizePublicKeys updates a users public keys. Returns true if there are changes. // SynchronizePublicKeys updates a user's public keys. Returns true if there are changes.
func SynchronizePublicKeys(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string) bool { func SynchronizePublicKeys(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string) bool {
var sshKeysNeedUpdate bool var sshKeysNeedUpdate bool
log.Trace("synchronizePublicKeys[%s]: Handling Public SSH Key synchronization for user %s", s.Name, usr.Name) log.Trace("synchronizePublicKeys[%s]: Handling Public SSH Key synchronization for user %s", s.Name, usr.Name)
// Get Public Keys from DB with current LDAP source // Get Public Keys from DB with the current auth source
var giteaKeys []string var giteaKeys []string
keys, err := db.Find[PublicKey](ctx, FindPublicKeyOptions{ keys, err := db.Find[PublicKey](ctx, FindPublicKeyOptions{
OwnerID: usr.ID, OwnerID: usr.ID,

View File

@ -612,8 +612,8 @@ func (err ErrOAuthApplicationNotFound) Unwrap() error {
return util.ErrNotExist return util.ErrNotExist
} }
// GetActiveOAuth2SourceByName returns a OAuth2 AuthSource based on the given name // GetActiveOAuth2SourceByAuthName returns a OAuth2 AuthSource based on the given name
func GetActiveOAuth2SourceByName(ctx context.Context, name string) (*Source, error) { func GetActiveOAuth2SourceByAuthName(ctx context.Context, name string) (*Source, error) {
authSource := new(Source) authSource := new(Source)
has, err := db.GetEngine(ctx).Where("name = ? and type = ? and is_active = ?", name, OAuth2, true).Get(authSource) has, err := db.GetEngine(ctx).Where("name = ? and type = ? and is_active = ?", name, OAuth2, true).Get(authSource)
if err != nil { if err != nil {

View File

@ -334,7 +334,7 @@ func UpdateSource(ctx context.Context, source *Source) error {
err = registerableSource.RegisterSource() err = registerableSource.RegisterSource()
if err != nil { if err != nil {
// restore original values since we cannot update the provider it self // restore original values since we cannot update the provider itself
if _, err := db.GetEngine(ctx).ID(source.ID).AllCols().Update(originalSource); err != nil { if _, err := db.GetEngine(ctx).ID(source.ID).AllCols().Update(originalSource); err != nil {
log.Error("UpdateSource: Error while wrapOpenIDConnectInitializeError: %v", err) log.Error("UpdateSource: Error while wrapOpenIDConnectInitializeError: %v", err)
} }

View File

@ -12,7 +12,7 @@ import (
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
) )
// OAuth2UsernameType is enum describing the way gitea 'name' should be generated from oauth2 data // OAuth2UsernameType is enum describing the way gitea generates its 'username' from oauth2 data
type OAuth2UsernameType string type OAuth2UsernameType string
const ( const (

View File

@ -3251,6 +3251,8 @@ auths.oauth2_required_claim_name_helper = Set this name to restrict login from t
auths.oauth2_required_claim_value = Required Claim Value 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.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.oauth2_group_claim_name = Claim name providing group names for this source. (Optional) auths.oauth2_group_claim_name = Claim name providing group names for this source. (Optional)
auths.oauth2_full_name_claim_name = Full Name Claim Name. (Optional, if set, the user's full name will always be synchronized with this claim)
auths.oauth2_ssh_public_key_claim_name = SSH Public Key Claim Name
auths.oauth2_admin_group = Group Claim value for administrator users. (Optional - requires claim name above) auths.oauth2_admin_group = Group Claim value for administrator users. (Optional - requires claim name above)
auths.oauth2_restricted_group = Group Claim value for restricted users. (Optional - requires claim name above) auths.oauth2_restricted_group = Group Claim value for restricted users. (Optional - requires claim name above)
auths.oauth2_map_group_to_team = Map claimed groups to Organization teams. (Optional - requires claim name above) auths.oauth2_map_group_to_team = Map claimed groups to Organization teams. (Optional - requires claim name above)

View File

@ -199,6 +199,9 @@ func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source {
AdminGroup: form.Oauth2AdminGroup, AdminGroup: form.Oauth2AdminGroup,
GroupTeamMap: form.Oauth2GroupTeamMap, GroupTeamMap: form.Oauth2GroupTeamMap,
GroupTeamMapRemoval: form.Oauth2GroupTeamMapRemoval, GroupTeamMapRemoval: form.Oauth2GroupTeamMapRemoval,
SSHPublicKeyClaimName: form.Oauth2SSHPublicKeyClaimName,
FullNameClaimName: form.Oauth2FullNameClaimName,
} }
} }

View File

@ -14,7 +14,6 @@ import (
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/externalaccount"
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
) )
@ -75,7 +74,7 @@ func TwoFactorPost(ctx *context.Context) {
} }
if ctx.Session.Get("linkAccount") != nil { if ctx.Session.Get("linkAccount") != nil {
err = externalaccount.LinkAccountFromStore(ctx, ctx.Session, u) err = linkAccountFromContext(ctx, u)
if err != nil { if err != nil {
ctx.ServerError("UserSignIn", err) ctx.ServerError("UserSignIn", err)
return return

View File

@ -329,6 +329,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe
"twofaUid", "twofaUid",
"twofaRemember", "twofaRemember",
"linkAccount", "linkAccount",
"linkAccountData",
}, map[string]any{ }, map[string]any{
session.KeyUID: u.ID, session.KeyUID: u.ID,
session.KeyUname: u.Name, session.KeyUname: u.Name,
@ -519,7 +520,7 @@ func SignUpPost(ctx *context.Context) {
Passwd: form.Password, Passwd: form.Password,
} }
if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, nil, false) { if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, nil) {
// error already handled // error already handled
return return
} }
@ -530,22 +531,22 @@ func SignUpPost(ctx *context.Context) {
// createAndHandleCreatedUser calls createUserInContext and // createAndHandleCreatedUser calls createUserInContext and
// then handleUserCreated. // then handleUserCreated.
func createAndHandleCreatedUser(ctx *context.Context, tpl templates.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool) bool { func createAndHandleCreatedUser(ctx *context.Context, tpl templates.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, possibleLinkAccountData *LinkAccountData) bool {
if !createUserInContext(ctx, tpl, form, u, overwrites, gothUser, allowLink) { if !createUserInContext(ctx, tpl, form, u, overwrites, possibleLinkAccountData) {
return false return false
} }
return handleUserCreated(ctx, u, gothUser) return handleUserCreated(ctx, u, possibleLinkAccountData)
} }
// createUserInContext creates a user and handles errors within a given context. // createUserInContext creates a user and handles errors within a given context.
// Optionally a template can be specified. // Optionally, a template can be specified.
func createUserInContext(ctx *context.Context, tpl templates.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool) (ok bool) { func createUserInContext(ctx *context.Context, tpl templates.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, possibleLinkAccountData *LinkAccountData) (ok bool) {
meta := &user_model.Meta{ meta := &user_model.Meta{
InitialIP: ctx.RemoteAddr(), InitialIP: ctx.RemoteAddr(),
InitialUserAgent: ctx.Req.UserAgent(), InitialUserAgent: ctx.Req.UserAgent(),
} }
if err := user_model.CreateUser(ctx, u, meta, overwrites); err != nil { if err := user_model.CreateUser(ctx, u, meta, overwrites); err != nil {
if allowLink && (user_model.IsErrUserAlreadyExist(err) || user_model.IsErrEmailAlreadyUsed(err)) { if possibleLinkAccountData != nil && (user_model.IsErrUserAlreadyExist(err) || user_model.IsErrEmailAlreadyUsed(err)) {
switch setting.OAuth2Client.AccountLinking { switch setting.OAuth2Client.AccountLinking {
case setting.OAuth2AccountLinkingAuto: case setting.OAuth2AccountLinkingAuto:
var user *user_model.User var user *user_model.User
@ -561,15 +562,15 @@ func createUserInContext(ctx *context.Context, tpl templates.TplName, form any,
} }
// TODO: probably we should respect 'remember' user's choice... // TODO: probably we should respect 'remember' user's choice...
linkAccount(ctx, user, *gothUser, true) oauth2LinkAccount(ctx, user, possibleLinkAccountData, true)
return false // user is already created here, all redirects are handled return false // user is already created here, all redirects are handled
case setting.OAuth2AccountLinkingLogin: case setting.OAuth2AccountLinkingLogin:
showLinkingLogin(ctx, *gothUser) showLinkingLogin(ctx, &possibleLinkAccountData.AuthSource, possibleLinkAccountData.GothUser)
return false // user will be created only after linking login return false // user will be created only after linking login
} }
} }
// handle error without template // handle error without a template
if len(tpl) == 0 { if len(tpl) == 0 {
ctx.ServerError("CreateUser", err) ctx.ServerError("CreateUser", err)
return false return false
@ -610,7 +611,7 @@ func createUserInContext(ctx *context.Context, tpl templates.TplName, form any,
// handleUserCreated does additional steps after a new user is created. // handleUserCreated does additional steps after a new user is created.
// It auto-sets admin for the only user, updates the optional external user and // It auto-sets admin for the only user, updates the optional external user and
// sends a confirmation email if required. // sends a confirmation email if required.
func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.User) (ok bool) { func handleUserCreated(ctx *context.Context, u *user_model.User, possibleLinkAccountData *LinkAccountData) (ok bool) {
// Auto-set admin for the only user. // Auto-set admin for the only user.
hasUsers, err := user_model.HasUsers(ctx) hasUsers, err := user_model.HasUsers(ctx)
if err != nil { if err != nil {
@ -631,8 +632,8 @@ func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.
} }
// update external user information // update external user information
if gothUser != nil { if possibleLinkAccountData != nil {
if err := externalaccount.EnsureLinkExternalToUser(ctx, u, *gothUser); err != nil { if err := externalaccount.EnsureLinkExternalToUser(ctx, possibleLinkAccountData.AuthSource.ID, u, possibleLinkAccountData.GothUser); err != nil {
log.Error("EnsureLinkExternalToUser failed: %v", err) log.Error("EnsureLinkExternalToUser failed: %v", err)
} }
} }

View File

@ -5,7 +5,6 @@ package auth
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"strings" "strings"
@ -21,8 +20,6 @@ import (
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/externalaccount" "code.gitea.io/gitea/services/externalaccount"
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
"github.com/markbates/goth"
) )
var tplLinkAccount templates.TplName = "user/auth/link_account" var tplLinkAccount templates.TplName = "user/auth/link_account"
@ -52,28 +49,28 @@ func LinkAccount(ctx *context.Context) {
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin"
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup"
gothUser, ok := ctx.Session.Get("linkAccountGothUser").(goth.User) linkAccountData := oauth2GetLinkAccountData(ctx)
// If you'd like to quickly debug the "link account" page layout, just uncomment the blow line // If you'd like to quickly debug the "link account" page layout, just uncomment the blow line
// Don't worry, when the below line exists, the lint won't pass: ineffectual assignment to gothUser (ineffassign) // Don't worry, when the below line exists, the lint won't pass: ineffectual assignment to gothUser (ineffassign)
// gothUser, ok = goth.User{Email: "invalid-email", Name: "."}, true // intentionally use invalid data to avoid pass the registration check // linkAccountData = &LinkAccountData{authSource, gothUser} // intentionally use invalid data to avoid pass the registration check
if !ok { if linkAccountData == nil {
// no account in session, so just redirect to the login page, then the user could restart the process // no account in session, so just redirect to the login page, then the user could restart the process
ctx.Redirect(setting.AppSubURL + "/user/login") ctx.Redirect(setting.AppSubURL + "/user/login")
return return
} }
if missingFields, ok := gothUser.RawData["__giteaAutoRegMissingFields"].([]string); ok { if missingFields, ok := linkAccountData.GothUser.RawData["__giteaAutoRegMissingFields"].([]string); ok {
ctx.Data["AutoRegistrationFailedPrompt"] = ctx.Tr("auth.oauth_callback_unable_auto_reg", gothUser.Provider, strings.Join(missingFields, ",")) ctx.Data["AutoRegistrationFailedPrompt"] = ctx.Tr("auth.oauth_callback_unable_auto_reg", linkAccountData.GothUser.Provider, strings.Join(missingFields, ","))
} }
uname, err := extractUserNameFromOAuth2(&gothUser) uname, err := extractUserNameFromOAuth2(&linkAccountData.GothUser)
if err != nil { if err != nil {
ctx.ServerError("UserSignIn", err) ctx.ServerError("UserSignIn", err)
return return
} }
email := gothUser.Email email := linkAccountData.GothUser.Email
ctx.Data["user_name"] = uname ctx.Data["user_name"] = uname
ctx.Data["email"] = email ctx.Data["email"] = email
@ -152,8 +149,8 @@ func LinkAccountPostSignIn(ctx *context.Context) {
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin"
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup"
gothUser := ctx.Session.Get("linkAccountGothUser") linkAccountData := oauth2GetLinkAccountData(ctx)
if gothUser == nil { if linkAccountData == nil {
ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session")) ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session"))
return return
} }
@ -169,11 +166,14 @@ func LinkAccountPostSignIn(ctx *context.Context) {
return return
} }
linkAccount(ctx, u, gothUser.(goth.User), signInForm.Remember) oauth2LinkAccount(ctx, u, linkAccountData, signInForm.Remember)
} }
func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, remember bool) { func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData *LinkAccountData, remember bool) {
updateAvatarIfNeed(ctx, gothUser.AvatarURL, u) oauth2SignInSync(ctx, &linkAccountData.AuthSource, u, linkAccountData.GothUser)
if ctx.Written() {
return
}
// If this user is enrolled in 2FA, we can't sign the user in just yet. // If this user is enrolled in 2FA, we can't sign the user in just yet.
// Instead, redirect them to the 2FA authentication page. // Instead, redirect them to the 2FA authentication page.
@ -185,7 +185,7 @@ func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, r
return return
} }
err = externalaccount.LinkAccountToUser(ctx, u, gothUser) err = externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSource.ID, u, linkAccountData.GothUser)
if err != nil { if err != nil {
ctx.ServerError("UserLinkAccount", err) ctx.ServerError("UserLinkAccount", err)
return return
@ -243,17 +243,11 @@ func LinkAccountPostRegister(ctx *context.Context) {
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin"
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup"
gothUserInterface := ctx.Session.Get("linkAccountGothUser") linkAccountData := oauth2GetLinkAccountData(ctx)
if gothUserInterface == nil { if linkAccountData == nil {
ctx.ServerError("UserSignUp", errors.New("not in LinkAccount session")) ctx.ServerError("UserSignUp", errors.New("not in LinkAccount session"))
return return
} }
gothUser, ok := gothUserInterface.(goth.User)
if !ok {
ctx.ServerError("UserSignUp", fmt.Errorf("session linkAccountGothUser type is %t but not goth.User", gothUserInterface))
return
}
if ctx.HasError() { if ctx.HasError() {
ctx.HTML(http.StatusOK, tplLinkAccount) ctx.HTML(http.StatusOK, tplLinkAccount)
return return
@ -296,31 +290,33 @@ func LinkAccountPostRegister(ctx *context.Context) {
} }
} }
authSource, err := auth.GetActiveOAuth2SourceByName(ctx, gothUser.Provider)
if err != nil {
ctx.ServerError("CreateUser", err)
return
}
u := &user_model.User{ u := &user_model.User{
Name: form.UserName, Name: form.UserName,
Email: form.Email, Email: form.Email,
Passwd: form.Password, Passwd: form.Password,
LoginType: auth.OAuth2, LoginType: auth.OAuth2,
LoginSource: authSource.ID, LoginSource: linkAccountData.AuthSource.ID,
LoginName: gothUser.UserID, LoginName: linkAccountData.GothUser.UserID,
} }
if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, nil, &gothUser, false) { if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, nil, linkAccountData) {
// error already handled // error already handled
return return
} }
source := authSource.Cfg.(*oauth2.Source) source := linkAccountData.AuthSource.Cfg.(*oauth2.Source)
if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil { if err := syncGroupsToTeams(ctx, source, &linkAccountData.GothUser, u); err != nil {
ctx.ServerError("SyncGroupsToTeams", err) ctx.ServerError("SyncGroupsToTeams", err)
return return
} }
handleSignIn(ctx, u, false) handleSignIn(ctx, u, false)
} }
func linkAccountFromContext(ctx *context.Context, user *user_model.User) error {
linkAccountData := oauth2GetLinkAccountData(ctx)
if linkAccountData == nil {
return errors.New("not in LinkAccount session")
}
return externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSource.ID, user, linkAccountData.GothUser)
}

View File

@ -20,7 +20,6 @@ import (
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/session" "code.gitea.io/gitea/modules/session"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/modules/web/middleware"
source_service "code.gitea.io/gitea/services/auth/source" source_service "code.gitea.io/gitea/services/auth/source"
"code.gitea.io/gitea/services/auth/source/oauth2" "code.gitea.io/gitea/services/auth/source/oauth2"
@ -35,9 +34,8 @@ import (
// SignInOAuth handles the OAuth2 login buttons // SignInOAuth handles the OAuth2 login buttons
func SignInOAuth(ctx *context.Context) { func SignInOAuth(ctx *context.Context) {
provider := ctx.PathParam("provider") authName := ctx.PathParam("provider")
authSource, err := auth.GetActiveOAuth2SourceByAuthName(ctx, authName)
authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider)
if err != nil { if err != nil {
ctx.ServerError("SignIn", err) ctx.ServerError("SignIn", err)
return return
@ -74,8 +72,6 @@ func SignInOAuth(ctx *context.Context) {
// SignInOAuthCallback handles the callback from the given provider // SignInOAuthCallback handles the callback from the given provider
func SignInOAuthCallback(ctx *context.Context) { func SignInOAuthCallback(ctx *context.Context) {
provider := ctx.PathParam("provider")
if ctx.Req.FormValue("error") != "" { if ctx.Req.FormValue("error") != "" {
var errorKeyValues []string var errorKeyValues []string
for k, vv := range ctx.Req.Form { for k, vv := range ctx.Req.Form {
@ -88,7 +84,8 @@ func SignInOAuthCallback(ctx *context.Context) {
} }
// first look if the provider is still active // first look if the provider is still active
authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider) authName := ctx.PathParam("provider")
authSource, err := auth.GetActiveOAuth2SourceByAuthName(ctx, authName)
if err != nil { if err != nil {
ctx.ServerError("SignIn", err) ctx.ServerError("SignIn", err)
return return
@ -133,7 +130,7 @@ func SignInOAuthCallback(ctx *context.Context) {
if u == nil { if u == nil {
if ctx.Doer != nil { if ctx.Doer != nil {
// attach user to the current signed-in user // attach user to the current signed-in user
err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser) err = externalaccount.LinkAccountToUser(ctx, authSource.ID, ctx.Doer, gothUser)
if err != nil { if err != nil {
ctx.ServerError("UserLinkAccount", err) ctx.ServerError("UserLinkAccount", err)
return return
@ -174,12 +171,11 @@ func SignInOAuthCallback(ctx *context.Context) {
gothUser.RawData = make(map[string]any) gothUser.RawData = make(map[string]any)
} }
gothUser.RawData["__giteaAutoRegMissingFields"] = missingFields gothUser.RawData["__giteaAutoRegMissingFields"] = missingFields
showLinkingLogin(ctx, gothUser) showLinkingLogin(ctx, authSource, gothUser)
return return
} }
u = &user_model.User{ u = &user_model.User{
Name: uname, Name: uname,
FullName: gothUser.Name,
Email: gothUser.Email, Email: gothUser.Email,
LoginType: auth.OAuth2, LoginType: auth.OAuth2,
LoginSource: authSource.ID, LoginSource: authSource.ID,
@ -196,7 +192,11 @@ func SignInOAuthCallback(ctx *context.Context) {
u.IsAdmin = isAdmin.ValueOrDefault(user_service.UpdateOptionField[bool]{FieldValue: false}).FieldValue u.IsAdmin = isAdmin.ValueOrDefault(user_service.UpdateOptionField[bool]{FieldValue: false}).FieldValue
u.IsRestricted = isRestricted.ValueOrDefault(setting.Service.DefaultUserIsRestricted) u.IsRestricted = isRestricted.ValueOrDefault(setting.Service.DefaultUserIsRestricted)
if !createAndHandleCreatedUser(ctx, templates.TplName(""), nil, u, overwriteDefault, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) { linkAccountData := &LinkAccountData{*authSource, gothUser}
if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingDisabled {
linkAccountData = nil
}
if !createAndHandleCreatedUser(ctx, "", nil, u, overwriteDefault, linkAccountData) {
// error already handled // error already handled
return return
} }
@ -207,7 +207,7 @@ func SignInOAuthCallback(ctx *context.Context) {
} }
} else { } else {
// no existing user is found, request attach or new account // no existing user is found, request attach or new account
showLinkingLogin(ctx, gothUser) showLinkingLogin(ctx, authSource, gothUser)
return return
} }
} }
@ -271,9 +271,22 @@ func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *g
return isAdmin, isRestricted return isAdmin, isRestricted
} }
func showLinkingLogin(ctx *context.Context, gothUser goth.User) { type LinkAccountData struct {
AuthSource auth.Source
GothUser goth.User
}
func oauth2GetLinkAccountData(ctx *context.Context) *LinkAccountData {
v, ok := ctx.Session.Get("linkAccountData").(LinkAccountData)
if !ok {
return nil
}
return &v
}
func showLinkingLogin(ctx *context.Context, authSource *auth.Source, gothUser goth.User) {
if err := updateSession(ctx, nil, map[string]any{ if err := updateSession(ctx, nil, map[string]any{
"linkAccountGothUser": gothUser, "linkAccountData": LinkAccountData{*authSource, gothUser},
}); err != nil { }); err != nil {
ctx.ServerError("updateSession", err) ctx.ServerError("updateSession", err)
return return
@ -281,7 +294,7 @@ func showLinkingLogin(ctx *context.Context, gothUser goth.User) {
ctx.Redirect(setting.AppSubURL + "/user/link_account") ctx.Redirect(setting.AppSubURL + "/user/link_account")
} }
func updateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) { func oauth2UpdateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) {
if setting.OAuth2Client.UpdateAvatar && len(url) > 0 { if setting.OAuth2Client.UpdateAvatar && len(url) > 0 {
resp, err := http.Get(url) resp, err := http.Get(url)
if err == nil { if err == nil {
@ -299,11 +312,14 @@ func updateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) {
} }
} }
func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model.User, gothUser goth.User) { func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_model.User, gothUser goth.User) {
updateAvatarIfNeed(ctx, gothUser.AvatarURL, u) oauth2SignInSync(ctx, authSource, u, gothUser)
if ctx.Written() {
return
}
needs2FA := false needs2FA := false
if !source.TwoFactorShouldSkip() { if !authSource.TwoFactorShouldSkip() {
_, err := auth.GetTwoFactorByUID(ctx, u.ID) _, err := auth.GetTwoFactorByUID(ctx, u.ID)
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
ctx.ServerError("UserSignIn", err) ctx.ServerError("UserSignIn", err)
@ -312,7 +328,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model
needs2FA = err == nil needs2FA = err == nil
} }
oauth2Source := source.Cfg.(*oauth2.Source) oauth2Source := authSource.Cfg.(*oauth2.Source)
groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(oauth2Source.GroupTeamMap) groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(oauth2Source.GroupTeamMap)
if err != nil { if err != nil {
ctx.ServerError("UnmarshalGroupTeamMapping", err) ctx.ServerError("UnmarshalGroupTeamMapping", err)
@ -338,7 +354,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model
} }
} }
if err := externalaccount.EnsureLinkExternalToUser(ctx, u, gothUser); err != nil { if err := externalaccount.EnsureLinkExternalToUser(ctx, authSource.ID, u, gothUser); err != nil {
ctx.ServerError("EnsureLinkExternalToUser", err) ctx.ServerError("EnsureLinkExternalToUser", err)
return return
} }

View File

@ -0,0 +1,88 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"fmt"
asymkey_model "code.gitea.io/gitea/models/asymkey"
"code.gitea.io/gitea/models/auth"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
asymkey_service "code.gitea.io/gitea/services/asymkey"
"code.gitea.io/gitea/services/auth/source/oauth2"
"code.gitea.io/gitea/services/context"
"github.com/markbates/goth"
)
func oauth2SignInSync(ctx *context.Context, authSource *auth.Source, u *user_model.User, gothUser goth.User) {
oauth2UpdateAvatarIfNeed(ctx, gothUser.AvatarURL, u)
oauth2Source, _ := authSource.Cfg.(*oauth2.Source)
if !authSource.IsOAuth2() || oauth2Source == nil {
ctx.ServerError("oauth2SignInSync", fmt.Errorf("source %s is not an OAuth2 source", gothUser.Provider))
return
}
// sync full name
fullNameKey := util.IfZero(oauth2Source.FullNameClaimName, "name")
fullName, _ := gothUser.RawData[fullNameKey].(string)
fullName = util.IfZero(fullName, gothUser.Name)
// need to update if the user has no full name set
shouldUpdateFullName := u.FullName == ""
// force to update if the attribute is set
shouldUpdateFullName = shouldUpdateFullName || oauth2Source.FullNameClaimName != ""
// only update if the full name is different
shouldUpdateFullName = shouldUpdateFullName && u.FullName != fullName
if shouldUpdateFullName {
u.FullName = fullName
if err := user_model.UpdateUserCols(ctx, u, "full_name"); err != nil {
log.Error("Unable to sync OAuth2 user full name %s: %v", gothUser.Provider, err)
}
}
err := oauth2UpdateSSHPubIfNeed(ctx, authSource, &gothUser, u)
if err != nil {
log.Error("Unable to sync OAuth2 SSH public key %s: %v", gothUser.Provider, err)
}
}
func oauth2SyncGetSSHKeys(source *oauth2.Source, gothUser *goth.User) ([]string, error) {
value, exists := gothUser.RawData[source.SSHPublicKeyClaimName]
if !exists {
return []string{}, nil
}
rawSlice, ok := value.([]any)
if !ok {
return nil, fmt.Errorf("invalid SSH public key value type: %T", value)
}
sshKeys := make([]string, 0, len(rawSlice))
for _, v := range rawSlice {
str, ok := v.(string)
if !ok {
return nil, fmt.Errorf("invalid SSH public key value item type: %T", v)
}
sshKeys = append(sshKeys, str)
}
return sshKeys, nil
}
func oauth2UpdateSSHPubIfNeed(ctx *context.Context, authSource *auth.Source, gothUser *goth.User, user *user_model.User) error {
oauth2Source, _ := authSource.Cfg.(*oauth2.Source)
if oauth2Source == nil || oauth2Source.SSHPublicKeyClaimName == "" {
return nil
}
sshKeys, err := oauth2SyncGetSSHKeys(oauth2Source, gothUser)
if err != nil {
return err
}
if !asymkey_model.SynchronizePublicKeys(ctx, user, authSource, sshKeys) {
return nil
}
return asymkey_service.RewriteAllPublicKeys(ctx)
}

View File

@ -361,7 +361,7 @@ func RegisterOpenIDPost(ctx *context.Context) {
Email: form.Email, Email: form.Email,
Passwd: password, Passwd: password,
} }
if !createUserInContext(ctx, tplSignUpOID, form, u, nil, nil, false) { if !createUserInContext(ctx, tplSignUpOID, form, u, nil, nil) {
// error already handled // error already handled
return return
} }

View File

@ -15,7 +15,6 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/externalaccount"
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
@ -150,7 +149,7 @@ func WebAuthnPasskeyLogin(ctx *context.Context) {
// Now handle account linking if that's requested // Now handle account linking if that's requested
if ctx.Session.Get("linkAccount") != nil { if ctx.Session.Get("linkAccount") != nil {
if err := externalaccount.LinkAccountFromStore(ctx, ctx.Session, user); err != nil { if err := linkAccountFromContext(ctx, user); err != nil {
ctx.ServerError("LinkAccountFromStore", err) ctx.ServerError("LinkAccountFromStore", err)
return return
} }
@ -268,7 +267,7 @@ func WebAuthnLoginAssertionPost(ctx *context.Context) {
// Now handle account linking if that's requested // Now handle account linking if that's requested
if ctx.Session.Get("linkAccount") != nil { if ctx.Session.Get("linkAccount") != nil {
if err := externalaccount.LinkAccountFromStore(ctx, ctx.Session, user); err != nil { if err := linkAccountFromContext(ctx, user); err != nil {
ctx.ServerError("LinkAccountFromStore", err) ctx.ServerError("LinkAccountFromStore", err)
return return
} }

View File

@ -27,6 +27,7 @@ type Provider interface {
DisplayName() string DisplayName() string
IconHTML(size int) template.HTML IconHTML(size int) template.HTML
CustomURLSettings() *CustomURLSettings CustomURLSettings() *CustomURLSettings
SupportSSHPublicKey() bool
} }
// GothProviderCreator provides a function to create a goth.Provider // GothProviderCreator provides a function to create a goth.Provider

View File

@ -14,6 +14,13 @@ import (
type BaseProvider struct { type BaseProvider struct {
name string name string
displayName string displayName string
// TODO: maybe some providers also support SSH public keys, then they can set this to true
supportSSHPublicKey bool
}
func (b *BaseProvider) SupportSSHPublicKey() bool {
return b.supportSSHPublicKey
} }
// Name provides the technical name for this provider // Name provides the technical name for this provider

View File

@ -17,6 +17,10 @@ import (
// OpenIDProvider is a GothProvider for OpenID // OpenIDProvider is a GothProvider for OpenID
type OpenIDProvider struct{} type OpenIDProvider struct{}
func (o *OpenIDProvider) SupportSSHPublicKey() bool {
return true
}
// Name provides the technical name for this provider // Name provides the technical name for this provider
func (o *OpenIDProvider) Name() string { func (o *OpenIDProvider) Name() string {
return "openidConnect" return "openidConnect"

View File

@ -27,6 +27,9 @@ type Source struct {
GroupTeamMap string GroupTeamMap string
GroupTeamMapRemoval bool GroupTeamMapRemoval bool
RestrictedGroup string RestrictedGroup string
SSHPublicKeyClaimName string
FullNameClaimName string
} }
// FromDB fills up an OAuth2Config from serialized format. // FromDB fills up an OAuth2Config from serialized format.

View File

@ -1,30 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package externalaccount
import (
"context"
"errors"
user_model "code.gitea.io/gitea/models/user"
"github.com/markbates/goth"
)
// Store represents a thing that stores things
type Store interface {
Get(any) any
Set(any, any) error
Release() error
}
// LinkAccountFromStore links the provided user with a stored external user
func LinkAccountFromStore(ctx context.Context, store Store, user *user_model.User) error {
gothUser := store.Get("linkAccountGothUser")
if gothUser == nil {
return errors.New("not in LinkAccount session")
}
return LinkAccountToUser(ctx, user, gothUser.(goth.User))
}

View File

@ -8,7 +8,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
@ -17,15 +16,11 @@ import (
"github.com/markbates/goth" "github.com/markbates/goth"
) )
func toExternalLoginUser(ctx context.Context, user *user_model.User, gothUser goth.User) (*user_model.ExternalLoginUser, error) { func toExternalLoginUser(authSourceID int64, user *user_model.User, gothUser goth.User) *user_model.ExternalLoginUser {
authSource, err := auth.GetActiveOAuth2SourceByName(ctx, gothUser.Provider)
if err != nil {
return nil, err
}
return &user_model.ExternalLoginUser{ return &user_model.ExternalLoginUser{
ExternalID: gothUser.UserID, ExternalID: gothUser.UserID,
UserID: user.ID, UserID: user.ID,
LoginSourceID: authSource.ID, LoginSourceID: authSourceID,
RawData: gothUser.RawData, RawData: gothUser.RawData,
Provider: gothUser.Provider, Provider: gothUser.Provider,
Email: gothUser.Email, Email: gothUser.Email,
@ -40,15 +35,12 @@ func toExternalLoginUser(ctx context.Context, user *user_model.User, gothUser go
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 // LinkAccountToUser link the gothUser to the user
func LinkAccountToUser(ctx context.Context, user *user_model.User, gothUser goth.User) error { func LinkAccountToUser(ctx context.Context, authSourceID int64, user *user_model.User, gothUser goth.User) error {
externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser) externalLoginUser := toExternalLoginUser(authSourceID, user, gothUser)
if err != nil {
return err
}
if err := user_model.LinkExternalToUser(ctx, user, externalLoginUser); err != nil { if err := user_model.LinkExternalToUser(ctx, user, externalLoginUser); err != nil {
return err return err
@ -72,12 +64,8 @@ func LinkAccountToUser(ctx context.Context, user *user_model.User, gothUser goth
} }
// EnsureLinkExternalToUser link the gothUser to the user // EnsureLinkExternalToUser link the gothUser to the user
func EnsureLinkExternalToUser(ctx context.Context, user *user_model.User, gothUser goth.User) error { func EnsureLinkExternalToUser(ctx context.Context, authSourceID int64, user *user_model.User, gothUser goth.User) error {
externalLoginUser, err := toExternalLoginUser(ctx, user, gothUser) externalLoginUser := toExternalLoginUser(authSourceID, user, gothUser)
if err != nil {
return err
}
return user_model.EnsureLinkExternalToUser(ctx, externalLoginUser) return user_model.EnsureLinkExternalToUser(ctx, externalLoginUser)
} }

View File

@ -18,45 +18,54 @@ type AuthenticationForm struct {
Type int `binding:"Range(2,7)"` Type int `binding:"Range(2,7)"`
Name string `binding:"Required;MaxSize(30)"` Name string `binding:"Required;MaxSize(30)"`
TwoFactorPolicy string TwoFactorPolicy string
IsActive bool
IsSyncEnabled bool
Host string // LDAP
Port int Host string
BindDN string Port int
BindPassword string BindDN string
UserBase string BindPassword string
UserDN string UserBase string
AttributeUsername string UserDN string
AttributeName string AttributeUsername string
AttributeSurname string AttributeName string
AttributeMail string AttributeSurname string
AttributeSSHPublicKey string AttributeMail string
AttributeAvatar string AttributeSSHPublicKey string
AttributesInBind bool AttributeAvatar string
UsePagedSearch bool AttributesInBind bool
SearchPageSize int UsePagedSearch bool
Filter string SearchPageSize int
AdminFilter string Filter string
GroupsEnabled bool AdminFilter string
GroupDN string GroupsEnabled bool
GroupFilter string GroupDN string
GroupMemberUID string GroupFilter string
UserUID string GroupMemberUID string
RestrictedFilter string UserUID string
AllowDeactivateAll bool RestrictedFilter string
IsActive bool AllowDeactivateAll bool
IsSyncEnabled bool GroupTeamMap string `binding:"ValidGroupTeamMap"`
SMTPAuth string GroupTeamMapRemoval bool
SMTPHost string
SMTPPort int // SMTP
AllowedDomains string SMTPAuth string
SecurityProtocol int `binding:"Range(0,2)"` SMTPHost string
TLS bool SMTPPort int
SkipVerify bool AllowedDomains string
HeloHostname string SecurityProtocol int `binding:"Range(0,2)"`
DisableHelo bool TLS bool
ForceSMTPS bool SkipVerify bool
PAMServiceName string HeloHostname string
PAMEmailDomain string DisableHelo bool
ForceSMTPS bool
// PAM
PAMServiceName string
PAMEmailDomain string
// Oauth2 & OIDC
Oauth2Provider string Oauth2Provider string
Oauth2Key string Oauth2Key string
Oauth2Secret string Oauth2Secret string
@ -76,13 +85,15 @@ type AuthenticationForm struct {
Oauth2RestrictedGroup string Oauth2RestrictedGroup string
Oauth2GroupTeamMap string `binding:"ValidGroupTeamMap"` Oauth2GroupTeamMap string `binding:"ValidGroupTeamMap"`
Oauth2GroupTeamMapRemoval bool Oauth2GroupTeamMapRemoval bool
SSPIAutoCreateUsers bool Oauth2SSHPublicKeyClaimName string
SSPIAutoActivateUsers bool Oauth2FullNameClaimName string
SSPIStripDomainNames bool
SSPISeparatorReplacement string `binding:"AlphaDashDot;MaxSize(5)"` // SSPI
SSPIDefaultLanguage string SSPIAutoCreateUsers bool
GroupTeamMap string `binding:"ValidGroupTeamMap"` SSPIAutoActivateUsers bool
GroupTeamMapRemoval bool SSPIStripDomainNames bool
SSPISeparatorReplacement string `binding:"AlphaDashDot;MaxSize(5)"`
SSPIDefaultLanguage string
} }
// Validate validates fields // Validate validates fields

View File

@ -301,19 +301,30 @@
<input id="oauth2_tenant" name="oauth2_tenant" value="{{if $cfg.CustomURLMapping}}{{$cfg.CustomURLMapping.Tenant}}{{end}}"> <input id="oauth2_tenant" name="oauth2_tenant" value="{{if $cfg.CustomURLMapping}}{{$cfg.CustomURLMapping.Tenant}}{{end}}">
</div> </div>
{{range .OAuth2Providers}}{{if .CustomURLSettings}} {{range .OAuth2Providers}}
<input id="{{.Name}}_SupportSSHPublicKey" value="{{.SupportSSHPublicKey}}" type="hidden">
{{if .CustomURLSettings}}
<input id="{{.Name}}_customURLSettings" type="hidden" data-required="{{.CustomURLSettings.Required}}" data-available="true"> <input id="{{.Name}}_customURLSettings" type="hidden" data-required="{{.CustomURLSettings.Required}}" data-available="true">
<input id="{{.Name}}_token_url" value="{{.CustomURLSettings.TokenURL.Value}}" data-available="{{.CustomURLSettings.TokenURL.Available}}" data-required="{{.CustomURLSettings.TokenURL.Required}}" type="hidden"> <input id="{{.Name}}_token_url" value="{{.CustomURLSettings.TokenURL.Value}}" data-available="{{.CustomURLSettings.TokenURL.Available}}" data-required="{{.CustomURLSettings.TokenURL.Required}}" type="hidden">
<input id="{{.Name}}_auth_url" value="{{.CustomURLSettings.AuthURL.Value}}" data-available="{{.CustomURLSettings.AuthURL.Available}}" data-required="{{.CustomURLSettings.AuthURL.Required}}" type="hidden"> <input id="{{.Name}}_auth_url" value="{{.CustomURLSettings.AuthURL.Value}}" data-available="{{.CustomURLSettings.AuthURL.Available}}" data-required="{{.CustomURLSettings.AuthURL.Required}}" type="hidden">
<input id="{{.Name}}_profile_url" value="{{.CustomURLSettings.ProfileURL.Value}}" data-available="{{.CustomURLSettings.ProfileURL.Available}}" data-required="{{.CustomURLSettings.ProfileURL.Required}}" type="hidden"> <input id="{{.Name}}_profile_url" value="{{.CustomURLSettings.ProfileURL.Value}}" data-available="{{.CustomURLSettings.ProfileURL.Available}}" data-required="{{.CustomURLSettings.ProfileURL.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}}_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"> <div class="field">
<label for="oauth2_scopes">{{ctx.Locale.Tr "admin.auths.oauth2_scopes"}}</label> <label for="oauth2_scopes">{{ctx.Locale.Tr "admin.auths.oauth2_scopes"}}</label>
<input id="oauth2_scopes" name="oauth2_scopes" value="{{if $cfg.Scopes}}{{StringUtils.Join $cfg.Scopes ","}}{{end}}"> <input id="oauth2_scopes" name="oauth2_scopes" value="{{if $cfg.Scopes}}{{StringUtils.Join $cfg.Scopes ","}}{{end}}">
</div> </div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.auths.oauth2_full_name_claim_name"}}</label>
<input name="oauth2_full_name_claim_name" value="{{$cfg.FullNameClaimName}}" placeholder="name">
</div>
<div class="field oauth2_ssh_public_key_claim_name">
<label>{{ctx.Locale.Tr "admin.auths.oauth2_ssh_public_key_claim_name"}}</label>
<input name="oauth2_ssh_public_key_claim_name" value="{{$cfg.SSHPublicKeyClaimName}}" placeholder="sshpubkey">
</div>
<div class="field"> <div class="field">
<label for="oauth2_required_claim_name">{{ctx.Locale.Tr "admin.auths.oauth2_required_claim_name"}}</label> <label for="oauth2_required_claim_name">{{ctx.Locale.Tr "admin.auths.oauth2_required_claim_name"}}</label>
<input id="oauth2_required_claim_name" name="oauth2_required_claim_name" value="{{$cfg.RequiredClaimName}}"> <input id="oauth2_required_claim_name" name="oauth2_required_claim_name" value="{{$cfg.RequiredClaimName}}">

View File

@ -63,19 +63,31 @@
<input id="oauth2_tenant" name="oauth2_tenant" value="{{.oauth2_tenant}}"> <input id="oauth2_tenant" name="oauth2_tenant" value="{{.oauth2_tenant}}">
</div> </div>
{{range .OAuth2Providers}}{{if .CustomURLSettings}} {{range .OAuth2Providers}}
<input id="{{.Name}}_SupportSSHPublicKey" value="{{.SupportSSHPublicKey}}" type="hidden">
{{if .CustomURLSettings}}
<input id="{{.Name}}_customURLSettings" type="hidden" data-required="{{.CustomURLSettings.Required}}" data-available="true"> <input id="{{.Name}}_customURLSettings" type="hidden" data-required="{{.CustomURLSettings.Required}}" data-available="true">
<input id="{{.Name}}_token_url" value="{{.CustomURLSettings.TokenURL.Value}}" data-available="{{.CustomURLSettings.TokenURL.Available}}" data-required="{{.CustomURLSettings.TokenURL.Required}}" type="hidden"> <input id="{{.Name}}_token_url" value="{{.CustomURLSettings.TokenURL.Value}}" data-available="{{.CustomURLSettings.TokenURL.Available}}" data-required="{{.CustomURLSettings.TokenURL.Required}}" type="hidden">
<input id="{{.Name}}_auth_url" value="{{.CustomURLSettings.AuthURL.Value}}" data-available="{{.CustomURLSettings.AuthURL.Available}}" data-required="{{.CustomURLSettings.AuthURL.Required}}" type="hidden"> <input id="{{.Name}}_auth_url" value="{{.CustomURLSettings.AuthURL.Value}}" data-available="{{.CustomURLSettings.AuthURL.Available}}" data-required="{{.CustomURLSettings.AuthURL.Required}}" type="hidden">
<input id="{{.Name}}_profile_url" value="{{.CustomURLSettings.ProfileURL.Value}}" data-available="{{.CustomURLSettings.ProfileURL.Available}}" data-required="{{.CustomURLSettings.ProfileURL.Required}}" type="hidden"> <input id="{{.Name}}_profile_url" value="{{.CustomURLSettings.ProfileURL.Value}}" data-available="{{.CustomURLSettings.ProfileURL.Available}}" data-required="{{.CustomURLSettings.ProfileURL.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}}_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"> <div class="field">
<label for="oauth2_scopes">{{ctx.Locale.Tr "admin.auths.oauth2_scopes"}}</label> <label for="oauth2_scopes">{{ctx.Locale.Tr "admin.auths.oauth2_scopes"}}</label>
<input id="oauth2_scopes" name="oauth2_scopes" value="{{.oauth2_scopes}}"> <input id="oauth2_scopes" name="oauth2_scopes" value="{{.oauth2_scopes}}">
</div> </div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.auths.oauth2_full_name_claim_name"}}</label>
<input name="oauth2_full_name_claim_name" value="{{.oauth2_full_name_claim_name}}" placeholder="name">
</div>
<div class="field oauth2_ssh_public_key_claim_name">
<label>{{ctx.Locale.Tr "admin.auths.oauth2_ssh_public_key_claim_name"}}</label>
<input name="oauth2_ssh_public_key_claim_name" value="{{.oauth2_ssh_public_key_claim_name}}" placeholder="sshpubkey">
</div>
<div class="field"> <div class="field">
<label for="oauth2_required_claim_name">{{ctx.Locale.Tr "admin.auths.oauth2_required_claim_name"}}</label> <label for="oauth2_required_claim_name">{{ctx.Locale.Tr "admin.auths.oauth2_required_claim_name"}}</label>
<input id="oauth2_required_claim_name" name="oauth2_required_claim_name" value="{{.oauth2_required_claim_name}}"> <input id="oauth2_required_claim_name" name="oauth2_required_claim_name" value="{{.oauth2_required_claim_name}}">

View File

@ -9,9 +9,11 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/http/httptest"
"strings" "strings"
"testing" "testing"
asymkey_model "code.gitea.io/gitea/models/asymkey"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
@ -20,9 +22,13 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/auth/source/oauth2"
"code.gitea.io/gitea/services/oauth2_provider" "code.gitea.io/gitea/services/oauth2_provider"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -931,3 +937,107 @@ func testOAuth2WellKnown(t *testing.T) {
defer test.MockVariableValue(&setting.OAuth2.Enabled, false)() defer test.MockVariableValue(&setting.OAuth2.Enabled, false)()
MakeRequest(t, NewRequest(t, "GET", urlOpenidConfiguration), http.StatusNotFound) MakeRequest(t, NewRequest(t, "GET", urlOpenidConfiguration), http.StatusNotFound)
} }
func addOAuth2Source(t *testing.T, authName string, cfg oauth2.Source) {
cfg.Provider = util.IfZero(cfg.Provider, "gitea")
err := auth_model.CreateSource(db.DefaultContext, &auth_model.Source{
Type: auth_model.OAuth2,
Name: authName,
IsActive: true,
Cfg: &cfg,
})
require.NoError(t, err)
}
func TestSignInOauthCallbackSyncSSHKeys(t *testing.T) {
defer tests.PrepareTestEnv(t)()
var mockServer *httptest.Server
mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
_, _ = w.Write([]byte(`{
"issuer": "` + mockServer.URL + `",
"authorization_endpoint": "` + mockServer.URL + `/authorize",
"token_endpoint": "` + mockServer.URL + `/token",
"userinfo_endpoint": "` + mockServer.URL + `/userinfo"
}`))
default:
http.NotFound(w, r)
}
}))
defer mockServer.Close()
ctx := t.Context()
oauth2Source := oauth2.Source{
Provider: "openidConnect",
ClientID: "test-client-id",
SSHPublicKeyClaimName: "sshpubkey",
FullNameClaimName: "name",
OpenIDConnectAutoDiscoveryURL: mockServer.URL + "/.well-known/openid-configuration",
}
addOAuth2Source(t, "test-oidc-source", oauth2Source)
authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(ctx, "test-oidc-source")
require.NoError(t, err)
sshKey1 := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf"
sshKey2 := "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIE7kM1R02+4ertDKGKEDcKG0s+2vyDDcIvceJ0Gqv5f1AAAABHNzaDo="
sshKey3 := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEHjnNEfE88W1pvBLdV3otv28x760gdmPao3lVD5uAt9"
cases := []struct {
testName string
mockFullName string
mockRawData map[string]any
expectedSSHPubKeys []string
}{
{
testName: "Login1",
mockFullName: "FullName1",
mockRawData: map[string]any{"sshpubkey": []any{sshKey1 + " any-comment"}},
expectedSSHPubKeys: []string{sshKey1},
},
{
testName: "Login2",
mockFullName: "FullName2",
mockRawData: map[string]any{"sshpubkey": []any{sshKey2 + " any-comment", sshKey3}},
expectedSSHPubKeys: []string{sshKey2, sshKey3},
},
{
testName: "Login3",
mockFullName: "FullName3",
mockRawData: map[string]any{},
expectedSSHPubKeys: []string{},
},
}
session := emptyTestSession(t)
for _, c := range cases {
t.Run(c.testName, func(t *testing.T) {
defer test.MockVariableValue(&setting.OAuth2Client.Username, "")()
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
defer test.MockVariableValue(&gothic.CompleteUserAuth, func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
return goth.User{
Provider: authSource.Cfg.(*oauth2.Source).Provider,
UserID: "oidc-userid",
Email: "oidc-email@example.com",
RawData: c.mockRawData,
Name: c.mockFullName,
}, nil
})()
req := NewRequest(t, "GET", "/user/oauth2/test-oidc-source/callback?code=XYZ&state=XYZ")
session.MakeRequest(t, req, http.StatusSeeOther)
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "oidc-userid"})
keys, _, err := db.FindAndCount[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{
ListOptions: db.ListOptionsAll,
OwnerID: user.ID,
LoginSourceID: authSource.ID,
})
require.NoError(t, err)
var sshPubKeys []string
for _, key := range keys {
sshPubKeys = append(sshPubKeys, key.Content)
}
assert.ElementsMatch(t, c.expectedSSHPubKeys, sshPubKeys)
assert.Equal(t, c.mockFullName, user.FullName)
})
}
}

View File

@ -9,6 +9,7 @@ import (
"strings" "strings"
"testing" "testing"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
@ -17,6 +18,7 @@ import (
"code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers" "code.gitea.io/gitea/routers"
"code.gitea.io/gitea/routers/web/auth"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
@ -103,8 +105,9 @@ func TestEnablePasswordSignInFormAndEnablePasskeyAuth(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
mockLinkAccount := func(ctx *context.Context) { mockLinkAccount := func(ctx *context.Context) {
authSource := auth_model.Source{ID: 1}
gothUser := goth.User{Email: "invalid-email", Name: "."} gothUser := goth.User{Email: "invalid-email", Name: "."}
_ = ctx.Session.Set("linkAccountGothUser", gothUser) _ = ctx.Session.Set("linkAccountData", auth.LinkAccountData{AuthSource: authSource, GothUser: gothUser})
} }
t.Run("EnablePasswordSignInForm=false", func(t *testing.T) { t.Run("EnablePasswordSignInForm=false", func(t *testing.T) {

View File

@ -102,6 +102,9 @@ function initAdminAuthentication() {
break; break;
} }
} }
const supportSshPublicKey = document.querySelector<HTMLInputElement>(`#${provider}_SupportSSHPublicKey`)?.value === 'true';
toggleElem('.field.oauth2_ssh_public_key_claim_name', supportSshPublicKey);
onOAuth2UseCustomURLChange(applyDefaultValues); onOAuth2UseCustomURLChange(applyDefaultValues);
} }