From 1067641e531a75876ec3f02798d6efd0fe74286c Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Tue, 19 Apr 2022 16:58:05 -0400 Subject: [PATCH] Feature: groups in Gitea Signed-off-by: techknowlogick --- README.md | 2 +- connector/gitea/gitea.go | 182 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 171 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 19bf7705..271376d6 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/connector/gitea/gitea.go b/connector/gitea/gitea.go index cd371d37..6b020994 100644 --- a/connector/gitea/gitea.go +++ b/connector/gitea/gitea.go @@ -20,11 +20,26 @@ import ( // Config holds configuration options for gitea logins. type Config struct { - BaseURL string `json:"baseURL"` - ClientID string `json:"clientID"` - ClientSecret string `json:"clientSecret"` - RedirectURI string `json:"redirectURI"` - UseLoginAsID bool `json:"useLoginAsID"` + BaseURL string `json:"baseURL"` + 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 { @@ -35,18 +50,20 @@ 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" } return &giteaConnector{ - baseURL: c.BaseURL, - redirectURI: c.RedirectURI, - clientID: c.ClientID, - clientSecret: c.ClientSecret, - logger: logger, - useLoginAsID: c.UseLoginAsID, + 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 +}