diff options
Diffstat (limited to 'internal')
-rw-r--r-- | internal/auth/auth.go | 279 | ||||
-rw-r--r-- | internal/auth/auth_test.go | 224 | ||||
-rw-r--r-- | internal/domain/domain.go | 51 | ||||
-rw-r--r-- | internal/domain/domain_config.go | 7 | ||||
-rw-r--r-- | internal/domain/domain_test.go | 1 | ||||
-rw-r--r-- | internal/domain/map.go | 11 | ||||
-rw-r--r-- | internal/httperrors/httperrors.go | 12 | ||||
-rw-r--r-- | internal/httperrors/httperrors_test.go | 12 |
8 files changed, 585 insertions, 12 deletions
diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 00000000..d5600d32 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,279 @@ +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}, + } +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 00000000..e8a95662 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,224 @@ +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) +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go index f50dacd8..3aef96f5 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -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) diff --git a/internal/domain/domain_config.go b/internal/domain/domain_config.go index ed7e0820..1acb3461 100644 --- a/internal/domain/domain_config.go +++ b/internal/domain/domain_config.go @@ -15,8 +15,11 @@ type domainConfig struct { } type domainsConfig struct { - Domains []domainConfig - HTTPSOnly bool `json:"https_only"` + Domains []domainConfig + HTTPSOnly bool `json:"https_only"` + Private bool `json:"private"` + ID int `json:"id"` + AccessControl bool `json:"access_control"` } func (c *domainConfig) Valid(rootDomain string) bool { diff --git a/internal/domain/domain_test.go b/internal/domain/domain_test.go index 130501d6..4cbc6cc2 100644 --- a/internal/domain/domain_test.go +++ b/internal/domain/domain_test.go @@ -38,6 +38,7 @@ func TestGroupServeHTTP(t *testing.T) { assert.HTTPBodyContains(t, testGroup.ServeHTTP, "GET", "http://group.test.io/project/subdir/", nil, "project-subsubdir") assert.HTTPBodyContains(t, testGroup.ServeHTTP, "GET", "http://group.test.io/project2/", nil, "project2-main") assert.HTTPBodyContains(t, testGroup.ServeHTTP, "GET", "http://group.test.io/project2/index.html", nil, "project2-main") + assert.HTTPRedirect(t, testGroup.ServeHTTP, "GET", "http://group.test.io/private.project/", nil) assert.HTTPError(t, testGroup.ServeHTTP, "GET", "http://group.test.io//about.gitlab.com/%2e%2e", nil) assert.HTTPError(t, testGroup.ServeHTTP, "GET", "http://group.test.io/symlink", nil) assert.HTTPError(t, testGroup.ServeHTTP, "GET", "http://group.test.io/symlink/index.html", nil) diff --git a/internal/domain/map.go b/internal/domain/map.go index 943f5c20..a6537bda 100644 --- a/internal/domain/map.go +++ b/internal/domain/map.go @@ -32,7 +32,7 @@ func (dm Map) addDomain(rootDomain, group, projectName string, config *domainCon dm[domainName] = newDomain } -func (dm Map) updateGroupDomain(rootDomain, group, projectName string, httpsOnly bool) { +func (dm Map) updateGroupDomain(rootDomain, group, projectName string, httpsOnly bool, private bool, accessControl bool, id int) { domainName := strings.ToLower(group + "." + rootDomain) groupDomain := dm[domainName] @@ -44,7 +44,10 @@ func (dm Map) updateGroupDomain(rootDomain, group, projectName string, httpsOnly } groupDomain.projects[projectName] = &project{ - HTTPSOnly: httpsOnly, + HTTPSOnly: httpsOnly, + Private: private, + AccessControl: accessControl, + ID: id, } dm[domainName] = groupDomain @@ -55,11 +58,11 @@ func (dm Map) readProjectConfig(rootDomain string, group, projectName string, co // This is necessary to preserve the previous behaviour where a // group domain is created even if no config.json files are // loaded successfully. Is it safe to remove this? - dm.updateGroupDomain(rootDomain, group, projectName, false) + dm.updateGroupDomain(rootDomain, group, projectName, false, false, false, -1) return } - dm.updateGroupDomain(rootDomain, group, projectName, config.HTTPSOnly) + dm.updateGroupDomain(rootDomain, group, projectName, config.HTTPSOnly, config.Private, config.AccessControl, config.ID) for _, domainConfig := range config.Domains { config := domainConfig // domainConfig is reused for each loop iteration diff --git a/internal/httperrors/httperrors.go b/internal/httperrors/httperrors.go index 92413e07..1ae5224b 100644 --- a/internal/httperrors/httperrors.go +++ b/internal/httperrors/httperrors.go @@ -14,6 +14,13 @@ type content struct { } var ( + content401 = content{ + http.StatusUnauthorized, + "Unauthorized (401)", + "401", + "You don't have permission to access the resource.", + `<p>The resource that you are attempting to access is protected and you don't have the necessary permissions to view it.</p>`, + } content404 = content{ http.StatusNotFound, "The page you're looking for could not be found (404)", @@ -155,6 +162,11 @@ func serveErrorPage(w http.ResponseWriter, c content) { fmt.Fprintln(w, generateErrorHTML(c)) } +// Serve401 returns a 401 error response / HTML page to the http.ResponseWriter +func Serve401(w http.ResponseWriter) { + serveErrorPage(w, content401) +} + // Serve404 returns a 404 error response / HTML page to the http.ResponseWriter func Serve404(w http.ResponseWriter) { serveErrorPage(w, content404) diff --git a/internal/httperrors/httperrors_test.go b/internal/httperrors/httperrors_test.go index 1a79d850..be532dfe 100644 --- a/internal/httperrors/httperrors_test.go +++ b/internal/httperrors/httperrors_test.go @@ -68,6 +68,18 @@ func TestServeErrorPage(t *testing.T) { assert.Equal(t, w.Status(), testingContent.status) } +func TestServe401(t *testing.T) { + w := newTestResponseWriter(httptest.NewRecorder()) + Serve401(w) + assert.Equal(t, w.Header().Get("Content-Type"), "text/html; charset=utf-8") + assert.Equal(t, w.Header().Get("X-Content-Type-Options"), "nosniff") + assert.Equal(t, w.Status(), content401.status) + assert.Contains(t, w.Content(), content401.title) + assert.Contains(t, w.Content(), content401.statusString) + assert.Contains(t, w.Content(), content401.header) + assert.Contains(t, w.Content(), content401.subHeader) +} + func TestServe404(t *testing.T) { w := newTestResponseWriter(httptest.NewRecorder()) Serve404(w) |