From e8186f1c0f194ce3f63bed9a564002b80c0859c9 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Wed, 8 Feb 2023 07:44:42 +0100 Subject: [PATCH] Map OIDC groups to Orgs/Teams (#21441) Fixes #19555 Test-Instructions: https://github.com/go-gitea/gitea/pull/21441#issuecomment-1419438000 This PR implements the mapping of user groups provided by OIDC providers to orgs teams in Gitea. The main part is a refactoring of the existing LDAP code to make it usable from different providers. Refactorings: - Moved the router auth code from module to service because of import cycles - Changed some model methods to take a `Context` parameter - Moved the mapping code from LDAP to a common location I've tested it with Keycloak but other providers should work too. The JSON mapping format is the same as for LDAP. ![grafik](https://user-images.githubusercontent.com/1666336/195634392-3fc540fc-b229-4649-99ac-91ae8e19df2d.png) --------- Co-authored-by: Lunny Xiao --- cmd/admin.go | 17 +++ docs/content/doc/usage/command-line.en-us.md | 2 + models/organization/org.go | 20 +-- models/organization/org_test.go | 16 +-- modules/auth/common.go | 22 +++ modules/context/api.go | 30 ---- modules/context/context.go | 32 ----- modules/context/org.go | 2 +- modules/repository/create_test.go | 4 +- modules/repository/repo.go | 2 +- modules/validation/binding.go | 24 +++- modules/web/middleware/binding.go | 2 + options/locale/locale_en-US.ini | 3 + routers/api/v1/api.go | 4 +- routers/api/v1/repo/fork.go | 2 +- routers/api/v1/repo/repo.go | 2 +- routers/web/admin/auths.go | 2 + routers/web/auth/linkaccount.go | 7 + routers/web/auth/oauth.go | 95 ++++++++---- routers/web/repo/issue_label.go | 2 +- routers/web/repo/setting.go | 2 +- routers/web/web.go | 2 +- services/auth/middleware.go | 60 ++++++++ .../auth/source/ldap/source_authenticate.go | 96 +++++++------ .../auth/source/ldap/source_group_sync.go | 94 ------------ services/auth/source/ldap/source_search.go | 136 ++++++------------ services/auth/source/ldap/source_sync.go | 11 +- services/auth/source/oauth2/source.go | 23 ++- services/auth/source/source_group_sync.go | 116 +++++++++++++++ services/forms/auth_form.go | 4 +- templates/admin/auth/edit.tmpl | 8 ++ templates/admin/auth/source/ldap.tmpl | 2 +- templates/admin/auth/source/oauth.tmpl | 8 ++ tests/integration/auth_ldap_test.go | 71 +++------ 34 files changed, 500 insertions(+), 423 deletions(-) create mode 100644 modules/auth/common.go create mode 100644 services/auth/middleware.go delete mode 100644 services/auth/source/ldap/source_group_sync.go create mode 100644 services/auth/source/source_group_sync.go diff --git a/cmd/admin.go b/cmd/admin.go index 4e2dc2bf06b..318c212d086 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -372,6 +372,15 @@ var ( Value: "", Usage: "Group Claim value for restricted users", }, + cli.StringFlag{ + Name: "group-team-map", + Value: "", + Usage: "JSON mapping between groups and org teams", + }, + cli.BoolFlag{ + Name: "group-team-map-removal", + Usage: "Activate automatic team membership removal depending on groups", + }, } microcmdAuthUpdateOauth = cli.Command{ @@ -853,6 +862,8 @@ func parseOAuth2Config(c *cli.Context) *oauth2.Source { GroupClaimName: c.String("group-claim-name"), AdminGroup: c.String("admin-group"), RestrictedGroup: c.String("restricted-group"), + GroupTeamMap: c.String("group-team-map"), + GroupTeamMapRemoval: c.Bool("group-team-map-removal"), } } @@ -935,6 +946,12 @@ func runUpdateOauth(c *cli.Context) error { if c.IsSet("restricted-group") { oAuth2Config.RestrictedGroup = c.String("restricted-group") } + if c.IsSet("group-team-map") { + oAuth2Config.GroupTeamMap = c.String("group-team-map") + } + if c.IsSet("group-team-map-removal") { + oAuth2Config.GroupTeamMapRemoval = c.Bool("group-team-map-removal") + } // update custom URL mapping customURLMapping := &oauth2.CustomURLMapping{} diff --git a/docs/content/doc/usage/command-line.en-us.md b/docs/content/doc/usage/command-line.en-us.md index f2e72d4fc03..9b861a9da39 100644 --- a/docs/content/doc/usage/command-line.en-us.md +++ b/docs/content/doc/usage/command-line.en-us.md @@ -137,6 +137,8 @@ Admin operations: - `--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) + - `--group-team-map`: JSON mapping between groups and org teams. (Optional) + - `--group-team-map-removal`: Activate automatic team membership removal depending on groups. (Optional) - Examples: - `gitea admin auth add-oauth --name external-github --provider github --key OBTAIN_FROM_SOURCE --secret OBTAIN_FROM_SOURCE` - `update-oauth`: diff --git a/models/organization/org.go b/models/organization/org.go index 05eaead60bf..852facf7047 100644 --- a/models/organization/org.go +++ b/models/organization/org.go @@ -110,22 +110,14 @@ func (org *Organization) CanCreateOrgRepo(uid int64) (bool, error) { return CanCreateOrgRepo(db.DefaultContext, org.ID, uid) } -func (org *Organization) getTeam(ctx context.Context, name string) (*Team, error) { - return GetTeam(ctx, org.ID, name) -} - // GetTeam returns named team of organization. -func (org *Organization) GetTeam(name string) (*Team, error) { - return org.getTeam(db.DefaultContext, name) -} - -func (org *Organization) getOwnerTeam(ctx context.Context) (*Team, error) { - return org.getTeam(ctx, OwnerTeamName) +func (org *Organization) GetTeam(ctx context.Context, name string) (*Team, error) { + return GetTeam(ctx, org.ID, name) } // GetOwnerTeam returns owner team of organization. -func (org *Organization) GetOwnerTeam() (*Team, error) { - return org.getOwnerTeam(db.DefaultContext) +func (org *Organization) GetOwnerTeam(ctx context.Context) (*Team, error) { + return org.GetTeam(ctx, OwnerTeamName) } // FindOrgTeams returns all teams of a given organization @@ -342,7 +334,7 @@ func CreateOrganization(org *Organization, owner *user_model.User) (err error) { } // GetOrgByName returns organization by given name. -func GetOrgByName(name string) (*Organization, error) { +func GetOrgByName(ctx context.Context, name string) (*Organization, error) { if len(name) == 0 { return nil, ErrOrgNotExist{0, name} } @@ -350,7 +342,7 @@ func GetOrgByName(name string) (*Organization, error) { LowerName: strings.ToLower(name), Type: user_model.UserTypeOrganization, } - has, err := db.GetEngine(db.DefaultContext).Get(u) + has, err := db.GetEngine(ctx).Get(u) if err != nil { return nil, err } else if !has { diff --git a/models/organization/org_test.go b/models/organization/org_test.go index 0a38365924e..cfa304d7b29 100644 --- a/models/organization/org_test.go +++ b/models/organization/org_test.go @@ -61,28 +61,28 @@ func TestUser_IsOrgMember(t *testing.T) { func TestUser_GetTeam(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) - team, err := org.GetTeam("team1") + team, err := org.GetTeam(db.DefaultContext, "team1") assert.NoError(t, err) assert.Equal(t, org.ID, team.OrgID) assert.Equal(t, "team1", team.LowerName) - _, err = org.GetTeam("does not exist") + _, err = org.GetTeam(db.DefaultContext, "does not exist") assert.True(t, organization.IsErrTeamNotExist(err)) nonOrg := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 2}) - _, err = nonOrg.GetTeam("team") + _, err = nonOrg.GetTeam(db.DefaultContext, "team") assert.True(t, organization.IsErrTeamNotExist(err)) } func TestUser_GetOwnerTeam(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) - team, err := org.GetOwnerTeam() + team, err := org.GetOwnerTeam(db.DefaultContext) assert.NoError(t, err) assert.Equal(t, org.ID, team.OrgID) nonOrg := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 2}) - _, err = nonOrg.GetOwnerTeam() + _, err = nonOrg.GetOwnerTeam(db.DefaultContext) assert.True(t, organization.IsErrTeamNotExist(err)) } @@ -115,15 +115,15 @@ func TestUser_GetMembers(t *testing.T) { func TestGetOrgByName(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - org, err := organization.GetOrgByName("user3") + org, err := organization.GetOrgByName(db.DefaultContext, "user3") assert.NoError(t, err) assert.EqualValues(t, 3, org.ID) assert.Equal(t, "user3", org.Name) - _, err = organization.GetOrgByName("user2") // user2 is an individual + _, err = organization.GetOrgByName(db.DefaultContext, "user2") // user2 is an individual assert.True(t, organization.IsErrOrgNotExist(err)) - _, err = organization.GetOrgByName("") // corner case + _, err = organization.GetOrgByName(db.DefaultContext, "") // corner case assert.True(t, organization.IsErrOrgNotExist(err)) } diff --git a/modules/auth/common.go b/modules/auth/common.go new file mode 100644 index 00000000000..77361f65614 --- /dev/null +++ b/modules/auth/common.go @@ -0,0 +1,22 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" +) + +func UnmarshalGroupTeamMapping(raw string) (map[string]map[string][]string, error) { + groupTeamMapping := make(map[string]map[string][]string) + if raw == "" { + return groupTeamMapping, nil + } + err := json.Unmarshal([]byte(raw), &groupTeamMapping) + if err != nil { + log.Error("Failed to unmarshal group team mapping: %v", err) + return nil, err + } + return groupTeamMapping, nil +} diff --git a/modules/context/api.go b/modules/context/api.go index 3f52c54d4cf..3f938948aed 100644 --- a/modules/context/api.go +++ b/modules/context/api.go @@ -19,7 +19,6 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web/middleware" - auth_service "code.gitea.io/gitea/services/auth" ) // APIContext is a specific context for API service @@ -215,35 +214,6 @@ func (ctx *APIContext) CheckForOTP() { } } -// APIAuth converts auth_service.Auth as a middleware -func APIAuth(authMethod auth_service.Method) func(*APIContext) { - return func(ctx *APIContext) { - // Get user from session if logged in. - var err error - ctx.Doer, err = authMethod.Verify(ctx.Req, ctx.Resp, ctx, ctx.Session) - if err != nil { - ctx.Error(http.StatusUnauthorized, "APIAuth", err) - return - } - - if ctx.Doer != nil { - if ctx.Locale.Language() != ctx.Doer.Language { - ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req) - } - ctx.IsBasicAuth = ctx.Data["AuthedMethod"].(string) == auth_service.BasicMethodName - ctx.IsSigned = true - ctx.Data["IsSigned"] = ctx.IsSigned - ctx.Data["SignedUser"] = ctx.Doer - ctx.Data["SignedUserID"] = ctx.Doer.ID - ctx.Data["SignedUserName"] = ctx.Doer.Name - ctx.Data["IsAdmin"] = ctx.Doer.IsAdmin - } else { - ctx.Data["SignedUserID"] = int64(0) - ctx.Data["SignedUserName"] = "" - } - } -} - // APIContexter returns apicontext as middleware func APIContexter() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { diff --git a/modules/context/context.go b/modules/context/context.go index 84f40ce063c..a2088217ff3 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -36,7 +36,6 @@ import ( "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web/middleware" - "code.gitea.io/gitea/services/auth" "gitea.com/go-chi/cache" "gitea.com/go-chi/session" @@ -659,37 +658,6 @@ func getCsrfOpts() CsrfOptions { } } -// Auth converts auth.Auth as a middleware -func Auth(authMethod auth.Method) func(*Context) { - return func(ctx *Context) { - var err error - ctx.Doer, err = authMethod.Verify(ctx.Req, ctx.Resp, ctx, ctx.Session) - if err != nil { - log.Error("Failed to verify user %v: %v", ctx.Req.RemoteAddr, err) - ctx.Error(http.StatusUnauthorized, "Verify") - return - } - if ctx.Doer != nil { - if ctx.Locale.Language() != ctx.Doer.Language { - ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req) - } - ctx.IsBasicAuth = ctx.Data["AuthedMethod"].(string) == auth.BasicMethodName - ctx.IsSigned = true - ctx.Data["IsSigned"] = ctx.IsSigned - ctx.Data["SignedUser"] = ctx.Doer - ctx.Data["SignedUserID"] = ctx.Doer.ID - ctx.Data["SignedUserName"] = ctx.Doer.Name - ctx.Data["IsAdmin"] = ctx.Doer.IsAdmin - } else { - ctx.Data["SignedUserID"] = int64(0) - ctx.Data["SignedUserName"] = "" - - // ensure the session uid is deleted - _ = ctx.Session.Delete("uid") - } - } -} - // Contexter initializes a classic context for a request. func Contexter(ctx context.Context) func(next http.Handler) http.Handler { _, rnd := templates.HTMLRenderer(ctx) diff --git a/modules/context/org.go b/modules/context/org.go index ff3a5ae7ec3..0add7f2c0c3 100644 --- a/modules/context/org.go +++ b/modules/context/org.go @@ -80,7 +80,7 @@ func HandleOrgAssignment(ctx *Context, args ...bool) { orgName := ctx.Params(":org") var err error - ctx.Org.Organization, err = organization.GetOrgByName(orgName) + ctx.Org.Organization, err = organization.GetOrgByName(ctx, orgName) if err != nil { if organization.IsErrOrgNotExist(err) { redirectUserID, err := user_model.LookupUserRedirect(orgName) diff --git a/modules/repository/create_test.go b/modules/repository/create_test.go index 293071bdce8..e620422bcb7 100644 --- a/modules/repository/create_test.go +++ b/modules/repository/create_test.go @@ -49,7 +49,7 @@ func TestIncludesAllRepositoriesTeams(t *testing.T) { assert.NoError(t, organization.CreateOrganization(org, user), "CreateOrganization") // Check Owner team. - ownerTeam, err := org.GetOwnerTeam() + ownerTeam, err := org.GetOwnerTeam(db.DefaultContext) assert.NoError(t, err, "GetOwnerTeam") assert.True(t, ownerTeam.IncludesAllRepositories, "Owner team includes all repositories") @@ -63,7 +63,7 @@ func TestIncludesAllRepositoriesTeams(t *testing.T) { } } // Get fresh copy of Owner team after creating repos. - ownerTeam, err = org.GetOwnerTeam() + ownerTeam, err = org.GetOwnerTeam(db.DefaultContext) assert.NoError(t, err, "GetOwnerTeam") // Create teams and check repositories. diff --git a/modules/repository/repo.go b/modules/repository/repo.go index d1a70e7c15c..c03e4699904 100644 --- a/modules/repository/repo.go +++ b/modules/repository/repo.go @@ -57,7 +57,7 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User, repoPath := repo_model.RepoPath(u.Name, opts.RepoName) if u.IsOrganization() { - t, err := organization.OrgFromUser(u).GetOwnerTeam() + t, err := organization.OrgFromUser(u).GetOwnerTeam(ctx) if err != nil { return nil, err } diff --git a/modules/validation/binding.go b/modules/validation/binding.go index ef0d01e80f1..1f904979ffd 100644 --- a/modules/validation/binding.go +++ b/modules/validation/binding.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" + "code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/git" "gitea.com/go-chi/binding" @@ -17,15 +18,14 @@ import ( const ( // ErrGitRefName is git reference name error ErrGitRefName = "GitRefNameError" - // ErrGlobPattern is returned when glob pattern is invalid ErrGlobPattern = "GlobPattern" - // ErrRegexPattern is returned when a regex pattern is invalid ErrRegexPattern = "RegexPattern" - // ErrUsername is username error ErrUsername = "UsernameError" + // ErrInvalidGroupTeamMap is returned when a group team mapping is invalid + ErrInvalidGroupTeamMap = "InvalidGroupTeamMap" ) // AddBindingRules adds additional binding rules @@ -37,6 +37,7 @@ func AddBindingRules() { addRegexPatternRule() addGlobOrRegexPatternRule() addUsernamePatternRule() + addValidGroupTeamMapRule() } func addGitRefNameBindingRule() { @@ -167,6 +168,23 @@ func addUsernamePatternRule() { }) } +func addValidGroupTeamMapRule() { + binding.AddRule(&binding.Rule{ + IsMatch: func(rule string) bool { + return strings.HasPrefix(rule, "ValidGroupTeamMap") + }, + IsValid: func(errs binding.Errors, name string, val interface{}) (bool, binding.Errors) { + _, err := auth.UnmarshalGroupTeamMapping(fmt.Sprintf("%v", val)) + if err != nil { + errs.Add([]string{name}, ErrInvalidGroupTeamMap, err.Error()) + return false, errs + } + + return true, errs + }, + }) +} + func portOnly(hostport string) string { colon := strings.IndexByte(hostport, ':') if colon == -1 { diff --git a/modules/web/middleware/binding.go b/modules/web/middleware/binding.go index 733f00a1d56..8b74a864d95 100644 --- a/modules/web/middleware/binding.go +++ b/modules/web/middleware/binding.go @@ -136,6 +136,8 @@ func Validate(errs binding.Errors, data map[string]interface{}, f Form, l transl data["ErrorMsg"] = trName + l.Tr("form.regex_pattern_error", errs[0].Message) case validation.ErrUsername: data["ErrorMsg"] = trName + l.Tr("form.username_error") + case validation.ErrInvalidGroupTeamMap: + data["ErrorMsg"] = trName + l.Tr("form.invalid_group_team_map_error", errs[0].Message) default: msg := errs[0].Classification if msg != "" && errs[0].Message != "" { diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index a7506986f69..f784b10c8db 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -477,6 +477,7 @@ include_error = ` must contain substring '%s'.` glob_pattern_error = ` glob pattern is invalid: %s.` regex_pattern_error = ` regex pattern is invalid: %s.` username_error = ` can only contain alphanumeric chars ('0-9','a-z','A-Z'), dash ('-'), underscore ('_') and dot ('.'). It cannot begin or end with non-alphanumeric chars, and consecutive non-alphanumeric chars are also forbidden.` +invalid_group_team_map_error = ` mapping is invalid: %s` unknown_error = Unknown error: captcha_incorrect = The CAPTCHA code is incorrect. password_not_match = The passwords do not match. @@ -2758,6 +2759,8 @@ auths.oauth2_required_claim_value_helper = Set this value to restrict login from auths.oauth2_group_claim_name = Claim name providing group names for this source. (Optional) 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_map_group_to_team = Map claimed groups to Organization teams. (Optional - requires claim name above) +auths.oauth2_map_group_to_team_removal = Remove users from synchronized teams if user does not belong to corresponding group. auths.enable_auto_register = Enable Auto Registration 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 diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 5f57977c290..1d2f8b18e01 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -507,7 +507,7 @@ func orgAssignment(args ...bool) func(ctx *context.APIContext) { var err error if assignOrg { - ctx.Org.Organization, err = organization.GetOrgByName(ctx.Params(":org")) + ctx.Org.Organization, err = organization.GetOrgByName(ctx, ctx.Params(":org")) if err != nil { if organization.IsErrOrgNotExist(err) { redirectUserID, err := user_model.LookupUserRedirect(ctx.Params(":org")) @@ -687,7 +687,7 @@ func Routes(ctx gocontext.Context) *web.Route { } // Get user from session if logged in. - m.Use(context.APIAuth(group)) + m.Use(auth.APIAuth(group)) m.Use(context.ToggleAPI(&context.ToggleOptions{ SignInRequired: setting.Service.RequireSignInView, diff --git a/routers/api/v1/repo/fork.go b/routers/api/v1/repo/fork.go index e4c7eb70414..5b564a8066c 100644 --- a/routers/api/v1/repo/fork.go +++ b/routers/api/v1/repo/fork.go @@ -108,7 +108,7 @@ func CreateFork(ctx *context.APIContext) { if form.Organization == nil { forker = ctx.Doer } else { - org, err := organization.GetOrgByName(*form.Organization) + org, err := organization.GetOrgByName(ctx, *form.Organization) if err != nil { if organization.IsErrOrgNotExist(err) { ctx.Error(http.StatusUnprocessableEntity, "", err) diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index e0cae5f82c7..1426d1dbccb 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -468,7 +468,7 @@ func CreateOrgRepo(ctx *context.APIContext) { // "403": // "$ref": "#/responses/forbidden" opt := web.GetForm(ctx).(*api.CreateRepoOption) - org, err := organization.GetOrgByName(ctx.Params(":org")) + org, err := organization.GetOrgByName(ctx, ctx.Params(":org")) if err != nil { if organization.IsErrOrgNotExist(err) { ctx.Error(http.StatusUnprocessableEntity, "", err) diff --git a/routers/web/admin/auths.go b/routers/web/admin/auths.go index 1bc166902c7..8ce45720fec 100644 --- a/routers/web/admin/auths.go +++ b/routers/web/admin/auths.go @@ -204,6 +204,8 @@ func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source { GroupClaimName: form.Oauth2GroupClaimName, RestrictedGroup: form.Oauth2RestrictedGroup, AdminGroup: form.Oauth2AdminGroup, + GroupTeamMap: form.Oauth2GroupTeamMap, + GroupTeamMapRemoval: form.Oauth2GroupTeamMapRemoval, } } diff --git a/routers/web/auth/linkaccount.go b/routers/web/auth/linkaccount.go index 6c409c6b9df..47a0daa06d9 100644 --- a/routers/web/auth/linkaccount.go +++ b/routers/web/auth/linkaccount.go @@ -16,6 +16,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" auth_service "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/auth/source/oauth2" "code.gitea.io/gitea/services/externalaccount" "code.gitea.io/gitea/services/forms" @@ -267,5 +268,11 @@ func LinkAccountPostRegister(ctx *context.Context) { return } + source := authSource.Cfg.(*oauth2.Source) + if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil { + ctx.ServerError("SyncGroupsToTeams", err) + return + } + handleSignIn(ctx, u, false) } diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index be60a0c73ba..a11417da16c 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -17,7 +17,9 @@ import ( "code.gitea.io/gitea/models/auth" org_model "code.gitea.io/gitea/models/organization" user_model "code.gitea.io/gitea/models/user" + auth_module "code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" @@ -27,6 +29,7 @@ import ( "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/middleware" auth_service "code.gitea.io/gitea/services/auth" + source_service "code.gitea.io/gitea/services/auth/source" "code.gitea.io/gitea/services/auth/source/oauth2" "code.gitea.io/gitea/services/externalaccount" "code.gitea.io/gitea/services/forms" @@ -963,12 +966,19 @@ func SignInOAuthCallback(ctx *context.Context) { IsActive: util.OptionalBoolOf(!setting.OAuth2Client.RegisterEmailConfirm), } - setUserGroupClaims(authSource, u, &gothUser) + source := authSource.Cfg.(*oauth2.Source) + + setUserAdminAndRestrictedFromGroupClaims(source, u, &gothUser) if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, overwriteDefault, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) { // error already handled return } + + if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil { + ctx.ServerError("SyncGroupsToTeams", err) + return + } } else { // no existing user is found, request attach or new account showLinkingLogin(ctx, gothUser) @@ -979,7 +989,7 @@ func SignInOAuthCallback(ctx *context.Context) { handleOAuth2SignIn(ctx, authSource, u, gothUser) } -func claimValueToStringSlice(claimValue interface{}) []string { +func claimValueToStringSet(claimValue interface{}) container.Set[string] { var groups []string switch rawGroup := claimValue.(type) { @@ -993,37 +1003,45 @@ func claimValueToStringSlice(claimValue interface{}) []string { str := fmt.Sprintf("%s", rawGroup) groups = strings.Split(str, ",") } - return groups + return container.SetOf(groups...) } -func setUserGroupClaims(loginSource *auth.Source, u *user_model.User, gothUser *goth.User) bool { - source := loginSource.Cfg.(*oauth2.Source) - if source.GroupClaimName == "" || (source.AdminGroup == "" && source.RestrictedGroup == "") { - return false +func syncGroupsToTeams(ctx *context.Context, source *oauth2.Source, gothUser *goth.User, u *user_model.User) error { + if source.GroupTeamMap != "" || source.GroupTeamMapRemoval { + groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap) + if err != nil { + return err + } + + groups := getClaimedGroups(source, gothUser) + + if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil { + return err + } } + return nil +} + +func getClaimedGroups(source *oauth2.Source, gothUser *goth.User) container.Set[string] { groupClaims, has := gothUser.RawData[source.GroupClaimName] if !has { - return false + return nil } - groups := claimValueToStringSlice(groupClaims) + return claimValueToStringSet(groupClaims) +} + +func setUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, u *user_model.User, gothUser *goth.User) bool { + groups := getClaimedGroups(source, gothUser) wasAdmin, wasRestricted := u.IsAdmin, u.IsRestricted if source.AdminGroup != "" { - u.IsAdmin = false + u.IsAdmin = groups.Contains(source.AdminGroup) } 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 - } + u.IsRestricted = groups.Contains(source.RestrictedGroup) } return wasAdmin != u.IsAdmin || wasRestricted != u.IsRestricted @@ -1070,6 +1088,15 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model needs2FA = err == nil } + oauth2Source := source.Cfg.(*oauth2.Source) + groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(oauth2Source.GroupTeamMap) + if err != nil { + ctx.ServerError("UnmarshalGroupTeamMapping", err) + return + } + + groups := getClaimedGroups(oauth2Source, &gothUser) + // If this user is enrolled in 2FA and this source doesn't override it, // we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page. if !needs2FA { @@ -1088,7 +1115,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model u.SetLastLogin() // Update GroupClaims - changed := setUserGroupClaims(source, u, &gothUser) + changed := setUserAdminAndRestrictedFromGroupClaims(oauth2Source, u, &gothUser) cols := []string{"last_login_unix"} if changed { cols = append(cols, "is_admin", "is_restricted") @@ -1099,6 +1126,13 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model return } + if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval { + if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil { + ctx.ServerError("SyncGroupsToTeams", err) + return + } + } + // update external user information if err := externalaccount.UpdateExternalUser(u, gothUser); err != nil { if !errors.Is(err, util.ErrNotExist) { @@ -1121,7 +1155,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model return } - changed := setUserGroupClaims(source, u, &gothUser) + changed := setUserAdminAndRestrictedFromGroupClaims(oauth2Source, u, &gothUser) if changed { if err := user_model.UpdateUserCols(ctx, u, "is_admin", "is_restricted"); err != nil { ctx.ServerError("UpdateUserCols", err) @@ -1129,6 +1163,13 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model } } + if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval { + if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil { + ctx.ServerError("SyncGroupsToTeams", err) + return + } + } + if err := updateSession(ctx, nil, map[string]interface{}{ // User needs to use 2FA, save data and redirect to 2FA page. "twofaUid": u.ID, @@ -1188,15 +1229,9 @@ func oAuth2UserLoginCallback(authSource *auth.Source, request *http.Request, res } if oauth2Source.RequiredClaimValue != "" { - groups := claimValueToStringSlice(claimInterface) - found := false - for _, group := range groups { - if group == oauth2Source.RequiredClaimValue { - found = true - break - } - } - if !found { + groups := claimValueToStringSet(claimInterface) + + if !groups.Contains(oauth2Source.RequiredClaimValue) { return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} } } diff --git a/routers/web/repo/issue_label.go b/routers/web/repo/issue_label.go index 01421dc9276..66e8920bd99 100644 --- a/routers/web/repo/issue_label.go +++ b/routers/web/repo/issue_label.go @@ -78,7 +78,7 @@ func RetrieveLabels(ctx *context.Context) { } ctx.Data["OrgLabels"] = orgLabels - org, err := organization.GetOrgByName(ctx.Repo.Owner.LowerName) + org, err := organization.GetOrgByName(ctx, ctx.Repo.Owner.LowerName) if err != nil { ctx.ServerError("GetOrgByName", err) return diff --git a/routers/web/repo/setting.go b/routers/web/repo/setting.go index 2cc263e5bbf..5c30795f228 100644 --- a/routers/web/repo/setting.go +++ b/routers/web/repo/setting.go @@ -1006,7 +1006,7 @@ func AddTeamPost(ctx *context.Context) { return } - team, err := organization.OrgFromUser(ctx.Repo.Owner).GetTeam(name) + team, err := organization.OrgFromUser(ctx.Repo.Owner).GetTeam(ctx, name) if err != nil { if organization.IsErrTeamNotExist(err) { ctx.Flash.Error(ctx.Tr("form.team_not_exist")) diff --git a/routers/web/web.go b/routers/web/web.go index 68989560535..88e27ad6789 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -203,7 +203,7 @@ func Routes(ctx gocontext.Context) *web.Route { } // Get user from session if logged in. - common = append(common, context.Auth(group)) + common = append(common, auth_service.Auth(group)) // GetHead allows a HEAD request redirect to GET if HEAD method is not defined for that route common = append(common, middleware.GetHead) diff --git a/services/auth/middleware.go b/services/auth/middleware.go new file mode 100644 index 00000000000..cccaab29981 --- /dev/null +++ b/services/auth/middleware.go @@ -0,0 +1,60 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "net/http" + + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/web/middleware" +) + +// Auth is a middleware to authenticate a web user +func Auth(authMethod Method) func(*context.Context) { + return func(ctx *context.Context) { + if err := authShared(ctx, authMethod); err != nil { + log.Error("Failed to verify user: %v", err) + ctx.Error(http.StatusUnauthorized, "Verify") + return + } + if ctx.Doer == nil { + // ensure the session uid is deleted + _ = ctx.Session.Delete("uid") + } + } +} + +// APIAuth is a middleware to authenticate an api user +func APIAuth(authMethod Method) func(*context.APIContext) { + return func(ctx *context.APIContext) { + if err := authShared(ctx.Context, authMethod); err != nil { + ctx.Error(http.StatusUnauthorized, "APIAuth", err) + } + } +} + +func authShared(ctx *context.Context, authMethod Method) error { + var err error + ctx.Doer, err = authMethod.Verify(ctx.Req, ctx.Resp, ctx, ctx.Session) + if err != nil { + return err + } + if ctx.Doer != nil { + if ctx.Locale.Language() != ctx.Doer.Language { + ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req) + } + ctx.IsBasicAuth = ctx.Data["AuthedMethod"].(string) == BasicMethodName + ctx.IsSigned = true + ctx.Data["IsSigned"] = ctx.IsSigned + ctx.Data["SignedUser"] = ctx.Doer + ctx.Data["SignedUserID"] = ctx.Doer.ID + ctx.Data["SignedUserName"] = ctx.Doer.Name + ctx.Data["IsAdmin"] = ctx.Doer.IsAdmin + } else { + ctx.Data["SignedUserID"] = int64(0) + ctx.Data["SignedUserName"] = "" + } + return nil +} diff --git a/services/auth/source/ldap/source_authenticate.go b/services/auth/source/ldap/source_authenticate.go index 321cf5540d9..fba8da79345 100644 --- a/services/auth/source/ldap/source_authenticate.go +++ b/services/auth/source/ldap/source_authenticate.go @@ -10,9 +10,10 @@ import ( asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/models/organization" user_model "code.gitea.io/gitea/models/user" + auth_module "code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/util" + source_service "code.gitea.io/gitea/services/auth/source" "code.gitea.io/gitea/services/mailer" user_service "code.gitea.io/gitea/services/user" ) @@ -64,61 +65,66 @@ func (source *Source) Authenticate(user *user_model.User, userName, password str } if user != nil { - if source.GroupsEnabled && (source.GroupTeamMap != "" || source.GroupTeamMapRemoval) { - orgCache := make(map[string]*organization.Organization) - teamCache := make(map[string]*organization.Team) - source.SyncLdapGroupsToTeams(user, sr.LdapTeamAdd, sr.LdapTeamRemove, orgCache, teamCache) - } if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(user, source.authSource, sr.SSHPublicKey) { - return user, asymkey_model.RewriteAllPublicKeys() + if err := asymkey_model.RewriteAllPublicKeys(); err != nil { + return user, err + } + } + } else { + // Fallback. + if len(sr.Username) == 0 { + sr.Username = userName } - return user, nil - } - - // Fallback. - if len(sr.Username) == 0 { - sr.Username = userName - } - if len(sr.Mail) == 0 { - sr.Mail = fmt.Sprintf("%s@localhost", sr.Username) - } + if len(sr.Mail) == 0 { + sr.Mail = fmt.Sprintf("%s@localhost", sr.Username) + } - user = &user_model.User{ - LowerName: strings.ToLower(sr.Username), - Name: sr.Username, - FullName: composeFullName(sr.Name, sr.Surname, sr.Username), - Email: sr.Mail, - LoginType: source.authSource.Type, - LoginSource: source.authSource.ID, - LoginName: userName, - IsAdmin: sr.IsAdmin, - } - overwriteDefault := &user_model.CreateUserOverwriteOptions{ - IsRestricted: util.OptionalBoolOf(sr.IsRestricted), - IsActive: util.OptionalBoolTrue, - } + user = &user_model.User{ + LowerName: strings.ToLower(sr.Username), + Name: sr.Username, + FullName: composeFullName(sr.Name, sr.Surname, sr.Username), + Email: sr.Mail, + LoginType: source.authSource.Type, + LoginSource: source.authSource.ID, + LoginName: userName, + IsAdmin: sr.IsAdmin, + } + overwriteDefault := &user_model.CreateUserOverwriteOptions{ + IsRestricted: util.OptionalBoolOf(sr.IsRestricted), + IsActive: util.OptionalBoolTrue, + } - err := user_model.CreateUser(user, overwriteDefault) - if err != nil { - return user, err - } + err := user_model.CreateUser(user, overwriteDefault) + if err != nil { + return user, err + } - mailer.SendRegisterNotifyMail(user) + mailer.SendRegisterNotifyMail(user) - if isAttributeSSHPublicKeySet && asymkey_model.AddPublicKeysBySource(user, source.authSource, sr.SSHPublicKey) { - err = asymkey_model.RewriteAllPublicKeys() - } - if err == nil && len(source.AttributeAvatar) > 0 { - _ = user_service.UploadAvatar(user, sr.Avatar) + if isAttributeSSHPublicKeySet && asymkey_model.AddPublicKeysBySource(user, source.authSource, sr.SSHPublicKey) { + if err := asymkey_model.RewriteAllPublicKeys(); err != nil { + return user, err + } + } + if len(source.AttributeAvatar) > 0 { + if err := user_service.UploadAvatar(user, sr.Avatar); err != nil { + return user, err + } + } } + if source.GroupsEnabled && (source.GroupTeamMap != "" || source.GroupTeamMapRemoval) { - orgCache := make(map[string]*organization.Organization) - teamCache := make(map[string]*organization.Team) - source.SyncLdapGroupsToTeams(user, sr.LdapTeamAdd, sr.LdapTeamRemove, orgCache, teamCache) + groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap) + if err != nil { + return user, err + } + if err := source_service.SyncGroupsToTeams(db.DefaultContext, user, sr.Groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil { + return user, err + } } - return user, err + return user, nil } // IsSkipLocalTwoFA returns if this source should skip local 2fa for password authentication diff --git a/services/auth/source/ldap/source_group_sync.go b/services/auth/source/ldap/source_group_sync.go deleted file mode 100644 index 95a60849228..00000000000 --- a/services/auth/source/ldap/source_group_sync.go +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package ldap - -import ( - "code.gitea.io/gitea/models" - "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/models/organization" - user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/log" -) - -// SyncLdapGroupsToTeams maps LDAP groups to organization and team memberships -func (source *Source) SyncLdapGroupsToTeams(user *user_model.User, ldapTeamAdd, ldapTeamRemove map[string][]string, orgCache map[string]*organization.Organization, teamCache map[string]*organization.Team) { - var err error - if source.GroupsEnabled && source.GroupTeamMapRemoval { - // when the user is not a member of configs LDAP group, remove mapped organizations/teams memberships - removeMappedMemberships(user, ldapTeamRemove, orgCache, teamCache) - } - for orgName, teamNames := range ldapTeamAdd { - org, ok := orgCache[orgName] - if !ok { - org, err = organization.GetOrgByName(orgName) - if err != nil { - // organization must be created before LDAP group sync - log.Warn("LDAP group sync: Could not find organisation %s: %v", orgName, err) - continue - } - orgCache[orgName] = org - } - - for _, teamName := range teamNames { - team, ok := teamCache[orgName+teamName] - if !ok { - team, err = org.GetTeam(teamName) - if err != nil { - // team must be created before LDAP group sync - log.Warn("LDAP group sync: Could not find team %s: %v", teamName, err) - continue - } - teamCache[orgName+teamName] = team - } - if isMember, err := organization.IsTeamMember(db.DefaultContext, org.ID, team.ID, user.ID); !isMember && err == nil { - log.Trace("LDAP group sync: adding user [%s] to team [%s]", user.Name, org.Name) - } else { - continue - } - err := models.AddTeamMember(team, user.ID) - if err != nil { - log.Error("LDAP group sync: Could not add user to team: %v", err) - } - } - } -} - -// remove membership to organizations/teams if user is not member of corresponding LDAP group -// e.g. lets assume user is member of LDAP group "x", but LDAP group team map contains LDAP groups "x" and "y" -// then users membership gets removed for all organizations/teams mapped by LDAP group "y" -func removeMappedMemberships(user *user_model.User, ldapTeamRemove map[string][]string, orgCache map[string]*organization.Organization, teamCache map[string]*organization.Team) { - var err error - for orgName, teamNames := range ldapTeamRemove { - org, ok := orgCache[orgName] - if !ok { - org, err = organization.GetOrgByName(orgName) - if err != nil { - // organization must be created before LDAP group sync - log.Warn("LDAP group sync: Could not find organisation %s: %v", orgName, err) - continue - } - orgCache[orgName] = org - } - for _, teamName := range teamNames { - team, ok := teamCache[orgName+teamName] - if !ok { - team, err = org.GetTeam(teamName) - if err != nil { - // team must must be created before LDAP group sync - log.Warn("LDAP group sync: Could not find team %s: %v", teamName, err) - continue - } - } - if isMember, err := organization.IsTeamMember(db.DefaultContext, org.ID, team.ID, user.ID); isMember && err == nil { - log.Trace("LDAP group sync: removing user [%s] from team [%s]", user.Name, org.Name) - } else { - continue - } - err = models.RemoveTeamMember(team, user.ID) - if err != nil { - log.Error("LDAP group sync: Could not remove user from team: %v", err) - } - } - } -} diff --git a/services/auth/source/ldap/source_search.go b/services/auth/source/ldap/source_search.go index 16f13029f92..5a2d25b0c4c 100644 --- a/services/auth/source/ldap/source_search.go +++ b/services/auth/source/ldap/source_search.go @@ -11,26 +11,24 @@ import ( "strconv" "strings" - "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/util" "github.com/go-ldap/ldap/v3" ) // SearchResult : user data type SearchResult struct { - Username string // Username - Name string // Name - Surname string // Surname - Mail string // E-mail address - SSHPublicKey []string // SSH Public Key - IsAdmin bool // if user is administrator - IsRestricted bool // if user is restricted - LowerName string // LowerName - Avatar []byte - LdapTeamAdd map[string][]string // organizations teams to add - LdapTeamRemove map[string][]string // organizations teams to remove + Username string // Username + Name string // Name + Surname string // Surname + Mail string // E-mail address + SSHPublicKey []string // SSH Public Key + IsAdmin bool // if user is administrator + IsRestricted bool // if user is restricted + LowerName string // LowerName + Avatar []byte + Groups container.Set[string] } func (source *Source) sanitizedUserQuery(username string) (string, bool) { @@ -196,9 +194,8 @@ func checkRestricted(l *ldap.Conn, ls *Source, userDN string) bool { } // List all group memberships of a user -func (source *Source) listLdapGroupMemberships(l *ldap.Conn, uid string, applyGroupFilter bool) []string { - var ldapGroups []string - var searchFilter string +func (source *Source) listLdapGroupMemberships(l *ldap.Conn, uid string, applyGroupFilter bool) container.Set[string] { + ldapGroups := make(container.Set[string]) groupFilter, ok := source.sanitizedGroupFilter(source.GroupFilter) if !ok { @@ -210,12 +207,12 @@ func (source *Source) listLdapGroupMemberships(l *ldap.Conn, uid string, applyGr return ldapGroups } + var searchFilter string if applyGroupFilter { searchFilter = fmt.Sprintf("(&(%s)(%s=%s))", groupFilter, source.GroupMemberUID, ldap.EscapeFilter(uid)) } else { searchFilter = fmt.Sprintf("(%s=%s)", source.GroupMemberUID, ldap.EscapeFilter(uid)) } - result, err := l.Search(ldap.NewSearchRequest( groupDN, ldap.ScopeWholeSubtree, @@ -237,44 +234,12 @@ func (source *Source) listLdapGroupMemberships(l *ldap.Conn, uid string, applyGr log.Error("LDAP search was successful, but found no DN!") continue } - ldapGroups = append(ldapGroups, entry.DN) + ldapGroups.Add(entry.DN) } return ldapGroups } -// parse LDAP groups and return map of ldap groups to organizations teams -func (source *Source) mapLdapGroupsToTeams() map[string]map[string][]string { - ldapGroupsToTeams := make(map[string]map[string][]string) - err := json.Unmarshal([]byte(source.GroupTeamMap), &ldapGroupsToTeams) - if err != nil { - log.Error("Failed to unmarshall LDAP teams map: %v", err) - return ldapGroupsToTeams - } - return ldapGroupsToTeams -} - -// getMappedMemberships : returns the organizations and teams to modify the users membership -func (source *Source) getMappedMemberships(usersLdapGroups []string, uid string) (map[string][]string, map[string][]string) { - // unmarshall LDAP group team map from configs - ldapGroupsToTeams := source.mapLdapGroupsToTeams() - membershipsToAdd := map[string][]string{} - membershipsToRemove := map[string][]string{} - for group, memberships := range ldapGroupsToTeams { - isUserInGroup := util.SliceContainsString(usersLdapGroups, group) - if isUserInGroup { - for org, teams := range memberships { - membershipsToAdd[org] = teams - } - } else if !isUserInGroup { - for org, teams := range memberships { - membershipsToRemove[org] = teams - } - } - } - return membershipsToAdd, membershipsToRemove -} - func (source *Source) getUserAttributeListedInGroup(entry *ldap.Entry) string { if strings.ToLower(source.UserUID) == "dn" { return entry.DN @@ -399,23 +364,6 @@ func (source *Source) SearchEntry(name, passwd string, directBind bool) *SearchR surname := sr.Entries[0].GetAttributeValue(source.AttributeSurname) mail := sr.Entries[0].GetAttributeValue(source.AttributeMail) - teamsToAdd := make(map[string][]string) - teamsToRemove := make(map[string][]string) - - // Check group membership - if source.GroupsEnabled { - userAttributeListedInGroup := source.getUserAttributeListedInGroup(sr.Entries[0]) - usersLdapGroups := source.listLdapGroupMemberships(l, userAttributeListedInGroup, true) - - if source.GroupFilter != "" && len(usersLdapGroups) == 0 { - return nil - } - - if source.GroupTeamMap != "" || source.GroupTeamMapRemoval { - teamsToAdd, teamsToRemove = source.getMappedMemberships(usersLdapGroups, userAttributeListedInGroup) - } - } - if isAttributeSSHPublicKeySet { sshPublicKey = sr.Entries[0].GetAttributeValues(source.AttributeSSHPublicKey) } @@ -431,6 +379,17 @@ func (source *Source) SearchEntry(name, passwd string, directBind bool) *SearchR Avatar = sr.Entries[0].GetRawAttributeValue(source.AttributeAvatar) } + // Check group membership + var usersLdapGroups container.Set[string] + if source.GroupsEnabled { + userAttributeListedInGroup := source.getUserAttributeListedInGroup(sr.Entries[0]) + usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, true) + + if source.GroupFilter != "" && len(usersLdapGroups) == 0 { + return nil + } + } + if !directBind && source.AttributesInBind { // binds user (checking password) after looking-up attributes in BindDN context err = bindUser(l, userDN, passwd) @@ -440,17 +399,16 @@ func (source *Source) SearchEntry(name, passwd string, directBind bool) *SearchR } return &SearchResult{ - LowerName: strings.ToLower(username), - Username: username, - Name: firstname, - Surname: surname, - Mail: mail, - SSHPublicKey: sshPublicKey, - IsAdmin: isAdmin, - IsRestricted: isRestricted, - Avatar: Avatar, - LdapTeamAdd: teamsToAdd, - LdapTeamRemove: teamsToRemove, + LowerName: strings.ToLower(username), + Username: username, + Name: firstname, + Surname: surname, + Mail: mail, + SSHPublicKey: sshPublicKey, + IsAdmin: isAdmin, + IsRestricted: isRestricted, + Avatar: Avatar, + Groups: usersLdapGroups, } } @@ -512,33 +470,29 @@ func (source *Source) SearchEntries() ([]*SearchResult, error) { result := make([]*SearchResult, 0, len(sr.Entries)) for _, v := range sr.Entries { - teamsToAdd := make(map[string][]string) - teamsToRemove := make(map[string][]string) - + var usersLdapGroups container.Set[string] if source.GroupsEnabled { userAttributeListedInGroup := source.getUserAttributeListedInGroup(v) if source.GroupFilter != "" { - usersLdapGroups := source.listLdapGroupMemberships(l, userAttributeListedInGroup, true) + usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, true) if len(usersLdapGroups) == 0 { continue } } if source.GroupTeamMap != "" || source.GroupTeamMapRemoval { - usersLdapGroups := source.listLdapGroupMemberships(l, userAttributeListedInGroup, false) - teamsToAdd, teamsToRemove = source.getMappedMemberships(usersLdapGroups, userAttributeListedInGroup) + usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, false) } } user := &SearchResult{ - Username: v.GetAttributeValue(source.AttributeUsername), - Name: v.GetAttributeValue(source.AttributeName), - Surname: v.GetAttributeValue(source.AttributeSurname), - Mail: v.GetAttributeValue(source.AttributeMail), - IsAdmin: checkAdmin(l, source, v.DN), - LdapTeamAdd: teamsToAdd, - LdapTeamRemove: teamsToRemove, + Username: v.GetAttributeValue(source.AttributeUsername), + Name: v.GetAttributeValue(source.AttributeName), + Surname: v.GetAttributeValue(source.AttributeSurname), + Mail: v.GetAttributeValue(source.AttributeMail), + IsAdmin: checkAdmin(l, source, v.DN), + Groups: usersLdapGroups, } if !user.IsAdmin { diff --git a/services/auth/source/ldap/source_sync.go b/services/auth/source/ldap/source_sync.go index 73e8309acaa..4571ff6540c 100644 --- a/services/auth/source/ldap/source_sync.go +++ b/services/auth/source/ldap/source_sync.go @@ -13,8 +13,10 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" user_model "code.gitea.io/gitea/models/user" + auth_module "code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" + source_service "code.gitea.io/gitea/services/auth/source" user_service "code.gitea.io/gitea/services/user" ) @@ -65,6 +67,11 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error { orgCache := make(map[string]*organization.Organization) teamCache := make(map[string]*organization.Team) + groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap) + if err != nil { + return err + } + for _, su := range sr { select { case <-ctx.Done(): @@ -173,7 +180,9 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error { } // Synchronize LDAP groups with organization and team memberships if source.GroupsEnabled && (source.GroupTeamMap != "" || source.GroupTeamMapRemoval) { - source.SyncLdapGroupsToTeams(usr, su.LdapTeamAdd, su.LdapTeamRemove, orgCache, teamCache) + if err := source_service.SyncGroupsToTeamsCached(ctx, usr, su.Groups, groupTeamMapping, source.GroupTeamMapRemoval, orgCache, teamCache); err != nil { + log.Error("SyncGroupsToTeamsCached: %v", err) + } } } diff --git a/services/auth/source/oauth2/source.go b/services/auth/source/oauth2/source.go index 0abebc04ecf..675005e55ab 100644 --- a/services/auth/source/oauth2/source.go +++ b/services/auth/source/oauth2/source.go @@ -8,13 +8,6 @@ import ( "code.gitea.io/gitea/modules/json" ) -// ________ _____ __ .__ ________ -// \_____ \ / _ \ __ ___/ |_| |__ \_____ \ -// / | \ / /_\ \| | \ __\ | \ / ____/ -// / | \/ | \ | /| | | Y \/ \ -// \_______ /\____|__ /____/ |__| |___| /\_______ \ -// \/ \/ \/ \/ - // Source holds configuration for the OAuth2 login source. type Source struct { Provider string @@ -24,13 +17,15 @@ type Source struct { CustomURLMapping *CustomURLMapping IconURL string - Scopes []string - RequiredClaimName string - RequiredClaimValue string - GroupClaimName string - AdminGroup string - RestrictedGroup string - SkipLocalTwoFA bool `json:",omitempty"` + Scopes []string + RequiredClaimName string + RequiredClaimValue string + GroupClaimName string + AdminGroup string + GroupTeamMap string + GroupTeamMapRemoval bool + RestrictedGroup string + SkipLocalTwoFA bool `json:",omitempty"` // reference to the authSource authSource *auth.Source diff --git a/services/auth/source/source_group_sync.go b/services/auth/source/source_group_sync.go new file mode 100644 index 00000000000..20b60953453 --- /dev/null +++ b/services/auth/source/source_group_sync.go @@ -0,0 +1,116 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package source + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/organization" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/log" +) + +type syncType int + +const ( + syncAdd syncType = iota + syncRemove +) + +// SyncGroupsToTeams maps authentication source groups to organization and team memberships +func SyncGroupsToTeams(ctx context.Context, user *user_model.User, sourceUserGroups container.Set[string], sourceGroupTeamMapping map[string]map[string][]string, performRemoval bool) error { + orgCache := make(map[string]*organization.Organization) + teamCache := make(map[string]*organization.Team) + return SyncGroupsToTeamsCached(ctx, user, sourceUserGroups, sourceGroupTeamMapping, performRemoval, orgCache, teamCache) +} + +// SyncGroupsToTeamsCached maps authentication source groups to organization and team memberships +func SyncGroupsToTeamsCached(ctx context.Context, user *user_model.User, sourceUserGroups container.Set[string], sourceGroupTeamMapping map[string]map[string][]string, performRemoval bool, orgCache map[string]*organization.Organization, teamCache map[string]*organization.Team) error { + membershipsToAdd, membershipsToRemove := resolveMappedMemberships(sourceUserGroups, sourceGroupTeamMapping) + + if performRemoval { + if err := syncGroupsToTeamsCached(ctx, user, membershipsToRemove, syncRemove, orgCache, teamCache); err != nil { + return fmt.Errorf("could not sync[remove] user groups: %w", err) + } + } + + if err := syncGroupsToTeamsCached(ctx, user, membershipsToAdd, syncAdd, orgCache, teamCache); err != nil { + return fmt.Errorf("could not sync[add] user groups: %w", err) + } + + return nil +} + +func resolveMappedMemberships(sourceUserGroups container.Set[string], sourceGroupTeamMapping map[string]map[string][]string) (map[string][]string, map[string][]string) { + membershipsToAdd := map[string][]string{} + membershipsToRemove := map[string][]string{} + for group, memberships := range sourceGroupTeamMapping { + isUserInGroup := sourceUserGroups.Contains(group) + if isUserInGroup { + for org, teams := range memberships { + membershipsToAdd[org] = teams + } + } else { + for org, teams := range memberships { + membershipsToRemove[org] = teams + } + } + } + return membershipsToAdd, membershipsToRemove +} + +func syncGroupsToTeamsCached(ctx context.Context, user *user_model.User, orgTeamMap map[string][]string, action syncType, orgCache map[string]*organization.Organization, teamCache map[string]*organization.Team) error { + for orgName, teamNames := range orgTeamMap { + var err error + org, ok := orgCache[orgName] + if !ok { + org, err = organization.GetOrgByName(ctx, orgName) + if err != nil { + if organization.IsErrOrgNotExist(err) { + // organization must be created before group sync + log.Warn("group sync: Could not find organisation %s: %v", orgName, err) + continue + } + return err + } + orgCache[orgName] = org + } + for _, teamName := range teamNames { + team, ok := teamCache[orgName+teamName] + if !ok { + team, err = org.GetTeam(ctx, teamName) + if err != nil { + if organization.IsErrTeamNotExist(err) { + // team must be created before group sync + log.Warn("group sync: Could not find team %s: %v", teamName, err) + continue + } + return err + } + teamCache[orgName+teamName] = team + } + + isMember, err := organization.IsTeamMember(ctx, org.ID, team.ID, user.ID) + if err != nil { + return err + } + + if action == syncAdd && !isMember { + if err := models.AddTeamMember(team, user.ID); err != nil { + log.Error("group sync: Could not add user to team: %v", err) + return err + } + } else if action == syncRemove && isMember { + if err := models.RemoveTeamMember(team, user.ID); err != nil { + log.Error("group sync: Could not remove user from team: %v", err) + return err + } + } + } + } + return nil +} diff --git a/services/forms/auth_form.go b/services/forms/auth_form.go index 0cede07f951..5625aa1e2ed 100644 --- a/services/forms/auth_form.go +++ b/services/forms/auth_form.go @@ -72,13 +72,15 @@ type AuthenticationForm struct { Oauth2GroupClaimName string Oauth2AdminGroup string Oauth2RestrictedGroup string + Oauth2GroupTeamMap string `binding:"ValidGroupTeamMap"` + Oauth2GroupTeamMapRemoval bool SkipLocalTwoFA bool SSPIAutoCreateUsers bool SSPIAutoActivateUsers bool SSPIStripDomainNames bool SSPISeparatorReplacement string `binding:"AlphaDashDot;MaxSize(5)"` SSPIDefaultLanguage string - GroupTeamMap string + GroupTeamMap string `binding:"ValidGroupTeamMap"` GroupTeamMapRemoval bool } diff --git a/templates/admin/auth/edit.tmpl b/templates/admin/auth/edit.tmpl index 91e4b1df525..a3c94a6cc21 100644 --- a/templates/admin/auth/edit.tmpl +++ b/templates/admin/auth/edit.tmpl @@ -361,6 +361,14 @@ +
+ + +
+
+ + +
{{end}} diff --git a/templates/admin/auth/source/ldap.tmpl b/templates/admin/auth/source/ldap.tmpl index b44eb799b9e..8d199854ae4 100644 --- a/templates/admin/auth/source/ldap.tmpl +++ b/templates/admin/auth/source/ldap.tmpl @@ -52,7 +52,7 @@
- +

{{.locale.Tr "admin.auths.restricted_filter_helper"}}

diff --git a/templates/admin/auth/source/oauth.tmpl b/templates/admin/auth/source/oauth.tmpl index 166373a3248..85c77343a52 100644 --- a/templates/admin/auth/source/oauth.tmpl +++ b/templates/admin/auth/source/oauth.tmpl @@ -98,4 +98,12 @@
+
+ + +
+
+ + +
diff --git a/tests/integration/auth_ldap_test.go b/tests/integration/auth_ldap_test.go index 9dfa4c73d83..883c9d80a3d 100644 --- a/tests/integration/auth_ldap_test.go +++ b/tests/integration/auth_ldap_test.go @@ -112,23 +112,14 @@ func getLDAPServerPort() string { return port } -func addAuthSourceLDAP(t *testing.T, sshKeyAttribute, groupFilter string, groupMapParams ...string) { - groupTeamMapRemoval := "off" - groupTeamMap := "" - if len(groupMapParams) == 2 { - groupTeamMapRemoval = groupMapParams[0] - groupTeamMap = groupMapParams[1] - } - +func buildAuthSourceLDAPPayload(csrf, sshKeyAttribute, groupFilter, groupTeamMap, groupTeamMapRemoval string) map[string]string { // Modify user filter to test group filter explicitly userFilter := "(&(objectClass=inetOrgPerson)(memberOf=cn=git,ou=people,dc=planetexpress,dc=com)(uid=%s))" if groupFilter != "" { userFilter = "(&(objectClass=inetOrgPerson)(uid=%s))" } - session := loginUser(t, "user1") - csrf := GetCSRF(t, session, "/admin/auths/new") - req := NewRequestWithValues(t, "POST", "/admin/auths/new", map[string]string{ + return map[string]string{ "_csrf": csrf, "type": "2", "name": "ldap", @@ -154,7 +145,19 @@ func addAuthSourceLDAP(t *testing.T, sshKeyAttribute, groupFilter string, groupM "group_team_map": groupTeamMap, "group_team_map_removal": groupTeamMapRemoval, "user_uid": "DN", - }) + } +} + +func addAuthSourceLDAP(t *testing.T, sshKeyAttribute, groupFilter string, groupMapParams ...string) { + groupTeamMapRemoval := "off" + groupTeamMap := "" + if len(groupMapParams) == 2 { + groupTeamMapRemoval = groupMapParams[0] + groupTeamMap = groupMapParams[1] + } + session := loginUser(t, "user1") + csrf := GetCSRF(t, session, "/admin/auths/new") + req := NewRequestWithValues(t, "POST", "/admin/auths/new", buildAuthSourceLDAPPayload(csrf, sshKeyAttribute, groupFilter, groupTeamMap, groupTeamMapRemoval)) session.MakeRequest(t, req, http.StatusSeeOther) } @@ -202,26 +205,7 @@ func TestLDAPAuthChange(t *testing.T) { binddn, _ := doc.Find(`input[name="bind_dn"]`).Attr("value") assert.Equal(t, binddn, "uid=gitea,ou=service,dc=planetexpress,dc=com") - req = NewRequestWithValues(t, "POST", href, map[string]string{ - "_csrf": csrf, - "type": "2", - "name": "ldap", - "host": getLDAPServerHost(), - "port": "389", - "bind_dn": "uid=gitea,ou=service,dc=planetexpress,dc=com", - "bind_password": "password", - "user_base": "ou=people,dc=planetexpress,dc=com", - "filter": "(&(objectClass=inetOrgPerson)(memberOf=cn=git,ou=people,dc=planetexpress,dc=com)(uid=%s))", - "admin_filter": "(memberOf=cn=admin_staff,ou=people,dc=planetexpress,dc=com)", - "restricted_filter": "(uid=leela)", - "attribute_username": "uid", - "attribute_name": "givenName", - "attribute_surname": "sn", - "attribute_mail": "mail", - "attribute_ssh_public_key": "", - "is_sync_enabled": "on", - "is_active": "on", - }) + req = NewRequestWithValues(t, "POST", href, buildAuthSourceLDAPPayload(csrf, "", "", "", "off")) session.MakeRequest(t, req, http.StatusSeeOther) req = NewRequest(t, "GET", href) @@ -395,7 +379,7 @@ func TestLDAPGroupTeamSyncAddMember(t *testing.T) { } defer tests.PrepareTestEnv(t)() addAuthSourceLDAP(t, "", "", "on", `{"cn=ship_crew,ou=people,dc=planetexpress,dc=com":{"org26": ["team11"]},"cn=admin_staff,ou=people,dc=planetexpress,dc=com": {"non-existent": ["non-existent"]}}`) - org, err := organization.GetOrgByName("org26") + org, err := organization.GetOrgByName(db.DefaultContext, "org26") assert.NoError(t, err) team, err := organization.GetTeam(db.DefaultContext, org.ID, "team11") assert.NoError(t, err) @@ -440,7 +424,7 @@ func TestLDAPGroupTeamSyncRemoveMember(t *testing.T) { } defer tests.PrepareTestEnv(t)() addAuthSourceLDAP(t, "", "", "on", `{"cn=dispatch,ou=people,dc=planetexpress,dc=com": {"org26": ["team11"]}}`) - org, err := organization.GetOrgByName("org26") + org, err := organization.GetOrgByName(db.DefaultContext, "org26") assert.NoError(t, err) team, err := organization.GetTeam(db.DefaultContext, org.ID, "team11") assert.NoError(t, err) @@ -468,24 +452,15 @@ func TestLDAPGroupTeamSyncRemoveMember(t *testing.T) { assert.False(t, isMember, "User membership should have been removed from team") } -// Login should work even if Team Group Map contains a broken JSON -func TestBrokenLDAPMapUserSignin(t *testing.T) { +func TestLDAPPreventInvalidGroupTeamMap(t *testing.T) { if skipLDAPTests() { t.Skip() return } defer tests.PrepareTestEnv(t)() - addAuthSourceLDAP(t, "", "", "on", `{"NOT_A_VALID_JSON"["MISSING_DOUBLE_POINT"]}`) - u := gitLDAPUsers[0] - - session := loginUserWithPassword(t, u.UserName, u.Password) - req := NewRequest(t, "GET", "/user/settings") - resp := session.MakeRequest(t, req, http.StatusOK) - - htmlDoc := NewHTMLParser(t, resp.Body) - - assert.Equal(t, u.UserName, htmlDoc.GetInputValueByName("name")) - assert.Equal(t, u.FullName, htmlDoc.GetInputValueByName("full_name")) - assert.Equal(t, u.Email, htmlDoc.Find(`label[for="email"]`).Siblings().First().Text()) + session := loginUser(t, "user1") + csrf := GetCSRF(t, session, "/admin/auths/new") + req := NewRequestWithValues(t, "POST", "/admin/auths/new", buildAuthSourceLDAPPayload(csrf, "", "", `{"NOT_A_VALID_JSON"["MISSING_DOUBLE_POINT"]}`, "off")) + session.MakeRequest(t, req, http.StatusOK) // StatusOK = failed, StatusSeeOther = ok }