mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-29 17:58:15 +08:00
Add device lock support (#21667)
* Add Device to LocketTarget proto * Refactor LockTarget.Match * Test lib/srv.ComputeLockTargets * Test lib/services.LockTargetsFromTLSIdentity * Test device lock during cert issuance * Add device lock targets where applicable * Add --device to `tctl lock` * Fix godocs (unrelated) * Review: Drop redundant comments
This commit is contained in:
@@ -4141,6 +4141,10 @@ message LockTarget {
|
||||
|
||||
// AccessRequest specifies the UUID of an access request.
|
||||
string AccessRequest = 7 [(gogoproto.jsontag) = "access_request,omitempty"];
|
||||
|
||||
// Device is the device ID of a trusted device.
|
||||
// Requires Teleport Enterprise.
|
||||
string Device = 8 [(gogoproto.jsontag) = "device,omitempty"];
|
||||
}
|
||||
|
||||
// AddressCondition represents a set of addresses. Presently the addresses are specfied
|
||||
|
||||
+8
-22
@@ -201,28 +201,14 @@ func (t LockTarget) Match(lock Lock) bool {
|
||||
return false
|
||||
}
|
||||
lockTarget := lock.Target()
|
||||
if t.User != "" && lockTarget.User != t.User {
|
||||
return false
|
||||
}
|
||||
if t.Role != "" && lockTarget.Role != t.Role {
|
||||
return false
|
||||
}
|
||||
if t.Login != "" && lockTarget.Login != t.Login {
|
||||
return false
|
||||
}
|
||||
if t.Node != "" && lockTarget.Node != t.Node {
|
||||
return false
|
||||
}
|
||||
if t.MFADevice != "" && lockTarget.MFADevice != t.MFADevice {
|
||||
return false
|
||||
}
|
||||
if t.WindowsDesktop != "" && lockTarget.WindowsDesktop != t.WindowsDesktop {
|
||||
return false
|
||||
}
|
||||
if t.AccessRequest != "" && lockTarget.AccessRequest != t.AccessRequest {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
return (t.User == "" || lockTarget.User == t.User) &&
|
||||
(t.Role == "" || lockTarget.Role == t.Role) &&
|
||||
(t.Login == "" || lockTarget.Login == t.Login) &&
|
||||
(t.Node == "" || lockTarget.Node == t.Node) &&
|
||||
(t.MFADevice == "" || lockTarget.MFADevice == t.MFADevice) &&
|
||||
(t.WindowsDesktop == "" || lockTarget.WindowsDesktop == t.WindowsDesktop) &&
|
||||
(t.AccessRequest == "" || lockTarget.AccessRequest == t.AccessRequest) &&
|
||||
(t.Device == "" || lockTarget.Device == t.Device)
|
||||
}
|
||||
|
||||
// String returns string representation of the LockTarget.
|
||||
|
||||
+961
-914
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -1521,6 +1521,7 @@ func (a *Server) AugmentContextUserCertificates(
|
||||
username: identity.Username,
|
||||
mfaVerified: identity.MFAVerified,
|
||||
activeAccessRequests: identity.ActiveRequests,
|
||||
deviceID: opts.DeviceExtensions.DeviceID, // Check lock against requested device.
|
||||
}); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
@@ -1651,6 +1652,7 @@ func (a *Server) generateUserCert(req certRequest) (*proto.Certs, error) {
|
||||
username: req.user.GetName(),
|
||||
mfaVerified: req.mfaVerified,
|
||||
activeAccessRequests: req.activeRequests.AccessRequests,
|
||||
deviceID: req.deviceExtensions.DeviceID,
|
||||
}); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
@@ -1960,6 +1962,9 @@ type verifyLocksForUserCertsReq struct {
|
||||
// activeAccessRequests are the UUIDs of active access requests for the user.
|
||||
// Eg: tlsca.Identity.ActiveRequests.
|
||||
activeAccessRequests []string
|
||||
// deviceID is the trusted device ID.
|
||||
// Eg: tlsca.Identity.DeviceExtensions.DeviceID
|
||||
deviceID string
|
||||
}
|
||||
|
||||
// verifyLocksForUserCerts verifies if any locks are in place before issuing new
|
||||
@@ -1971,7 +1976,7 @@ func (a *Server) verifyLocksForUserCerts(req verifyLocksForUserCertsReq) error {
|
||||
lockTargets := []types.LockTarget{
|
||||
{User: req.username},
|
||||
{MFADevice: req.mfaVerified},
|
||||
// TODO(codingllama): Verify device locks.
|
||||
{Device: req.deviceID},
|
||||
}
|
||||
lockTargets = append(lockTargets,
|
||||
services.RolesToLockTargets(checker.RoleNames())...,
|
||||
|
||||
+57
-8
@@ -1333,7 +1333,7 @@ func TestServer_AugmentContextUserCertificates_errors(t *testing.T) {
|
||||
// Authenticate.
|
||||
// user1 covers most of the tests.
|
||||
// user2 is mainly used to test mismatched certificates against user1.
|
||||
// user3 is used to test locking.
|
||||
// user3 is used to test user locks.
|
||||
_, sshRaw1, xCert1, sshCert1, identity1 := authenticate(t, user1.GetName(), pass1)
|
||||
_, sshRaw2, xCert2, _, _ := authenticate(t, user2.GetName(), pass2)
|
||||
_, _, xCert3, _, identity3 := authenticate(t, user3.GetName(), pass3)
|
||||
@@ -1619,7 +1619,7 @@ func TestServer_AugmentContextUserCertificates_errors(t *testing.T) {
|
||||
{
|
||||
name: "locked user",
|
||||
x509Cert: xCert3,
|
||||
identity: identity3, // user3 is locked!
|
||||
identity: identity3, // user3 locked below.
|
||||
createAuthCtx: func(ctx context.Context) (*Context, error) {
|
||||
// Authorize user3...
|
||||
authCtx, err := ctxFromAuthorize(ctx)
|
||||
@@ -1639,7 +1639,7 @@ func TestServer_AugmentContextUserCertificates_errors(t *testing.T) {
|
||||
// ...and lock them right after.
|
||||
user3Lock, err := types.NewLock("user3-lock", types.LockSpecV2{
|
||||
Target: lockTarget,
|
||||
Message: "locked for testing",
|
||||
Message: "user locked",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1648,10 +1648,48 @@ func TestServer_AugmentContextUserCertificates_errors(t *testing.T) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
<-watcher.Events() // Wait for the lock to go through.
|
||||
// Wait for the lock to propagate.
|
||||
<-watcher.Events()
|
||||
return authCtx, nil
|
||||
},
|
||||
wantErr: "locked for testing",
|
||||
wantErr: "user locked",
|
||||
},
|
||||
{
|
||||
name: "locked device",
|
||||
x509Cert: xCert1,
|
||||
identity: identity1, // device locked below.
|
||||
createOpts: func(t *testing.T) *AugmentUserCertificateOpts {
|
||||
opts := &AugmentUserCertificateOpts{
|
||||
DeviceExtensions: &DeviceExtensions{
|
||||
DeviceID: "bad-device-1",
|
||||
AssetTag: "bad-device-tag",
|
||||
CredentialID: "bad-device-credential",
|
||||
},
|
||||
}
|
||||
|
||||
// Create a target matching the device device.
|
||||
lockTarget := types.LockTarget{
|
||||
Device: opts.DeviceExtensions.DeviceID,
|
||||
}
|
||||
watcher, err := authServer.lockWatcher.Subscribe(ctx, lockTarget)
|
||||
require.NoError(t, err, "Subscribe failed")
|
||||
defer watcher.Close()
|
||||
|
||||
// Lock the device before returning opts.
|
||||
lock, err := types.NewLock("bad-device-lock", types.LockSpecV2{
|
||||
Target: lockTarget,
|
||||
Message: "device locked",
|
||||
})
|
||||
require.NoError(t, err, "NewLock failed")
|
||||
require.NoError(t,
|
||||
authServer.UpsertLock(ctx, lock),
|
||||
"NewLock failed")
|
||||
|
||||
// Wait for the lock to propagate.
|
||||
<-watcher.Events()
|
||||
return opts
|
||||
},
|
||||
wantErr: "device locked",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
@@ -1859,8 +1897,9 @@ func TestGenerateUserCertWithLocks(t *testing.T) {
|
||||
accessInfo := services.AccessInfoFromUser(user)
|
||||
accessChecker, err := services.NewAccessChecker(accessInfo, p.clusterName.GetClusterName(), p.a)
|
||||
require.NoError(t, err)
|
||||
mfaID := "test-mfa-id"
|
||||
requestID := "test-access-request"
|
||||
const mfaID = "test-mfa-id"
|
||||
const requestID = "test-access-request"
|
||||
const deviceID = "deviceid1"
|
||||
keygen := testauthority.New()
|
||||
_, pub, err := keygen.GetNewKeyPairFromPool()
|
||||
require.NoError(t, err)
|
||||
@@ -1870,12 +1909,22 @@ func TestGenerateUserCertWithLocks(t *testing.T) {
|
||||
mfaVerified: mfaID,
|
||||
publicKey: pub,
|
||||
activeRequests: services.RequestIDs{AccessRequests: []string{requestID}},
|
||||
deviceExtensions: DeviceExtensions{
|
||||
DeviceID: deviceID,
|
||||
AssetTag: "assettag1",
|
||||
CredentialID: "credentialid1",
|
||||
},
|
||||
}
|
||||
_, err = p.a.generateUserCert(certReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
testTargets := append(
|
||||
[]types.LockTarget{{User: user.GetName()}, {MFADevice: mfaID}, {AccessRequest: requestID}},
|
||||
[]types.LockTarget{
|
||||
{User: user.GetName()},
|
||||
{MFADevice: mfaID},
|
||||
{AccessRequest: requestID},
|
||||
{Device: deviceID},
|
||||
},
|
||||
services.RolesToLockTargets(user.GetRoles())...,
|
||||
)
|
||||
for _, target := range testTargets {
|
||||
|
||||
@@ -159,7 +159,8 @@ Loop:
|
||||
if r, ok := c.Identity.(BuiltinRole); ok && r.Role == types.RoleNode {
|
||||
lockTargets = append(lockTargets,
|
||||
types.LockTarget{Node: r.GetServerID()},
|
||||
types.LockTarget{Node: r.Identity.Username})
|
||||
types.LockTarget{Node: r.Identity.Username},
|
||||
)
|
||||
}
|
||||
return lockTargets
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ func TestContextLockTargets(t *testing.T) {
|
||||
Identity: tlsca.Identity{
|
||||
Username: "node.cluster",
|
||||
Groups: []string{"role1", "role2"},
|
||||
DeviceExtensions: tlsca.DeviceExtensions{
|
||||
DeviceID: "device1",
|
||||
},
|
||||
},
|
||||
},
|
||||
UnmappedIdentity: WrapIdentity(tlsca.Identity{
|
||||
@@ -57,6 +60,7 @@ func TestContextLockTargets(t *testing.T) {
|
||||
{Role: "role1"},
|
||||
{Role: "role2"},
|
||||
{Role: "mapped-role"},
|
||||
{Device: "device1"},
|
||||
}
|
||||
require.ElementsMatch(t, authContext.LockTargets(), expected)
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@ func LockTargetsFromTLSIdentity(id tlsca.Identity) []types.LockTarget {
|
||||
if id.MFAVerified != "" {
|
||||
lockTargets = append(lockTargets, types.LockTarget{MFADevice: id.MFAVerified})
|
||||
}
|
||||
if id.DeviceExtensions.DeviceID != "" {
|
||||
lockTargets = append(lockTargets, types.LockTarget{Device: id.DeviceExtensions.DeviceID})
|
||||
}
|
||||
lockTargets = append(lockTargets, AccessRequestsToLockTargets(id.ActiveRequests)...)
|
||||
return lockTargets
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright 2023 Gravitational, Inc
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package services_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"google.golang.org/protobuf/testing/protocmp"
|
||||
|
||||
"github.com/gravitational/teleport/api/types"
|
||||
"github.com/gravitational/teleport/lib/services"
|
||||
"github.com/gravitational/teleport/lib/tlsca"
|
||||
)
|
||||
|
||||
func TestLockTargetsFromTLSIdentity(t *testing.T) {
|
||||
t.Run("all locks", func(t *testing.T) {
|
||||
identity := tlsca.Identity{
|
||||
Username: "llama",
|
||||
Groups: []string{"access", "editor"},
|
||||
MFAVerified: "mfa-device-id",
|
||||
ActiveRequests: []string{"access-request-1", "access-request-2"},
|
||||
DeviceExtensions: tlsca.DeviceExtensions{
|
||||
DeviceID: "trusted-device-id",
|
||||
},
|
||||
}
|
||||
|
||||
// Test.
|
||||
got := services.LockTargetsFromTLSIdentity(identity)
|
||||
|
||||
want := []types.LockTarget{
|
||||
{User: identity.Username},
|
||||
{MFADevice: identity.MFAVerified},
|
||||
{Device: identity.DeviceExtensions.DeviceID},
|
||||
}
|
||||
// Insert roles at the start to match `got`s order.
|
||||
// The test itself doesn't care about the order, it's just easier to test
|
||||
// this way.
|
||||
want = append(services.RolesToLockTargets(identity.Groups), want...)
|
||||
for _, request := range identity.ActiveRequests {
|
||||
want = append(want, types.LockTarget{AccessRequest: request})
|
||||
}
|
||||
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
|
||||
t.Errorf("LockTargetsFromTLSIdentity mismatch (-want +got)\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -573,7 +573,7 @@ func (p *lockCollector) Subscribe(ctx context.Context, targets ...types.LockTarg
|
||||
}
|
||||
|
||||
// CheckLockInForce returns an AccessDenied error if there is a lock in force
|
||||
// matching at at least one of the targets.
|
||||
// matching at least one of the targets.
|
||||
func (p *lockCollector) CheckLockInForce(mode constants.LockingMode, targets ...types.LockTarget) error {
|
||||
p.currentRW.RLock()
|
||||
defer p.currentRW.RUnlock()
|
||||
|
||||
+8
-3
@@ -1179,7 +1179,12 @@ func ComputeLockTargets(clusterName, serverID string, id IdentityContext) []type
|
||||
{Login: id.Login},
|
||||
{Node: serverID},
|
||||
{Node: auth.HostFQDN(serverID, clusterName)},
|
||||
{MFADevice: id.Certificate.Extensions[teleport.CertExtensionMFAVerified]},
|
||||
}
|
||||
if mfaDevice := id.Certificate.Extensions[teleport.CertExtensionMFAVerified]; mfaDevice != "" {
|
||||
lockTargets = append(lockTargets, types.LockTarget{MFADevice: mfaDevice})
|
||||
}
|
||||
if trustedDevice := id.Certificate.Extensions[teleport.CertExtensionDeviceID]; trustedDevice != "" {
|
||||
lockTargets = append(lockTargets, types.LockTarget{Device: trustedDevice})
|
||||
}
|
||||
roles := apiutils.Deduplicate(append(id.AccessChecker.RoleNames(), id.UnmappedRoles...))
|
||||
lockTargets = append(lockTargets, services.RolesToLockTargets(roles)...)
|
||||
@@ -1187,7 +1192,7 @@ func ComputeLockTargets(clusterName, serverID string, id IdentityContext) []type
|
||||
return lockTargets
|
||||
}
|
||||
|
||||
// SetRequest sets the ssh request that was issued by the client.
|
||||
// SetSSHRequest sets the ssh request that was issued by the client.
|
||||
// Will return an error if called more than once for a single server context.
|
||||
func (c *ServerContext) SetSSHRequest(e *ssh.Request) error {
|
||||
c.mu.Lock()
|
||||
@@ -1200,7 +1205,7 @@ func (c *ServerContext) SetSSHRequest(e *ssh.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRequest returns the ssh request that was issued by the client and saved on
|
||||
// GetSSHRequest returns the ssh request that was issued by the client and saved on
|
||||
// this ServerContext by SetExecRequest, or an error if it has not been set.
|
||||
func (c *ServerContext) GetSSHRequest() (*ssh.Request, error) {
|
||||
c.mu.RLock()
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"google.golang.org/protobuf/testing/protocmp"
|
||||
|
||||
"github.com/gravitational/teleport"
|
||||
"github.com/gravitational/teleport/api/types"
|
||||
@@ -219,3 +220,65 @@ func TestIdentityContext_GetUserMetadata(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeLockTargets(t *testing.T) {
|
||||
t.Run("all locks", func(t *testing.T) {
|
||||
const clusterName = "mycluster"
|
||||
const serverID = "myserver"
|
||||
const mfaDevice = "my-mfa-device-1"
|
||||
const trustedDevice = "my-trusted-device-1"
|
||||
mappedRoles := []string{"access", "editor"}
|
||||
unmappedRoles := []string{"unmapped-role-1", "unmapped-role-2", "access"}
|
||||
accessRequests := []string{"access-request-1", "access-request-2"}
|
||||
|
||||
identityCtx := IdentityContext{
|
||||
TeleportUser: "llama",
|
||||
Impersonator: "alpaca",
|
||||
Login: "camel",
|
||||
Certificate: &ssh.Certificate{
|
||||
Permissions: ssh.Permissions{
|
||||
Extensions: map[string]string{
|
||||
teleport.CertExtensionMFAVerified: mfaDevice,
|
||||
teleport.CertExtensionDeviceID: trustedDevice,
|
||||
},
|
||||
},
|
||||
},
|
||||
AccessChecker: &fixedRolesChecker{
|
||||
roleNames: mappedRoles,
|
||||
},
|
||||
UnmappedRoles: unmappedRoles,
|
||||
ActiveRequests: accessRequests,
|
||||
}
|
||||
|
||||
got := ComputeLockTargets(clusterName, serverID, identityCtx)
|
||||
want := []types.LockTarget{
|
||||
{User: identityCtx.TeleportUser},
|
||||
{Login: identityCtx.Login},
|
||||
{Node: serverID},
|
||||
{Node: serverID + "." + clusterName},
|
||||
{MFADevice: mfaDevice},
|
||||
{Device: trustedDevice},
|
||||
}
|
||||
for _, role := range mappedRoles {
|
||||
want = append(want, types.LockTarget{Role: role})
|
||||
}
|
||||
for _, role := range unmappedRoles[:len(unmappedRoles)-1] /* skip duplicate role */ {
|
||||
want = append(want, types.LockTarget{Role: role})
|
||||
}
|
||||
for _, request := range accessRequests {
|
||||
want = append(want, types.LockTarget{AccessRequest: request})
|
||||
}
|
||||
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
|
||||
t.Errorf("ComputeLockTargets mismatch (-want +got)\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type fixedRolesChecker struct {
|
||||
services.AccessChecker
|
||||
roleNames []string
|
||||
}
|
||||
|
||||
func (c *fixedRolesChecker) RoleNames() []string {
|
||||
return c.roleNames
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ func (c *LockCommand) Initialize(app *kingpin.Application, config *service.Confi
|
||||
c.mainCmd.Flag("mfa-device", "UUID of a user MFA device to disable.").StringVar(&c.spec.Target.MFADevice)
|
||||
c.mainCmd.Flag("windows-desktop", "Name of a Windows desktop to disable.").StringVar(&c.spec.Target.WindowsDesktop)
|
||||
c.mainCmd.Flag("access-request", "UUID of an access request to disable.").StringVar(&c.spec.Target.AccessRequest)
|
||||
c.mainCmd.Flag("device", "UUID of a trusted device to disable.").StringVar(&c.spec.Target.Device)
|
||||
c.mainCmd.Flag("message", "Message to display to locked-out users.").StringVar(&c.spec.Message)
|
||||
c.mainCmd.Flag("expires", "Time point (RFC3339) when the lock expires.").StringVar(&c.expires)
|
||||
c.mainCmd.Flag("ttl", "Time duration after which the lock expires.").DurationVar(&c.ttl)
|
||||
|
||||
Reference in New Issue
Block a user