diff --git a/authorize/check_response.go b/authorize/check_response.go index d11cbc37d..01f7f5e7f 100644 --- a/authorize/check_response.go +++ b/authorize/check_response.go @@ -45,10 +45,10 @@ func (a *Authorize) handleResultDenied( switch { case result.Deny.Reasons.Has(criteria.ReasonRouteNotFound): denyStatusCode = http.StatusNotFound - denyStatusText = http.StatusText(http.StatusNotFound) + denyStatusText = httputil.DetailsText(http.StatusNotFound) case result.Deny.Reasons.Has(criteria.ReasonInvalidClientCertificate): denyStatusCode = httputil.StatusInvalidClientCertificate - denyStatusText = httputil.StatusText(httputil.StatusInvalidClientCertificate) + denyStatusText = httputil.DetailsText(httputil.StatusInvalidClientCertificate) } return a.deniedResponse(ctx, in, denyStatusCode, denyStatusText, nil) @@ -65,9 +65,18 @@ func (a *Authorize) handleResultNotAllowed( // when the user is unauthenticated it means they haven't // logged in yet, so redirect to authenticate return a.requireLoginResponse(ctx, in, isForwardAuthVerify) + case result.Allow.Reasons.Has(criteria.ReasonDeviceUnauthenticated): + // when the user's device is unauthenticated it means they haven't + // registered a webauthn device yet, so redirect to the webauthn flow + return a.requireWebAuthnResponse(ctx, in, result, isForwardAuthVerify) + case result.Allow.Reasons.Has(criteria.ReasonDeviceUnauthorized): + return a.deniedResponse(ctx, in, + httputil.StatusDeviceUnauthorized, + httputil.DetailsText(httputil.StatusDeviceUnauthorized), + nil) } - return a.deniedResponse(ctx, in, http.StatusForbidden, http.StatusText(http.StatusForbidden), nil) + return a.deniedResponse(ctx, in, http.StatusForbidden, httputil.DetailsText(http.StatusForbidden), nil) } func (a *Authorize) okResponse(headers http.Header) *envoy_service_auth_v3.CheckResponse { @@ -94,16 +103,6 @@ func (a *Authorize) deniedResponse( in *envoy_service_auth_v3.CheckRequest, code int32, reason string, headers map[string]string, ) (*envoy_service_auth_v3.CheckResponse, error) { - var details string - switch code { - case httputil.StatusInvalidClientCertificate: - details = httputil.StatusText(httputil.StatusInvalidClientCertificate) - case http.StatusForbidden: - details = http.StatusText(http.StatusForbidden) - default: - details = reason - } - // create a http response writer recorder w := httptest.NewRecorder() r := getHTTPRequestFromCheckRequest(in) @@ -114,7 +113,7 @@ func (a *Authorize) deniedResponse( // run the request through our go error handler httpErr := httputil.HTTPError{ Status: int(code), - Err: errors.New(details), + Err: errors.New(reason), DebugURL: debugEndpoint, RequestID: requestid.FromContext(ctx), } @@ -172,10 +171,50 @@ func (a *Authorize) requireLoginResponse( q := signinURL.Query() // always assume https scheme - url := getCheckRequestURL(in) - url.Scheme = "https" + checkRequestURL := getCheckRequestURL(in) + checkRequestURL.Scheme = "https" - q.Set(urlutil.QueryRedirectURI, url.String()) + q.Set(urlutil.QueryRedirectURI, checkRequestURL.String()) + signinURL.RawQuery = q.Encode() + redirectTo := urlutil.NewSignedURL(state.sharedKey, signinURL).String() + + return a.deniedResponse(ctx, in, http.StatusFound, "Login", map[string]string{ + "Location": redirectTo, + }) +} + +func (a *Authorize) requireWebAuthnResponse( + ctx context.Context, + in *envoy_service_auth_v3.CheckRequest, + result *evaluator.Result, + isForwardAuthVerify bool, +) (*envoy_service_auth_v3.CheckResponse, error) { + opts := a.currentOptions.Load() + state := a.state.Load() + authenticateURL, err := opts.GetAuthenticateURL() + if err != nil { + return nil, err + } + + if !a.shouldRedirect(in) || isForwardAuthVerify { + return a.deniedResponse(ctx, in, http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized), nil) + } + + signinURL := authenticateURL.ResolveReference(&url.URL{ + Path: "/.pomerium/webauthn", + }) + q := signinURL.Query() + + // always assume https scheme + checkRequestURL := getCheckRequestURL(in) + checkRequestURL.Scheme = "https" + + if deviceType, ok := result.Allow.AdditionalData["device_type"].(string); ok { + q.Set(urlutil.QueryDeviceType, deviceType) + } else { + q.Set(urlutil.QueryDeviceType, "default") + } + q.Set(urlutil.QueryRedirectURI, checkRequestURL.String()) signinURL.RawQuery = q.Encode() redirectTo := urlutil.NewSignedURL(state.sharedKey, signinURL).String() diff --git a/authorize/check_response_test.go b/authorize/check_response_test.go index 80d3906fe..222cf560e 100644 --- a/authorize/check_response_test.go +++ b/authorize/check_response_test.go @@ -175,7 +175,8 @@ func TestRequireLogin(t *testing.T) { require.NoError(t, err) t.Run("accept empty", func(t *testing.T) { - res, err := a.requireLoginResponse(context.Background(), &envoy_service_auth_v3.CheckRequest{}, false) + res, err := a.requireLoginResponse(context.Background(), &envoy_service_auth_v3.CheckRequest{}, + false) require.NoError(t, err) assert.Equal(t, http.StatusFound, int(res.GetDeniedResponse().GetStatus().GetCode())) }) diff --git a/internal/httputil/errors.go b/internal/httputil/errors.go index ebb5ea06b..4096ae3f4 100644 --- a/internal/httputil/errors.go +++ b/internal/httputil/errors.go @@ -30,7 +30,7 @@ func NewError(status int, err error) error { // Error implements the `error` interface. func (e *HTTPError) Error() string { - return http.StatusText(e.Status) + ": " + e.Err.Error() + return StatusText(e.Status) + ": " + e.Err.Error() } // Unwrap implements the `error` Unwrap interface. @@ -55,7 +55,7 @@ func (e *HTTPError) ErrorResponse(w http.ResponseWriter, r *http.Request) { DebugURL *url.URL `json:",omitempty"` }{ Status: e.Status, - StatusText: http.StatusText(e.Status), + StatusText: StatusText(e.Status), Error: e.Error(), RequestID: reqID, CanDebug: e.Status/100 == 4 && (e.DebugURL != nil || reqID != ""), diff --git a/internal/httputil/httputil.go b/internal/httputil/httputil.go index 8cbde943c..2706a67d1 100644 --- a/internal/httputil/httputil.go +++ b/internal/httputil/httputil.go @@ -1,16 +1,41 @@ package httputil -// StatusInvalidClientCertificate is the status code returned when a -// client's certificate is invalid. This is the same status code used -// by nginx for this purpose. -const StatusInvalidClientCertificate = 495 +import "net/http" + +const ( + // StatusDeviceUnauthorized is the status code returned when a client's + // device credential is not authorized to access a page. + StatusDeviceUnauthorized = 450 + // StatusInvalidClientCertificate is the status code returned when a + // client's certificate is invalid. This is the same status code used + // by nginx for this purpose. + StatusInvalidClientCertificate = 495 +) + +var detailsText = map[int]string{ + StatusDeviceUnauthorized: "your device fails to meet the requirements necessary to access this page, please contact your administrator for assistance", +} + +// DetailsText returns extra details for an HTTP status code. It returns StatusText if not found. +func DetailsText(code int) string { + txt, ok := detailsText[code] + if ok { + return txt + } + + return StatusText(code) +} var statusText = map[int]string{ + StatusDeviceUnauthorized: "device not authorized", StatusInvalidClientCertificate: "a valid client certificate is required to access this page", } -// StatusText returns a text for the HTTP status code. It returns the empty -// string if the code is unknown. +// StatusText returns a text for the HTTP status code. It returns http.StatusText if not found. func StatusText(code int) string { - return statusText[code] + txt, ok := statusText[code] + if ok { + return txt + } + return http.StatusText(code) } diff --git a/pkg/policy/criteria/criteria.go b/pkg/policy/criteria/criteria.go index 895436fed..1fbc2671f 100644 --- a/pkg/policy/criteria/criteria.go +++ b/pkg/policy/criteria/criteria.go @@ -75,6 +75,71 @@ func NewCriterionRule( return r1 } +// NewCriterionDeviceRule generates a new rule for a criterion which +// requires a device and session. If there is no device "device-unauthenticated" +// is returned. If there is no session "user-unauthenticated" is returned. +func NewCriterionDeviceRule( + g *generator.Generator, + name string, + passReason, failReason Reason, + body ast.Body, + deviceType string, +) *ast.Rule { + r1 := g.NewRule(name) + + additionalData := map[string]interface{}{ + "device_type": deviceType, + } + + sharedBody := ast.Body{ + ast.Assign.Expr(ast.VarTerm("device_type_id"), ast.StringTerm(deviceType)), + ast.MustParseExpr(`session := get_session(input.session.id)`), + ast.MustParseExpr(`device_credential := get_device_credential(session, device_type_id)`), + ast.MustParseExpr(`device_enrollment := get_device_enrollment(device_credential)`), + } + + // case 1: rule passes, session exists, device exists + r1.Head.Value = NewCriterionTermWithAdditionalData(true, passReason, additionalData) + r1.Body = append(sharedBody, body...) + + // case 2: rule fails, session exists, device exists + r2 := &ast.Rule{ + Head: &ast.Head{ + Value: NewCriterionTermWithAdditionalData(false, failReason, additionalData), + }, + Body: append(sharedBody, ast.Body{ + ast.MustParseExpr(`session.id != ""`), + ast.MustParseExpr(`device_credential.id != ""`), + ast.MustParseExpr(`device_enrollment.id != ""`), + }...), + } + r1.Else = r2 + + // case 3: device not authenticated, session exists, device does not exist + r3 := &ast.Rule{ + Head: &ast.Head{ + Value: NewCriterionTermWithAdditionalData(false, ReasonDeviceUnauthenticated, additionalData), + }, + Body: append(sharedBody, ast.Body{ + ast.MustParseExpr(`session.id != ""`), + }...), + } + r2.Else = r3 + + // case 4: user not authenticated, session does not exist + r4 := &ast.Rule{ + Head: &ast.Head{ + Value: NewCriterionTermWithAdditionalData(false, ReasonUserUnauthenticated, additionalData), + }, + Body: ast.Body{ + ast.NewExpr(ast.BooleanTerm(true)), + }, + } + r3.Else = r4 + + return r1 +} + // NewCriterionSessionRule generates a new rule for a criterion which // requires a session. If there is no session "user-unauthenticated" // is returned. diff --git a/pkg/policy/criteria/device.go b/pkg/policy/criteria/device.go new file mode 100644 index 000000000..306d854da --- /dev/null +++ b/pkg/policy/criteria/device.go @@ -0,0 +1,106 @@ +package criteria + +import ( + "fmt" + + "github.com/open-policy-agent/opa/ast" + + "github.com/pomerium/pomerium/pkg/policy/generator" + "github.com/pomerium/pomerium/pkg/policy/parser" + "github.com/pomerium/pomerium/pkg/policy/rules" +) + +const ( + deviceOperatorApproved = "approved" + deviceOperatorIs = "is" + deviceOperatorType = "type" +) + +var deviceOperatorLookup = map[string]struct{}{ + deviceOperatorApproved: {}, + deviceOperatorIs: {}, + deviceOperatorType: {}, +} + +type deviceCriterion struct { + g *Generator +} + +func (deviceCriterion) DataType() CriterionDataType { + return generator.CriterionDataTypeUnknown +} + +func (deviceCriterion) Name() string { + return "device" +} + +func (c deviceCriterion) GenerateRule(_ string, data parser.Value) (*ast.Rule, []*ast.Rule, error) { + obj, ok := data.(parser.Object) + if !ok { + return nil, nil, fmt.Errorf("expected object for device criterion, got: %T", data) + } + + for k := range obj { + _, ok := deviceOperatorLookup[k] + if !ok { + return nil, nil, fmt.Errorf("unexpected field in device criterion: %s", k) + } + } + + var body ast.Body + + switch { + case obj.Truthy(deviceOperatorApproved): + // must be approved + body = append(body, ast.Body{ + ast.MustParseExpr(`count([x|x:=device_enrollment.approved_by]) > 0`), + }...) + case obj.Falsy(deviceOperatorApproved): + // must *not* be approved + body = append(body, ast.Body{ + ast.MustParseExpr(`count([x|x:=device_enrollment.approved_by]) == 0`), + }...) + } + + if v, ok := obj[deviceOperatorIs]; ok { + s, ok := v.(parser.String) + if !ok { + return nil, nil, fmt.Errorf("expected string for device criterion is operator, got %T", v) + } + body = append(body, ast.Body{ + ast.Assign.Expr(ast.VarTerm("is_expect"), ast.StringTerm(string(s))), + ast.MustParseExpr(`is_expect == device_credential.id`), + }...) + } + + deviceType := "default" + if v, ok := obj[deviceOperatorType]; ok { + s, ok := v.(parser.String) + if !ok { + return nil, nil, fmt.Errorf("expected string for device criterion type operator, got %T", v) + } + deviceType = string(s) + body = append(body, ast.Body{ + ast.MustParseExpr(`device_credential.id != ""`), + }...) + } + + rule := NewCriterionDeviceRule(c.g, c.Name(), + ReasonDeviceOK, ReasonDeviceUnauthorized, + body, deviceType) + return rule, []*ast.Rule{ + rules.GetDeviceCredential(), + rules.GetDeviceEnrollment(), + rules.GetSession(), + rules.ObjectGet(), + }, nil +} + +// Device returns a Criterion based on the User's device state. +func Device(generator *Generator) Criterion { + return deviceCriterion{g: generator} +} + +func init() { + Register(Device) +} diff --git a/pkg/policy/criteria/device_test.go b/pkg/policy/criteria/device_test.go new file mode 100644 index 000000000..2fc61cf8a --- /dev/null +++ b/pkg/policy/criteria/device_test.go @@ -0,0 +1,168 @@ +package criteria + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/pomerium/pomerium/pkg/grpc/device" + "github.com/pomerium/pomerium/pkg/grpc/session" +) + +func TestDevice(t *testing.T) { + mkDeviceSession := func(sessionID, deviceType, deviceCredentialID string) *session.Session { + return &session.Session{ + Id: sessionID, + DeviceCredentials: []*session.Session_DeviceCredential{ + {TypeId: deviceType, Credential: &session.Session_DeviceCredential_Id{Id: deviceCredentialID}}, + }, + } + } + + t.Run("no session", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - device: + is: dc1 +`, []dataBrokerRecord{}, Input{Session: InputSession{ID: "s1"}}) + require.NoError(t, err) + require.Equal(t, A{false, A{ReasonUserUnauthenticated}, M{"device_type": "default"}}, res["allow"]) + require.Equal(t, A{false, A{}}, res["deny"]) + }) + t.Run("no device credential", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - device: + is: dc1 +`, []dataBrokerRecord{ + mkDeviceSession("s1", "default", "dc1"), + }, Input{Session: InputSession{ID: "s1"}}) + require.NoError(t, err) + require.Equal(t, A{false, A{ReasonDeviceUnauthenticated}, M{"device_type": "default"}}, res["allow"]) + require.Equal(t, A{false, A{}}, res["deny"]) + }) + t.Run("allowed by is", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - device: + is: dc1 +`, []dataBrokerRecord{ + mkDeviceSession("s1", "default", "dc1"), + &device.Credential{Id: "dc1", EnrollmentId: "de1"}, + &device.Enrollment{Id: "de1"}, + }, Input{Session: InputSession{ID: "s1"}}) + require.NoError(t, err) + require.Equal(t, A{true, A{ReasonDeviceOK}, M{"device_type": "default"}}, res["allow"]) + require.Equal(t, A{false, A{}}, res["deny"]) + }) + t.Run("not allowed by is", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - device: + is: dc2 +`, []dataBrokerRecord{ + mkDeviceSession("s1", "default", "dc1"), + &device.Credential{Id: "dc1", EnrollmentId: "de1"}, + &device.Enrollment{Id: "de1"}, + &device.Credential{Id: "dc2", EnrollmentId: "de2"}, + &device.Enrollment{Id: "de2"}, + }, Input{Session: InputSession{ID: "s1"}}) + require.NoError(t, err) + require.Equal(t, A{false, A{ReasonDeviceUnauthorized}, M{"device_type": "default"}}, res["allow"]) + require.Equal(t, A{false, A{}}, res["deny"]) + }) + t.Run("allowed by approved", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - device: + approved: true +`, []dataBrokerRecord{ + mkDeviceSession("s1", "default", "dc1"), + &device.Credential{Id: "dc1", EnrollmentId: "de1"}, + &device.Enrollment{Id: "de1", ApprovedBy: "u1"}, + }, Input{Session: InputSession{ID: "s1"}}) + require.NoError(t, err) + require.Equal(t, A{true, A{ReasonDeviceOK}, M{"device_type": "default"}}, res["allow"]) + require.Equal(t, A{false, A{}}, res["deny"]) + }) + t.Run("not allowed by approved", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - device: + approved: true +`, []dataBrokerRecord{ + mkDeviceSession("s1", "default", "dc1"), + &device.Credential{Id: "dc1", EnrollmentId: "de1"}, + &device.Enrollment{Id: "de1"}, + }, Input{Session: InputSession{ID: "s1"}}) + require.NoError(t, err) + require.Equal(t, A{false, A{ReasonDeviceUnauthorized}, M{"device_type": "default"}}, res["allow"]) + require.Equal(t, A{false, A{}}, res["deny"]) + }) + t.Run("allowed by not approved", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - device: + approved: false +`, []dataBrokerRecord{ + mkDeviceSession("s1", "default", "dc1"), + &device.Credential{Id: "dc1", EnrollmentId: "de1"}, + &device.Enrollment{Id: "de1"}, + }, Input{Session: InputSession{ID: "s1"}}) + require.NoError(t, err) + require.Equal(t, A{true, A{ReasonDeviceOK}, M{"device_type": "default"}}, res["allow"]) + require.Equal(t, A{false, A{}}, res["deny"]) + }) + t.Run("not allowed by not approved", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - device: + approved: false +`, []dataBrokerRecord{ + mkDeviceSession("s1", "default", "dc1"), + &device.Credential{Id: "dc1", EnrollmentId: "de1"}, + &device.Enrollment{Id: "de1", ApprovedBy: "u1"}, + }, Input{Session: InputSession{ID: "s1"}}) + require.NoError(t, err) + require.Equal(t, A{false, A{ReasonDeviceUnauthorized}, M{"device_type": "default"}}, res["allow"]) + require.Equal(t, A{false, A{}}, res["deny"]) + }) + t.Run("allowed by type", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - device: + type: t1 +`, []dataBrokerRecord{ + mkDeviceSession("s1", "t1", "dc1"), + &device.Credential{Id: "dc1", EnrollmentId: "de1", TypeId: "t1"}, + &device.Enrollment{Id: "de1", ApprovedBy: "u1"}, + }, Input{Session: InputSession{ID: "s1"}}) + require.NoError(t, err) + require.Equal(t, A{true, A{ReasonDeviceOK}, M{"device_type": "t1"}}, res["allow"]) + require.Equal(t, A{false, A{}}, res["deny"]) + }) + t.Run("not allowed by type", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - device: + type: t2 +`, []dataBrokerRecord{ + mkDeviceSession("s1", "t1", "dc1"), + &device.Credential{Id: "dc1", EnrollmentId: "de1", TypeId: "t1"}, + &device.Enrollment{Id: "de1", ApprovedBy: "u1"}, + }, Input{Session: InputSession{ID: "s1"}}) + require.NoError(t, err) + require.Equal(t, A{false, A{ReasonDeviceUnauthenticated}, M{"device_type": "t2"}}, res["allow"]) + require.Equal(t, A{false, A{}}, res["deny"]) + }) +} diff --git a/pkg/policy/rules/rules.go b/pkg/policy/rules/rules.go index 79540b35f..b3e361a7f 100644 --- a/pkg/policy/rules/rules.go +++ b/pkg/policy/rules/rules.go @@ -49,6 +49,31 @@ get_user_email(session, user) = v { `) } +// GetDeviceCredential gets the device credential for the given session. +func GetDeviceCredential() *ast.Rule { + return ast.MustParseRule(` +get_device_credential(session, device_type_id) = v { + device_credential_id := [x.Credential.Id|x:=session.device_credentials[_];x.type_id==device_type_id][0] + v = get_databroker_record("type.googleapis.com/pomerium.device.Credential", device_credential_id) + v != null +} else = {} { + true +} +`) +} + +// GetDeviceEnrollment gets the device enrollment for the given device credential. +func GetDeviceEnrollment() *ast.Rule { + return ast.MustParseRule(` +get_device_enrollment(device_credential) = v { + v = get_databroker_record("type.googleapis.com/pomerium.device.Enrollment", device_credential.enrollment_id) + v != null +} else = {} { + true +} +`) +} + // GetDirectoryUser returns the directory user for the given session. func GetDirectoryUser() *ast.Rule { return ast.MustParseRule(`