From f3e08a296831c14afade74f5903ab89bf51f6eb2 Mon Sep 17 00:00:00 2001 From: Pawel Boguslawski Date: Tue, 8 Mar 2022 20:52:14 +0100 Subject: [PATCH] Reverse proxy authentication to Woodpeck and Gitea This mod adds option to authenticate user using HTTP header set by reverse proxy. It forwards specified HTTP header with authenticated username in requests to Gitea. Requirements: * Gitea must be configured for reverse proxy authentication and must accept HTTP header auth in API calls (https://github.com/go-gitea/gitea/pull/15119). To enable set the following variables in woodpecker server environment (woodpecker running behind reverse proxy): * internal woodpecker server URL, i.e.: WOODPECKER_HOST_INTERNAL=http://192.168.1.100:8000 * enable reverse proxy auth in woodpecker and forwarding auth header to gitea: WOODPECKER_GITEA_REV_PROXY_AUTH=true * set name of header with authenticated username (set by reverse proxy), i.e.: WOODPECKER_GITEA_REV_PROXY_AUTH_HEADER=X-Forward-Username Related: https://github.com/go-gitea/gitea/pull/15119 Author-Change-Id: IB#1107569 --- cmd/server/flags.go | 18 ++++ cmd/server/server.go | 18 ++++ cmd/server/setup.go | 13 ++- server/api/repo.go | 22 +++- server/config.go | 4 +- server/remote/gitea/gitea.go | 131 +++++++++++++++++++++--- server/router/middleware/token/token.go | 9 ++ shared/token/token.go | 10 +- 8 files changed, 197 insertions(+), 28 deletions(-) diff --git a/cmd/server/flags.go b/cmd/server/flags.go index 0a55d84bbd..98bf001f8f 100644 --- a/cmd/server/flags.go +++ b/cmd/server/flags.go @@ -1,4 +1,5 @@ // Copyright 2019 Laszlo Fogas +// Copyright 2022 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/ // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -11,6 +12,8 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +// +// This file has been modified by Informatyka Boguslawski sp. z o.o. sp.k. package main @@ -44,6 +47,11 @@ var flags = []cli.Flag{ Name: "server-host", Usage: "server fully qualified url (://)", }, + &cli.StringFlag{ + EnvVars: []string{"WOODPECKER_HOST_INTERNAL"}, + Name: "server-host-internal", + Usage: "server internal fully qualified url (://)", + }, &cli.StringFlag{ EnvVars: []string{"WOODPECKER_SERVER_ADDR"}, Name: "server-addr", @@ -146,6 +154,16 @@ var flags = []cli.Flag{ Name: "keepalive-min-time", Usage: "server-side enforcement policy on the minimum amount of time a client should wait before sending a keepalive ping.", }, + &cli.BoolFlag{ + EnvVars: []string{"WOODPECKER_GITEA_REV_PROXY_AUTH"}, + Name: "gitea-rev-proxy-auth", + Usage: "enable gitea authentication using HTTP header specified in WOODPECKER_GITEA_REV_PROXY_AUTH_HEADER", + }, + &cli.StringFlag{ + EnvVars: []string{"WOODPECKER_GITEA_REV_PROXY_AUTH_HEADER"}, + Name: "gitea-rev-proxy-auth-header", + Usage: "HTTP header with gitea authenticated user login", + }, &cli.StringFlag{ EnvVars: []string{"WOODPECKER_SECRET_ENDPOINT"}, Name: "secret-service", diff --git a/cmd/server/server.go b/cmd/server/server.go index 2e7ac5d9d4..514833b589 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -1,4 +1,5 @@ // Copyright 2018 Drone.IO Inc. +// Copyright 2022 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/ // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -11,6 +12,8 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +// +// This file has been modified by Informatyka Boguslawski sp. z o.o. sp.k. package main @@ -99,6 +102,18 @@ func run(c *cli.Context) error { ) } + if strings.Contains(c.String("server-host-internal"), "://localhost") { + log.Warn().Msg( + "WOODPECKER_HOST_INTERNAL should probably be publicly accessible (not localhost)", + ) + } + + if strings.HasSuffix(c.String("server-host-internal"), "/") { + log.Fatal().Msg( + "WOODPECKER_HOST_INTERNAL must not have trailing slash", + ) + } + _remote, err := setupRemote(c) if err != nil { log.Fatal().Err(err).Msg("") @@ -290,6 +305,9 @@ func setupEvilGlobals(c *cli.Context, v store.Store, r remote.Remote) { server.Config.Server.Key = c.String("server-key") server.Config.Server.Pass = c.String("agent-secret") server.Config.Server.Host = c.String("server-host") + server.Config.Server.HostInternal = c.String("server-host-internal") + server.Config.Server.RevProxyAuth = c.Bool("gitea-rev-proxy-auth") + if c.IsSet("server-dev-oauth-host") { server.Config.Server.OAuthHost = c.String("server-dev-oauth-host") } else { diff --git a/cmd/server/setup.go b/cmd/server/setup.go index e113ffe1ce..3dc1a795bb 100644 --- a/cmd/server/setup.go +++ b/cmd/server/setup.go @@ -1,4 +1,5 @@ // Copyright 2018 Drone.IO Inc. +// Copyright 2022 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/ // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -11,6 +12,8 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +// +// This file has been modified by Informatyka Boguslawski sp. z o.o. sp.k. package main @@ -228,10 +231,12 @@ func setupGitea(c *cli.Context) (remote.Remote, error) { return nil, err } opts := gitea.Opts{ - URL: strings.TrimRight(server.String(), "/"), - Client: c.String("gitea-client"), - Secret: c.String("gitea-secret"), - SkipVerify: c.Bool("gitea-skip-verify"), + URL: strings.TrimRight(server.String(), "/"), + Client: c.String("gitea-client"), + Secret: c.String("gitea-secret"), + SkipVerify: c.Bool("gitea-skip-verify"), + RevProxyAuth: c.Bool("gitea-rev-proxy-auth"), + RevProxyAuthHeader: c.String("gitea-rev-proxy-auth-header"), } if len(opts.URL) == 0 { log.Fatal().Msg("WOODPECKER_GITEA_URL must be set") diff --git a/server/api/repo.go b/server/api/repo.go index e0e3b0c84b..c41a66d799 100644 --- a/server/api/repo.go +++ b/server/api/repo.go @@ -1,4 +1,5 @@ // Copyright 2018 Drone.IO Inc. +// Copyright 2022 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/ // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -11,6 +12,8 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +// +// This file has been modified by Informatyka Boguslawski sp. z o.o. sp.k. package api @@ -79,9 +82,14 @@ func PostRepo(c *gin.Context) { return } + host := server.Config.Server.Host + if server.Config.Server.HostInternal != "" { + host = server.Config.Server.HostInternal + } + link := fmt.Sprintf( "%s/hook?access_token=%s", - server.Config.Server.Host, + host, sig, ) @@ -218,7 +226,11 @@ func DeleteRepo(c *gin.Context) { } } - if err := server.Config.Services.Remote.Deactivate(c, user, repo, server.Config.Server.Host); err != nil { + host := server.Config.Server.Host + if server.Config.Server.HostInternal != "" { + host = server.Config.Server.HostInternal + } + if err := server.Config.Services.Remote.Deactivate(c, user, repo, host); err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return } @@ -241,6 +253,9 @@ func RepairRepo(c *gin.Context) { // reconstruct the link host := server.Config.Server.Host + if server.Config.Server.HostInternal != "" { + host = server.Config.Server.HostInternal + } link := fmt.Sprintf( "%s/hook?access_token=%s", host, @@ -335,6 +350,9 @@ func MoveRepo(c *gin.Context) { // reconstruct the link host := server.Config.Server.Host + if server.Config.Server.HostInternal != "" { + host = server.Config.Server.HostInternal + } link := fmt.Sprintf( "%s/hook?access_token=%s", host, diff --git a/server/config.go b/server/config.go index 3384cb184b..a08e06a4a2 100644 --- a/server/config.go +++ b/server/config.go @@ -1,5 +1,5 @@ // Copyright 2018 Drone.IO Inc. -// Copyright 2021 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/ +// Copyright 2022 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/ // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -53,6 +53,8 @@ var Config = struct { Cert string OAuthHost string Host string + HostInternal string + RevProxyAuth bool Port string Pass string Docs string diff --git a/server/remote/gitea/gitea.go b/server/remote/gitea/gitea.go index fb29823b20..5832c5c32e 100644 --- a/server/remote/gitea/gitea.go +++ b/server/remote/gitea/gitea.go @@ -1,5 +1,5 @@ // Copyright 2018 Drone.IO Inc. -// Copyright 2021 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/ +// Copyright 2022 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/ // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -43,19 +43,24 @@ const ( ) type Gitea struct { - URL string - Machine string - ClientID string - ClientSecret string - SkipVerify bool + URL string + Machine string + ClientID string + ClientSecret string + SkipVerify bool + RevProxyAuth bool + RevProxyAuthHeader string + RevProxyAuthHeaderValue string } // Opts defines configuration options. type Opts struct { - URL string // Gitea server url. - Client string // OAuth2 Client ID - Secret string // OAuth2 Client Secret - SkipVerify bool // Skip ssl verification. + URL string // Gitea server url. + Client string // OAuth2 Client ID + Secret string // OAuth2 Client Secret + SkipVerify bool // Skip ssl verification. + RevProxyAuth bool // Enable reverse proxy authentication using RevProxyAuthHeader. + RevProxyAuthHeader string // Name of HTTP header with username for reverse proxy authentication. } // New returns a Remote implementation that integrates with Gitea, @@ -70,17 +75,64 @@ func New(opts Opts) (remote.Remote, error) { u.Host = host } return &Gitea{ - URL: opts.URL, - Machine: u.Host, - ClientID: opts.Client, - ClientSecret: opts.Secret, - SkipVerify: opts.SkipVerify, + URL: opts.URL, + Machine: u.Host, + ClientID: opts.Client, + ClientSecret: opts.Secret, + SkipVerify: opts.SkipVerify, + RevProxyAuth: opts.RevProxyAuth, + RevProxyAuthHeader: opts.RevProxyAuthHeader, }, nil } -// Login authenticates an account with Gitea using basic authentication. The -// Gitea account details are returned when the user is successfully authenticated. +// Login authenticates an account with Gitea. The Gitea account details +// are returned when the user is successfully authenticated. func (c *Gitea) Login(ctx context.Context, w http.ResponseWriter, req *http.Request) (*model.User, error) { + // Authenticate using reverse proxy header if enabled. + if c.RevProxyAuth { + c.ClientID = req.Header.Get(c.RevProxyAuthHeader) + c.RevProxyAuthHeaderValue = c.ClientID + c.ClientSecret = "" + + client, err := c.newClientBasicAuth(ctx, c.ClientID, "") + if err != nil { + return nil, err + } + + // Since api does not return token secret, if drone token exists create new one. + resp, err := client.DeleteAccessToken("drone") + if err != nil && !(resp != nil && resp.StatusCode == 404) { + return nil, err + } + token, _, terr := client.CreateAccessToken( + gitea.CreateAccessTokenOption{Name: "drone"}, + ) + if terr != nil { + return nil, terr + } + accessToken := token.Token + + client, err = c.newClientToken(ctx, accessToken) + if err != nil { + return nil, err + } + + account, _, err := client.GetMyUserInfo() + if err != nil { + return nil, err + } + + return &model.User{ + Token: accessToken, + Secret: "", + Expiry: 0, + Login: account.UserName, + Email: account.Email, + Avatar: expandAvatar(c.URL, account.AvatarURL), + }, nil + } + + // oAuth2 authentication. config := &oauth2.Config{ ClientID: c.ClientID, ClientSecret: c.ClientSecret, @@ -443,6 +495,22 @@ func (c *Gitea) Hook(ctx context.Context, r *http.Request) (*model.Repo, *model. return parseHook(r) } +// authTransport forwards authentication HTTP header to gitea. +type authTransport struct { + headerName string + headerValue string + underlyingTransport http.RoundTripper +} + +func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Add(t.headerName, t.headerValue) + if t.underlyingTransport != nil { + return t.underlyingTransport.RoundTrip(req) + } else { + return http.DefaultTransport.RoundTrip(req) + } +} + // helper function to return the Gitea client with Token func (c *Gitea) newClientToken(ctx context.Context, token string) (*gitea.Client, error) { httpClient := &http.Client{} @@ -451,9 +519,38 @@ func (c *Gitea) newClientToken(ctx context.Context, token string) (*gitea.Client TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } } + // Forward authentication header in every HTTP request to Gitea + // in reverse proxy authentication mode. + if c.RevProxyAuth { + httpClient.Transport = &authTransport{ + headerName: c.RevProxyAuthHeader, + headerValue: c.RevProxyAuthHeaderValue, + underlyingTransport: httpClient.Transport, + } + } return gitea.NewClient(c.URL, gitea.SetToken(token), gitea.SetHTTPClient(httpClient), gitea.SetContext(ctx)) } +// helper function to return the Gitea client with Basic Auth +func (c *Gitea) newClientBasicAuth(ctx context.Context, username, password string) (*gitea.Client, error) { + httpClient := &http.Client{} + if c.SkipVerify { + httpClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + } + // Forward authentication header in every HTTP request to Gitea + // in reverse proxy authentication mode. + if c.RevProxyAuth { + httpClient.Transport = &authTransport{ + headerName: c.RevProxyAuthHeader, + headerValue: c.RevProxyAuthHeaderValue, + underlyingTransport: httpClient.Transport, + } + } + return gitea.NewClient(c.URL, gitea.SetBasicAuth(username, password), gitea.SetHTTPClient(httpClient), gitea.SetContext(ctx)) +} + // getStatus is a helper function that converts a Woodpecker // status to a Gitea status. func getStatus(status model.StatusValue) gitea.StatusState { diff --git a/server/router/middleware/token/token.go b/server/router/middleware/token/token.go index 62dd87e805..cc71ba8760 100644 --- a/server/router/middleware/token/token.go +++ b/server/router/middleware/token/token.go @@ -1,4 +1,5 @@ // Copyright 2018 Drone.IO Inc. +// Copyright 2022 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/ // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -11,6 +12,8 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +// +// This file has been modified by Informatyka Boguslawski sp. z o.o. sp.k. package token @@ -34,6 +37,12 @@ func Refresh(c *gin.Context) { return } + // Don't use oAuth2 if authentication using HTTP header is enabled. + if server.Config.Server.RevProxyAuth { + c.Next() + return + } + // check if the remote includes the ability to // refresh the user token. _remote := server.Config.Services.Remote diff --git a/shared/token/token.go b/shared/token/token.go index 3f15537181..6b39c3af7f 100644 --- a/shared/token/token.go +++ b/shared/token/token.go @@ -1,4 +1,5 @@ // Copyright 2018 Drone.IO Inc. +// Copyright 2022 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/ // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -11,6 +12,8 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +// +// This file has been modified by Informatyka Boguslawski sp. z o.o. sp.k. package token @@ -52,16 +55,15 @@ func parse(raw string, fn SecretFunc) (*Token, error) { } func ParseRequest(r *http.Request, fn SecretFunc) (*Token, error) { - // first we attempt to get the token from the + // First we attempt to get the token from the // authorization header. token := r.Header.Get("Authorization") if len(token) != 0 { log.Trace().Msgf("token.ParseRequest: found token in header: %s", token) bearer := token - if _, err := fmt.Sscanf(token, "Bearer %s", &bearer); err != nil { - return nil, err + if _, err := fmt.Sscanf(token, "Bearer %s", &bearer); err == nil { + return parse(bearer, fn) } - return parse(bearer, fn) } token = r.Header.Get("X-Gitlab-Token")