chore: add workspace proxies to the backend (#7032)

Co-authored-by: Dean Sheather <dean@deansheather.com>
This commit is contained in:
Steven Masley
2023-04-17 14:57:21 -05:00
committed by GitHub
parent dc5e16ae22
commit 658246d5f2
61 changed files with 3641 additions and 757 deletions

37
coderd/httpmw/actor.go Normal file
View File

@ -0,0 +1,37 @@
package httpmw
import (
"net/http"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/codersdk"
)
// RequireAPIKeyOrWorkspaceProxyAuth is middleware that should be inserted after
// optional ExtractAPIKey and ExtractWorkspaceProxy middlewares to ensure one of
// the two authentication methods is provided.
//
// If both are provided, an error is returned to avoid misuse.
func RequireAPIKeyOrWorkspaceProxyAuth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, hasAPIKey := APIKeyOptional(r)
_, hasWorkspaceProxy := WorkspaceProxyOptional(r)
if hasAPIKey && hasWorkspaceProxy {
httpapi.Write(r.Context(), w, http.StatusBadRequest, codersdk.Response{
Message: "API key and external proxy authentication provided, but only one is allowed",
})
return
}
if !hasAPIKey && !hasWorkspaceProxy {
httpapi.Write(r.Context(), w, http.StatusUnauthorized, codersdk.Response{
Message: "API key or external proxy authentication required, but none provided",
})
return
}
next.ServeHTTP(w, r)
})
}
}

143
coderd/httpmw/actor_test.go Normal file
View File

@ -0,0 +1,143 @@
package httpmw_test
import (
"fmt"
"net/http"
"net/http/httptest"
"net/http/httputil"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/dbfake"
"github.com/coder/coder/coderd/database/dbgen"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/codersdk"
)
func TestRequireAPIKeyOrWorkspaceProxyAuth(t *testing.T) {
t.Parallel()
t.Run("None", func(t *testing.T) {
t.Parallel()
r := httptest.NewRequest(http.MethodGet, "/", nil)
rw := httptest.NewRecorder()
httpmw.RequireAPIKeyOrWorkspaceProxyAuth()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("should not have been called")
})).ServeHTTP(rw, r)
require.Equal(t, http.StatusUnauthorized, rw.Code)
})
t.Run("APIKey", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
user = dbgen.User(t, db, database.User{})
_, token = dbgen.APIKey(t, db, database.APIKey{
UserID: user.ID,
ExpiresAt: database.Now().AddDate(0, 0, 1),
})
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
r.Header.Set(codersdk.SessionTokenHeader, token)
var called int64
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(
httpmw.RequireAPIKeyOrWorkspaceProxyAuth()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&called, 1)
rw.WriteHeader(http.StatusOK)
}))).
ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
dump, err := httputil.DumpResponse(res, true)
require.NoError(t, err)
t.Log(string(dump))
require.Equal(t, http.StatusOK, rw.Code)
require.Equal(t, int64(1), atomic.LoadInt64(&called))
})
t.Run("WorkspaceProxy", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
user = dbgen.User(t, db, database.User{})
_, userToken = dbgen.APIKey(t, db, database.APIKey{
UserID: user.ID,
ExpiresAt: database.Now().AddDate(0, 0, 1),
})
proxy, proxyToken = dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{})
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
r.Header.Set(codersdk.SessionTokenHeader, userToken)
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, fmt.Sprintf("%s:%s", proxy.ID, proxyToken))
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: db,
})(
httpmw.RequireAPIKeyOrWorkspaceProxyAuth()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusOK)
})))).
ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
dump, err := httputil.DumpResponse(res, true)
require.NoError(t, err)
t.Log(string(dump))
require.Equal(t, http.StatusBadRequest, rw.Code)
})
t.Run("Both", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
proxy, token = dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{})
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, fmt.Sprintf("%s:%s", proxy.ID, token))
var called int64
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: db,
})(
httpmw.RequireAPIKeyOrWorkspaceProxyAuth()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&called, 1)
rw.WriteHeader(http.StatusOK)
}))).
ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
dump, err := httputil.DumpResponse(res, true)
require.NoError(t, err)
t.Log(string(dump))
require.Equal(t, http.StatusOK, rw.Code)
require.Equal(t, int64(1), atomic.LoadInt64(&called))
})
}

View File

@ -47,9 +47,10 @@ type userAuthKey struct{}
type Authorization struct {
Actor rbac.Subject
// Username is required for logging and human friendly related
// identification.
Username string
// ActorName is required for logging and human friendly related identification.
// It is usually the "username" of the user, but it can be the name of the
// external workspace proxy or other service type actor.
ActorName string
}
// UserAuthorizationOptional may return the roles and scope used for
@ -99,6 +100,10 @@ type ExtractAPIKeyConfig struct {
// will be deleted and the request will continue. If the request is not a
// cookie-based request, the request will be rejected with a 401.
Optional bool
// SessionTokenFunc is a custom function that can be used to extract the API
// key. If nil, the default behavior is used.
SessionTokenFunc func(r *http.Request) string
}
// ExtractAPIKeyMW calls ExtractAPIKey with the given config on each request,
@ -145,7 +150,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
// like workspace applications.
write := func(code int, response codersdk.Response) (*database.APIKey, *Authorization, bool) {
if cfg.RedirectToLogin {
RedirectToLogin(rw, r, response.Message)
RedirectToLogin(rw, r, nil, response.Message)
return nil, nil, false
}
@ -167,7 +172,11 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
return nil, nil, false
}
token := apiTokenFromRequest(r)
tokenFunc := APITokenFromRequest
if cfg.SessionTokenFunc != nil {
tokenFunc = cfg.SessionTokenFunc
}
token := tokenFunc(r)
if token == "" {
return optionalWrite(http.StatusUnauthorized, codersdk.Response{
Message: SignedOutErrorMessage,
@ -364,7 +373,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
// Actor is the user's authorization context.
authz := Authorization{
Username: roles.Username,
ActorName: roles.Username,
Actor: rbac.Subject{
ID: key.UserID.String(),
Roles: rbac.RoleNames(roles.Roles),
@ -376,14 +385,14 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
return &key, &authz, true
}
// apiTokenFromRequest returns the api token from the request.
// APITokenFromRequest returns the api token from the request.
// Find the session token from:
// 1: The cookie
// 1: The devurl cookie
// 3: The old cookie
// 4. The coder_session_token query parameter
// 5. The custom auth header
func apiTokenFromRequest(r *http.Request) string {
func APITokenFromRequest(r *http.Request) string {
cookie, err := r.Cookie(codersdk.SessionTokenCookie)
if err == nil && cookie.Value != "" {
return cookie.Value
@ -432,7 +441,11 @@ func SplitAPIToken(token string) (id string, secret string, err error) {
// RedirectToLogin redirects the user to the login page with the `message` and
// `redirect` query parameters set.
func RedirectToLogin(rw http.ResponseWriter, r *http.Request, message string) {
//
// If dashboardURL is nil, the redirect will be relative to the current
// request's host. If it is not nil, the redirect will be absolute with dashboard
// url as the host.
func RedirectToLogin(rw http.ResponseWriter, r *http.Request, dashboardURL *url.URL, message string) {
path := r.URL.Path
if r.URL.RawQuery != "" {
path += "?" + r.URL.RawQuery
@ -446,6 +459,16 @@ func RedirectToLogin(rw http.ResponseWriter, r *http.Request, message string) {
Path: "/login",
RawQuery: q.Encode(),
}
// If dashboardURL is provided, we want to redirect to the dashboard
// login page.
if dashboardURL != nil {
cpy := *dashboardURL
cpy.Path = u.Path
cpy.RawQuery = u.RawQuery
u = &cpy
}
http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect)
// See other forces a GET request rather than keeping the current method
// (like temporary redirect does).
http.Redirect(rw, r, u.String(), http.StatusSeeOther)
}

View File

@ -73,7 +73,7 @@ func TestAPIKey(t *testing.T) {
location, err := res.Location()
require.NoError(t, err)
require.NotEmpty(t, location.Query().Get("message"))
require.Equal(t, http.StatusTemporaryRedirect, res.StatusCode)
require.Equal(t, http.StatusSeeOther, res.StatusCode)
})
t.Run("InvalidFormat", func(t *testing.T) {
@ -526,7 +526,7 @@ func TestAPIKey(t *testing.T) {
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, res.StatusCode)
require.Equal(t, http.StatusSeeOther, res.StatusCode)
u, err := res.Location()
require.NoError(t, err)
require.Equal(t, "/login", u.Path)

View File

@ -57,7 +57,7 @@ func ExtractUserParam(db database.Store, redirectToLoginOnMe bool) func(http.Han
apiKey, ok := APIKeyOptional(r)
if !ok {
if redirectToLoginOnMe {
RedirectToLogin(rw, r, SignedOutErrorMessage)
RedirectToLogin(rw, r, nil, SignedOutErrorMessage)
return
}

View File

@ -32,7 +32,7 @@ func ExtractWorkspaceAgent(db database.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
tokenValue := apiTokenFromRequest(r)
tokenValue := APITokenFromRequest(r)
if tokenValue == "" {
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
Message: fmt.Sprintf("Cookie %q must be provided.", codersdk.SessionTokenCookie),

View File

@ -0,0 +1,158 @@
package httpmw
import (
"context"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"net/http"
"strings"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/dbauthz"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/codersdk"
)
const (
// WorkspaceProxyAuthTokenHeader is the auth header used for requests from
// external workspace proxies.
//
// The format of an external proxy token is:
// <proxy id>:<proxy secret>
//
//nolint:gosec
WorkspaceProxyAuthTokenHeader = "Coder-External-Proxy-Token"
)
type workspaceProxyContextKey struct{}
// WorkspaceProxyOptional may return the workspace proxy from the ExtractWorkspaceProxy
// middleware.
func WorkspaceProxyOptional(r *http.Request) (database.WorkspaceProxy, bool) {
proxy, ok := r.Context().Value(workspaceProxyContextKey{}).(database.WorkspaceProxy)
return proxy, ok
}
// WorkspaceProxy returns the workspace proxy from the ExtractWorkspaceProxy
// middleware.
func WorkspaceProxy(r *http.Request) database.WorkspaceProxy {
proxy, ok := WorkspaceProxyOptional(r)
if !ok {
panic("developer error: ExtractWorkspaceProxy middleware not provided")
}
return proxy
}
type ExtractWorkspaceProxyConfig struct {
DB database.Store
// Optional indicates whether the middleware should be optional. If true,
// any requests without the external proxy auth token header will be
// allowed to continue and no workspace proxy will be set on the request
// context.
Optional bool
}
// ExtractWorkspaceProxy extracts the external workspace proxy from the request
// using the external proxy auth token header.
func ExtractWorkspaceProxy(opts ExtractWorkspaceProxyConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
token := r.Header.Get(WorkspaceProxyAuthTokenHeader)
if token == "" {
if opts.Optional {
next.ServeHTTP(w, r)
return
}
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
Message: "Missing required external proxy token",
})
return
}
// Split the token and lookup the corresponding workspace proxy.
parts := strings.Split(token, ":")
if len(parts) != 2 {
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
Message: "Invalid external proxy token",
})
return
}
proxyID, err := uuid.Parse(parts[0])
if err != nil {
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
Message: "Invalid external proxy token",
})
return
}
secret := parts[1]
if len(secret) != 64 {
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
Message: "Invalid external proxy token",
})
return
}
// Get the proxy.
// nolint:gocritic // Get proxy by ID to check auth token
proxy, err := opts.DB.GetWorkspaceProxyByID(dbauthz.AsSystemRestricted(ctx), proxyID)
if xerrors.Is(err, sql.ErrNoRows) {
// Proxy IDs are public so we don't care about leaking them via
// timing attacks.
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
Message: "Invalid external proxy token",
Detail: "Proxy not found.",
})
return
}
if err != nil {
httpapi.InternalServerError(w, err)
return
}
if proxy.Deleted {
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
Message: "Invalid external proxy token",
Detail: "Proxy has been deleted.",
})
return
}
// Do a subtle constant time comparison of the hash of the secret.
hashedSecret := sha256.Sum256([]byte(secret))
if subtle.ConstantTimeCompare(proxy.TokenHashedSecret, hashedSecret[:]) != 1 {
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
Message: "Invalid external proxy token",
Detail: "Invalid proxy token secret.",
})
return
}
ctx = r.Context()
ctx = context.WithValue(ctx, workspaceProxyContextKey{}, proxy)
//nolint:gocritic // Workspace proxies have full permissions. The
// workspace proxy auth middleware is not mounted to every route, so
// they can still only access the routes that the middleware is
// mounted to.
ctx = dbauthz.AsSystemRestricted(ctx)
subj, ok := dbauthz.ActorFromContext(ctx)
if !ok {
// This should never happen
httpapi.InternalServerError(w, xerrors.New("developer error: ExtractWorkspaceProxy missing rbac actor"))
return
}
// Use the same subject for the userAuthKey
ctx = context.WithValue(ctx, userAuthKey{}, Authorization{
Actor: subj,
ActorName: "proxy_" + proxy.Name,
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

View File

@ -0,0 +1,163 @@
package httpmw_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/dbfake"
"github.com/coder/coder/coderd/database/dbgen"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/cryptorand"
)
func TestExtractWorkspaceProxy(t *testing.T) {
t.Parallel()
successHandler := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
// Only called if the API key passes through the handler.
httpapi.Write(context.Background(), rw, http.StatusOK, codersdk.Response{
Message: "It worked!",
})
})
t.Run("NoHeader", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: db,
})(successHandler).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
})
t.Run("InvalidFormat", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, "test:wow-hello")
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: db,
})(successHandler).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
})
t.Run("InvalidID", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, "test:wow")
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: db,
})(successHandler).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
})
t.Run("InvalidSecretLength", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, fmt.Sprintf("%s:%s", uuid.NewString(), "wow"))
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: db,
})(successHandler).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
secret, err := cryptorand.HexString(64)
require.NoError(t, err)
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, fmt.Sprintf("%s:%s", uuid.NewString(), secret))
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: db,
})(successHandler).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
})
t.Run("InvalidSecret", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
proxy, _ = dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{})
)
// Use a different secret so they don't match!
secret, err := cryptorand.HexString(64)
require.NoError(t, err)
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, fmt.Sprintf("%s:%s", proxy.ID.String(), secret))
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: db,
})(successHandler).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
})
t.Run("Valid", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
proxy, secret = dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{})
)
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, fmt.Sprintf("%s:%s", proxy.ID.String(), secret))
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: db,
})(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
// Checks that it exists on the context!
_ = httpmw.WorkspaceProxy(r)
successHandler.ServeHTTP(rw, r)
})).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
})
}