mirror of
https://github.com/gravitational/teleport
synced 2024-10-20 17:23:22 +00:00
Add passwordless login/registration to auth and web (#10632)
Wire passwordless registration and authorization into Auth and Proxy APIs, thus making passwordless logins possible. API changes are described by RFD 52: Passwordless [1]. #9160 [1] https://github.com/gravitational/teleport/blob/master/rfd/0052-passwordless.md#authentication-api-changes * Add passwordless settings to Auth protos * Update generated protos * Register: Apply DeviceUsage in lib/auth * Register: Apply DeviceUsage in lib/web * Login: Generate passwordless challenge * Login: Allow passwordless authentication * Wire passwordless in lib/web endpoints * Make mocku2f passwordless setup a bit nicer
This commit is contained in:
parent
61fce15ee2
commit
5023235909
File diff suppressed because it is too large
Load diff
|
@ -818,6 +818,19 @@ enum DeviceType {
|
|||
DEVICE_TYPE_WEBAUTHN = 3;
|
||||
}
|
||||
|
||||
enum DeviceUsage {
|
||||
DEVICE_USAGE_UNSPECIFIED = 0;
|
||||
|
||||
// Device intended for MFA use, but not for passwordless.
|
||||
// Allows both FIDO and FIDO2 devices.
|
||||
// Resident keys not required.
|
||||
DEVICE_USAGE_MFA = 1;
|
||||
|
||||
// Device intended for both MFA and passwordless.
|
||||
// Requires a FIDO2 device and takes a resident key slot.
|
||||
DEVICE_USAGE_PASSWORDLESS = 2;
|
||||
}
|
||||
|
||||
// MFAAuthenticateChallenge is a challenge for all MFA devices registered for a
|
||||
// user.
|
||||
message MFAAuthenticateChallenge {
|
||||
|
@ -928,7 +941,11 @@ message AddMFADeviceResponse {
|
|||
// AddMFADeviceRequestInit describes the new MFA device.
|
||||
message AddMFADeviceRequestInit {
|
||||
string DeviceName = 1;
|
||||
reserved 2; // LegacyDeviceType LegacyType
|
||||
DeviceType DeviceType = 3;
|
||||
// DeviceUsage is the requested usage for the device.
|
||||
// Defaults to DEVICE_USAGE_MFA.
|
||||
DeviceUsage DeviceUsage = 4 [ (gogoproto.jsontag) = "device_usage,omitempty" ];
|
||||
}
|
||||
|
||||
// AddMFADeviceResponseAck is a confirmation of successful device registration.
|
||||
|
@ -1374,12 +1391,17 @@ message UserCredentials {
|
|||
bytes Password = 2 [ (gogoproto.jsontag) = "password" ];
|
||||
}
|
||||
|
||||
// ContextUser marks requests that rely in the currently authenticated user.
|
||||
message ContextUser {}
|
||||
|
||||
// Passwordless marks requests for passwordless challenges.
|
||||
message Passwordless {}
|
||||
|
||||
// CreateAuthenticateChallengeRequest is a request for creating MFA authentication challenges for a
|
||||
// users mfa devices.
|
||||
message CreateAuthenticateChallengeRequest {
|
||||
// Request defines how the request will be verified before creating challenges.
|
||||
// This field can be empty, which implies the request is to create challenges for the
|
||||
// user in context (logged in user).
|
||||
// An empty Request is equivalent to context_user being set.
|
||||
oneof Request {
|
||||
// UserCredentials verifies request with username and password. Used with logins or
|
||||
// when the logged in user wants to change their password.
|
||||
|
@ -1389,6 +1411,12 @@ message CreateAuthenticateChallengeRequest {
|
|||
// VerifyAccountRecovery (step 2 of the recovery process after RPC StartAccountRecovery).
|
||||
string RecoveryStartTokenID = 2
|
||||
[ (gogoproto.jsontag) = "recovery_start_token_id,omitempty" ];
|
||||
// ContextUser issues a challenge for the currently-authenticated user.
|
||||
// Default option if no other is provided.
|
||||
ContextUser ContextUser = 3 [ (gogoproto.jsontag) = "context_user,omitempty" ];
|
||||
// Passwordless issues a passwordless challenge (authenticated user not
|
||||
// required).
|
||||
Passwordless Passwordless = 4 [ (gogoproto.jsontag) = "passwordless,omitempty" ];
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1412,6 +1440,9 @@ message CreateRegisterChallengeRequest {
|
|||
string TokenID = 1 [ (gogoproto.jsontag) = "token_id" ];
|
||||
// DeviceType is the type of MFA device to make a register challenge for.
|
||||
DeviceType DeviceType = 2 [ (gogoproto.jsontag) = "device_type" ];
|
||||
// DeviceUsage is the requested usage for the device.
|
||||
// Defaults to DEVICE_USAGE_MFA.
|
||||
DeviceUsage DeviceUsage = 3 [ (gogoproto.jsontag) = "device_usage,omitempty" ];
|
||||
}
|
||||
|
||||
// PaginatedResource represents one of the supported resources.
|
||||
|
|
|
@ -260,7 +260,8 @@ func (s *Server) VerifyAccountRecovery(ctx context.Context, req *proto.VerifyAcc
|
|||
}
|
||||
|
||||
if err := s.verifyAuthnWithRecoveryLock(ctx, startToken, func() error {
|
||||
_, err := s.validateMFAAuthResponse(ctx, startToken.GetUser(), req.GetMFAAuthenticateResponse())
|
||||
_, _, err := s.validateMFAAuthResponse(
|
||||
ctx, req.GetMFAAuthenticateResponse(), startToken.GetUser(), false /* passwordless */)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
|
|
|
@ -1291,7 +1291,7 @@ func TestGetAccountRecoveryCodes(t *testing.T) {
|
|||
|
||||
func triggerLoginLock(t *testing.T, srv *Server, username string) {
|
||||
for i := 1; i <= defaults.MaxLoginAttempts; i++ {
|
||||
_, err := srv.authenticateUser(context.Background(), AuthenticateUserRequest{
|
||||
_, _, err := srv.authenticateUser(context.Background(), AuthenticateUserRequest{
|
||||
Username: username,
|
||||
OTP: &OTPCreds{},
|
||||
})
|
||||
|
|
114
lib/auth/auth.go
114
lib/auth/auth.go
|
@ -1307,6 +1307,7 @@ func (a *Server) PreAuthenticatedSignIn(user string, identity tlsca.Identity) (t
|
|||
// CreateAuthenticateChallenge implements AuthService.CreateAuthenticateChallenge.
|
||||
func (a *Server) CreateAuthenticateChallenge(ctx context.Context, req *proto.CreateAuthenticateChallengeRequest) (*proto.MFAAuthenticateChallenge, error) {
|
||||
var username string
|
||||
var passwordless bool
|
||||
|
||||
switch req.GetRequest().(type) {
|
||||
case *proto.CreateAuthenticateChallengeRequest_UserCredentials:
|
||||
|
@ -1331,7 +1332,10 @@ func (a *Server) CreateAuthenticateChallenge(ctx context.Context, req *proto.Cre
|
|||
|
||||
username = token.GetUser()
|
||||
|
||||
default:
|
||||
case *proto.CreateAuthenticateChallengeRequest_Passwordless:
|
||||
passwordless = true // Allows empty username.
|
||||
|
||||
default: // unset or CreateAuthenticateChallengeRequest_ContextUser.
|
||||
var err error
|
||||
username, err = GetClientUsername(ctx)
|
||||
if err != nil {
|
||||
|
@ -1339,7 +1343,7 @@ func (a *Server) CreateAuthenticateChallenge(ctx context.Context, req *proto.Cre
|
|||
}
|
||||
}
|
||||
|
||||
challenges, err := a.mfaAuthChallenge(ctx, username)
|
||||
challenges, err := a.mfaAuthChallenge(ctx, username, passwordless)
|
||||
if err != nil {
|
||||
log.Error(trace.DebugReport(err))
|
||||
return nil, trace.AccessDenied("unable to create MFA challenges")
|
||||
|
@ -1368,17 +1372,19 @@ func (a *Server) CreateRegisterChallenge(ctx context.Context, req *proto.CreateR
|
|||
}
|
||||
|
||||
regChal, err := a.createRegisterChallenge(ctx, &newRegisterChallengeRequest{
|
||||
username: token.GetUser(),
|
||||
token: token,
|
||||
deviceType: req.GetDeviceType(),
|
||||
username: token.GetUser(),
|
||||
token: token,
|
||||
deviceType: req.GetDeviceType(),
|
||||
deviceUsage: req.GetDeviceUsage(),
|
||||
})
|
||||
|
||||
return regChal, trace.Wrap(err)
|
||||
}
|
||||
|
||||
type newRegisterChallengeRequest struct {
|
||||
username string
|
||||
deviceType proto.DeviceType
|
||||
username string
|
||||
deviceType proto.DeviceType
|
||||
deviceUsage proto.DeviceUsage
|
||||
|
||||
// token is a user token resource.
|
||||
// It is used as following:
|
||||
|
@ -1447,7 +1453,8 @@ func (a *Server) createRegisterChallenge(ctx context.Context, req *newRegisterCh
|
|||
Identity: identity,
|
||||
}
|
||||
|
||||
credentialCreation, err := webRegistration.Begin(ctx, req.username, false /* passwordless */)
|
||||
passwordless := req.deviceUsage == proto.DeviceUsage_DEVICE_USAGE_PASSWORDLESS
|
||||
credentialCreation, err := webRegistration.Begin(ctx, req.username, passwordless)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
@ -3184,7 +3191,7 @@ func (a *Server) isMFARequired(ctx context.Context, checker services.AccessCheck
|
|||
|
||||
// mfaAuthChallenge constructs an MFAAuthenticateChallenge for all MFA devices
|
||||
// registered by the user.
|
||||
func (a *Server) mfaAuthChallenge(ctx context.Context, user string) (*proto.MFAAuthenticateChallenge, error) {
|
||||
func (a *Server) mfaAuthChallenge(ctx context.Context, user string, passwordless bool) (*proto.MFAAuthenticateChallenge, error) {
|
||||
// Check what kind of MFA is enabled.
|
||||
apref, err := a.GetAuthPreference(ctx)
|
||||
if err != nil {
|
||||
|
@ -3212,16 +3219,42 @@ func (a *Server) mfaAuthChallenge(ctx context.Context, user string) (*proto.MFAA
|
|||
webConfig = val
|
||||
}
|
||||
|
||||
devs, err := a.Identity.GetMFADevices(ctx, user, true)
|
||||
// Handle passwordless separately, it works differently from MFA.
|
||||
if passwordless {
|
||||
if !enableWebauthn {
|
||||
return nil, trace.BadParameter("passwordless requires WebAuthn")
|
||||
}
|
||||
webLogin := &wanlib.PasswordlessFlow{
|
||||
Webauthn: webConfig,
|
||||
Identity: a.Identity,
|
||||
}
|
||||
assertion, err := webLogin.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
return &proto.MFAAuthenticateChallenge{
|
||||
WebauthnChallenge: wanlib.CredentialAssertionToProto(assertion),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// User required for non-passwordless.
|
||||
if user == "" {
|
||||
return nil, trace.BadParameter("user required")
|
||||
}
|
||||
|
||||
devs, err := a.Identity.GetMFADevices(ctx, user, true /* withSecrets */)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
groupedDevs := groupByDeviceType(devs, enableWebauthn)
|
||||
challenge := &proto.MFAAuthenticateChallenge{}
|
||||
|
||||
// TOTP challenge.
|
||||
if enableTOTP && groupedDevs.TOTP {
|
||||
challenge.TOTP = &proto.TOTPChallenge{}
|
||||
}
|
||||
|
||||
// WebAuthn challenge.
|
||||
if len(groupedDevs.Webauthn) > 0 {
|
||||
webLogin := &wanlib.LoginFlow{
|
||||
U2F: u2fPref,
|
||||
|
@ -3264,32 +3297,63 @@ func groupByDeviceType(devs []*types.MFADevice, groupWebauthn bool) devicesByTyp
|
|||
return res
|
||||
}
|
||||
|
||||
func (a *Server) validateMFAAuthResponse(ctx context.Context, user string, resp *proto.MFAAuthenticateResponse) (*types.MFADevice, error) {
|
||||
// validateMFAAuthResponse validates an MFA or passwordless challenge.
|
||||
// Returns the device used to solve the challenge (if applicable) and the
|
||||
// username.
|
||||
func (a *Server) validateMFAAuthResponse(
|
||||
ctx context.Context,
|
||||
resp *proto.MFAAuthenticateResponse, user string, passwordless bool) (*types.MFADevice, string, error) {
|
||||
// Sanity check user/passwordless.
|
||||
if user == "" && !passwordless {
|
||||
return nil, "", trace.BadParameter("user required")
|
||||
}
|
||||
|
||||
switch res := resp.Response.(type) {
|
||||
case *proto.MFAAuthenticateResponse_TOTP:
|
||||
return a.checkOTP(user, res.TOTP.Code)
|
||||
// cases in order of preference
|
||||
case *proto.MFAAuthenticateResponse_Webauthn:
|
||||
// Read necessary configurations.
|
||||
cap, err := a.GetAuthPreference(ctx)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
return nil, "", trace.Wrap(err)
|
||||
}
|
||||
u2f, err := cap.GetU2F()
|
||||
switch {
|
||||
case trace.IsNotFound(err): // OK, may happen.
|
||||
case err != nil: // Unexpected.
|
||||
return nil, "", trace.Wrap(err)
|
||||
}
|
||||
u2f, _ := cap.GetU2F()
|
||||
webConfig, err := cap.GetWebauthn()
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
return nil, "", trace.Wrap(err)
|
||||
}
|
||||
webLogin := &wanlib.LoginFlow{
|
||||
U2F: u2f,
|
||||
Webauthn: webConfig,
|
||||
Identity: a.Identity,
|
||||
|
||||
assertionResp := wanlib.CredentialAssertionResponseFromProto(res.Webauthn)
|
||||
var dev *types.MFADevice
|
||||
if passwordless {
|
||||
webLogin := &wanlib.PasswordlessFlow{
|
||||
Webauthn: webConfig,
|
||||
Identity: a.Identity,
|
||||
}
|
||||
dev, user, err = webLogin.Finish(ctx, assertionResp)
|
||||
} else {
|
||||
webLogin := &wanlib.LoginFlow{
|
||||
U2F: u2f,
|
||||
Webauthn: webConfig,
|
||||
Identity: a.Identity,
|
||||
}
|
||||
dev, err = webLogin.Finish(ctx, user, wanlib.CredentialAssertionResponseFromProto(res.Webauthn))
|
||||
}
|
||||
dev, err := webLogin.Finish(ctx, user, wanlib.CredentialAssertionResponseFromProto(res.Webauthn))
|
||||
if err != nil {
|
||||
return nil, trace.AccessDenied("MFA response validation failed: %v", err)
|
||||
return nil, "", trace.AccessDenied("MFA response validation failed: %v", err)
|
||||
}
|
||||
return dev, nil
|
||||
return dev, user, nil
|
||||
|
||||
case *proto.MFAAuthenticateResponse_TOTP:
|
||||
dev, err := a.checkOTP(user, res.TOTP.Code)
|
||||
return dev, user, trace.Wrap(err)
|
||||
|
||||
default:
|
||||
return nil, trace.BadParameter("unknown or missing MFAAuthenticateResponse type %T", resp.Response)
|
||||
return nil, "", trace.BadParameter("unknown or missing MFAAuthenticateResponse type %T", resp.Response)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -22,10 +22,12 @@ import (
|
|||
"github.com/gravitational/teleport/api/client/proto"
|
||||
"github.com/gravitational/teleport/api/constants"
|
||||
"github.com/gravitational/teleport/api/types"
|
||||
wanlib "github.com/gravitational/teleport/lib/auth/webauthn"
|
||||
"github.com/gravitational/teleport/lib/auth/mocku2f"
|
||||
"github.com/gravitational/teleport/lib/defaults"
|
||||
"github.com/gravitational/trace"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
wanlib "github.com/gravitational/teleport/lib/auth/webauthn"
|
||||
)
|
||||
|
||||
func TestServer_CreateAuthenticateChallenge_authPreference(t *testing.T) {
|
||||
|
@ -156,7 +158,8 @@ func TestServer_CreateAuthenticateChallenge_authPreference(t *testing.T) {
|
|||
UserCredentials: &proto.UserCredentials{
|
||||
Username: username,
|
||||
Password: []byte(password),
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
test.assertChallenge(challenge)
|
||||
|
@ -164,85 +167,6 @@ func TestServer_CreateAuthenticateChallenge_authPreference(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// sshPubKey is a randomly-generated public key used for login tests.
|
||||
//
|
||||
// The corresponding private key is:
|
||||
// -----BEGIN PRIVATE KEY-----
|
||||
// MHcCAQEEIAKuZeB4WL4KAl5cnCrMYBy3kAX9qHt/g6OAbGGd7f3VoAoGCCqGSM49
|
||||
// AwEHoUQDQgAEa/6A3YLbc/TyJ4lED2BT8iThuw6HcrDX3dRixwkPDjWYBOP4qrJ/
|
||||
// jlGaPwXyuzeLuZgpFde7UiM1EHM2ClfGpw==
|
||||
// -----END PRIVATE KEY-----
|
||||
const sshPubKey = `ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGv+gN2C23P08ieJRA9gU/Ik4bsOh3Kw193UYscJDw41mATj+Kqyf45Rmj8F8rs3i7mYKRXXu1IjNRBzNgpXxqc=`
|
||||
|
||||
func TestServer_AuthenticateUser_mfaDevices(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svr := newTestTLSServer(t)
|
||||
authServer := svr.Auth()
|
||||
mfa := configureForMFA(t, svr)
|
||||
username := mfa.User
|
||||
password := mfa.Password
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
solveChallenge func(*proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error)
|
||||
}{
|
||||
{name: "OK TOTP device", solveChallenge: mfa.TOTPDev.SolveAuthn},
|
||||
{name: "OK Webauthn device", solveChallenge: mfa.WebDev.SolveAuthn},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
// makeRun is used to test both SSH and Web login by switching the
|
||||
// authenticate function.
|
||||
makeRun := func(authenticate func(*Server, AuthenticateUserRequest) error) func(t *testing.T) {
|
||||
return func(t *testing.T) {
|
||||
// 1st step: acquire challenge
|
||||
challenge, err := authServer.CreateAuthenticateChallenge(context.Background(), &proto.CreateAuthenticateChallengeRequest{
|
||||
Request: &proto.CreateAuthenticateChallengeRequest_UserCredentials{UserCredentials: &proto.UserCredentials{
|
||||
Username: username,
|
||||
Password: []byte(password),
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Solve challenge (client-side)
|
||||
resp, err := test.solveChallenge(challenge)
|
||||
authReq := AuthenticateUserRequest{
|
||||
Username: username,
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
switch {
|
||||
case resp.GetWebauthn() != nil:
|
||||
authReq.Webauthn = wanlib.CredentialAssertionResponseFromProto(resp.GetWebauthn())
|
||||
case resp.GetTOTP() != nil:
|
||||
authReq.OTP = &OTPCreds{
|
||||
Password: []byte(password),
|
||||
Token: resp.GetTOTP().Code,
|
||||
}
|
||||
default:
|
||||
t.Fatalf("Unexpected solved challenge type: %T", resp.Response)
|
||||
}
|
||||
|
||||
// 2nd step: finish login - either SSH or Web
|
||||
require.NoError(t, authenticate(authServer, authReq))
|
||||
}
|
||||
}
|
||||
t.Run(test.name+"/ssh", makeRun(func(s *Server, req AuthenticateUserRequest) error {
|
||||
_, err := s.AuthenticateSSHUser(AuthenticateSSHRequest{
|
||||
AuthenticateUserRequest: req,
|
||||
PublicKey: []byte(sshPubKey),
|
||||
TTL: 24 * time.Hour,
|
||||
})
|
||||
return err
|
||||
}))
|
||||
t.Run(test.name+"/web", makeRun(func(s *Server, req AuthenticateUserRequest) error {
|
||||
_, err := s.AuthenticateWebUser(req)
|
||||
return err
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAuthenticateChallenge_WithAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
@ -261,7 +185,7 @@ func TestCreateAuthenticateChallenge_WithAuth(t *testing.T) {
|
|||
// TODO(codingllama): Use a public endpoint to verify?
|
||||
mfaResp, err := u.webDev.SolveAuthn(res)
|
||||
require.NoError(t, err)
|
||||
_, err = srv.Auth().validateMFAAuthResponse(ctx, u.username, mfaResp)
|
||||
_, _, err = srv.Auth().validateMFAAuthResponse(ctx, mfaResp, u.username, false /* passwordless */)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
|
@ -476,6 +400,310 @@ func TestCreateRegisterChallenge(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// sshPubKey is a randomly-generated public key used for login tests.
|
||||
//
|
||||
// The corresponding private key is:
|
||||
// -----BEGIN PRIVATE KEY-----
|
||||
// MHcCAQEEIAKuZeB4WL4KAl5cnCrMYBy3kAX9qHt/g6OAbGGd7f3VoAoGCCqGSM49
|
||||
// AwEHoUQDQgAEa/6A3YLbc/TyJ4lED2BT8iThuw6HcrDX3dRixwkPDjWYBOP4qrJ/
|
||||
// jlGaPwXyuzeLuZgpFde7UiM1EHM2ClfGpw==
|
||||
// -----END PRIVATE KEY-----
|
||||
const sshPubKey = `ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGv+gN2C23P08ieJRA9gU/Ik4bsOh3Kw193UYscJDw41mATj+Kqyf45Rmj8F8rs3i7mYKRXXu1IjNRBzNgpXxqc=`
|
||||
|
||||
func TestServer_AuthenticateUser_mfaDevices(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svr := newTestTLSServer(t)
|
||||
authServer := svr.Auth()
|
||||
mfa := configureForMFA(t, svr)
|
||||
username := mfa.User
|
||||
password := mfa.Password
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
solveChallenge func(*proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error)
|
||||
}{
|
||||
{name: "OK TOTP device", solveChallenge: mfa.TOTPDev.SolveAuthn},
|
||||
{name: "OK Webauthn device", solveChallenge: mfa.WebDev.SolveAuthn},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
// makeRun is used to test both SSH and Web login by switching the
|
||||
// authenticate function.
|
||||
makeRun := func(authenticate func(*Server, AuthenticateUserRequest) error) func(t *testing.T) {
|
||||
return func(t *testing.T) {
|
||||
// 1st step: acquire challenge
|
||||
challenge, err := authServer.CreateAuthenticateChallenge(context.Background(), &proto.CreateAuthenticateChallengeRequest{
|
||||
Request: &proto.CreateAuthenticateChallengeRequest_UserCredentials{UserCredentials: &proto.UserCredentials{
|
||||
Username: username,
|
||||
Password: []byte(password),
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Solve challenge (client-side)
|
||||
resp, err := test.solveChallenge(challenge)
|
||||
authReq := AuthenticateUserRequest{
|
||||
Username: username,
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
switch {
|
||||
case resp.GetWebauthn() != nil:
|
||||
authReq.Webauthn = wanlib.CredentialAssertionResponseFromProto(resp.GetWebauthn())
|
||||
case resp.GetTOTP() != nil:
|
||||
authReq.OTP = &OTPCreds{
|
||||
Password: []byte(password),
|
||||
Token: resp.GetTOTP().Code,
|
||||
}
|
||||
default:
|
||||
t.Fatalf("Unexpected solved challenge type: %T", resp.Response)
|
||||
}
|
||||
|
||||
// 2nd step: finish login - either SSH or Web
|
||||
require.NoError(t, authenticate(authServer, authReq))
|
||||
}
|
||||
}
|
||||
t.Run(test.name+"/ssh", makeRun(func(s *Server, req AuthenticateUserRequest) error {
|
||||
_, err := s.AuthenticateSSHUser(AuthenticateSSHRequest{
|
||||
AuthenticateUserRequest: req,
|
||||
PublicKey: []byte(sshPubKey),
|
||||
TTL: 24 * time.Hour,
|
||||
})
|
||||
return err
|
||||
}))
|
||||
t.Run(test.name+"/web", makeRun(func(s *Server, req AuthenticateUserRequest) error {
|
||||
_, err := s.AuthenticateWebUser(req)
|
||||
return err
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_Authenticate_passwordless(t *testing.T) {
|
||||
t.Parallel()
|
||||
svr := newTestTLSServer(t)
|
||||
authServer := svr.Auth()
|
||||
|
||||
// Configure Auth separately, we want a passwordless-capable device
|
||||
// registered too.
|
||||
ctx := context.Background()
|
||||
authPreference, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
||||
Type: constants.Local,
|
||||
SecondFactor: constants.SecondFactorOptional,
|
||||
Webauthn: &types.Webauthn{
|
||||
RPID: "localhost",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, authServer.SetAuthPreference(ctx, authPreference))
|
||||
|
||||
// Create user and initial WebAuthn device (MFA).
|
||||
const user = "llama"
|
||||
const password = "p@ssw0rd"
|
||||
_, _, err = CreateUserAndRole(authServer, user, []string{"llama", "root"})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, authServer.UpsertPassword(user, []byte(password)))
|
||||
userClient, err := svr.NewClient(TestUser(user))
|
||||
require.NoError(t, err)
|
||||
webDev, err := RegisterTestDevice(
|
||||
ctx, userClient, "web", proto.DeviceType_DEVICE_TYPE_WEBAUTHN, nil /* authenticator */)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Acquire a privilege token so we can register a passwordless device
|
||||
// synchronously.
|
||||
mfaChallenge, err := userClient.CreateAuthenticateChallenge(ctx, &proto.CreateAuthenticateChallengeRequest{
|
||||
Request: &proto.CreateAuthenticateChallengeRequest_ContextUser{
|
||||
ContextUser: &proto.ContextUser{}, // already authenticated
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
mfaResp, err := webDev.SolveAuthn(mfaChallenge)
|
||||
require.NoError(t, err)
|
||||
token, err := userClient.CreatePrivilegeToken(ctx, &proto.CreatePrivilegeTokenRequest{
|
||||
ExistingMFAResponse: mfaResp,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Register passwordless device.
|
||||
registerChallenge, err := userClient.CreateRegisterChallenge(ctx, &proto.CreateRegisterChallengeRequest{
|
||||
TokenID: token.GetName(),
|
||||
DeviceType: proto.DeviceType_DEVICE_TYPE_WEBAUTHN,
|
||||
DeviceUsage: proto.DeviceUsage_DEVICE_USAGE_PASSWORDLESS,
|
||||
})
|
||||
require.NoError(t, err, "Failed to create passwordless registration challenge")
|
||||
pwdKey, err := mocku2f.Create()
|
||||
require.NoError(t, err)
|
||||
pwdKey.SetPasswordless()
|
||||
const origin = "https://localhost"
|
||||
ccr, err := pwdKey.SignCredentialCreation(origin, wanlib.CredentialCreationFromProto(registerChallenge.GetWebauthn()))
|
||||
require.NoError(t, err)
|
||||
_, err = userClient.AddMFADeviceSync(ctx, &proto.AddMFADeviceSyncRequest{
|
||||
TokenID: token.GetName(),
|
||||
NewDeviceName: "pwdless1",
|
||||
NewMFAResponse: &proto.MFARegisterResponse{
|
||||
Response: &proto.MFARegisterResponse_Webauthn{
|
||||
Webauthn: wanlib.CredentialCreationResponseToProto(ccr),
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "Failed to register passwordless device")
|
||||
|
||||
// userWebID is what identifies the user for usernameless/passwordless.
|
||||
userWebID := registerChallenge.GetWebauthn().PublicKey.User.Id
|
||||
|
||||
// Use a proxy client for now on; the user's identity isn't established yet.
|
||||
proxyClient, err := svr.NewClient(TestBuiltin(types.RoleProxy))
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
authenticate func(t *testing.T, resp *wanlib.CredentialAssertionResponse)
|
||||
}{
|
||||
{
|
||||
name: "ssh",
|
||||
authenticate: func(t *testing.T, resp *wanlib.CredentialAssertionResponse) {
|
||||
loginResp, err := proxyClient.AuthenticateSSHUser(AuthenticateSSHRequest{
|
||||
AuthenticateUserRequest: AuthenticateUserRequest{
|
||||
Webauthn: resp,
|
||||
},
|
||||
PublicKey: []byte(sshPubKey),
|
||||
TTL: 24 * time.Hour,
|
||||
})
|
||||
require.NoError(t, err, "Failed to perform passwordless authentication")
|
||||
require.NotNil(t, loginResp, "SSH response nil")
|
||||
require.NotEmpty(t, loginResp.Cert, "SSH certificate empty")
|
||||
require.Equal(t, user, loginResp.Username, "Unexpected username")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "web",
|
||||
authenticate: func(t *testing.T, resp *wanlib.CredentialAssertionResponse) {
|
||||
session, err := proxyClient.AuthenticateWebUser(AuthenticateUserRequest{
|
||||
Webauthn: resp,
|
||||
})
|
||||
require.NoError(t, err, "Failed to perform passwordless authentication")
|
||||
require.Equal(t, user, session.GetUser(), "Unexpected username")
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
// Fail a login attempt so have a non-empty list of attempts.
|
||||
_, err := proxyClient.AuthenticateSSHUser(AuthenticateSSHRequest{
|
||||
AuthenticateUserRequest: AuthenticateUserRequest{
|
||||
Username: user,
|
||||
Webauthn: &wanlib.CredentialAssertionResponse{}, // bad response
|
||||
},
|
||||
PublicKey: []byte(sshPubKey),
|
||||
TTL: 24 * time.Hour,
|
||||
})
|
||||
require.True(t, trace.IsAccessDenied(err), "got err = %v, want AccessDenied")
|
||||
attempts, err := authServer.GetUserLoginAttempts(user)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, attempts, "Want at least one failed login attempt")
|
||||
|
||||
// Create a passwordless challenge.
|
||||
mfaChallenge, err := proxyClient.CreateAuthenticateChallenge(ctx, &proto.CreateAuthenticateChallengeRequest{
|
||||
Request: &proto.CreateAuthenticateChallengeRequest_Passwordless{
|
||||
Passwordless: &proto.Passwordless{},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "Failed to create passwordless challenge")
|
||||
|
||||
// Sign challenge (mocks user interaction).
|
||||
assertionResp, err := pwdKey.SignAssertion(origin, wanlib.CredentialAssertionFromProto(mfaChallenge.GetWebauthnChallenge()))
|
||||
require.NoError(t, err)
|
||||
assertionResp.AssertionResponse.UserHandle = userWebID // identify user, a real device would set this
|
||||
|
||||
// Complete login procedure (SSH or Web).
|
||||
test.authenticate(t, assertionResp)
|
||||
|
||||
// Verify zeroed login attempts. This is a proxy for various other user
|
||||
// checks (locked, etc).
|
||||
attempts, err = authServer.GetUserLoginAttempts(user)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, attempts, "Login attempts not reset")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_Authenticate_nonPasswordlessRequiresUsername(t *testing.T) {
|
||||
t.Parallel()
|
||||
svr := newTestTLSServer(t)
|
||||
|
||||
// We don't mind about the specifics of the configuration, as long as we have
|
||||
// a user and TOTP/WebAuthn devices.
|
||||
mfa := configureForMFA(t, svr)
|
||||
username := mfa.User
|
||||
password := mfa.Password
|
||||
|
||||
userClient, err := svr.NewClient(TestUser(username))
|
||||
require.NoError(t, err)
|
||||
proxyClient, err := svr.NewClient(TestBuiltin(types.RoleProxy))
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
tests := []struct {
|
||||
name string
|
||||
dev *TestDevice
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "OTP",
|
||||
dev: mfa.TOTPDev,
|
||||
wantErr: "username", // Error contains "username"
|
||||
},
|
||||
{
|
||||
name: "WebAuthn",
|
||||
dev: mfa.WebDev,
|
||||
wantErr: "invalid Webauthn response", // generic error as it _could_ be a passwordless attempt
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
mfaChallenge, err := userClient.CreateAuthenticateChallenge(ctx, &proto.CreateAuthenticateChallengeRequest{
|
||||
Request: &proto.CreateAuthenticateChallengeRequest_ContextUser{
|
||||
ContextUser: &proto.ContextUser{},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
mfaResp, err := test.dev.SolveAuthn(mfaChallenge)
|
||||
require.NoError(t, err)
|
||||
|
||||
var req AuthenticateUserRequest
|
||||
switch {
|
||||
case mfaResp.GetWebauthn() != nil:
|
||||
req.Webauthn = wanlib.CredentialAssertionResponseFromProto(mfaResp.GetWebauthn())
|
||||
case mfaResp.GetTOTP() != nil:
|
||||
req.OTP = &OTPCreds{
|
||||
Password: []byte(password),
|
||||
Token: mfaResp.GetTOTP().Code,
|
||||
}
|
||||
}
|
||||
|
||||
// SSH.
|
||||
_, err = proxyClient.AuthenticateSSHUser(AuthenticateSSHRequest{
|
||||
AuthenticateUserRequest: req,
|
||||
PublicKey: []byte(sshPubKey),
|
||||
TTL: 24 * time.Hour,
|
||||
})
|
||||
require.Error(t, err, "SSH authentication expected fail (missing username)")
|
||||
require.Contains(t, err.Error(), test.wantErr)
|
||||
|
||||
// Web.
|
||||
_, err = proxyClient.AuthenticateWebUser(req)
|
||||
require.Error(t, err, "Web authentication expected fail (missing username)")
|
||||
require.Contains(t, err.Error(), test.wantErr)
|
||||
|
||||
// Get one right so we don't lock the user between tests.
|
||||
req.Username = username
|
||||
_, err = proxyClient.AuthenticateWebUser(req)
|
||||
require.NoError(t, err, "Web authentication expected to succeed")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type configureMFAResp struct {
|
||||
User, Password string
|
||||
TOTPDev, WebDev *TestDevice
|
||||
|
|
|
@ -1813,7 +1813,8 @@ func (g *GRPCServer) DeleteRole(ctx context.Context, req *proto.DeleteRoleReques
|
|||
func doMFAPresenceChallenge(ctx context.Context, actx *grpcContext, stream proto.AuthService_MaintainSessionPresenceServer, challengeReq *proto.PresenceMFAChallengeRequest) error {
|
||||
user := actx.User.GetName()
|
||||
|
||||
authChallenge, err := actx.authServer.mfaAuthChallenge(ctx, user)
|
||||
const passwordless = false
|
||||
authChallenge, err := actx.authServer.mfaAuthChallenge(ctx, user, passwordless)
|
||||
if err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
|
@ -1835,7 +1836,7 @@ func doMFAPresenceChallenge(ctx context.Context, actx *grpcContext, stream proto
|
|||
return trace.BadParameter("expected MFAAuthenticateResponse, got %T", challengeResp)
|
||||
}
|
||||
|
||||
if _, err := actx.authServer.validateMFAAuthResponse(ctx, user, challengeResp); err != nil {
|
||||
if _, _, err := actx.authServer.validateMFAAuthResponse(ctx, challengeResp, user, passwordless); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
|
||||
|
@ -1964,7 +1965,8 @@ func addMFADeviceAuthChallenge(gctx *grpcContext, stream proto.AuthService_AddMF
|
|||
ctx := stream.Context()
|
||||
|
||||
// Note: authChallenge may be empty if this user has no existing MFA devices.
|
||||
authChallenge, err := auth.mfaAuthChallenge(ctx, user)
|
||||
const passwordless = false
|
||||
authChallenge, err := auth.mfaAuthChallenge(ctx, user, passwordless)
|
||||
if err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
|
@ -1984,7 +1986,7 @@ func addMFADeviceAuthChallenge(gctx *grpcContext, stream proto.AuthService_AddMF
|
|||
}
|
||||
// Only validate if there was a challenge.
|
||||
if authChallenge.TOTP != nil || authChallenge.WebauthnChallenge != nil {
|
||||
if _, err := auth.validateMFAAuthResponse(ctx, user, authResp); err != nil {
|
||||
if _, _, err := auth.validateMFAAuthResponse(ctx, authResp, user, passwordless); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
}
|
||||
|
@ -2006,6 +2008,7 @@ func addMFADeviceRegisterChallenge(gctx *grpcContext, stream proto.AuthService_A
|
|||
res, err := auth.createRegisterChallenge(ctx, &newRegisterChallengeRequest{
|
||||
username: user,
|
||||
deviceType: initReq.DeviceType,
|
||||
deviceUsage: initReq.DeviceUsage,
|
||||
webIdentityOverride: webIdentity,
|
||||
})
|
||||
if err != nil {
|
||||
|
@ -2088,7 +2091,8 @@ func deleteMFADeviceAuthChallenge(gctx *grpcContext, stream proto.AuthService_De
|
|||
auth := gctx.authServer
|
||||
user := gctx.User.GetName()
|
||||
|
||||
authChallenge, err := auth.mfaAuthChallenge(ctx, user)
|
||||
const passwordless = false
|
||||
authChallenge, err := auth.mfaAuthChallenge(ctx, user, passwordless)
|
||||
if err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
|
@ -2107,7 +2111,7 @@ func deleteMFADeviceAuthChallenge(gctx *grpcContext, stream proto.AuthService_De
|
|||
if authResp == nil {
|
||||
return trace.BadParameter("expected MFAAuthenticateResponse, got %T", req)
|
||||
}
|
||||
if _, err := auth.validateMFAAuthResponse(ctx, user, authResp); err != nil {
|
||||
if _, _, err := auth.validateMFAAuthResponse(ctx, authResp, user, passwordless); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
return nil
|
||||
|
@ -2251,7 +2255,8 @@ func userSingleUseCertsAuthChallenge(gctx *grpcContext, stream proto.AuthService
|
|||
auth := gctx.authServer
|
||||
user := gctx.User.GetName()
|
||||
|
||||
challenge, err := auth.mfaAuthChallenge(ctx, user)
|
||||
const passwordless = false
|
||||
challenge, err := auth.mfaAuthChallenge(ctx, user, passwordless)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
@ -2272,7 +2277,7 @@ func userSingleUseCertsAuthChallenge(gctx *grpcContext, stream proto.AuthService
|
|||
if authResp == nil {
|
||||
return nil, trace.BadParameter("expected MFAAuthenticateResponse, got %T", req.Request)
|
||||
}
|
||||
mfaDev, err := auth.validateMFAAuthResponse(ctx, user, authResp)
|
||||
mfaDev, _, err := auth.validateMFAAuthResponse(ctx, authResp, user, passwordless)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
|
|
@ -88,6 +88,8 @@ func TestMFADeviceManagement(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
webKey2.PreferRPID = true
|
||||
const webDev2Name = "webauthn2"
|
||||
const pwdlessDevName = "pwdless"
|
||||
|
||||
addTests := []struct {
|
||||
desc string
|
||||
opts mfaAddTestOpts
|
||||
|
@ -229,6 +231,41 @@ func TestMFADeviceManagement(t *testing.T) {
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "add passwordless device",
|
||||
opts: mfaAddTestOpts{
|
||||
initReq: &proto.AddMFADeviceRequestInit{
|
||||
DeviceName: pwdlessDevName,
|
||||
DeviceType: proto.DeviceType_DEVICE_TYPE_WEBAUTHN,
|
||||
DeviceUsage: proto.DeviceUsage_DEVICE_USAGE_PASSWORDLESS,
|
||||
},
|
||||
authHandler: devs.webAuthHandler,
|
||||
checkAuthErr: require.NoError,
|
||||
registerHandler: func(t *testing.T, challenge *proto.MFARegisterChallenge) *proto.MFARegisterResponse {
|
||||
require.NotNil(t, challenge.GetWebauthn(), "WebAuthn challenge cannot be nil")
|
||||
|
||||
key, err := mocku2f.Create()
|
||||
require.NoError(t, err)
|
||||
key.PreferRPID = true
|
||||
key.SetPasswordless()
|
||||
|
||||
ccr, err := key.SignCredentialCreation(webOrigin, wanlib.CredentialCreationFromProto(challenge.GetWebauthn()))
|
||||
require.NoError(t, err)
|
||||
|
||||
return &proto.MFARegisterResponse{
|
||||
Response: &proto.MFARegisterResponse_Webauthn{
|
||||
Webauthn: wanlib.CredentialCreationResponseToProto(ccr),
|
||||
},
|
||||
}
|
||||
},
|
||||
checkRegisterErr: require.NoError,
|
||||
assertRegisteredDev: func(t *testing.T, dev *types.MFADevice) {
|
||||
// Do a few simple device checks - lib/auth/webauthn goes in depth.
|
||||
require.NotNil(t, dev.GetWebauthn(), "WebAuthnDevice cannot be nil")
|
||||
require.True(t, true, dev.GetWebauthn().ResidentKey, "ResidentKey should be set to true")
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range addTests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
|
@ -246,7 +283,7 @@ func TestMFADeviceManagement(t *testing.T) {
|
|||
deviceIDs[dev.GetName()] = dev.Id
|
||||
}
|
||||
sort.Strings(deviceNames)
|
||||
require.Equal(t, deviceNames, []string{devs.TOTPName, devs.WebName, webDev2Name})
|
||||
require.Equal(t, deviceNames, []string{pwdlessDevName, devs.TOTPName, devs.WebName, webDev2Name})
|
||||
|
||||
// Delete several of the MFA devices.
|
||||
deleteTests := []struct {
|
||||
|
@ -321,6 +358,16 @@ func TestMFADeviceManagement(t *testing.T) {
|
|||
checkErr: require.NoError,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "delete pwdless device by name",
|
||||
opts: mfaDeleteTestOpts{
|
||||
initReq: &proto.DeleteMFADeviceRequestInit{
|
||||
DeviceName: pwdlessDevName,
|
||||
},
|
||||
authHandler: devs.webAuthHandler,
|
||||
checkErr: require.NoError,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "delete webauthn device by name",
|
||||
opts: mfaDeleteTestOpts{
|
||||
|
|
|
@ -48,10 +48,11 @@ type AuthenticateUserRequest struct {
|
|||
|
||||
// CheckAndSetDefaults checks and sets defaults
|
||||
func (a *AuthenticateUserRequest) CheckAndSetDefaults() error {
|
||||
if a.Username == "" {
|
||||
switch {
|
||||
case a.Username == "" && a.Webauthn != nil: // OK, passwordless.
|
||||
case a.Username == "":
|
||||
return trace.BadParameter("missing parameter 'username'")
|
||||
}
|
||||
if a.Pass == nil && a.Webauthn == nil && a.OTP == nil && a.Session == nil {
|
||||
case a.Pass == nil && a.Webauthn == nil && a.OTP == nil && a.Session == nil:
|
||||
return trace.BadParameter("at least one authentication method is required")
|
||||
}
|
||||
return nil
|
||||
|
@ -77,16 +78,28 @@ type SessionCreds struct {
|
|||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// AuthenticateUser authenticates user based on the request type
|
||||
func (s *Server) AuthenticateUser(req AuthenticateUserRequest) error {
|
||||
mfaDev, err := s.authenticateUser(context.TODO(), req)
|
||||
// AuthenticateUser authenticates user based on the request type.
|
||||
// Returns the username of the authenticated user.
|
||||
func (s *Server) AuthenticateUser(req AuthenticateUserRequest) (string, error) {
|
||||
user := req.Username
|
||||
|
||||
mfaDev, actualUser, err := s.authenticateUser(context.TODO(), req)
|
||||
// err is handled below.
|
||||
switch {
|
||||
case user != "" && actualUser != "" && user != actualUser:
|
||||
log.Warnf("Authenticate user mismatch (%q vs %q). Using request user (%q)", user, actualUser, user)
|
||||
case user == "" && actualUser != "":
|
||||
log.Debugf("User %q authenticated via passwordless", actualUser)
|
||||
user = actualUser
|
||||
}
|
||||
|
||||
event := &apievents.UserLogin{
|
||||
Metadata: apievents.Metadata{
|
||||
Type: events.UserLoginEvent,
|
||||
Code: events.UserLocalLoginFailureCode,
|
||||
},
|
||||
UserMetadata: apievents.UserMetadata{
|
||||
User: req.Username,
|
||||
User: user,
|
||||
},
|
||||
Method: events.LoginMethodLocal,
|
||||
}
|
||||
|
@ -105,14 +118,27 @@ func (s *Server) AuthenticateUser(req AuthenticateUserRequest) error {
|
|||
if err := s.emitter.EmitAuditEvent(s.closeCtx, event); err != nil {
|
||||
log.WithError(err).Warn("Failed to emit login event.")
|
||||
}
|
||||
return err
|
||||
return user, trace.Wrap(err)
|
||||
}
|
||||
|
||||
func (s *Server) authenticateUser(ctx context.Context, req AuthenticateUserRequest) (*types.MFADevice, error) {
|
||||
// authenticateWebauthnError is the generic error message returned for failed
|
||||
// WebAuthn authentication attempts.
|
||||
const authenticateWebauthnError = "invalid Webauthn response"
|
||||
|
||||
// authenticateUser authenticates a user through various methods (password, MFA,
|
||||
// passwordless)
|
||||
// Returns the device used to authenticate (if applicable) and the username.
|
||||
func (s *Server) authenticateUser(ctx context.Context, req AuthenticateUserRequest) (*types.MFADevice, string, error) {
|
||||
if err := req.CheckAndSetDefaults(); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
return nil, "", trace.Wrap(err)
|
||||
}
|
||||
user := req.Username
|
||||
passwordless := user == ""
|
||||
|
||||
// Only one path if passwordless, other variants shouldn't see an empty user.
|
||||
if passwordless {
|
||||
return s.authenticatePasswordless(ctx, req)
|
||||
}
|
||||
|
||||
// Try 2nd-factor-enabled authentication schemes first.
|
||||
var authenticateFn func() (*types.MFADevice, error)
|
||||
|
@ -126,9 +152,10 @@ func (s *Server) authenticateUser(ctx context.Context, req AuthenticateUserReque
|
|||
Webauthn: wanlib.CredentialAssertionResponseToProto(req.Webauthn),
|
||||
},
|
||||
}
|
||||
return s.validateMFAAuthResponse(ctx, user, mfaResponse)
|
||||
dev, _, err := s.validateMFAAuthResponse(ctx, mfaResponse, user, passwordless)
|
||||
return dev, trace.Wrap(err)
|
||||
}
|
||||
failMsg = "invalid Webauthn response"
|
||||
failMsg = authenticateWebauthnError
|
||||
case req.OTP != nil:
|
||||
authenticateFn = func() (*types.MFADevice, error) {
|
||||
// OTP cannot be validated by validateMFAAuthResponse because we need to
|
||||
|
@ -152,28 +179,28 @@ func (s *Server) authenticateUser(ctx context.Context, req AuthenticateUserReque
|
|||
case err != nil:
|
||||
log.Debugf("User %v failed to authenticate: %v.", user, err)
|
||||
if fieldErr := getErrorByTraceField(err); fieldErr != nil {
|
||||
return nil, trace.Wrap(fieldErr)
|
||||
return nil, "", trace.Wrap(fieldErr)
|
||||
}
|
||||
|
||||
return nil, trace.AccessDenied(failMsg)
|
||||
return nil, "", trace.AccessDenied(failMsg)
|
||||
case dev == nil:
|
||||
log.Debugf(
|
||||
"MFA authentication returned nil device (Webauthn = %v, TOTP = %v): %v.",
|
||||
req.Webauthn != nil, req.OTP != nil, err)
|
||||
return nil, trace.AccessDenied(failMsg)
|
||||
return nil, "", trace.AccessDenied(failMsg)
|
||||
default:
|
||||
return dev, nil
|
||||
return dev, user, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try password-only authentication last.
|
||||
if req.Pass == nil {
|
||||
return nil, trace.AccessDenied("unsupported authentication method")
|
||||
return nil, "", trace.AccessDenied("unsupported authentication method")
|
||||
}
|
||||
|
||||
authPreference, err := s.GetAuthPreference(ctx)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
return nil, "", trace.Wrap(err)
|
||||
}
|
||||
|
||||
// When using password only make sure that auth preference does not require
|
||||
|
@ -186,37 +213,62 @@ func (s *Server) authenticateUser(ctx context.Context, req AuthenticateUserReque
|
|||
// registered.
|
||||
devs, err := s.Identity.GetMFADevices(ctx, user, false /* withSecrets */)
|
||||
if err != nil && !trace.IsNotFound(err) {
|
||||
return nil, trace.Wrap(err)
|
||||
return nil, "", trace.Wrap(err)
|
||||
}
|
||||
if len(devs) != 0 {
|
||||
log.Warningf("MFA bypass attempt by user %q, access denied.", user)
|
||||
return nil, trace.AccessDenied("missing second factor authentication")
|
||||
return nil, "", trace.AccessDenied("missing second factor authentication")
|
||||
}
|
||||
default:
|
||||
// Some form of MFA is required but none provided. Either client is
|
||||
// buggy (didn't send MFA response) or someone is trying to bypass
|
||||
// MFA.
|
||||
log.Warningf("MFA bypass attempt by user %q, access denied.", user)
|
||||
return nil, trace.AccessDenied("missing second factor")
|
||||
return nil, "", trace.AccessDenied("missing second factor")
|
||||
}
|
||||
if err = s.WithUserLock(user, func() error {
|
||||
return s.checkPasswordWOToken(user, req.Pass.Password)
|
||||
}); err != nil {
|
||||
if fieldErr := getErrorByTraceField(err); fieldErr != nil {
|
||||
return nil, trace.Wrap(fieldErr)
|
||||
return nil, "", trace.Wrap(fieldErr)
|
||||
}
|
||||
// provide obscure message on purpose, while logging the real
|
||||
// error server side
|
||||
log.Debugf("User %v failed to authenticate: %v.", user, err)
|
||||
return nil, trace.AccessDenied("invalid username or password")
|
||||
return nil, "", trace.AccessDenied("invalid username or password")
|
||||
}
|
||||
return nil, nil
|
||||
return nil, user, nil
|
||||
}
|
||||
|
||||
func (s *Server) authenticatePasswordless(ctx context.Context, req AuthenticateUserRequest) (*types.MFADevice, string, error) {
|
||||
mfaResponse := &proto.MFAAuthenticateResponse{
|
||||
Response: &proto.MFAAuthenticateResponse_Webauthn{
|
||||
Webauthn: wanlib.CredentialAssertionResponseToProto(req.Webauthn),
|
||||
},
|
||||
}
|
||||
dev, user, err := s.validateMFAAuthResponse(ctx, mfaResponse, "", true /* passwordless */)
|
||||
if err != nil {
|
||||
log.Debugf("Passwordless authentication failed: %v", err)
|
||||
return nil, "", trace.AccessDenied(authenticateWebauthnError)
|
||||
}
|
||||
|
||||
// A distinction between passwordless and "plain" MFA is that we can't
|
||||
// acquire the user lock beforehand (or at all on failures!)
|
||||
// We do grab it here so successful logins go through the regular process.
|
||||
if err := s.WithUserLock(user, func() error { return nil }); err != nil {
|
||||
log.Debugf("WithUserLock for user %q failed during passwordless authentication: %v", user, err)
|
||||
return nil, user, trace.AccessDenied(authenticateWebauthnError)
|
||||
}
|
||||
|
||||
return dev, user, nil
|
||||
}
|
||||
|
||||
// AuthenticateWebUser authenticates web user, creates and returns a web session
|
||||
// if authentication is successful. In case the existing session ID is used to authenticate,
|
||||
// returns the existing session instead of creating a new one
|
||||
func (s *Server) AuthenticateWebUser(req AuthenticateUserRequest) (types.WebSession, error) {
|
||||
username := req.Username // Empty if passwordless.
|
||||
|
||||
ctx := context.TODO()
|
||||
authPref, err := s.GetAuthPreference(ctx)
|
||||
if err != nil {
|
||||
|
@ -228,13 +280,13 @@ func (s *Server) AuthenticateWebUser(req AuthenticateUserRequest) (types.WebSess
|
|||
// This condition uses Session as a blanket check, because any new method added
|
||||
// to the local auth will be disabled by default.
|
||||
if !authPref.GetAllowLocalAuth() && req.Session == nil {
|
||||
s.emitNoLocalAuthEvent(req.Username)
|
||||
s.emitNoLocalAuthEvent(username)
|
||||
return nil, trace.AccessDenied(noLocalAuth)
|
||||
}
|
||||
|
||||
if req.Session != nil {
|
||||
session, err := s.GetWebSession(context.TODO(), types.GetWebSessionRequest{
|
||||
User: req.Username,
|
||||
User: username,
|
||||
SessionID: req.Session.ID,
|
||||
})
|
||||
if err != nil {
|
||||
|
@ -243,11 +295,13 @@ func (s *Server) AuthenticateWebUser(req AuthenticateUserRequest) (types.WebSess
|
|||
return session, nil
|
||||
}
|
||||
|
||||
if err := s.AuthenticateUser(req); err != nil {
|
||||
actualUser, err := s.AuthenticateUser(req)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
username = actualUser
|
||||
|
||||
user, err := s.GetUser(req.Username, false)
|
||||
user, err := s.GetUser(username, false /* withSecrets */)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
@ -349,13 +403,15 @@ func AuthoritiesToTrustedCerts(authorities []types.CertAuthority) []TrustedCerts
|
|||
// AuthenticateSSHUser authenticates an SSH user and returns SSH and TLS
|
||||
// certificates for the public key in req.
|
||||
func (s *Server) AuthenticateSSHUser(req AuthenticateSSHRequest) (*SSHLoginResponse, error) {
|
||||
username := req.Username // Empty if passwordless.
|
||||
|
||||
ctx := context.TODO()
|
||||
authPref, err := s.GetAuthPreference(ctx)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
if !authPref.GetAllowLocalAuth() {
|
||||
s.emitNoLocalAuthEvent(req.Username)
|
||||
s.emitNoLocalAuthEvent(username)
|
||||
return nil, trace.AccessDenied(noLocalAuth)
|
||||
}
|
||||
|
||||
|
@ -364,13 +420,15 @@ func (s *Server) AuthenticateSSHUser(req AuthenticateSSHRequest) (*SSHLoginRespo
|
|||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
if err := s.AuthenticateUser(req.AuthenticateUserRequest); err != nil {
|
||||
actualUser, err := s.AuthenticateUser(req.AuthenticateUserRequest)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
username = actualUser
|
||||
|
||||
// It's safe to extract the roles and traits directly from services.User as
|
||||
// this endpoint is only used for local accounts.
|
||||
user, err := s.GetUser(req.Username, false)
|
||||
user, err := s.GetUser(username, false /* withSecrets */)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
@ -406,7 +464,7 @@ func (s *Server) AuthenticateSSHUser(req AuthenticateSSHRequest) (*SSHLoginRespo
|
|||
}
|
||||
UserLoginCount.Inc()
|
||||
return &SSHLoginResponse{
|
||||
Username: req.Username,
|
||||
Username: username,
|
||||
Cert: certs.SSH,
|
||||
TLSCert: certs.TLS,
|
||||
HostSigners: AuthoritiesToTrustedCerts(hostCertAuthorities),
|
||||
|
|
|
@ -143,6 +143,14 @@ func CreateWithKeyHandle(keyHandle []byte) (*Key, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
// SetPasswordless sets common passwordless options in Key.
|
||||
// Options are AllowResidentKey, IgnoreAllowedCredentials and SetUV.
|
||||
func (muk *Key) SetPasswordless() {
|
||||
muk.AllowResidentKey = true // Passwordless keys must be resident.
|
||||
muk.IgnoreAllowedCredentials = true // Empty for passwordless challenges.
|
||||
muk.SetUV = true // UV required for passwordless.
|
||||
}
|
||||
|
||||
func (muk *Key) RegisterResponse(req *u2f.RegisterRequest) (*u2f.RegisterResponse, error) {
|
||||
appIDHash := sha256.Sum256([]byte(req.AppID))
|
||||
|
||||
|
@ -201,7 +209,7 @@ func (muk *Key) signRegister(appIDHash, clientDataHash []byte) (*signRegisterRes
|
|||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
var flags = uint8(u2fRegistrationFlags)
|
||||
flags := uint8(u2fRegistrationFlags)
|
||||
if muk.SetUV {
|
||||
// Mimic WebAuthn flags if SetUV is true.
|
||||
flags = uint8(protocol.FlagUserPresent | protocol.FlagUserVerified | protocol.FlagAttestedCredentialData)
|
||||
|
|
|
@ -122,7 +122,7 @@ func (s *Server) ChangePassword(req services.ChangePasswordReq) error {
|
|||
Token: req.SecondFactorToken,
|
||||
}
|
||||
}
|
||||
if _, err := s.authenticateUser(ctx, authReq); err != nil {
|
||||
if _, _, err := s.authenticateUser(ctx, authReq); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
|
||||
|
|
|
@ -499,7 +499,7 @@ func (s *Server) CreatePrivilegeToken(ctx context.Context, req *proto.CreatePriv
|
|||
switch {
|
||||
case req.GetExistingMFAResponse() == nil:
|
||||
// Allows users with no devices to bypass second factor re-auth.
|
||||
devices, err := s.Identity.GetMFADevices(ctx, username, false)
|
||||
devices, err := s.Identity.GetMFADevices(ctx, username, false /* withSecrets */)
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, trace.Wrap(err)
|
||||
|
@ -511,7 +511,8 @@ func (s *Server) CreatePrivilegeToken(ctx context.Context, req *proto.CreatePriv
|
|||
|
||||
default:
|
||||
if err := s.WithUserLock(username, func() error {
|
||||
_, err := s.validateMFAAuthResponse(ctx, username, req.GetExistingMFAResponse())
|
||||
_, _, err := s.validateMFAAuthResponse(
|
||||
ctx, req.GetExistingMFAResponse(), username, false /* passwordless */)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
|
|
|
@ -87,6 +87,8 @@ type SSOLoginConsoleResponse struct {
|
|||
type MFAChallengeRequest struct {
|
||||
User string `json:"user"`
|
||||
Pass string `json:"pass"`
|
||||
// Passwordless explicitly requests a passwordless/usernameless challenge.
|
||||
Passwordless bool `json:"passwordless"`
|
||||
}
|
||||
|
||||
// CreateSSHCertReq are passed by web client
|
||||
|
|
|
@ -1652,27 +1652,39 @@ func (h *Handler) getResetPasswordToken(ctx context.Context, tokenID string) (in
|
|||
}
|
||||
|
||||
// mfaLoginBegin is the first step in the MFA authentication ceremony, which
|
||||
// may be completed either via mfaLoginFinish (SSH) or mfaLoginFinishSession (Web).
|
||||
// may be completed either via mfaLoginFinish (SSH) or mfaLoginFinishSession
|
||||
// (Web).
|
||||
//
|
||||
// POST /webapi/mfa/login/begin
|
||||
//
|
||||
// {"user": "alex", "pass": "abc123"}
|
||||
// {"passwordless": true}
|
||||
//
|
||||
// Successful response:
|
||||
//
|
||||
// {"webauthn_challenge": {...}, "totp_challenge": true}
|
||||
// {"webauthn_challenge": {...}} // passwordless
|
||||
func (h *Handler) mfaLoginBegin(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
||||
var req *client.MFAChallengeRequest
|
||||
if err := httplib.ReadJSON(r, &req); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
mfaChallenge, err := h.auth.proxyClient.CreateAuthenticateChallenge(r.Context(), &proto.CreateAuthenticateChallengeRequest{
|
||||
Request: &proto.CreateAuthenticateChallengeRequest_UserCredentials{UserCredentials: &proto.UserCredentials{
|
||||
Username: req.User,
|
||||
Password: []byte(req.Pass),
|
||||
}},
|
||||
})
|
||||
mfaReq := &proto.CreateAuthenticateChallengeRequest{}
|
||||
if req.Passwordless {
|
||||
mfaReq.Request = &proto.CreateAuthenticateChallengeRequest_Passwordless{
|
||||
Passwordless: &proto.Passwordless{},
|
||||
}
|
||||
} else {
|
||||
mfaReq.Request = &proto.CreateAuthenticateChallengeRequest_UserCredentials{
|
||||
UserCredentials: &proto.UserCredentials{
|
||||
Username: req.User,
|
||||
Password: []byte(req.Pass),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
mfaChallenge, err := h.auth.proxyClient.CreateAuthenticateChallenge(r.Context(), mfaReq)
|
||||
if err != nil {
|
||||
return nil, trace.AccessDenied("bad auth credentials")
|
||||
}
|
||||
|
@ -1723,10 +1735,14 @@ func (h *Handler) mfaLoginFinishSession(w http.ResponseWriter, r *http.Request,
|
|||
if err != nil {
|
||||
return nil, trace.AccessDenied("bad auth credentials")
|
||||
}
|
||||
if err := SetSessionCookie(w, req.User, session.GetName()); err != nil {
|
||||
|
||||
// Fetch user from session, user is empty for passwordless requests.
|
||||
user := session.GetUser()
|
||||
if err := SetSessionCookie(w, user, session.GetName()); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
ctx, err := h.auth.newSessionContext(req.User, session.GetName())
|
||||
|
||||
ctx, err := h.auth.newSessionContext(user, session.GetName())
|
||||
if err != nil {
|
||||
return nil, trace.AccessDenied("need auth")
|
||||
}
|
||||
|
|
|
@ -33,6 +33,8 @@ import (
|
|||
"github.com/gravitational/trace"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
wanlib "github.com/gravitational/teleport/lib/auth/webauthn"
|
||||
)
|
||||
|
||||
func TestWebauthnLogin_ssh(t *testing.T) {
|
||||
|
@ -43,7 +45,6 @@ func TestWebauthnLogin_ssh(t *testing.T) {
|
|||
Webauthn: &types.Webauthn{
|
||||
RPID: env.server.TLS.ClusterName(),
|
||||
},
|
||||
// Use default Webauthn configuration.
|
||||
})
|
||||
user := clusterMFA.User
|
||||
password := clusterMFA.Password
|
||||
|
@ -99,7 +100,6 @@ func TestWebauthnLogin_web(t *testing.T) {
|
|||
Webauthn: &types.Webauthn{
|
||||
RPID: env.server.TLS.ClusterName(),
|
||||
},
|
||||
// Use default Webauthn configuration.
|
||||
})
|
||||
user := clusterMFA.User
|
||||
password := clusterMFA.Password
|
||||
|
@ -138,6 +138,100 @@ func TestWebauthnLogin_web(t *testing.T) {
|
|||
require.NotEmpty(t, createSessionResp.SessionExpires.Unix())
|
||||
}
|
||||
|
||||
func TestAuthenticate_passwordless(t *testing.T) {
|
||||
env := newWebPack(t, 1)
|
||||
clusterMFA := configureClusterForMFA(t, env, &types.AuthPreferenceSpecV2{
|
||||
Type: constants.Local,
|
||||
SecondFactor: constants.SecondFactorOn,
|
||||
Webauthn: &types.Webauthn{
|
||||
RPID: env.server.TLS.ClusterName(),
|
||||
},
|
||||
})
|
||||
user := clusterMFA.User
|
||||
device := clusterMFA.WebDev.Key
|
||||
|
||||
// Fake a passwordless device. Typically this would require a separate
|
||||
// registration, but because we use fake devices we can get away with it.
|
||||
device.SetPasswordless()
|
||||
|
||||
// Fetch the WebAuthn User Handle. In a real-world scenario the device stores
|
||||
// the handle alongside the credentials during registration.
|
||||
ctx := context.Background()
|
||||
authServer := env.server.Auth()
|
||||
wla, err := authServer.GetWebauthnLocalAuth(ctx, user)
|
||||
require.NoError(t, err)
|
||||
userHandle := wla.UserID
|
||||
|
||||
// Prepare SSH key to be signed.
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
pub, err := ssh.NewPublicKey(&priv.PublicKey)
|
||||
require.NoError(t, err)
|
||||
pubBytes := ssh.MarshalAuthorizedKey(pub)
|
||||
|
||||
clt, err := client.NewWebClient(env.proxies[0].webURL.String(), roundtrip.HTTPClient(client.NewInsecureWebClient()))
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
login func(t *testing.T, assertionResp *wanlib.CredentialAssertionResponse)
|
||||
}{
|
||||
{
|
||||
name: "ssh",
|
||||
login: func(t *testing.T, assertionResp *wanlib.CredentialAssertionResponse) {
|
||||
ep := clt.Endpoint("webapi", "mfa", "login", "finish")
|
||||
sshResp, err := clt.PostJSON(ctx, ep, &client.AuthenticateSSHUserRequest{
|
||||
WebauthnChallengeResponse: assertionResp, // no username
|
||||
PubKey: pubBytes,
|
||||
TTL: 24 * time.Hour,
|
||||
})
|
||||
require.NoError(t, err, "Passwordless authentication failed")
|
||||
loginResp := &auth.SSHLoginResponse{}
|
||||
require.NoError(t, json.Unmarshal(sshResp.Bytes(), loginResp))
|
||||
require.Equal(t, user, loginResp.Username)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "web",
|
||||
login: func(t *testing.T, assertionResp *wanlib.CredentialAssertionResponse) {
|
||||
ep := clt.Endpoint("webapi", "mfa", "login", "finishsession")
|
||||
sessionResp, err := clt.PostJSON(ctx, ep, &client.AuthenticateWebUserRequest{
|
||||
WebauthnAssertionResponse: assertionResp, // no username
|
||||
})
|
||||
require.NoError(t, err, "Passwordless authentication failed")
|
||||
createSessionResp := &CreateSessionResponse{}
|
||||
require.NoError(t, json.Unmarshal(sessionResp.Bytes(), createSessionResp))
|
||||
require.NotEmpty(t, createSessionResp.TokenType)
|
||||
require.NotEmpty(t, createSessionResp.Token)
|
||||
require.NotEmpty(t, createSessionResp.TokenExpiresIn)
|
||||
require.NotEmpty(t, createSessionResp.SessionExpires.Unix())
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
// Request passwordless challenge.
|
||||
ep := clt.Endpoint("webapi", "mfa", "login", "begin")
|
||||
beginResp, err := clt.PostJSON(ctx, ep, &client.MFAChallengeRequest{
|
||||
Passwordless: true, // no username and password
|
||||
})
|
||||
require.NoError(t, err, "Failed to create passwordless challenge")
|
||||
mfaChallenge := &client.MFAAuthenticateChallenge{}
|
||||
require.NoError(t, json.Unmarshal(beginResp.Bytes(), mfaChallenge))
|
||||
require.NotNil(t, mfaChallenge.WebauthnChallenge, "Want non-nil WebAuthn challenge")
|
||||
|
||||
// Sign challenge and set user handle.
|
||||
origin := "https://" + env.server.TLS.ClusterName()
|
||||
assertionResp, err := device.SignAssertion(origin, mfaChallenge.WebauthnChallenge)
|
||||
require.NoError(t, err)
|
||||
assertionResp.AssertionResponse.UserHandle = userHandle
|
||||
|
||||
// Complete passwordless login.
|
||||
test.login(t, assertionResp)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticate_rateLimiting(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
|
|
|
@ -2398,37 +2398,55 @@ func TestCreateRegisterChallenge(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
deviceType string
|
||||
name string
|
||||
req *createRegisterChallengeRequest
|
||||
assertChallenge func(t *testing.T, c *client.MFARegisterChallenge)
|
||||
}{
|
||||
{
|
||||
name: "totp challenge",
|
||||
deviceType: "totp",
|
||||
name: "totp",
|
||||
req: &createRegisterChallengeRequest{
|
||||
DeviceType: "totp",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "webauthn challenge",
|
||||
deviceType: "webauthn",
|
||||
name: "webauthn",
|
||||
req: &createRegisterChallengeRequest{
|
||||
DeviceType: "webauthn",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "passwordless",
|
||||
req: &createRegisterChallengeRequest{
|
||||
DeviceType: "webauthn",
|
||||
DeviceUsage: "passwordless",
|
||||
},
|
||||
assertChallenge: func(t *testing.T, c *client.MFARegisterChallenge) {
|
||||
// rrk=true is a good proxy for passwordless.
|
||||
require.NotNil(t, c.Webauthn.Response.AuthenticatorSelection.RequireResidentKey, "rrk cannot be nil")
|
||||
require.True(t, *c.Webauthn.Response.AuthenticatorSelection.RequireResidentKey, "rrk cannot be false")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
endpoint := clt.Endpoint("webapi", "mfa", "token", token.GetName(), "registerchallenge")
|
||||
res, err := clt.PostJSON(ctx, endpoint, &createRegisterChallengeRequest{
|
||||
DeviceType: tc.deviceType,
|
||||
})
|
||||
res, err := clt.PostJSON(ctx, endpoint, tc.req)
|
||||
require.NoError(t, err)
|
||||
|
||||
var chal client.MFARegisterChallenge
|
||||
require.NoError(t, json.Unmarshal(res.Bytes(), &chal))
|
||||
|
||||
switch tc.deviceType {
|
||||
switch tc.req.DeviceType {
|
||||
case "totp":
|
||||
require.NotNil(t, chal.TOTP.QRCode)
|
||||
require.NotNil(t, chal.TOTP.QRCode, "TOTP QR code cannot be nil")
|
||||
case "webauthn":
|
||||
require.NotNil(t, chal.Webauthn)
|
||||
require.NotNil(t, chal.Webauthn, "WebAuthn challenge cannot be nil")
|
||||
}
|
||||
|
||||
if tc.assertChallenge != nil {
|
||||
tc.assertChallenge(t, &chal)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ package web
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gravitational/teleport/api/client/proto"
|
||||
"github.com/gravitational/teleport/lib/auth/webauthn"
|
||||
|
@ -146,6 +147,10 @@ func (h *Handler) createAuthenticateChallengeWithTokenHandle(w http.ResponseWrit
|
|||
type createRegisterChallengeRequest struct {
|
||||
// DeviceType is the type of MFA device to get a register challenge for.
|
||||
DeviceType string `json:"deviceType"`
|
||||
// DeviceUsage is the intended usage of the device (MFA, Passwordless, etc).
|
||||
// It mimics the proto.DeviceUsage enum.
|
||||
// Defaults to MFA.
|
||||
DeviceUsage string `json:"deviceUsage"`
|
||||
}
|
||||
|
||||
// createRegisterChallengeWithTokenHandle creates and returns MFA register challenges for a new device for the specified device type.
|
||||
|
@ -165,9 +170,20 @@ func (h *Handler) createRegisterChallengeWithTokenHandle(w http.ResponseWriter,
|
|||
return nil, trace.BadParameter("MFA device type %q unsupported", req.DeviceType)
|
||||
}
|
||||
|
||||
var deviceUsage proto.DeviceUsage
|
||||
switch strings.ToLower(req.DeviceUsage) {
|
||||
case "", "mfa":
|
||||
deviceUsage = proto.DeviceUsage_DEVICE_USAGE_MFA
|
||||
case "passwordless":
|
||||
deviceUsage = proto.DeviceUsage_DEVICE_USAGE_PASSWORDLESS
|
||||
default:
|
||||
return nil, trace.BadParameter("device usage %q unsupported", req.DeviceUsage)
|
||||
}
|
||||
|
||||
chal, err := h.cfg.ProxyClient.CreateRegisterChallenge(r.Context(), &proto.CreateRegisterChallengeRequest{
|
||||
TokenID: p.ByName("token"),
|
||||
DeviceType: deviceType,
|
||||
TokenID: p.ByName("token"),
|
||||
DeviceType: deviceType,
|
||||
DeviceUsage: deviceUsage,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
|
|
Loading…
Reference in a new issue