Feature: groups in Gitea
Signed-off-by: techknowlogick <techknowlogick@gitea.io>
This commit is contained in:
		| @@ -79,7 +79,7 @@ Dex implements the following connectors: | ||||
| | [Bitbucket Cloud](https://dexidp.io/docs/connectors/bitbucketcloud/) | yes | yes | no | alpha | | | ||||
| | [OpenShift](https://dexidp.io/docs/connectors/openshift/) | no | yes | no | alpha | | | ||||
| | [Atlassian Crowd](https://dexidp.io/docs/connectors/atlassiancrowd/) | yes | yes | yes * | beta | preferred_username claim must be configured through config | | ||||
| | [Gitea](https://dexidp.io/docs/connectors/gitea/) | yes | no | yes | alpha | | | ||||
| | [Gitea](https://dexidp.io/docs/connectors/gitea/) | yes | no | yes | beta | | | ||||
| | [OpenStack Keystone](https://dexidp.io/docs/connectors/keystone/) | yes | yes | no | alpha | | | ||||
|  | ||||
| Stable, beta, and alpha are defined as: | ||||
|   | ||||
| @@ -24,9 +24,24 @@ type Config struct { | ||||
| 	ClientID      string `json:"clientID"` | ||||
| 	ClientSecret  string `json:"clientSecret"` | ||||
| 	RedirectURI   string `json:"redirectURI"` | ||||
| 	Orgs          []Org  `json:"orgs"` | ||||
| 	LoadAllGroups bool   `json:"loadAllGroups"` | ||||
| 	UseLoginAsID  bool   `json:"useLoginAsID"` | ||||
| } | ||||
|  | ||||
| // Org holds org-team filters, in which teams are optional. | ||||
| type Org struct { | ||||
| 	// Organization name in gitea (not slug, full name). Only users in this gitea | ||||
| 	// organization can authenticate. | ||||
| 	Name string `json:"name"` | ||||
|  | ||||
| 	// Names of teams in a gitea organization. A user will be able to | ||||
| 	// authenticate if they are members of at least one of these teams. Users | ||||
| 	// in the organization can authenticate if this field is omitted from the | ||||
| 	// config file. | ||||
| 	Teams []string `json:"teams,omitempty"` | ||||
| } | ||||
|  | ||||
| type giteaUser struct { | ||||
| 	ID       int    `json:"id"` | ||||
| 	Name     string `json:"full_name"` | ||||
| @@ -35,7 +50,7 @@ type giteaUser struct { | ||||
| 	IsAdmin  bool   `json:"is_admin"` | ||||
| } | ||||
|  | ||||
| // Open returns a strategy for logging in through GitLab. | ||||
| // Open returns a strategy for logging in through Gitea | ||||
| func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) { | ||||
| 	if c.BaseURL == "" { | ||||
| 		c.BaseURL = "https://gitea.com" | ||||
| @@ -43,9 +58,11 @@ func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) | ||||
| 	return &giteaConnector{ | ||||
| 		baseURL:       c.BaseURL, | ||||
| 		redirectURI:   c.RedirectURI, | ||||
| 		orgs:          c.Orgs, | ||||
| 		clientID:      c.ClientID, | ||||
| 		clientSecret:  c.ClientSecret, | ||||
| 		logger:        logger, | ||||
| 		loadAllGroups: c.LoadAllGroups, | ||||
| 		useLoginAsID:  c.UseLoginAsID, | ||||
| 	}, nil | ||||
| } | ||||
| @@ -64,10 +81,13 @@ var ( | ||||
| type giteaConnector struct { | ||||
| 	baseURL      string | ||||
| 	redirectURI  string | ||||
| 	orgs         []Org | ||||
| 	clientID     string | ||||
| 	clientSecret string | ||||
| 	logger       log.Logger | ||||
| 	httpClient   *http.Client | ||||
| 	// if set to true and no orgs are configured then connector loads all user claims (all orgs and team) | ||||
| 	loadAllGroups bool | ||||
| 	// if set to true will use the user's handle rather than their numeric id as the ID | ||||
| 	useLoginAsID bool | ||||
| } | ||||
| @@ -130,6 +150,7 @@ func (c *giteaConnector) HandleCallback(s connector.Scopes, r *http.Request) (id | ||||
| 	if username == "" { | ||||
| 		username = user.Email | ||||
| 	} | ||||
|  | ||||
| 	identity = connector.Identity{ | ||||
| 		UserID:            strconv.Itoa(user.ID), | ||||
| 		Username:          username, | ||||
| @@ -141,6 +162,15 @@ func (c *giteaConnector) HandleCallback(s connector.Scopes, r *http.Request) (id | ||||
| 		identity.UserID = user.Username | ||||
| 	} | ||||
|  | ||||
| 	// Only set identity.Groups if 'orgs', 'org', or 'groups' scope are specified. | ||||
| 	if c.groupsRequired() { | ||||
| 		groups, err := c.getGroups(ctx, client) | ||||
| 		if err != nil { | ||||
| 			return identity, err | ||||
| 		} | ||||
| 		identity.Groups = groups | ||||
| 	} | ||||
|  | ||||
| 	if s.OfflineAccess { | ||||
| 		data := connectorData{ | ||||
| 			AccessToken:  token.AccessToken, | ||||
| @@ -232,9 +262,132 @@ func (c *giteaConnector) Refresh(ctx context.Context, s connector.Scopes, ident | ||||
| 	ident.PreferredUsername = user.Username | ||||
| 	ident.Email = user.Email | ||||
|  | ||||
| 	// Only set identity.Groups if 'orgs', 'org', or 'groups' scope are specified. | ||||
| 	if c.groupsRequired() { | ||||
| 		groups, err := c.getGroups(ctx, client) | ||||
| 		if err != nil { | ||||
| 			return ident, err | ||||
| 		} | ||||
| 		ident.Groups = groups | ||||
| 	} | ||||
|  | ||||
| 	return ident, nil | ||||
| } | ||||
|  | ||||
| // getGroups retrieves Gitea orgs and teams a user is in, if any. | ||||
| func (c *giteaConnector) getGroups(ctx context.Context, client *http.Client) ([]string, error) { | ||||
| 	if len(c.orgs) > 0 { | ||||
| 		return c.groupsForOrgs(ctx, client) | ||||
| 	} else if c.loadAllGroups { | ||||
| 		return c.userGroups(ctx, client) | ||||
| 	} | ||||
| 	return nil, nil | ||||
| } | ||||
|  | ||||
| // formatTeamName returns unique team name. | ||||
| // Orgs might have the same team names. To make team name unique it should be prefixed with the org name. | ||||
| func formatTeamName(org string, team string) string { | ||||
| 	return fmt.Sprintf("%s:%s", org, team) | ||||
| } | ||||
|  | ||||
| // groupsForOrgs returns list of groups that user belongs to in approved list | ||||
| func (c *giteaConnector) groupsForOrgs(ctx context.Context, client *http.Client) ([]string, error) { | ||||
| 	groups, err := c.userGroups(ctx, client) | ||||
| 	if err != nil { | ||||
| 		return groups, err | ||||
| 	} | ||||
|  | ||||
| 	keys := make(map[string]bool) | ||||
| 	for _, o := range c.orgs { | ||||
| 		keys[o.Name] = true | ||||
| 		if o.Teams != nil { | ||||
| 			for _, t := range o.Teams { | ||||
| 				keys[formatTeamName(o.Name, t)] = true | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	atLeastOne := false | ||||
| 	filteredGroups := make([]string, 0) | ||||
| 	for _, g := range groups { | ||||
| 		if _, value := keys[g]; value { | ||||
| 			filteredGroups = append(filteredGroups, g) | ||||
| 			atLeastOne = true | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if !atLeastOne { | ||||
| 		return []string{}, fmt.Errorf("gitea: User does not belong to any of the approved groups") | ||||
| 	} | ||||
| 	return filteredGroups, nil | ||||
| } | ||||
|  | ||||
| type organization struct { | ||||
| 	ID   int64  `json:"id"` | ||||
| 	Name string `json:"username"` | ||||
| } | ||||
|  | ||||
| type team struct { | ||||
| 	ID           int64         `json:"id"` | ||||
| 	Name         string        `json:"name"` | ||||
| 	Organization *organization `json:"organization"` | ||||
| } | ||||
|  | ||||
| func (c *giteaConnector) userGroups(ctx context.Context, client *http.Client) ([]string, error) { | ||||
| 	apiURL := c.baseURL + "/api/v1/user/teams" | ||||
| 	groups := make([]string, 0) | ||||
| 	page := 1 | ||||
| 	limit := 20 | ||||
| 	for { | ||||
| 		var teams []team | ||||
| 		req, err := http.NewRequest("GET", fmt.Sprintf("%s?page=%d&limit=%d", apiURL, page, limit), nil) | ||||
| 		if err != nil { | ||||
| 			return groups, fmt.Errorf("gitea: new req: %v", err) | ||||
| 		} | ||||
|  | ||||
| 		req = req.WithContext(ctx) | ||||
| 		resp, err := client.Do(req) | ||||
| 		if err != nil { | ||||
| 			return groups, fmt.Errorf("gitea: get URL %v", err) | ||||
| 		} | ||||
| 		defer resp.Body.Close() | ||||
|  | ||||
| 		if resp.StatusCode != http.StatusOK { | ||||
| 			body, err := io.ReadAll(resp.Body) | ||||
| 			if err != nil { | ||||
| 				return groups, fmt.Errorf("gitea: read body: %v", err) | ||||
| 			} | ||||
| 			return groups, fmt.Errorf("%s: %s", resp.Status, body) | ||||
| 		} | ||||
|  | ||||
| 		if err := json.NewDecoder(resp.Body).Decode(&teams); err != nil { | ||||
| 			return groups, fmt.Errorf("failed to decode response: %v", err) | ||||
| 		} | ||||
|  | ||||
| 		if len(teams) == 0 { | ||||
| 			break | ||||
| 		} | ||||
|  | ||||
| 		for _, t := range teams { | ||||
| 			groups = append(groups, t.Organization.Name) | ||||
| 			groups = append(groups, formatTeamName(t.Organization.Name, t.Name)) | ||||
| 		} | ||||
|  | ||||
| 		page++ | ||||
| 	} | ||||
|  | ||||
| 	// remove duplicate slice variables | ||||
| 	keys := make(map[string]struct{}) | ||||
| 	list := []string{} | ||||
| 	for _, group := range groups { | ||||
| 		if _, exists := keys[group]; !exists { | ||||
| 			keys[group] = struct{}{} | ||||
| 			list = append(list, group) | ||||
| 		} | ||||
| 	} | ||||
| 	groups = list | ||||
| 	return groups, nil | ||||
| } | ||||
|  | ||||
| // user queries the Gitea API for profile information using the provided client. The HTTP | ||||
| // client is expected to be constructed by the golang.org/x/oauth2 package, which inserts | ||||
| // a bearer token as part of the request. | ||||
| @@ -264,3 +417,8 @@ func (c *giteaConnector) user(ctx context.Context, client *http.Client) (giteaUs | ||||
| 	} | ||||
| 	return u, nil | ||||
| } | ||||
|  | ||||
| // groupsRequired returns whether dex needs to request groups from Gitea. | ||||
| func (c *giteaConnector) groupsRequired() bool { | ||||
| 	return len(c.orgs) > 0 || c.loadAllGroups | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user