Commit 9cf40354 authored by Tuomo Ala-Vannesluoma's avatar Tuomo Ala-Vannesluoma

Add support for private projects and authentication with GitLab API

parent c4a419ed
......@@ -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`. 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://example.com/auth -auth-secret something-very-secret -auth-server https://gitlab.com
```
#### How it works
1. GitLab pages looks for `access_control`, `private` and `id` fields in `config.json` files
in `pages-root/group/project` directories.
2. For projects that have `access_control` and `private` 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 {
......@@ -138,10 +140,21 @@ func (a *theApp) serveContent(ww http.ResponseWriter, r *http.Request, https boo
host, domain := a.getHostAndDomain(r)
if a.Auth.TryAuthenticate(&w, r) {
return
}
if a.tryAuxiliaryHandlers(&w, r, https, host, domain) {
return
}
// Only for private domains that have access control enabled
if domain.IsAccessControlEnabled(r) && domain.IsPrivate(r) {
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)
......@@ -291,6 +304,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
}
......@@ -248,11 +248,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 +286,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
......
package auth
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"strings"
"time"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
"gitlab.com/gitlab-org/gitlab-pages/internal/httperrors"
)
const (
apiURLTemplate = "%s/api/v4/projects/%d?access_token=%s"
authorizeURLTemplate = "%s/oauth/authorize?client_id=%s&redirect_uri=%s&response_type=code&state=%s"
tokenURLTemplate = "%s/oauth/token"
tokenContentTemplate = "client_id=%s&client_secret=%s&code=%s&grant_type=authorization_code&redirect_uri=%s"
callbackPath = "/auth"
)
// Auth handles authenticating users with GitLab API
type Auth struct {
clientID string
clientSecret string
redirectURI string
gitLabServer string
store *sessions.CookieStore
apiClient *http.Client
}
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"`
}
func (a *Auth) checkSession(w http.ResponseWriter, r *http.Request) bool {
// Create or get session
session, err := a.store.Get(r, "gitlab-pages")
if err != nil {
// Save cookie again
session.Save(r, w)
http.Redirect(w, r, getRequestAddress(r), 302)
return true
}
return false
}
func (a *Auth) getSession(r *http.Request) *sessions.Session {
session, _ := a.store.Get(r, "gitlab-pages")
return session
}
// 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) bool {
if a == nil {
return false
}
if a.checkSession(w, r) {
return true
}
session := a.getSession(r)
// If callback from authentication and the state matches
if r.URL.Path != callbackPath {
return false
}
// If callback is not successful
errorParam := r.URL.Query().Get("error")
if errorParam != "" {
httperrors.Serve401(w)
return true
}
if verifyCodeAndStateGiven(r) {
if !validateState(r, session) {
// State is NOT ok
httperrors.Serve401(w)
return true
}
// Fetch access token with authorization code
token, err := a.fetchAccessToken(r.URL.Query().Get("code"))
// Fetching token not OK
if err != nil {
httperrors.Serve503(w)
return true
}
// Store access token
session.Values["access_token"] = token.AccessToken
session.Save(r, w)
// Redirect back to requested URI
http.Redirect(w, r, session.Values["uri"].(string), 302)
return true
}
return false
}
func getRequestAddress(r *http.Request) string {
if r.TLS != nil {
return "https://" + r.Host + r.RequestURI
}
return "http://" + r.Host + r.RequestURI
}
func validateState(r *http.Request, session *sessions.Session) 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(code string) (tokenResponse, error) {
token := tokenResponse{}
// Prepare request
url := fmt.Sprintf(tokenURLTemplate, a.gitLabServer)
content := fmt.Sprintf(tokenContentTemplate, a.clientID, a.clientSecret, code, a.redirectURI)
req, err := http.NewRequest("POST", url, strings.NewReader(content))
if err != nil {
return token, err
}
// Request token
resp, err := a.apiClient.Do(req)
if err != nil {
return token, err
}
if resp.StatusCode != 200 {
return token, errors.New("response was not OK")
}
// Parse response
body, _ := ioutil.ReadAll(resp.Body)
defer resp.Body.Close()
err = json.Unmarshal(body, &token)
if err != nil {
return token, err
}
return token, nil
}
// CheckAuthentication checks if user is authenticated and has access to the project
func (a *Auth) CheckAuthentication(w http.ResponseWriter, r *http.Request, projectID int) bool {
if a == nil {
return false
}
if a.checkSession(w, r) {
return true
}
session := a.getSession(r)
// If no access token redirect to OAuth login page
if session.Values["access_token"] == nil {
// Generate state hash and store requested address
state := base64.URLEncoding.EncodeToString(securecookie.GenerateRandomKey(16))
session.Values["state"] = state
session.Values["uri"] = getRequestAddress(r)
session.Save(r, w)
// Redirect to OAuth login
url := fmt.Sprintf(authorizeURLTemplate, a.gitLabServer, a.clientID, a.redirectURI, state)
http.Redirect(w, r, url, 302)
return true
}
// Access token exists, authorize request
url := fmt.Sprintf(apiURLTemplate, a.gitLabServer, projectID, session.Values["access_token"].(string))
resp, err := a.apiClient.Get(url)
if checkResponseForInvalidToken(resp, err) {
// Invalidate access token and redirect back for refreshing and re-authenticating
delete(session.Values, "access_token")
session.Save(r, w)
http.Redirect(w, r, getRequestAddress(r), 302)
return true
}
if err != nil || resp.StatusCode != 200 {
httperrors.Serve401(w)
return true
}
return false
}
func checkResponseForInvalidToken(resp *http.Response, err error) bool {
if err == nil && resp.StatusCode == 401 {
errResp := errorResponse{}
// Parse response
body, _ := ioutil.ReadAll(resp.Body)
defer resp.Body.Close()
err = json.Unmarshal(body, &errResp)
if err != nil {
return false
}
if errResp.Error == "invalid_token" {
// Token is invalid
return true
}
}
return false
}
// New when authentication supported this will be used to create authentication handler
func New(pagesDomain string, storeSecret string, clientID string, clientSecret string,
redirectURI string, gitLabServer string) *Auth {
store := sessions.NewCookieStore([]byte(storeSecret))
store.Options = &sessions.Options{
Path: "/",
Domain: pagesDomain,
}
return &Auth{
clientID: clientID,
clientSecret: clientSecret,
redirectURI: redirectURI,
gitLabServer: strings.TrimRight(gitLabServer, "/"),
store: store,
apiClient: &http.Client{Timeout: 5 * time.Second},
}
}
package auth_test
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gorilla/sessions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gitlab.com/gitlab-org/gitlab-pages/internal/auth"
)
func TestTryAuthenticate(t *testing.T) {
auth := auth.New("pages.gitlab-example.com",
"something-very-secret",
"id",
"secret",
"http://pages.gitlab-example.com/auth",
"http://gitlab-example.com")
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))
}
func TestTryAuthenticateWithError(t *testing.T) {
auth := auth.New("pages.gitlab-example.com",
"something-very-secret",
"id",
"secret",
"http://pages.gitlab-example.com/auth",
"http://gitlab-example.com")
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))
assert.Equal(t, 401, result.Code)
}
func TestTryAuthenticateWithCodeButInvalidState(t *testing.T) {
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",
"http://gitlab-example.com")
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))
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":
assert.Equal(t, "abc", r.URL.Query().Get("access_token"))
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))
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":
assert.Equal(t, "abc", r.URL.Query().Get("access_token"))
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":
assert.Equal(t, "abc", r.URL.Query().Get("access_token"))
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, 401, 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":
assert.Equal(t, "abc", r.URL.Query().Get("access_token"))
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)
}
......@@ -24,7 +24,10 @@ type locationDirectoryError struct {
}
type project struct {
HTTPSOnly bool
HTTPSOnly bool
Private bool
AccessControl bool
ID int
}
type projects map[string]*project
......@@ -97,6 +100,14 @@ func setContentType(w http.ResponseWriter, fullPath string) {
}
}
func (d *D) getProject(r *http.Request) *project {
split := strings.SplitN(r.URL.Path, "/", 3)
if len(split) < 2 {
return nil
}
return d.projects[split[1]]
}
// IsHTTPSOnly figures out if the request should be handled with HTTPS
// only by looking at group and project level config.
func (d *D) IsHTTPSOnly(r *http.Request) bool {
......@@ -104,20 +115,48 @@ func (d *D) IsHTTPSOnly(r *http.Request) bool {
return d.config.HTTPSOnly
}
split := strings.SplitN(r.URL.Path, "/", 3)
if len(split) < 2 {
return false
project := d.getProject(r)
if project != nil {
return project.HTTPSOnly
}
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 {
project := d.getProject(r)
if project != nil {
return project.AccessControl
}
project := d.projects[split[1]]
return false
}
// IsPrivate figures out if the request is to a project that needs user to sign in
func (d *D) IsPrivate(r *http.Request) bool {
project := d.getProject(r)
if project != nil {
return project.HTTPSOnly
return project.Private
}
return false
}
// GetID figures out what is the ID of the project user tries to access
func (d *D) GetID(r *http.Request) int {
project := d.getProject(r)
if project != nil {
return project.ID
}
return -1
}
func (d *D) serveFile(w http.ResponseWriter, r *http.Request, origPath string) error {
fullPath := handleGZip(w, r, origPath)
......
......@@ -15,8 +15,11 @@ type domainConfig struct {
}
type domainsConfig struct {