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:
Alan Parra 2022-03-04 15:41:35 -03:00 committed by GitHub
parent 61fce15ee2
commit 5023235909
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1921 additions and 791 deletions

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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