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 | | | | [Bitbucket Cloud](https://dexidp.io/docs/connectors/bitbucketcloud/) | yes | yes | no | alpha | | | ||||||
| | [OpenShift](https://dexidp.io/docs/connectors/openshift/) | no | 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 | | | [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 | | | | [OpenStack Keystone](https://dexidp.io/docs/connectors/keystone/) | yes | yes | no | alpha | | | ||||||
|  |  | ||||||
| Stable, beta, and alpha are defined as: | Stable, beta, and alpha are defined as: | ||||||
|   | |||||||
| @@ -20,11 +20,26 @@ import ( | |||||||
|  |  | ||||||
| // Config holds configuration options for gitea logins. | // Config holds configuration options for gitea logins. | ||||||
| type Config struct { | type Config struct { | ||||||
| 	BaseURL      string `json:"baseURL"` | 	BaseURL       string `json:"baseURL"` | ||||||
| 	ClientID     string `json:"clientID"` | 	ClientID      string `json:"clientID"` | ||||||
| 	ClientSecret string `json:"clientSecret"` | 	ClientSecret  string `json:"clientSecret"` | ||||||
| 	RedirectURI  string `json:"redirectURI"` | 	RedirectURI   string `json:"redirectURI"` | ||||||
| 	UseLoginAsID bool   `json:"useLoginAsID"` | 	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 { | type giteaUser struct { | ||||||
| @@ -35,18 +50,20 @@ type giteaUser struct { | |||||||
| 	IsAdmin  bool   `json:"is_admin"` | 	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) { | func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) { | ||||||
| 	if c.BaseURL == "" { | 	if c.BaseURL == "" { | ||||||
| 		c.BaseURL = "https://gitea.com" | 		c.BaseURL = "https://gitea.com" | ||||||
| 	} | 	} | ||||||
| 	return &giteaConnector{ | 	return &giteaConnector{ | ||||||
| 		baseURL:      c.BaseURL, | 		baseURL:       c.BaseURL, | ||||||
| 		redirectURI:  c.RedirectURI, | 		redirectURI:   c.RedirectURI, | ||||||
| 		clientID:     c.ClientID, | 		orgs:          c.Orgs, | ||||||
| 		clientSecret: c.ClientSecret, | 		clientID:      c.ClientID, | ||||||
| 		logger:       logger, | 		clientSecret:  c.ClientSecret, | ||||||
| 		useLoginAsID: c.UseLoginAsID, | 		logger:        logger, | ||||||
|  | 		loadAllGroups: c.LoadAllGroups, | ||||||
|  | 		useLoginAsID:  c.UseLoginAsID, | ||||||
| 	}, nil | 	}, nil | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -64,10 +81,13 @@ var ( | |||||||
| type giteaConnector struct { | type giteaConnector struct { | ||||||
| 	baseURL      string | 	baseURL      string | ||||||
| 	redirectURI  string | 	redirectURI  string | ||||||
|  | 	orgs         []Org | ||||||
| 	clientID     string | 	clientID     string | ||||||
| 	clientSecret string | 	clientSecret string | ||||||
| 	logger       log.Logger | 	logger       log.Logger | ||||||
| 	httpClient   *http.Client | 	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 | 	// if set to true will use the user's handle rather than their numeric id as the ID | ||||||
| 	useLoginAsID bool | 	useLoginAsID bool | ||||||
| } | } | ||||||
| @@ -130,6 +150,7 @@ func (c *giteaConnector) HandleCallback(s connector.Scopes, r *http.Request) (id | |||||||
| 	if username == "" { | 	if username == "" { | ||||||
| 		username = user.Email | 		username = user.Email | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	identity = connector.Identity{ | 	identity = connector.Identity{ | ||||||
| 		UserID:            strconv.Itoa(user.ID), | 		UserID:            strconv.Itoa(user.ID), | ||||||
| 		Username:          username, | 		Username:          username, | ||||||
| @@ -141,6 +162,15 @@ func (c *giteaConnector) HandleCallback(s connector.Scopes, r *http.Request) (id | |||||||
| 		identity.UserID = user.Username | 		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 { | 	if s.OfflineAccess { | ||||||
| 		data := connectorData{ | 		data := connectorData{ | ||||||
| 			AccessToken:  token.AccessToken, | 			AccessToken:  token.AccessToken, | ||||||
| @@ -232,9 +262,132 @@ func (c *giteaConnector) Refresh(ctx context.Context, s connector.Scopes, ident | |||||||
| 	ident.PreferredUsername = user.Username | 	ident.PreferredUsername = user.Username | ||||||
| 	ident.Email = user.Email | 	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 | 	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 | // 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 | // client is expected to be constructed by the golang.org/x/oauth2 package, which inserts | ||||||
| // a bearer token as part of the request. | // 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 | 	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