Compare commits

...

5 Commits

Author SHA1 Message Date
Brecht Van Lommel
87261f3fb9
Fix blame view missing lines (#22826)
Creating a new buffered reader for every part of the blame can miss
lines, as it will read and buffer bytes that the next buffered reader
will not get.

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2023-02-09 11:51:02 +08:00
John Olheiser
4dd7d61ac8
Load issue before accessing index in merge message (#22822)
Fixes #22821

Signed-off-by: jolheiser <john.olheiser@gmail.com>
2023-02-09 10:47:52 +08:00
Michal
5ae07d4c2f
include build info in Prometheus metrics (#22819)
Related to: https://github.com/go-gitea/gitea/issues/18061

This PR adds build info to the Prometheus metrics. This includes:
- goarch: https://pkg.go.dev/runtime#GOARCH
- goos: https://pkg.go.dev/runtime#pkg-constants
- goversion: https://pkg.go.dev/runtime#Version
- gitea version: just exposes the existing
code.gitea.io/gitea/modules/setting.AppVer

It's a similar approach to what some other Golang projects are doing,
e.g. Prometheus:
https://github.com/prometheus/common/blob/main/version/info.go

example /metrics response from Prometheus:
```
# HELP prometheus_build_info A metric with a constant '1' value labeled by version, revision, branch, goversion from which prometheus was built, and the goos and goarch for the build.
# TYPE prometheus_build_info gauge
prometheus_build_info{branch="HEAD",goarch="amd64",goos="linux",goversion="go1.19.4",revision="c0d8a56c69014279464c0e15d8bfb0e153af0dab",version="2.41.0"} 1
```

/metrics response from gitea with this PR:
```
# HELP gitea_build_info Build information
# TYPE gitea_build_info gauge
gitea_build_info{goarch="amd64",goos="linux",goversion="go1.20",version="2c6cc0b8c"} 1
```

Signed-off-by: Michal Wasilewski <mwasilewski@gmx.com>

<!--

Please check the following:

1. Make sure you are targeting the `main` branch, pull requests on
release branches are only allowed for bug fixes.
2. Read contributing guidelines:
https://github.com/go-gitea/gitea/blob/main/CONTRIBUTING.md
3. Describe what your pull request does and which issue you're targeting
(if any)

-->

Signed-off-by: Michal Wasilewski <mwasilewski@gmx.com>
2023-02-08 19:54:01 +02:00
Jason Song
7d3c4c3e8a
Fix rerun button of Actions (#22798)
When clicking the return button, the page should be refreshed. However,
the browser may cancel the previous fetch request, and it fails to rerun
the job. It's easier to reproduce the bug in Safari or Firefox than
Chrome for some reason.

<img width="384" alt="image"
src="https://user-images.githubusercontent.com/9418365/217142792-a783f9a1-7089-44db-b7d8-46c46c72d284.png">


<img width="752" alt="image"
src="https://user-images.githubusercontent.com/9418365/217132406-b8381b63-b323-474e-935b-2596b1b5c046.png">
2023-02-08 15:55:57 +08:00
KN4CK3R
e8186f1c0f
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 <xiaolunwen@gmail.com>
2023-02-08 14:44:42 +08:00
39 changed files with 558 additions and 448 deletions

View File

@ -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{}

View File

@ -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`:

View File

@ -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) {
// GetTeam returns named team of organization.
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)
}
// 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 {

View File

@ -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))
}

22
modules/auth/common.go Normal file
View File

@ -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
}

View File

@ -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 {

View File

@ -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)

View File

@ -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)

View File

@ -23,6 +23,7 @@ type BlameReader struct {
cmd *Command
output io.WriteCloser
reader io.ReadCloser
bufferedReader *bufio.Reader
done chan error
lastSha *string
}
@ -33,8 +34,6 @@ var shaLineRegex = regexp.MustCompile("^([a-z0-9]{40})")
func (r *BlameReader) NextPart() (*BlamePart, error) {
var blamePart *BlamePart
reader := bufio.NewReader(r.reader)
if r.lastSha != nil {
blamePart = &BlamePart{*r.lastSha, make([]string, 0)}
}
@ -44,7 +43,7 @@ func (r *BlameReader) NextPart() (*BlamePart, error) {
var err error
for err != io.EOF {
line, isPrefix, err = reader.ReadLine()
line, isPrefix, err = r.bufferedReader.ReadLine()
if err != nil && err != io.EOF {
return blamePart, err
}
@ -66,7 +65,7 @@ func (r *BlameReader) NextPart() (*BlamePart, error) {
r.lastSha = &sha1
// need to munch to end of line...
for isPrefix {
_, isPrefix, err = reader.ReadLine()
_, isPrefix, err = r.bufferedReader.ReadLine()
if err != nil && err != io.EOF {
return blamePart, err
}
@ -81,7 +80,7 @@ func (r *BlameReader) NextPart() (*BlamePart, error) {
// need to munch to end of line...
for isPrefix {
_, isPrefix, err = reader.ReadLine()
_, isPrefix, err = r.bufferedReader.ReadLine()
if err != nil && err != io.EOF {
return blamePart, err
}
@ -96,6 +95,7 @@ func (r *BlameReader) NextPart() (*BlamePart, error) {
// Close BlameReader - don't run NextPart after invoking that
func (r *BlameReader) Close() error {
err := <-r.done
r.bufferedReader = nil
_ = r.reader.Close()
_ = r.output.Close()
return err
@ -126,10 +126,13 @@ func CreateBlameReader(ctx context.Context, repoPath, commitID, file string) (*B
done <- err
}(cmd, repoPath, stdout, done)
bufferedReader := bufio.NewReader(reader)
return &BlameReader{
cmd: cmd,
output: stdout,
reader: reader,
bufferedReader: bufferedReader,
done: done,
}, nil
}

View File

@ -28,7 +28,7 @@ func TestReadingBlameOutput(t *testing.T) {
},
{
"f32b0a9dfd09a60f616f29158f772cedd89942d2",
[]string{},
[]string{"", "Do not make any changes to this repo it is used for unit testing"},
},
}

View File

@ -4,7 +4,10 @@
package metrics
import (
"runtime"
activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/modules/setting"
"github.com/prometheus/client_golang/prometheus"
)
@ -17,6 +20,7 @@ type Collector struct {
Accesses *prometheus.Desc
Actions *prometheus.Desc
Attachments *prometheus.Desc
BuildInfo *prometheus.Desc
Comments *prometheus.Desc
Follows *prometheus.Desc
HookTasks *prometheus.Desc
@ -62,6 +66,16 @@ func NewCollector() Collector {
"Number of Attachments",
nil, nil,
),
BuildInfo: prometheus.NewDesc(
namespace+"build_info",
"Build information",
[]string{
"goarch",
"goos",
"goversion",
"version",
}, nil,
),
Comments: prometheus.NewDesc(
namespace+"comments",
"Number of Comments",
@ -195,6 +209,7 @@ func (c Collector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.Accesses
ch <- c.Actions
ch <- c.Attachments
ch <- c.BuildInfo
ch <- c.Comments
ch <- c.Follows
ch <- c.HookTasks
@ -241,6 +256,15 @@ func (c Collector) Collect(ch chan<- prometheus.Metric) {
prometheus.GaugeValue,
float64(stats.Counter.Attachment),
)
ch <- prometheus.MustNewConstMetric(
c.BuildInfo,
prometheus.GaugeValue,
1,
runtime.GOARCH,
runtime.GOOS,
runtime.Version(),
setting.AppVer,
)
ch <- prometheus.MustNewConstMetric(
c.Comments,
prometheus.GaugeValue,

View File

@ -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.

View File

@ -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
}

View File

@ -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 {

View File

@ -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 != "" {

View File

@ -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

View File

@ -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,

View File

@ -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)

View File

@ -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)

View File

@ -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,
}
}

View File

@ -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)
}

View File

@ -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}
}
}

View File

@ -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

View File

@ -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"))

View File

@ -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)

View File

@ -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
}

View File

@ -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,17 +65,12 @@ 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
}
return user, nil
}
} else {
// Fallback.
if len(sr.Username) == 0 {
sr.Username = userName
@ -107,18 +103,28 @@ func (source *Source) Authenticate(user *user_model.User, userName, password str
mailer.SendRegisterNotifyMail(user)
if isAttributeSSHPublicKeySet && asymkey_model.AddPublicKeysBySource(user, source.authSource, sr.SSHPublicKey) {
err = asymkey_model.RewriteAllPublicKeys()
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 err == nil && len(source.AttributeAvatar) > 0 {
_ = user_service.UploadAvatar(user, sr.Avatar)
}
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 source.GroupsEnabled && (source.GroupTeamMap != "" || source.GroupTeamMapRemoval) {
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, nil
}
// IsSkipLocalTwoFA returns if this source should skip local 2fa for password authentication

View File

@ -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)
}
}
}
}

View File

@ -11,9 +11,8 @@ 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"
)
@ -29,8 +28,7 @@ type SearchResult struct {
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
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)
@ -449,8 +408,7 @@ func (source *Source) SearchEntry(name, passwd string, directBind bool) *SearchR
IsAdmin: isAdmin,
IsRestricted: isRestricted,
Avatar: Avatar,
LdapTeamAdd: teamsToAdd,
LdapTeamRemove: teamsToRemove,
Groups: usersLdapGroups,
}
}
@ -512,22 +470,19 @@ 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)
}
}
@ -537,8 +492,7 @@ func (source *Source) SearchEntries() ([]*SearchResult, error) {
Surname: v.GetAttributeValue(source.AttributeSurname),
Mail: v.GetAttributeValue(source.AttributeMail),
IsAdmin: checkAdmin(l, source, v.DN),
LdapTeamAdd: teamsToAdd,
LdapTeamRemove: teamsToRemove,
Groups: usersLdapGroups,
}
if !user.IsAdmin {

View File

@ -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)
}
}
}

View File

@ -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
@ -29,6 +22,8 @@ type Source struct {
RequiredClaimValue string
GroupClaimName string
AdminGroup string
GroupTeamMap string
GroupTeamMapRemoval bool
RestrictedGroup string
SkipLocalTwoFA bool `json:",omitempty"`

View File

@ -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
}

View File

@ -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
}

View File

@ -98,6 +98,9 @@ func GetDefaultMergeMessage(ctx context.Context, baseGitRepo *git.Repository, pr
}
for _, ref := range refs {
if ref.RefAction == references.XRefActionCloses {
if err := ref.LoadIssue(ctx); err != nil {
return "", "", err
}
closeIssueIndexes = append(closeIssueIndexes, fmt.Sprintf("%s %s%d", closeWord, issueReference, ref.Issue.Index))
}
}

View File

@ -361,6 +361,14 @@
<label for="oauth2_restricted_group">{{.locale.Tr "admin.auths.oauth2_restricted_group"}}</label>
<input id="oauth2_restricted_group" name="oauth2_restricted_group" value="{{$cfg.RestrictedGroup}}">
</div>
<div class="field">
<label>{{.locale.Tr "admin.auths.oauth2_map_group_to_team"}}</label>
<input name="oauth2_group_team_map" value="{{$cfg.GroupTeamMap}}" placeholder='e.g. {"Developer": {"MyGiteaOrganization": ["MyGiteaTeam1", "MyGiteaTeam2"]}}'>
</div>
<div class="ui checkbox">
<label>{{.locale.Tr "admin.auths.oauth2_map_group_to_team_removal"}}</label>
<input name="oauth2_group_team_map_removal" type="checkbox" {{if $cfg.GroupTeamMapRemoval}}checked{{end}}>
</div>
{{end}}
<!-- SSPI -->

View File

@ -52,7 +52,7 @@
</div>
<div class="field">
<label for="restricted_filter">{{.locale.Tr "admin.auths.restricted_filter"}}</label>
<input id="restricted_filter" name="admin_filter" value="{{.restricted_filter}}">
<input id="restricted_filter" name="restricted_filter" value="{{.restricted_filter}}">
<p class="help">{{.locale.Tr "admin.auths.restricted_filter_helper"}}</p>
</div>
<div class="field">

View File

@ -98,4 +98,12 @@
<label for="oauth2_restricted_group">{{.locale.Tr "admin.auths.oauth2_restricted_group"}}</label>
<input id="oauth2_restricted_group" name="oauth2_restricted_group" value="{{.oauth2_group_claim_name}}">
</div>
<div class="field">
<label>{{.locale.Tr "admin.auths.oauth2_map_group_to_team"}}</label>
<input name="oauth2_group_team_map" value="{{.group_team_map}}" placeholder='e.g. {"Developer": {"MyGiteaOrganization": ["MyGiteaTeam1", "MyGiteaTeam2"]}}'>
</div>
<div class="ui checkbox">
<label>{{.locale.Tr "admin.auths.oauth2_map_group_to_team_removal"}}</label>
<input name="oauth2_group_team_map_removal" type="checkbox" {{if .group_team_map_removal}}checked{{end}}>
</div>
</div>

View File

@ -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
}

View File

@ -20,7 +20,8 @@
<SvgIcon name="octicon-meter" class="ui text yellow" class-name="job-status-rotate" v-else-if="job.status === 'running'"/>
<SvgIcon name="octicon-x-circle-fill" class="red" v-else/>
{{ job.name }}
<button class="job-brief-rerun" @click="rerunJob(index)" v-if="job.canRerun">
<!-- TODO: it will be a better idea to move "button" out from "a", and the ".prevent" will not be needed. But it needs some work with CSS -->
<button class="job-brief-rerun" @click.prevent="rerunJob(index)" v-if="job.canRerun">
<SvgIcon name="octicon-sync" class="ui text black"/>
</button>
</a>
@ -162,12 +163,14 @@ const sfc = {
}
},
// rerun a job
rerunJob(idx) {
this.fetch(`${this.run.link}/jobs/${idx}/rerun`);
async rerunJob(idx) {
const jobLink = `${this.run.link}/jobs/${idx}`;
await this.fetchPost(`${jobLink}/rerun`);
window.location.href = jobLink;
},
// cancel a run
cancelRun() {
this.fetch(`${this.run.link}/cancel`);
this.fetchPost(`${this.run.link}/cancel`);
},
createLogLine(line) {
@ -205,7 +208,7 @@ const sfc = {
// for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc
return {step: idx, cursor: it.cursor, expanded: it.expanded};
});
const resp = await this.fetch(
const resp = await this.fetchPost(
`${this.actionsURL}/runs/${this.runIndex}/jobs/${this.jobIndex}`,
JSON.stringify({logCursors}),
);
@ -245,7 +248,7 @@ const sfc = {
}
},
fetch(url, body) {
fetchPost(url, body) {
return fetch(url, {
method: 'POST',
headers: {