internal/auth/auth.go (562 lines of code) (raw):
package auth
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
"github.com/sirupsen/logrus"
"gitlab.com/gitlab-org/labkit/log"
"golang.org/x/crypto/hkdf"
"gitlab.com/gitlab-org/gitlab-pages/internal"
"gitlab.com/gitlab-org/gitlab-pages/internal/config"
"gitlab.com/gitlab-org/gitlab-pages/internal/errortracking"
"gitlab.com/gitlab-org/gitlab-pages/internal/feature"
"gitlab.com/gitlab-org/gitlab-pages/internal/httperrors"
"gitlab.com/gitlab-org/gitlab-pages/internal/httptransport"
"gitlab.com/gitlab-org/gitlab-pages/internal/logging"
"gitlab.com/gitlab-org/gitlab-pages/internal/request"
"gitlab.com/gitlab-org/gitlab-pages/internal/source"
)
// nolint: gosec // auth constants, not credentials
// gosec: G101: Potential hardcoded credentials
const (
apiURLUserTemplate = "%s/api/v4/user"
apiURLProjectTemplate = "%s/api/v4/projects/%d/pages_access"
authorizeURLTemplate = "%s/oauth/authorize?client_id=%s&redirect_uri=%s&response_type=code&state=%s&scope=%s"
tokenURLTemplate = "%s/oauth/token"
callbackPath = "/auth"
authorizeProxyTemplate = "%s?domain=%s&state=%s"
failAuthErrMsg = "failed to authenticate request"
fetchAccessTokenErrMsg = "fetching access token failed"
queryParameterErrMsg = "failed to parse domain query parameter"
saveSessionErrMsg = "failed to save the session"
domainQueryParameterErrMsg = "domain query parameter only supports http/https protocol"
projectPrefix = "_project_prefix"
)
var (
errResponseNotOk = errors.New("response was not ok")
errAuthNotConfigured = errors.New("authentication is not configured")
errGenerateKeys = errors.New("could not generate auth keys")
)
// Auth handles authenticating users with GitLab API
type Auth struct {
pagesDomain string
clientID string
clientSecret string
redirectURI string
internalGitlabServer string // used for exchanging OAuth code for token and Accessing API and checking if the user has access to the project
publicGitlabServer string // used for redirecting users to gitlab on the start of OAuth workflow
authSecret string
authScope string
jwtSigningKey []byte
jwtExpiry time.Duration
apiClient *http.Client
store sessions.Store
now func() time.Time // allows to stub time.Now() easily in tests
cookieSessionTimeout time.Duration
allowNamespaceInPath bool
}
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
}
type errorResponse struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
// TryAuthenticate tries to authenticate user and fetch access token if request is a callback to /auth?
func (a *Auth) TryAuthenticate(w http.ResponseWriter, r *http.Request, domains source.Source) bool {
if a == nil {
return false
}
session, err := a.checkSession(w, r)
if err != nil {
return true
}
// Request is for auth
if r.URL.Path != callbackPath {
return false
}
logRequest(r).Info("Receive OAuth authentication callback")
if a.handleProxyingAuth(session, w, r, domains) {
return true
}
// If callback is not successful
errorParam := r.URL.Query().Get("error")
if errorParam != "" {
logRequest(r).WithField("error", errorParam).Warn("OAuth endpoint returned error")
httperrors.Serve401(w)
return true
}
if verifyCodeAndStateGiven(r) {
a.checkAuthenticationResponse(session, w, r)
return true
}
return false
}
func (a *Auth) checkAuthenticationResponse(session *hostSession, w http.ResponseWriter, r *http.Request) {
if !validateState(r, session) {
// State is NOT ok
logRequest(r).Warn("Authentication state did not match expected")
httperrors.Serve401(w)
return
}
redirectURI, ok := session.Values["uri"].(string)
if !ok {
logRequest(r).Error("Can not extract redirect uri from session")
httperrors.Serve500(w)
return
}
decryptedCode, err := a.DecryptCode(r.URL.Query().Get("code"),
getRequestDomain(r, session.getNamespaceInPathFromSession()))
if err != nil {
logRequest(r).WithError(err).Error("failed to decrypt secure code")
errortracking.CaptureErrWithReqAndStackTrace(err, r)
httperrors.Serve500(w)
return
}
// Fetch access token with authorization code
token, err := a.fetchAccessToken(r.Context(), decryptedCode)
if err != nil {
if errors.Is(err, context.Canceled) {
httperrors.Serve404(w)
return
}
// Fetching token not OK
logRequest(r).WithError(err).WithField(
"redirect_uri", redirectURI,
).Error(fetchAccessTokenErrMsg)
errortracking.CaptureErrWithReqAndStackTrace(err, r, errortracking.WithField("redirect_uri", redirectURI))
httperrors.Serve503(w)
return
}
// Store access token
session.Values["access_token"] = token.AccessToken
// In final /auth call, updating session path with project prefix.
// This will prevent leaking restricted and private projects/subgroups pages under the same top level group
// https://gitlab.com/gitlab-org/gitlab-pages/-/issues/1088
if feature.ProjectPrefixCookiePath.Enabled() && session.Values[projectPrefix] != nil {
session.appendPath(session.Values[projectPrefix].(string))
logRequest(r).WithField("Prefix Path", session.Values[projectPrefix].(string)).
Info("Appending project prefix in session cookie path")
// If project prefix is useful anywhere, we can avoid deleting it from session.
delete(session.Values, projectPrefix)
}
err = session.Save(r, w)
if err != nil {
logRequest(r).WithError(err).Error(saveSessionErrMsg)
errortracking.CaptureErrWithReqAndStackTrace(err, r)
httperrors.Serve500(w)
return
}
// Redirect back to requested URI
logRequest(r).WithField(
"redirect_uri", redirectURI,
).Info("Authentication was successful, redirecting user back to requested page")
http.Redirect(w, r, redirectURI, http.StatusFound)
}
func (a *Auth) domainAllowed(ctx context.Context, name string, domains source.Source) bool {
isConfigured := (name == a.pagesDomain) || strings.HasSuffix(name, "."+a.pagesDomain)
if isConfigured {
return true
}
domain, err := domains.GetDomain(ctx, name)
// domain exists and there is no error
return (domain != nil && err == nil)
}
// nolint: gocyclo // TODO refactor this function https://gitlab.com/gitlab-org/gitlab-pages/-/issues/813
func (a *Auth) handleProxyingAuth(session *hostSession, w http.ResponseWriter, r *http.Request, domains source.Source) bool {
// handle auth callback e.g. https://gitlab.io/auth?domain=domain&state=state
if shouldProxyAuthToGitlab(r) {
domain := r.URL.Query().Get("domain")
state := r.URL.Query().Get("state")
proxyurl, err := url.Parse(domain)
if err != nil {
logRequest(r).WithField("domain_query", domain).Error(queryParameterErrMsg)
errortracking.CaptureErrWithReqAndStackTrace(err, r, errortracking.WithField("domain_query", domain))
httperrors.Serve500(w)
return true
}
// domain query param can only contain https or http URLs.
if proxyurl.Scheme != "http" && proxyurl.Scheme != "https" {
logRequest(r).WithField("domain_query", domain).Warn(domainQueryParameterErrMsg)
httperrors.Serve401(w)
return true
}
host, _, err := net.SplitHostPort(proxyurl.Host)
if err != nil {
host = proxyurl.Host
}
if !a.domainAllowed(r.Context(), host, domains) {
logRequest(r).WithFields(logrus.Fields{
"domain_query": domain,
"domain_host": host,
}).Warn("Domain is not configured")
httperrors.Serve401(w)
return true
}
logRequest(r).WithField("domain_query", domain).Info("User is authenticating via domain")
session.Values["proxy_auth_domain"] = domain
session.appendPath(r.URL.Path)
err = session.Save(r, w)
if err != nil {
logRequest(r).WithError(err).Error(saveSessionErrMsg)
errortracking.CaptureErrWithReqAndStackTrace(err, r)
httperrors.Serve500(w)
return true
}
url := fmt.Sprintf(authorizeURLTemplate, a.publicGitlabServer, a.clientID, a.redirectURI, state, a.authScope)
logRequest(r).WithFields(logrus.Fields{
"public_gitlab_server": a.publicGitlabServer,
"domain_query": domain,
}).Info("Redirecting user to gitlab for oauth")
http.Redirect(w, r, url, http.StatusFound)
return true
}
// If auth request callback should be proxied to custom domain
// redirect to originating domain set in the cookie as proxy_auth_domain
if shouldProxyCallbackToCustomDomain(session) {
// Get domain started auth process
proxyDomain := session.Values["proxy_auth_domain"].(string)
logRequest(r).WithField("proxy_auth_domain", proxyDomain).Info("Redirecting auth callback to custom domain")
// Clear proxying from session
delete(session.Values, "proxy_auth_domain")
session.appendPath(r.URL.Path)
err := session.Save(r, w)
if err != nil {
logRequest(r).WithError(err).Error(saveSessionErrMsg)
errortracking.CaptureErrWithReqAndStackTrace(err, r)
httperrors.Serve500(w)
return true
}
query := r.URL.Query()
// prevent https://tools.ietf.org/html/rfc6749#section-10.6 and
// https://gitlab.com/gitlab-org/gitlab-pages/-/issues/262 by encrypting
// and signing the OAuth code
signedCode, err := a.EncryptAndSignCode(proxyDomain, query.Get("code"))
if err != nil {
logRequest(r).WithError(err).Error(saveSessionErrMsg)
errortracking.CaptureErrWithReqAndStackTrace(err, r)
httperrors.Serve503(w)
return true
}
// prevent forwarding access token, more context on the security issue
// https://gitlab.com/gitlab-org/gitlab/-/issues/285244#note_451266051
query.Del("token")
// replace code with signed code
query.Set("code", signedCode)
// Redirect pages to originating domain with code and state to finish
// authentication process
http.Redirect(w, r, proxyDomain+r.URL.Path+"?"+query.Encode(), http.StatusFound)
return true
}
return false
}
func getRequestAddress(r *http.Request) string {
if request.IsHTTPS(r) {
return "https://" + r.Host + r.RequestURI
}
return "http://" + r.Host + r.RequestURI
}
func getRequestDomain(r *http.Request, namespace string) string {
requestDomain := r.Host
if len(namespace) > 0 && strings.HasPrefix(r.Host, namespace) {
requestDomain = strings.TrimPrefix(r.Host, namespace+".") + "/" + namespace
}
if request.IsHTTPS(r) {
return "https://" + requestDomain
}
return "http://" + requestDomain
}
func shouldProxyAuthToGitlab(r *http.Request) bool {
return r.URL.Query().Get("domain") != "" && r.URL.Query().Get("state") != ""
}
func shouldProxyCallbackToCustomDomain(session *hostSession) bool {
return session.Values["proxy_auth_domain"] != nil
}
func validateState(r *http.Request, session *hostSession) bool {
state := r.URL.Query().Get("state")
if state == "" {
// No state param
return false
}
// Check state
if session.Values["state"] == nil || session.Values["state"].(string) != state {
// State does not match
return false
}
// State ok
return true
}
func verifyCodeAndStateGiven(r *http.Request) bool {
return r.URL.Query().Get("code") != "" && r.URL.Query().Get("state") != ""
}
func (a *Auth) fetchAccessToken(ctx context.Context, code string) (tokenResponse, error) {
token := tokenResponse{}
// Prepare request
fetchURL, err := url.Parse(fmt.Sprintf(tokenURLTemplate, a.internalGitlabServer))
if err != nil {
return token, err
}
content := url.Values{}
content.Set("client_id", a.clientID)
content.Set("client_secret", a.clientSecret)
content.Set("code", code)
content.Set("grant_type", "authorization_code")
content.Set("redirect_uri", a.redirectURI)
req, err := http.NewRequestWithContext(ctx, "POST", fetchURL.String(), strings.NewReader(content.Encode()))
if err != nil {
return token, err
}
// Request token
resp, err := a.apiClient.Do(req)
if err != nil {
return token, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
err = errResponseNotOk
errortracking.CaptureErrWithReqAndStackTrace(err, req)
return token, err
}
// Parse response
err = json.NewDecoder(resp.Body).Decode(&token)
if err != nil {
return token, err
}
return token, nil
}
func (a *Auth) checkSessionIsValid(w http.ResponseWriter, r *http.Request, domain internal.Domain) *hostSession {
session, err := a.checkSession(w, r)
if err != nil {
return nil
}
// redirect to /auth?domain=%s&state=%s
if !isAuthInHeader(r) && a.checkTokenExists(session, w, r, domain) {
return nil
}
return session
}
func (a *Auth) checkTokenExists(session *hostSession, w http.ResponseWriter, r *http.Request, domain internal.Domain) bool {
// If no access token redirect to OAuth login page
if session.Values["access_token"] == nil {
logRequest(r).Debug("No access token exists, redirecting user to OAuth2 login")
// When the user tries to authenticate and reload the page concurrently,
// gitlab pages might receive a authentication request with the state already set.
// In these cases, we should re-use the state instead of creating a new one.
if session.Values["state"] == nil {
//Generate state hash and store requested address
session.Values["state"] = base64.URLEncoding.EncodeToString(securecookie.GenerateRandomKey(16))
}
session.Values["uri"] = getRequestAddress(r)
// Clear possible proxying
delete(session.Values, "proxy_auth_domain")
if feature.ProjectPrefixCookiePath.Enabled() {
if prefix := domain.GetProjectPrefix(r); len(prefix) > 1 {
session.Values[projectPrefix] = prefix
}
// After successful authentication, user is redirected to /auth url
// To utilise same session, appended /auth in session path
session.appendPath("/auth")
}
err := session.Save(r, w)
if err != nil {
logRequest(r).WithError(err).Error(saveSessionErrMsg)
errortracking.CaptureErrWithReqAndStackTrace(err, r)
httperrors.Serve500(w)
return true
}
// Because the pages domain might be in public suffix list, we have to
// redirect to pages domain to trigger authorization flow
http.Redirect(w,
r,
a.getProxyAddress(r, session.Values["state"].(string), session.getNamespaceInPathFromSession()),
http.StatusFound)
return true
}
return false
}
func (a *Auth) getProxyAddress(r *http.Request, state string, namespace string) string {
return fmt.Sprintf(authorizeProxyTemplate, a.redirectURI, getRequestDomain(r, namespace), state)
}
func destroySession(session *hostSession, w http.ResponseWriter, r *http.Request) {
logRequest(r).Debug("Destroying session")
// Invalidate access token and redirect back for refreshing and re-authenticating
delete(session.Values, "access_token")
err := session.Save(r, w)
if err != nil {
logRequest(r).WithError(err).Error(saveSessionErrMsg)
errortracking.CaptureErrWithReqAndStackTrace(err, r)
httperrors.Serve500(w)
return
}
http.Redirect(w, r, getRequestAddress(r), http.StatusFound)
}
// IsAuthSupported checks if pages is running with the authentication support
func (a *Auth) IsAuthSupported() bool {
return a != nil
}
// checkAuthentication checks if user is authenticated and has access to the project
// will return contentServed = false when authFailed = true
func (a *Auth) checkAuthentication(w http.ResponseWriter, r *http.Request, domain internal.Domain) bool {
logRequest(r).Debug("Authenticate request")
if a == nil {
logRequest(r).Error(errAuthNotConfigured)
errortracking.CaptureErrWithReqAndStackTrace(errAuthNotConfigured, r)
httperrors.Serve500(w)
return true
}
session := a.checkSessionIsValid(w, r, domain)
if session == nil {
return true
}
projectID := domain.GetProjectID(r)
req, err := a.buildAuthRequest(r, projectID, session)
if err != nil {
handleAuthError(r, err, failAuthErrMsg)
httperrors.Serve500(w)
return true
}
resp, err := a.apiClient.Do(req)
if err != nil {
if errors.Is(err, context.Canceled) {
httperrors.Serve404(w)
} else {
handleAuthError(r, err, "Failed to retrieve info with token")
domain.ServeNotFoundAuthFailed(w, r)
}
return true
}
defer resp.Body.Close()
if checkResponseForInvalidToken(resp, session, w, r) {
return true
}
if resp.StatusCode != http.StatusOK {
// call serve404 handler when auth fails
err := fmt.Errorf("unexpected response fetching access token status: %d", resp.StatusCode)
logRequest(r).WithError(err).WithFields(log.Fields{
"status": resp.StatusCode,
"status_text": resp.Status,
}).Error("Unexpected response fetching access token")
errortracking.CaptureErrWithReqAndStackTrace(err, r)
domain.ServeNotFoundAuthFailed(w, r)
return true
}
return false
}
// buildAuthURL constructs the API URL based on project ID.
func (a *Auth) buildAuthURL(projectID uint64) string {
if projectID > 0 {
return fmt.Sprintf(apiURLProjectTemplate, a.internalGitlabServer, projectID)
}
return fmt.Sprintf(apiURLUserTemplate, a.internalGitlabServer)
}
// buildAuthRequest creates an authenticated HTTP request.
func (a *Auth) buildAuthRequest(r *http.Request, projectID uint64, session *hostSession) (*http.Request, error) {
authURL := a.buildAuthURL(projectID)
req, err := http.NewRequestWithContext(r.Context(), "GET", authURL, nil)
if err != nil {
return nil, err
}
if isAuthInHeader(r) {
logRequest(r).Info("Private pages accessed using authorization header")
req.Header.Add("Authorization", r.Header.Get("Authorization"))
} else {
req.Header.Add("Authorization", "Bearer "+session.Values["access_token"].(string))
}
return req, nil
}
// handleAuthError logs the error and captures it in error tracking.
func handleAuthError(r *http.Request, err error, message string) {
logRequest(r).WithError(err).Error(message)
errortracking.CaptureErrWithReqAndStackTrace(err, r)
}
// CheckAuthenticationWithoutProject checks if user is authenticated and has a valid token
func (a *Auth) CheckAuthenticationWithoutProject(w http.ResponseWriter, r *http.Request, domain internal.Domain) bool {
if a == nil {
// No auth supported
return false
}
return a.checkAuthentication(w, r, domain)
}
// GetTokenIfExists returns the token if it exists
func (a *Auth) GetTokenIfExists(w http.ResponseWriter, r *http.Request) (string, error) {
if a == nil {
return "", nil
}
session, err := a.checkSession(w, r)
if err != nil {
return "", errors.New("error retrieving the session")
}
if session.Values["access_token"] != nil {
return session.Values["access_token"].(string), nil
}
return "", nil
}
// RequireAuth will trigger authentication flow if no token exists
func (a *Auth) RequireAuth(w http.ResponseWriter, r *http.Request, domain internal.Domain) bool {
return a.checkSessionIsValid(w, r, domain) == nil
}
// CheckResponseForInvalidToken checks response for invalid token and destroys session if it was invalid
func (a *Auth) CheckResponseForInvalidToken(w http.ResponseWriter, r *http.Request,
resp *http.Response,
) bool {
if a == nil {
// No auth supported
return false
}
session, err := a.checkSession(w, r)
if err != nil {
return true
}
if checkResponseForInvalidToken(resp, session, w, r) {
return true
}
return false
}
func (a *Auth) getNamespaceInPath(r *http.Request) string {
if !a.allowNamespaceInPath {
return ""
}
namespaceInPath := r.Header.Get("X-Gitlab-Namespace-In-Path")
if namespaceInPath == "" || !strings.HasPrefix(r.Host, namespaceInPath+"."+a.pagesDomain) {
return ""
}
return namespaceInPath
}
func checkResponseForInvalidToken(resp *http.Response, session *hostSession, w http.ResponseWriter, r *http.Request) bool {
if resp.StatusCode == http.StatusUnauthorized {
errResp := errorResponse{}
// Parse response
defer resp.Body.Close()
err := json.NewDecoder(resp.Body).Decode(&errResp)
if err != nil {
errortracking.CaptureErrWithReqAndStackTrace(err, r)
return false
}
if isAuthInHeader(r) {
logRequest(r).Warn("Authorization token provided in request header was invalid")
httperrors.Serve404(w)
return true
} else if errResp.Error == "invalid_token" {
// Token is invalid
logRequest(r).Warn("Access token was invalid, destroying session")
destroySession(session, w, r)
return true
}
}
return false
}
func isAuthInHeader(r *http.Request) bool {
if !feature.AuthorizationHeader.Enabled() {
return false
}
token := strings.TrimSpace(r.Header.Get("Authorization"))
return token != ""
}
func logRequest(r *http.Request) *logrus.Entry {
return logging.LogRequest(r).WithField("state", r.URL.Query().Get("state"))
}
// generateKeys derives count hkdf keys from a secret, ensuring the key is
// the same for the same secret used across multiple instances
func generateKeys(secret string, count int) ([][]byte, error) {
keys := make([][]byte, count)
hkdfReader := hkdf.New(sha256.New, []byte(secret), []byte{}, []byte("PAGES_SIGNING_AND_ENCRYPTION_KEY"))
for i := 0; i < count; i++ {
key := make([]byte, 32)
if _, err := io.ReadFull(hkdfReader, key); err != nil {
return nil, err
}
keys[i] = key
}
if len(keys) < count {
return nil, errGenerateKeys
}
return keys, nil
}
// Options carry required auth parameters used to populate Auth struct
type Options struct {
PagesDomain string
StoreSecret string
ClientID string
ClientSecret string
RedirectURI string
InternalGitlabServer string
PublicGitlabServer string
AuthScope string
AuthTimeout time.Duration
CookieSessionTimeout time.Duration
AllowNamespaceInPath bool
ClientCfg config.HTTPClientCfg
}
// New when authentication supported this will be used to create authentication handler
func New(options *Options) (*Auth, error) {
// generate 3 keys, 2 for the cookie store and 1 for JWT signing
keys, err := generateKeys(options.StoreSecret, 3)
if err != nil {
return nil, err
}
httpTransport := httptransport.NewTransportWithClientCert(options.ClientCfg)
return &Auth{
pagesDomain: options.PagesDomain,
clientID: options.ClientID,
clientSecret: options.ClientSecret,
redirectURI: options.RedirectURI,
internalGitlabServer: strings.TrimRight(options.InternalGitlabServer, "/"),
publicGitlabServer: strings.TrimRight(options.PublicGitlabServer, "/"),
apiClient: &http.Client{
Timeout: options.AuthTimeout,
Transport: httpTransport,
},
store: sessions.NewCookieStore(keys[0], keys[1]),
authSecret: options.StoreSecret,
authScope: options.AuthScope,
jwtSigningKey: keys[2],
jwtExpiry: time.Minute,
now: time.Now,
cookieSessionTimeout: options.CookieSessionTimeout,
allowNamespaceInPath: options.AllowNamespaceInPath,
}, nil
}