authorize: add support for webauthn device policy enforcement (#2700)

* authorize: add support for webauthn device policy enforcement

* update docs

* group statuses
This commit is contained in:
Caleb Doxsey 2021-10-25 09:41:03 -06:00 committed by GitHub
parent 9d4ebcf871
commit 3497c39b9b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 456 additions and 27 deletions

View file

@ -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()

View file

@ -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()))
})

View file

@ -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 != ""),

View file

@ -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)
}

View file

@ -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.

View file

@ -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)
}

View file

@ -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"])
})
}

View file

@ -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(`