Commit 86ad3207 authored by Nick Thomas's avatar Nick Thomas 💃

Merge branch 'auth' into 'master'

Make GitLab pages support access control

This change adds support for access controlled pages by configuration
provided from GitLab to the `config.json`. When project is not public
and access control is enabled for it, pages will require user to
authenticate. This is done by redirecting user to GitLab authorize
endpoint. If project visiblity is public, then access will not be checked.

Pages will store the access token in a session cookie. When access token
is invalid the authentication will be done again.

This work is related to the feature request gitlab-ce#33422, check also
MR gitlab-ce!18589 and omnibus-gitlab!2583.

## Changes
* New fields in the `config.json`
* Auth package for handling OAuth and checking access to a project when necessary
* Test for auth and also acceptance tests

See merge request !94
parents d07b803b f919cbee
Pipeline #32111067 passed with stage
in 5 minutes and 11 seconds
......@@ -160,6 +160,29 @@ $ ./gitlab-pages -listen-http "10.0.0.1:8080" -listen-https "[fd00::1]:8080" -pa
This is most useful in dual-stack environments (IPv4+IPv6) where both Gitlab
Pages and another HTTP server have to co-exist on the same server.
### GitLab access control
GitLab access control is configured with properties `auth-client-id`, `auth-client-secret`, `auth-redirect-uri`, `auth-server` and `auth-secret`. Client ID, secret and redirect uri are configured in the GitLab and should match. `auth-server` points to a GitLab instance used for authentication. `auth-redirect-uri` should be `http(s)://pages-domain/auth`. Note that if the pages-domain is not handled by GitLab pages, then the `auth-redirect-uri` should use some reserved namespace prefix (such as `http(s)://projects.pages-domain/auth`). Using HTTPS is _strongly_ encouraged. `auth-secret` is used to encrypt the session cookie, and it should be strong enough.
Example:
```
$ make
$ ./gitlab-pages -listen-http "10.0.0.1:8080" -listen-https "[fd00::1]:8080" -pages-root path/to/gitlab/shared/pages -pages-domain example.com -auth-client-id <id> -auth-client-secret <secret> -auth-redirect-uri https://projects.example.com/auth -auth-secret something-very-secret -auth-server https://gitlab.com
```
#### How it works
1. GitLab pages looks for `access_control` and `id` fields in `config.json` files
in `pages-root/group/project` directories.
2. For projects that have `access_control` set to `true` pages will require user to authenticate.
3. When user accesses a project that requires authentication, user will be redirected
to GitLab to log in and grant access for GitLab pages.
4. When user grant's access to GitLab pages, pages will use the OAuth2 `code` to get an access
token which is stored in the user session cookie.
5. Pages will now check user's access to a project with a access token stored in the user
session cookie. This is done via a request to GitLab API with the user's access token.
6. If token is invalidated, user will be redirected again to GitLab to authorize pages again.
### Enable Prometheus Metrics
For monitoring purposes, you can pass the `-metrics-address` flag when starting.
......
This diff is collapsed.
......@@ -19,6 +19,7 @@ import (
"gitlab.com/gitlab-org/gitlab-pages/internal/admin"
"gitlab.com/gitlab-org/gitlab-pages/internal/artifact"
"gitlab.com/gitlab-org/gitlab-pages/internal/auth"
"gitlab.com/gitlab-org/gitlab-pages/internal/domain"
"gitlab.com/gitlab-org/gitlab-pages/internal/httperrors"
"gitlab.com/gitlab-org/gitlab-pages/metrics"
......@@ -39,6 +40,7 @@ type theApp struct {
dm domain.Map
lock sync.RWMutex
Artifact *artifact.Artifact
Auth *auth.Auth
}
func (a *theApp) isReady() bool {
......@@ -92,6 +94,32 @@ func (a *theApp) getHostAndDomain(r *http.Request) (host string, domain *domain.
return host, a.domain(host)
}
func (a *theApp) checkAuthenticationIfNotExists(domain *domain.D, w http.ResponseWriter, r *http.Request) bool {
if domain == nil || !domain.HasProject(r) {
// Only if auth is supported
if a.Auth.IsAuthSupported() {
// To avoid user knowing if pages exist, we will force user to login and authorize pages
if a.Auth.CheckAuthenticationWithoutProject(w, r) {
return true
}
// User is authenticated, show the 404
httperrors.Serve404(w)
return true
}
}
// Without auth, fall back to 404
if domain == nil {
httperrors.Serve404(w)
return true
}
return false
}
func (a *theApp) tryAuxiliaryHandlers(w http.ResponseWriter, r *http.Request, https bool, host string, domain *domain.D) bool {
// short circuit content serving to check for a status page
if r.RequestURI == a.appConfig.StatusPath {
......@@ -116,8 +144,7 @@ func (a *theApp) tryAuxiliaryHandlers(w http.ResponseWriter, r *http.Request, ht
return true
}
if domain == nil {
httperrors.Serve404(w)
if a.checkAuthenticationIfNotExists(domain, w, r) {
return true
}
......@@ -138,20 +165,59 @@ func (a *theApp) serveContent(ww http.ResponseWriter, r *http.Request, https boo
host, domain := a.getHostAndDomain(r)
if a.Auth.TryAuthenticate(&w, r, a.dm, &a.lock) {
return
}
if a.tryAuxiliaryHandlers(&w, r, https, host, domain) {
return
}
// Only for projects that have access control enabled
if domain.IsAccessControlEnabled(r) {
log.WithFields(log.Fields{
"host": r.Host,
"path": r.RequestURI,
}).Debug("Authenticate request")
if a.Auth.CheckAuthentication(&w, r, domain.GetID(r)) {
return
}
}
// Serve static file, applying CORS headers if necessary
if a.DisableCrossOriginRequests {
domain.ServeHTTP(&w, r)
a.serveFileOrNotFound(domain)(&w, r)
} else {
corsHandler.ServeHTTP(&w, r, domain.ServeHTTP)
corsHandler.ServeHTTP(&w, r, a.serveFileOrNotFound(domain))
}
metrics.ProcessedRequests.WithLabelValues(strconv.Itoa(w.status), r.Method).Inc()
}
func (a *theApp) serveFileOrNotFound(domain *domain.D) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fileServed := domain.ServeFileHTTP(w, r)
if !fileServed {
// We need to trigger authentication flow here if file does not exist to prevent exposing possibly private project existence,
// because the projects override the paths of the namespace project and they might be private even though
// namespace project is public.
if domain.IsNamespaceProject(r) {
if a.Auth.CheckAuthenticationWithoutProject(w, r) {
return
}
httperrors.Serve404(w)
return
}
domain.ServeNotFoundHTTP(w, r)
}
}
}
func (a *theApp) ServeHTTP(ww http.ResponseWriter, r *http.Request) {
https := r.TLS != nil
a.serveContent(ww, r, https)
......@@ -291,6 +357,11 @@ func runApp(config appConfig) {
a.Artifact = artifact.New(config.ArtifactsServer, config.ArtifactsServerTimeout, config.Domain)
}
if config.ClientID != "" {
a.Auth = auth.New(config.Domain, config.StoreSecret, config.ClientID, config.ClientSecret,
config.RedirectURI, config.GitLabServer)
}
configureLogging(config.LogFormat, config.LogVerbose)
if err := mimedb.LoadTypes(); err != nil {
......
......@@ -25,4 +25,10 @@ type appConfig struct {
LogFormat string
LogVerbose bool
StoreSecret string
GitLabServer string
ClientID string
ClientSecret string
RedirectURI string
}
......@@ -144,6 +144,30 @@ func RunPagesProcessWithSSLCertFile(t *testing.T, pagesPath string, listeners []
return runPagesProcess(t, true, pagesPath, listeners, promPort, []string{"SSL_CERT_FILE=" + sslCertFile}, extraArgs...)
}
func RunPagesProcessWithAuth(t *testing.T, pagesPath string, listeners []ListenSpec, promPort string) (teardown func()) {
return runPagesProcess(t, true, pagesPath, listeners, promPort, nil, "-auth-client-id=1",
"-auth-client-secret=1",
"-auth-server=https://gitlab-auth.com",
"-auth-redirect-uri=https://projects.gitlab-example.com/auth",
"-auth-secret=something-very-secret")
}
func RunPagesProcessWithAuthServer(t *testing.T, pagesPath string, listeners []ListenSpec, promPort string, authServer string) (teardown func()) {
return runPagesProcess(t, true, pagesPath, listeners, promPort, nil, "-auth-client-id=1",
"-auth-client-secret=1",
"-auth-server="+authServer,
"-auth-redirect-uri=https://projects.gitlab-example.com/auth",
"-auth-secret=something-very-secret")
}
func RunPagesProcessWithAuthServerWithSSL(t *testing.T, pagesPath string, listeners []ListenSpec, promPort string, sslCertFile string, authServer string) (teardown func()) {
return runPagesProcess(t, true, pagesPath, listeners, promPort, []string{"SSL_CERT_FILE=" + sslCertFile}, "-auth-client-id=1",
"-auth-client-secret=1",
"-auth-server="+authServer,
"-auth-redirect-uri=https://projects.gitlab-example.com/auth",
"-auth-secret=something-very-secret")
}
func runPagesProcess(t *testing.T, wait bool, pagesPath string, listeners []ListenSpec, promPort string, extraEnv []string, extraArgs ...string) (teardown func()) {
_, err := os.Stat(pagesPath)
require.NoError(t, err)
......@@ -248,11 +272,18 @@ func getPagesDaemonArgs(t *testing.T) []string {
// Does a HTTP(S) GET against the listener specified, setting a fake
// Host: and constructing the URL from the listener and the URL suffix.
func GetPageFromListener(t *testing.T, spec ListenSpec, host, urlsuffix string) (*http.Response, error) {
return GetPageFromListenerWithCookie(t, spec, host, urlsuffix, "")
}
func GetPageFromListenerWithCookie(t *testing.T, spec ListenSpec, host, urlsuffix string, cookie string) (*http.Response, error) {
url := spec.URL(urlsuffix)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
req.Host = host
......@@ -279,11 +310,18 @@ func DoPagesRequest(t *testing.T, req *http.Request) (*http.Response, error) {
}
func GetRedirectPage(t *testing.T, spec ListenSpec, host, urlsuffix string) (*http.Response, error) {
return GetRedirectPageWithCookie(t, spec, host, urlsuffix, "")
}
func GetRedirectPageWithCookie(t *testing.T, spec ListenSpec, host, urlsuffix string, cookie string) (*http.Response, error) {
url := spec.URL(urlsuffix)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
req.Host = host
......
......@@ -12,6 +12,7 @@ import (
"time"
"gitlab.com/gitlab-org/gitlab-pages/internal/httperrors"
"gitlab.com/gitlab-org/gitlab-pages/internal/httptransport"
)
const (
......@@ -43,7 +44,7 @@ func New(server string, timeoutSeconds int, pagesDomain string) *Artifact {
suffix: "." + strings.ToLower(pagesDomain),
client: &http.Client{
Timeout: time.Second * time.Duration(timeoutSeconds),
Transport: transport,
Transport: httptransport.Transport,
},
}
}
......
This diff is collapsed.
package auth_test
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
"github.com/gorilla/sessions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gitlab.com/gitlab-org/gitlab-pages/internal/auth"
"gitlab.com/gitlab-org/gitlab-pages/internal/domain"
)
func createAuth(t *testing.T) *auth.Auth {
return auth.New("pages.gitlab-example.com",
"something-very-secret",
"id",
"secret",
"http://pages.gitlab-example.com/auth",
"http://gitlab-example.com")
}
func TestTryAuthenticate(t *testing.T) {
auth := createAuth(t)
result := httptest.NewRecorder()
reqURL, err := url.Parse("/something/else")
require.NoError(t, err)
r := &http.Request{URL: reqURL}
assert.Equal(t, false, auth.TryAuthenticate(result, r, make(domain.Map), &sync.RWMutex{}))
}
func TestTryAuthenticateWithError(t *testing.T) {
auth := createAuth(t)
result := httptest.NewRecorder()
reqURL, err := url.Parse("/auth?error=access_denied")
require.NoError(t, err)
r := &http.Request{URL: reqURL}
assert.Equal(t, true, auth.TryAuthenticate(result, r, make(domain.Map), &sync.RWMutex{}))
assert.Equal(t, 401, result.Code)
}
func TestTryAuthenticateWithCodeButInvalidState(t *testing.T) {
store := sessions.NewCookieStore([]byte("something-very-secret"))
auth := createAuth(t)
result := httptest.NewRecorder()
reqURL, err := url.Parse("/auth?code=1&state=invalid")
require.NoError(t, err)
r := &http.Request{URL: reqURL}
session, _ := store.Get(r, "gitlab-pages")
session.Values["state"] = "state"
session.Save(r, result)
assert.Equal(t, true, auth.TryAuthenticate(result, r, make(domain.Map), &sync.RWMutex{}))
assert.Equal(t, 401, result.Code)
}
func TestTryAuthenticateWithCodeAndState(t *testing.T) {
apiServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/oauth/token":
assert.Equal(t, "POST", r.Method)
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "{\"access_token\":\"abc\"}")
case "/api/v4/projects/1000/pages_access":
assert.Equal(t, "Bearer abc", r.Header.Get("Authorization"))
w.WriteHeader(http.StatusOK)
default:
t.Logf("Unexpected r.URL.RawPath: %q", r.URL.Path)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
}
}))
apiServer.Start()
defer apiServer.Close()
store := sessions.NewCookieStore([]byte("something-very-secret"))
auth := auth.New("pages.gitlab-example.com",
"something-very-secret",
"id",
"secret",
"http://pages.gitlab-example.com/auth",
apiServer.URL)
result := httptest.NewRecorder()
reqURL, err := url.Parse("/auth?code=1&state=state")
require.NoError(t, err)
r := &http.Request{URL: reqURL}
session, _ := store.Get(r, "gitlab-pages")
session.Values["uri"] = "http://pages.gitlab-example.com/project/"
session.Values["state"] = "state"
session.Save(r, result)
assert.Equal(t, true, auth.TryAuthenticate(result, r, make(domain.Map), &sync.RWMutex{}))
assert.Equal(t, 302, result.Code)
assert.Equal(t, "http://pages.gitlab-example.com/project/", result.Header().Get("Location"))
}
func TestCheckAuthenticationWhenAccess(t *testing.T) {
apiServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v4/projects/1000/pages_access":
assert.Equal(t, "Bearer abc", r.Header.Get("Authorization"))
w.WriteHeader(http.StatusOK)
default:
t.Logf("Unexpected r.URL.RawPath: %q", r.URL.Path)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
}
}))
apiServer.Start()
defer apiServer.Close()
store := sessions.NewCookieStore([]byte("something-very-secret"))
auth := auth.New("pages.gitlab-example.com",
"something-very-secret",
"id",
"secret",
"http://pages.gitlab-example.com/auth",
apiServer.URL)
result := httptest.NewRecorder()
reqURL, err := url.Parse("/auth?code=1&state=state")
require.NoError(t, err)
r := &http.Request{URL: reqURL}
session, _ := store.Get(r, "gitlab-pages")
session.Values["access_token"] = "abc"
session.Save(r, result)
assert.Equal(t, false, auth.CheckAuthentication(result, r, 1000))
assert.Equal(t, 200, result.Code)
}
func TestCheckAuthenticationWhenNoAccess(t *testing.T) {
apiServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v4/projects/1000/pages_access":
assert.Equal(t, "Bearer abc", r.Header.Get("Authorization"))
w.WriteHeader(http.StatusUnauthorized)
default:
t.Logf("Unexpected r.URL.RawPath: %q", r.URL.Path)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
}
}))
apiServer.Start()
defer apiServer.Close()
store := sessions.NewCookieStore([]byte("something-very-secret"))
auth := auth.New("pages.gitlab-example.com",
"something-very-secret",
"id",
"secret",
"http://pages.gitlab-example.com/auth",
apiServer.URL)
result := httptest.NewRecorder()
reqURL, err := url.Parse("/auth?code=1&state=state")
require.NoError(t, err)
r := &http.Request{URL: reqURL}
session, _ := store.Get(r, "gitlab-pages")
session.Values["access_token"] = "abc"
session.Save(r, result)
assert.Equal(t, true, auth.CheckAuthentication(result, r, 1000))
assert.Equal(t, 404, result.Code)
}
func TestCheckAuthenticationWhenInvalidToken(t *testing.T) {
apiServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v4/projects/1000/pages_access":
assert.Equal(t, "Bearer abc", r.Header.Get("Authorization"))
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, "{\"error\":\"invalid_token\"}")
default:
t.Logf("Unexpected r.URL.RawPath: %q", r.URL.Path)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
}
}))
apiServer.Start()
defer apiServer.Close()
store := sessions.NewCookieStore([]byte("something-very-secret"))
auth := auth.New("pages.gitlab-example.com",
"something-very-secret",
"id",
"secret",
"http://pages.gitlab-example.com/auth",
apiServer.URL)
result := httptest.NewRecorder()
reqURL, err := url.Parse("/auth?code=1&state=state")
require.NoError(t, err)
r := &http.Request{URL: reqURL}
session, _ := store.Get(r, "gitlab-pages")
session.Values["access_token"] = "abc"
session.Save(r, result)
assert.Equal(t, true, auth.CheckAuthentication(result, r, 1000))
assert.Equal(t, 302, result.Code)
}
func TestCheckAuthenticationWithoutProject(t *testing.T) {
apiServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v4/user":
assert.Equal(t, "Bearer abc", r.Header.Get("Authorization"))
w.WriteHeader(http.StatusOK)
default:
t.Logf("Unexpected r.URL.RawPath: %q", r.URL.Path)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
}
}))
apiServer.Start()
defer apiServer.Close()
store := sessions.NewCookieStore([]byte("something-very-secret"))
auth := auth.New("pages.gitlab-example.com",
"something-very-secret",
"id",
"secret",
"http://pages.gitlab-example.com/auth",
apiServer.URL)
result := httptest.NewRecorder()
reqURL, err := url.Parse("/auth?code=1&state=state")
require.NoError(t, err)
r := &http.Request{URL: reqURL}
session, _ := store.Get(r, "gitlab-pages")
session.Values["access_token"] = "abc"
session.Save(r, result)
assert.Equal(t, false, auth.CheckAuthenticationWithoutProject(result, r))
assert.Equal(t, 200, result.Code)
}
func TestCheckAuthenticationWithoutProjectWhenInvalidToken(t *testing.T) {
apiServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v4/user":
assert.Equal(t, "Bearer abc", r.Header.Get("Authorization"))
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, "{\"error\":\"invalid_token\"}")
default:
t.Logf("Unexpected r.URL.RawPath: %q", r.URL.Path)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
}
}))
apiServer.Start()
defer apiServer.Close()
store := sessions.NewCookieStore([]byte("something-very-secret"))
auth := auth.New("pages.gitlab-example.com",
"something-very-secret",
"id",
"secret",
"http://pages.gitlab-example.com/auth",
apiServer.URL)
result := httptest.NewRecorder()
reqURL, err := url.Parse("/auth?code=1&state=state")
require.NoError(t, err)
r := &http.Request{URL: reqURL}
session, _ := store.Get(r, "gitlab-pages")
session.Values["access_token"] = "abc"
session.Save(r, result)
assert.Equal(t, true, auth.CheckAuthenticationWithoutProject(result, r))
assert.Equal(t, 302, result.Code)
}
......@@ -25,7 +25,10 @@ type locationDirectoryError struct {
}
type project struct {
HTTPSOnly bool
NamespaceProject bool
HTTPSOnly bool
AccessControl bool
ID uint64
}
type projects map[string]*project
......@@ -151,6 +154,79 @@ func (d *D) IsHTTPSOnly(r *http.Request) bool {
return false
}
// IsAccessControlEnabled figures out if the request is to a project that has access control enabled
func (d *D) IsAccessControlEnabled(r *http.Request) bool {
if d == nil {
return false
}
// Check custom domain config (e.g. http://example.com)
if d.config != nil {
return d.config.AccessControl
}
// Check projects served under the group domain, including the default one
if project, _, _ := d.getProjectWithSubpath(r); project != nil {
return project.AccessControl
}
return false
}
// IsNamespaceProject figures out if the request is to a namespace project
func (d *D) IsNamespaceProject(r *http.Request) bool {
if d == nil {
return false
}
// If request is to a custom domain, we do not handle it as a namespace project
// as there can't be multiple projects under the same custom domain
if d.config != nil {
return false
}
// Check projects served under the group domain, including the default one
if project, _, _ := d.getProjectWithSubpath(r); project != nil {
return project.NamespaceProject
}
return false
}
// GetID figures out what is the ID of the project user tries to access
func (d *D) GetID(r *http.Request) uint64 {
if d == nil {
return 0
}
if d.config != nil {
return d.config.ID
}
if project, _, _ := d.getProjectWithSubpath(r); project != nil {
return project.ID
}
return 0
}
// HasProject figures out if the project exists that the user tries to access
func (d *D) HasProject(r *http.Request) bool {
if d == nil {
return false
}
if d.config != nil {
return true
}
if project, _, _ := d.getProjectWithSubpath(r); project != nil {
return true
}
return false
}
func (d *D) serveFile(w http.ResponseWriter, r *http.Request, origPath string) error {
fullPath := handleGZip(w, r, origPath)
......@@ -165,9 +241,11 @@ func (d *D) serveFile(w http.ResponseWriter, r *http.Request, origPath string) e
return err
}
// Set caching headers
w.Header().Set("Cache-Control", "max-age=600")
w.Header().Set("Expires", time.Now().Add(10*time.Minute).Format(time.RFC1123))
if !d.IsAccessControlEnabled(r) {
// Set caching headers
w.Header().Set("Cache-Control", "max-age=600")
w.Header().Set("Expires", time.Now().Add(10*time.Minute).Format(time.RFC1123))
}
// ServeContent sets Content-Type for us
http.ServeContent(w, r, origPath, fi.ModTime(), file)
......@@ -279,20 +357,26 @@ func (d *D) tryFile(w http.ResponseWriter, r *http.Request, projectName string,
return d.serveFile(w, r, fullPath)
}
func (d *D) serveFromGroup(w http.ResponseWriter, r *http.Request) {
func (d *D) serveFileFromGroup(w http.ResponseWriter, r *http.Request) bool {
project, projectName, subPath := d.getProjectWithSubpath(r)
if project == nil {
httperrors.Serve404(w)
return
return true
}
if d.tryFile(w, r, projectName, subPath) == nil {
return
return true
}