diff options
author | Tuomo Ala-Vannesluoma <tuomoav@gmail.com> | 2018-04-06 18:23:58 +0300 |
---|---|---|
committer | Tuomo Ala-Vannesluoma <tuomoav@gmail.com> | 2018-06-30 22:50:19 +0300 |
commit | 9cf40354085f4b4446f06d4d03926dcaa6ab9565 (patch) | |
tree | eb6ade3a82feec5f9b258417f41a8fecd3e9aec9 /internal/auth | |
parent | c4a419ed595281f62977fd47aa30d225c4eddb5d (diff) |
Add support for private projects and authentication with GitLab API
Diffstat (limited to 'internal/auth')
-rw-r--r-- | internal/auth/auth.go | 279 | ||||
-rw-r--r-- | internal/auth/auth_test.go | 224 |
2 files changed, 503 insertions, 0 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) +} |