mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-25 10:40:02 +08:00
- Password reset flow (ChangeUserAuthentication) was skipping CallLoginHooks and GetUserOrLoginState so the resulting web session was issues with only the user's static roles/traits. - Update ChangeUserAuthentication to call the login hooks and resolve the UserLoginState before creating the session to match the normal login flow.
996 lines
32 KiB
Go
996 lines
32 KiB
Go
/*
|
|
* Teleport
|
|
* Copyright (C) 2023 Gravitational, Inc.
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
package auth_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base32"
|
|
"fmt"
|
|
"math/rand/v2"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gravitational/trace"
|
|
"github.com/jonboulle/clockwork"
|
|
"github.com/pquerna/otp/totp"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/gravitational/teleport/api/client/proto"
|
|
"github.com/gravitational/teleport/api/constants"
|
|
mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1"
|
|
"github.com/gravitational/teleport/api/types"
|
|
"github.com/gravitational/teleport/api/types/accesslist"
|
|
apievents "github.com/gravitational/teleport/api/types/events"
|
|
"github.com/gravitational/teleport/api/types/header"
|
|
wanpb "github.com/gravitational/teleport/api/types/webauthn"
|
|
"github.com/gravitational/teleport/lib/auth"
|
|
"github.com/gravitational/teleport/lib/auth/authcatest"
|
|
"github.com/gravitational/teleport/lib/auth/authclient"
|
|
"github.com/gravitational/teleport/lib/auth/authtest"
|
|
"github.com/gravitational/teleport/lib/defaults"
|
|
"github.com/gravitational/teleport/lib/events"
|
|
"github.com/gravitational/teleport/lib/events/eventstest"
|
|
"github.com/gravitational/teleport/lib/services"
|
|
)
|
|
|
|
func TestUserNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
as, err := authtest.NewAuthServer(authtest.AuthServerConfig{Dir: t.TempDir()})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, as.Close()) })
|
|
username := "unknown-user"
|
|
password := "feefiefoefum"
|
|
|
|
err = as.AuthServer.CheckPasswordWOToken(ctx, username, []byte(password))
|
|
require.Error(t, err)
|
|
// Make sure the error is not a NotFound. That would be a username oracle.
|
|
require.True(t, trace.IsBadParameter(err))
|
|
}
|
|
|
|
func TestPasswordLengthChange(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
as, err := authtest.NewAuthServer(authtest.AuthServerConfig{
|
|
Dir: t.TempDir(),
|
|
AuthPreferenceSpec: &types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOff,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, as.Close()) })
|
|
|
|
const (
|
|
username = "llama@goteleport.com"
|
|
password = "a"
|
|
// Pre-calculated bcrypt hash of "a" at minimum cost.
|
|
passwordHash = `$2a$10$xyxHFtG04s0kegyq1jwGB.faThKRIzDTCArbvKPxH6UKHriWz79H6`
|
|
)
|
|
|
|
u, _, err := authtest.CreateUserAndRole(as.AuthServer, username, []string{username}, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Set an initial password that is shorter than minimum length
|
|
u.SetLocalAuth(&types.LocalAuthSecrets{PasswordHash: []byte(passwordHash)})
|
|
_, err = as.AuthServer.UpsertUser(ctx, u)
|
|
require.NoError(t, err)
|
|
|
|
// Ensure that a shorter password still works for auth
|
|
err = as.AuthServer.CheckPasswordWOToken(ctx, username, []byte(password))
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestChangePassword(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
|
|
clock := clockwork.NewFakeClock()
|
|
as, err := authtest.NewAuthServer(authtest.AuthServerConfig{
|
|
Dir: t.TempDir(),
|
|
Clock: clock,
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, as.Close()) })
|
|
mockEmitter := &eventstest.MockRecorderEmitter{}
|
|
as.AuthServer.SetEmitter(mockEmitter)
|
|
|
|
req, err := prepareForPasswordChange(as.AuthServer, "user1", []byte("abcdef123456"), constants.SecondFactorOff)
|
|
require.NoError(t, err)
|
|
|
|
req.NewPassword = []byte("defceba654321")
|
|
|
|
err = as.AuthServer.ChangePassword(ctx, req)
|
|
require.NoError(t, err)
|
|
require.Equal(t, events.UserPasswordChangeEvent, mockEmitter.LastEvent().GetType())
|
|
require.Equal(t, "user1", mockEmitter.LastEvent().(*apievents.UserPasswordChange).User)
|
|
shouldLockAfterFailedAttempts(t, as.AuthServer, req)
|
|
|
|
// advance time and make sure we can login again
|
|
clock.Advance(defaults.AccountLockInterval + time.Second)
|
|
req.OldPassword = req.NewPassword
|
|
req.NewPassword = []byte("123456abcdef")
|
|
err = as.AuthServer.ChangePassword(ctx, req)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestChangePasswordWithOTP(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
clock := clockwork.NewFakeClock()
|
|
as, err := authtest.NewAuthServer(authtest.AuthServerConfig{
|
|
Dir: t.TempDir(),
|
|
Clock: clock,
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, as.Close()) })
|
|
|
|
req, err := prepareForPasswordChange(as.AuthServer, "user2", []byte("abcdef123456"), constants.SecondFactorOTP)
|
|
require.NoError(t, err)
|
|
|
|
otpSecret := base32.StdEncoding.EncodeToString([]byte("def456"))
|
|
dev, err := services.NewTOTPDevice("otp", otpSecret, clock.Now())
|
|
require.NoError(t, err)
|
|
ctx := context.Background()
|
|
err = as.AuthServer.UpsertMFADevice(ctx, req.User, dev)
|
|
require.NoError(t, err)
|
|
|
|
validToken, err := totp.GenerateCode(otpSecret, clock.Now())
|
|
require.NoError(t, err)
|
|
|
|
// change password
|
|
req.NewPassword = []byte("defceba654321")
|
|
req.SecondFactorToken = validToken
|
|
err = as.AuthServer.ChangePassword(ctx, req)
|
|
require.NoError(t, err)
|
|
|
|
shouldLockAfterFailedAttempts(t, as.AuthServer, req)
|
|
|
|
// advance time and make sure we can login again
|
|
clock.Advance(defaults.AccountLockInterval + time.Second)
|
|
|
|
validToken, _ = totp.GenerateCode(otpSecret, clock.Now())
|
|
req.OldPassword = req.NewPassword
|
|
req.NewPassword = []byte("123456abcdef")
|
|
req.SecondFactorToken = validToken
|
|
err = as.AuthServer.ChangePassword(ctx, req)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestServer_ChangePassword(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := newTestTLSServer(t)
|
|
|
|
mfa := configureForMFA(t, srv)
|
|
username := mfa.User
|
|
password := mfa.Password
|
|
userClient, err := srv.NewClient(authtest.TestUser(username))
|
|
require.NoError(t, err)
|
|
passwordlessDev, err := authtest.RegisterTestDevice(
|
|
context.Background(),
|
|
userClient,
|
|
"passwordless-1",
|
|
proto.DeviceType_DEVICE_TYPE_WEBAUTHN,
|
|
mfa.TOTPDev,
|
|
authtest.WithPasswordless())
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
oldPass string
|
|
newPass string
|
|
device *authtest.Device
|
|
challengeRequest *proto.CreateAuthenticateChallengeRequest
|
|
}{
|
|
{
|
|
name: "OK TOTP-based change",
|
|
oldPass: password,
|
|
newPass: "llamasarecool11",
|
|
device: mfa.TOTPDev,
|
|
challengeRequest: &proto.CreateAuthenticateChallengeRequest{
|
|
Request: &proto.CreateAuthenticateChallengeRequest_ContextUser{
|
|
ContextUser: &proto.ContextUser{},
|
|
},
|
|
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
|
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_CHANGE_PASSWORD,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "OK TOTP-based change (legacy flow)",
|
|
oldPass: "llamasarecool11",
|
|
newPass: "llamasarecool12",
|
|
device: mfa.TOTPDev,
|
|
challengeRequest: &proto.CreateAuthenticateChallengeRequest{
|
|
Request: &proto.CreateAuthenticateChallengeRequest_UserCredentials{
|
|
UserCredentials: &proto.UserCredentials{
|
|
Username: username,
|
|
Password: []byte("llamasarecool11"),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "OK Webauthn-based change",
|
|
oldPass: "llamasarecool12",
|
|
newPass: "llamasarecool13",
|
|
device: mfa.WebDev,
|
|
challengeRequest: &proto.CreateAuthenticateChallengeRequest{
|
|
Request: &proto.CreateAuthenticateChallengeRequest_ContextUser{
|
|
ContextUser: &proto.ContextUser{},
|
|
},
|
|
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
|
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_CHANGE_PASSWORD,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "OK with verification explicitly set to discouraged",
|
|
oldPass: "llamasarecool13",
|
|
newPass: "llamasarecool14",
|
|
device: mfa.WebDev,
|
|
challengeRequest: &proto.CreateAuthenticateChallengeRequest{
|
|
Request: &proto.CreateAuthenticateChallengeRequest_ContextUser{
|
|
ContextUser: &proto.ContextUser{},
|
|
},
|
|
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
|
UserVerificationRequirement: "discouraged",
|
|
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_CHANGE_PASSWORD,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "OK passwordless change",
|
|
oldPass: "",
|
|
newPass: "llamasarecool15",
|
|
device: passwordlessDev,
|
|
challengeRequest: &proto.CreateAuthenticateChallengeRequest{
|
|
Request: &proto.CreateAuthenticateChallengeRequest_ContextUser{
|
|
ContextUser: &proto.ContextUser{},
|
|
},
|
|
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
|
UserVerificationRequirement: "required",
|
|
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_CHANGE_PASSWORD,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
authServer := srv.Auth()
|
|
ctx := context.Background()
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
oldPass := []byte(test.oldPass)
|
|
newPass := []byte(test.newPass)
|
|
|
|
// Acquire and solve an MFA challenge.
|
|
mfaChallenge, err := userClient.CreateAuthenticateChallenge(ctx, test.challengeRequest)
|
|
require.NoError(t, err, "creating challenge")
|
|
mfaResp, err := test.device.SolveAuthn(mfaChallenge)
|
|
require.NoError(t, err, "solving challenge with device")
|
|
|
|
// Change password.
|
|
req := &proto.ChangePasswordRequest{
|
|
User: username,
|
|
OldPassword: oldPass,
|
|
NewPassword: newPass,
|
|
}
|
|
switch {
|
|
case mfaResp.GetTOTP() != nil:
|
|
req.SecondFactorToken = mfaResp.GetTOTP().Code
|
|
case mfaResp.GetWebauthn() != nil:
|
|
req.Webauthn = mfaResp.GetWebauthn()
|
|
}
|
|
require.NoError(t, userClient.ChangePassword(ctx, req), "changing password")
|
|
|
|
// Did the password change take effect?
|
|
require.NoError(t, authServer.CheckPasswordWOToken(ctx, username, newPass), "password change didn't take effect")
|
|
})
|
|
}
|
|
}
|
|
|
|
// This test asserts that an attacker is unable to change password without
|
|
// providing the old one if they take over a user's web session and use a
|
|
// different type of WebAuthn challenge that would be normally requested by the
|
|
// Web UI. This is a regression test for
|
|
// https://github.com/gravitational/teleport-private/issues/1369.
|
|
func TestServer_ChangePassword_Fails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := newTestTLSServer(t)
|
|
mfa := configureForMFA(t, server)
|
|
authServer := server.Auth()
|
|
ctx := context.Background()
|
|
username := mfa.User
|
|
password := mfa.Password
|
|
|
|
tests := []struct {
|
|
name string
|
|
oldPass string
|
|
device *authtest.Device
|
|
challengeRequest *proto.CreateAuthenticateChallengeRequest
|
|
createChallengeAssertion require.ErrorAssertionFunc
|
|
}{
|
|
{
|
|
name: "No old password, TOTP challenge",
|
|
oldPass: "",
|
|
device: mfa.TOTPDev,
|
|
challengeRequest: &proto.CreateAuthenticateChallengeRequest{
|
|
Request: &proto.CreateAuthenticateChallengeRequest_ContextUser{
|
|
ContextUser: &proto.ContextUser{},
|
|
},
|
|
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
|
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_NO,
|
|
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_CHANGE_PASSWORD,
|
|
},
|
|
},
|
|
createChallengeAssertion: require.NoError,
|
|
},
|
|
{
|
|
name: "No old password, WebAuthn challenge",
|
|
oldPass: "",
|
|
device: mfa.WebDev,
|
|
challengeRequest: &proto.CreateAuthenticateChallengeRequest{
|
|
Request: &proto.CreateAuthenticateChallengeRequest_ContextUser{
|
|
ContextUser: &proto.ContextUser{},
|
|
},
|
|
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
|
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_NO,
|
|
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_CHANGE_PASSWORD,
|
|
},
|
|
},
|
|
createChallengeAssertion: require.NoError,
|
|
},
|
|
{
|
|
name: "Empty challenge request",
|
|
oldPass: password,
|
|
device: mfa.WebDev,
|
|
challengeRequest: &proto.CreateAuthenticateChallengeRequest{},
|
|
createChallengeAssertion: require.Error,
|
|
},
|
|
{
|
|
name: "Unspecified challenge scope",
|
|
oldPass: password,
|
|
device: mfa.WebDev,
|
|
challengeRequest: &proto.CreateAuthenticateChallengeRequest{
|
|
Request: &proto.CreateAuthenticateChallengeRequest_ContextUser{
|
|
ContextUser: &proto.ContextUser{},
|
|
},
|
|
ChallengeExtensions: &mfav1.ChallengeExtensions{},
|
|
},
|
|
createChallengeAssertion: require.Error,
|
|
},
|
|
{
|
|
name: "Illegal challenge scope",
|
|
oldPass: password,
|
|
device: mfa.WebDev,
|
|
challengeRequest: &proto.CreateAuthenticateChallengeRequest{
|
|
Request: &proto.CreateAuthenticateChallengeRequest_ContextUser{
|
|
ContextUser: &proto.ContextUser{},
|
|
},
|
|
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
|
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
|
},
|
|
},
|
|
createChallengeAssertion: require.NoError,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
newPass := []byte("capybarasarecool123")
|
|
oldPass := []byte(test.oldPass)
|
|
|
|
userClient, err := server.NewClient(authtest.TestUser(username))
|
|
require.NoError(t, err)
|
|
defer userClient.Close()
|
|
|
|
// Acquire and solve an MFA challenge.
|
|
mfaChallenge, err := userClient.CreateAuthenticateChallenge(ctx, test.challengeRequest)
|
|
test.createChallengeAssertion(t, err, "creating challenge")
|
|
if err != nil {
|
|
return
|
|
}
|
|
mfaResp, err := test.device.SolveAuthn(mfaChallenge)
|
|
require.NoError(t, err, "solving challenge with device")
|
|
|
|
// Change password.
|
|
req := &proto.ChangePasswordRequest{
|
|
User: username,
|
|
OldPassword: oldPass,
|
|
NewPassword: newPass,
|
|
}
|
|
switch {
|
|
case mfaResp.GetTOTP() != nil:
|
|
req.SecondFactorToken = mfaResp.GetTOTP().Code
|
|
case mfaResp.GetWebauthn() != nil:
|
|
req.Webauthn = mfaResp.GetWebauthn()
|
|
}
|
|
err = userClient.ChangePassword(ctx, req)
|
|
assert.True(t,
|
|
trace.IsAccessDenied(err),
|
|
"ChangePassword error mismatch, want=AccessDenied, got=%v (%T)",
|
|
err, trace.Unwrap(err))
|
|
|
|
// Did the password change take effect?
|
|
assert.Error(t, authServer.CheckPasswordWOToken(ctx, username, newPass), "password was changed")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestChangeUserAuthentication(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as, err := authtest.NewAuthServer(authtest.AuthServerConfig{Dir: t.TempDir()})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, as.Close()) })
|
|
authServer := as.AuthServer
|
|
clock := as.Clock()
|
|
ctx := context.Background()
|
|
|
|
tests := []struct {
|
|
name string
|
|
setAuthPreference func(t *testing.T)
|
|
getReq func(t *testing.T, resetTokenID string) *proto.ChangeUserAuthenticationRequest
|
|
getInvalidReq func(t *testing.T, resetTokenID string) *proto.ChangeUserAuthenticationRequest
|
|
}{
|
|
{
|
|
name: "with second factor off and password only",
|
|
setAuthPreference: func(t *testing.T) {
|
|
authPreference, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOff,
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = authServer.UpsertAuthPreference(ctx, authPreference)
|
|
require.NoError(t, err)
|
|
},
|
|
getReq: func(t *testing.T, resetTokenID string) *proto.ChangeUserAuthenticationRequest {
|
|
return &proto.ChangeUserAuthenticationRequest{
|
|
TokenID: resetTokenID,
|
|
NewPassword: []byte("password1357"),
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "with second factor otp",
|
|
setAuthPreference: func(t *testing.T) {
|
|
authPreference, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOTP,
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = authServer.UpsertAuthPreference(ctx, authPreference)
|
|
require.NoError(t, err)
|
|
},
|
|
getReq: func(t *testing.T, resetTokenID string) *proto.ChangeUserAuthenticationRequest {
|
|
registerChal, err := authServer.CreateRegisterChallenge(ctx, &proto.CreateRegisterChallengeRequest{
|
|
TokenID: resetTokenID,
|
|
DeviceType: proto.DeviceType_DEVICE_TYPE_TOTP,
|
|
})
|
|
require.NoError(t, err, "CreateRegisterChallenge")
|
|
|
|
_, registerSolved, err := authtest.NewTestDeviceFromChallenge(registerChal, authtest.WithTestDeviceClock(clock))
|
|
require.NoError(t, err, "NewTestDeviceFromChallenge")
|
|
|
|
return &proto.ChangeUserAuthenticationRequest{
|
|
TokenID: resetTokenID,
|
|
NewPassword: []byte("password2468"),
|
|
NewMFARegisterResponse: registerSolved,
|
|
}
|
|
},
|
|
// Invalid MFA fields when auth settings set to only otp.
|
|
getInvalidReq: func(t *testing.T, resetTokenID string) *proto.ChangeUserAuthenticationRequest {
|
|
return &proto.ChangeUserAuthenticationRequest{
|
|
TokenID: resetTokenID,
|
|
NewPassword: []byte("password2468"),
|
|
NewMFARegisterResponse: &proto.MFARegisterResponse{
|
|
Response: &proto.MFARegisterResponse_Webauthn{
|
|
Webauthn: &wanpb.CredentialCreationResponse{},
|
|
},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "with second factor webauthn",
|
|
setAuthPreference: func(t *testing.T) {
|
|
authPreference, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorWebauthn,
|
|
Webauthn: &types.Webauthn{
|
|
RPID: "localhost",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = authServer.UpsertAuthPreference(ctx, authPreference)
|
|
require.NoError(t, err)
|
|
},
|
|
getReq: func(t *testing.T, resetTokenID string) *proto.ChangeUserAuthenticationRequest {
|
|
registerChal, err := authServer.CreateRegisterChallenge(ctx, &proto.CreateRegisterChallengeRequest{
|
|
TokenID: resetTokenID,
|
|
DeviceType: proto.DeviceType_DEVICE_TYPE_WEBAUTHN,
|
|
DeviceUsage: proto.DeviceUsage_DEVICE_USAGE_MFA,
|
|
})
|
|
require.NoError(t, err, "CreateRegisterChallenge")
|
|
|
|
_, registerSolved, err := authtest.NewTestDeviceFromChallenge(registerChal)
|
|
require.NoError(t, err, "NewTestDeviceFromChallenge")
|
|
|
|
return &proto.ChangeUserAuthenticationRequest{
|
|
TokenID: resetTokenID,
|
|
NewPassword: []byte("password3579"),
|
|
NewMFARegisterResponse: registerSolved,
|
|
}
|
|
},
|
|
// Invalid totp fields when auth settings set to only webauthn.
|
|
getInvalidReq: func(t *testing.T, resetTokenID string) *proto.ChangeUserAuthenticationRequest {
|
|
return &proto.ChangeUserAuthenticationRequest{
|
|
TokenID: resetTokenID,
|
|
NewPassword: []byte("password3579"),
|
|
NewMFARegisterResponse: &proto.MFARegisterResponse{
|
|
Response: &proto.MFARegisterResponse_TOTP{},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "with passwordless",
|
|
setAuthPreference: func(t *testing.T) {
|
|
authPreference, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorWebauthn,
|
|
Webauthn: &types.Webauthn{
|
|
RPID: "localhost",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = authServer.UpsertAuthPreference(ctx, authPreference)
|
|
require.NoError(t, err)
|
|
},
|
|
getReq: func(t *testing.T, resetTokenID string) *proto.ChangeUserAuthenticationRequest {
|
|
registerChal, err := authServer.CreateRegisterChallenge(ctx, &proto.CreateRegisterChallengeRequest{
|
|
TokenID: resetTokenID,
|
|
DeviceType: proto.DeviceType_DEVICE_TYPE_WEBAUTHN,
|
|
DeviceUsage: proto.DeviceUsage_DEVICE_USAGE_PASSWORDLESS,
|
|
})
|
|
require.NoError(t, err, "CreateRegisterChallenge")
|
|
|
|
_, registerSolved, err := authtest.NewTestDeviceFromChallenge(registerChal, authtest.WithPasswordless())
|
|
require.NoError(t, err, "NewTestDeviceFromChallenge")
|
|
|
|
return &proto.ChangeUserAuthenticationRequest{
|
|
TokenID: resetTokenID,
|
|
NewMFARegisterResponse: registerSolved,
|
|
}
|
|
},
|
|
// Missing webauthn for passwordless.
|
|
getInvalidReq: func(t *testing.T, resetTokenID string) *proto.ChangeUserAuthenticationRequest {
|
|
return &proto.ChangeUserAuthenticationRequest{
|
|
TokenID: resetTokenID,
|
|
NewMFARegisterResponse: &proto.MFARegisterResponse{
|
|
Response: &proto.MFARegisterResponse_TOTP{},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "with second factor on",
|
|
setAuthPreference: func(t *testing.T) {
|
|
authPreference, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOn,
|
|
Webauthn: &types.Webauthn{
|
|
RPID: "localhost",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = authServer.UpsertAuthPreference(ctx, authPreference)
|
|
require.NoError(t, err)
|
|
},
|
|
getReq: func(t *testing.T, resetTokenID string) *proto.ChangeUserAuthenticationRequest {
|
|
registerChal, err := authServer.CreateRegisterChallenge(ctx, &proto.CreateRegisterChallengeRequest{
|
|
TokenID: resetTokenID,
|
|
DeviceType: proto.DeviceType_DEVICE_TYPE_WEBAUTHN,
|
|
DeviceUsage: proto.DeviceUsage_DEVICE_USAGE_MFA,
|
|
})
|
|
require.NoError(t, err, "CreateRegisterChallenge")
|
|
|
|
_, registerSolved, err := authtest.NewTestDeviceFromChallenge(registerChal)
|
|
require.NoError(t, err, "NewTestDeviceFromChallenge")
|
|
|
|
return &proto.ChangeUserAuthenticationRequest{
|
|
TokenID: resetTokenID,
|
|
NewPassword: []byte("password4680"),
|
|
NewMFARegisterResponse: registerSolved,
|
|
NewDeviceName: "new-device",
|
|
}
|
|
},
|
|
// Empty register response, when auth settings requires second factors.
|
|
getInvalidReq: func(t *testing.T, resetTokenID string) *proto.ChangeUserAuthenticationRequest {
|
|
return &proto.ChangeUserAuthenticationRequest{
|
|
TokenID: resetTokenID,
|
|
NewPassword: []byte("password4680"),
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "with second factor optional and no second factor",
|
|
setAuthPreference: func(t *testing.T) {
|
|
authPreference, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOptional,
|
|
Webauthn: &types.Webauthn{
|
|
RPID: "localhost",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = authServer.UpsertAuthPreference(ctx, authPreference)
|
|
require.NoError(t, err)
|
|
},
|
|
getReq: func(t *testing.T, resetTokenID string) *proto.ChangeUserAuthenticationRequest {
|
|
return &proto.ChangeUserAuthenticationRequest{
|
|
TokenID: resetTokenID,
|
|
NewPassword: []byte("password5791"),
|
|
}
|
|
},
|
|
},
|
|
}
|
|
for _, c := range tests {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
username := fmt.Sprintf("llama%v@goteleport.com", rand.Int())
|
|
_, _, err := authtest.CreateUserAndRole(authServer, username, []string{username}, nil)
|
|
require.NoError(t, err)
|
|
|
|
c.setAuthPreference(t)
|
|
|
|
resetToken, err := authServer.CreateResetPasswordToken(ctx, authclient.CreateUserTokenRequest{
|
|
Name: username,
|
|
})
|
|
require.NoError(t, err)
|
|
token := resetToken.GetName()
|
|
|
|
if c.getInvalidReq != nil {
|
|
invalidReq := c.getInvalidReq(t, token)
|
|
_, err := auth.ChangeUserAuthentication(ctx, authServer, invalidReq)
|
|
require.True(t, trace.IsBadParameter(err))
|
|
}
|
|
|
|
validReq := c.getReq(t, token)
|
|
_, err = auth.ChangeUserAuthentication(ctx, authServer, validReq)
|
|
require.NoError(t, err)
|
|
|
|
// Test password is updated.
|
|
if len(validReq.NewPassword) != 0 {
|
|
err := authServer.CheckPasswordWOToken(ctx, username, validReq.NewPassword)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Test device was registered.
|
|
if validReq.NewMFARegisterResponse != nil {
|
|
devs, err := authServer.Services.GetMFADevices(ctx, username, false /* without secrets*/)
|
|
require.NoError(t, err)
|
|
require.Len(t, devs, 1)
|
|
|
|
// Test device name setting.
|
|
dev := devs[0]
|
|
var wantName string
|
|
switch {
|
|
case validReq.NewDeviceName != "":
|
|
wantName = validReq.NewDeviceName
|
|
case dev.GetTotp() != nil:
|
|
wantName = "otp"
|
|
case dev.GetWebauthn() != nil:
|
|
wantName = "webauthn"
|
|
}
|
|
require.Equal(t, wantName, dev.GetName(), "device name mismatch")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestChangeUserAuthentication_AccessListRolesApplied verifies that roles
|
|
// granted via Access Lists are included in the web session created by the
|
|
// password-reset flow (ChangeUserAuthentication).
|
|
//
|
|
// Regression test for: https://github.com/gravitational/teleport/issues/65722
|
|
func TestChangeUserAuthentication_AccessListRolesApplied(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
|
|
as, err := authtest.NewAuthServer(authtest.AuthServerConfig{Dir: t.TempDir()})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, as.Close()) })
|
|
authServer := as.AuthServer
|
|
|
|
// Disable second factor so the reset request only needs a password.
|
|
authPref, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOff,
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = authServer.UpsertAuthPreference(ctx, authPref)
|
|
require.NoError(t, err)
|
|
|
|
// Create the role that the access list will grant.
|
|
grantedRole, err := types.NewRole("al-granted-role", types.RoleSpecV6{})
|
|
require.NoError(t, err)
|
|
_, err = authServer.UpsertRole(ctx, grantedRole)
|
|
require.NoError(t, err)
|
|
|
|
// Create a user with a single static role only.
|
|
username := "reset-al-test@goteleport.com"
|
|
_, _, err = authtest.CreateUserAndRole(authServer, username, []string{"access"}, nil)
|
|
require.NoError(t, err)
|
|
|
|
user, err := authServer.GetUser(ctx, username, false)
|
|
require.NoError(t, err)
|
|
|
|
// Create an access list that grants al-granted-role to its members.
|
|
al, err := accesslist.NewAccessList(header.Metadata{
|
|
Name: "test-access-list",
|
|
}, accesslist.Spec{
|
|
Title: "Test Access List",
|
|
Owners: []accesslist.Owner{
|
|
{Name: username, Description: "owner"},
|
|
},
|
|
Audit: accesslist.Audit{
|
|
NextAuditDate: authServer.GetClock().Now().Add(24 * time.Hour),
|
|
},
|
|
Grants: accesslist.Grants{
|
|
Roles: []string{"al-granted-role"},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = authServer.UpsertAccessList(ctx, al)
|
|
require.NoError(t, err)
|
|
|
|
// Add the user as a member of the access list.
|
|
member, err := accesslist.NewAccessListMember(header.Metadata{
|
|
Name: username,
|
|
}, accesslist.AccessListMemberSpec{
|
|
AccessList: al.GetName(),
|
|
Name: username,
|
|
Joined: authServer.GetClock().Now(),
|
|
Expires: authServer.GetClock().Now().Add(24 * time.Hour),
|
|
Reason: "test",
|
|
AddedBy: user.GetName(),
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = authServer.UpsertAccessListMember(ctx, member)
|
|
require.NoError(t, err)
|
|
|
|
// Create a reset password token and call ChangeUserAuthentication.
|
|
resetToken, err := authServer.CreateResetPasswordToken(ctx, authclient.CreateUserTokenRequest{
|
|
Name: username,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
resp, err := authServer.ChangeUserAuthentication(ctx, &proto.ChangeUserAuthenticationRequest{
|
|
TokenID: resetToken.GetName(),
|
|
NewPassword: []byte("newpassword123!"),
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp.WebSession)
|
|
|
|
// Parse the TLS certificate from the web session and verify it contains
|
|
// the Access List-granted role.
|
|
_, identity := parseX509PEMAndIdentity(t, resp.WebSession.GetTLSCert())
|
|
require.Contains(t, identity.Groups, "al-granted-role",
|
|
"expected Access List-granted role in session certificate, got roles: %v", identity.Groups)
|
|
}
|
|
|
|
func TestChangeUserAuthenticationWithErrors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
authPreference, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactors: []types.SecondFactorType{types.SecondFactorType_SECOND_FACTOR_TYPE_OTP},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
as, err := authtest.NewAuthServer(authtest.AuthServerConfig{
|
|
Dir: t.TempDir(),
|
|
AuthPreferenceSpec: &authPreference.(*types.AuthPreferenceV2).Spec,
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, as.Close()) })
|
|
|
|
ctx := context.Background()
|
|
|
|
username := "joe@example.com"
|
|
_, _, err = authtest.CreateUserAndRole(as.AuthServer, username, []string{username}, nil)
|
|
require.NoError(t, err)
|
|
|
|
token, err := as.AuthServer.CreateResetPasswordToken(ctx, authclient.CreateUserTokenRequest{
|
|
Name: username,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
validPassword := []byte("qwertyQWERTY1")
|
|
validTokenID := token.GetName()
|
|
|
|
type testCase struct {
|
|
desc string
|
|
secondFactor constants.SecondFactorType
|
|
req *proto.ChangeUserAuthenticationRequest
|
|
}
|
|
|
|
testCases := []testCase{
|
|
{
|
|
secondFactor: constants.SecondFactorOff,
|
|
desc: "invalid tokenID value",
|
|
req: &proto.ChangeUserAuthenticationRequest{
|
|
TokenID: "what_token",
|
|
NewPassword: validPassword,
|
|
},
|
|
},
|
|
{
|
|
secondFactor: constants.SecondFactorOff,
|
|
desc: "invalid password",
|
|
req: &proto.ChangeUserAuthenticationRequest{
|
|
TokenID: validTokenID,
|
|
NewPassword: []byte("short"),
|
|
},
|
|
},
|
|
{
|
|
secondFactor: constants.SecondFactorOTP,
|
|
desc: "missing second factor",
|
|
req: &proto.ChangeUserAuthenticationRequest{
|
|
TokenID: validTokenID,
|
|
NewPassword: validPassword,
|
|
},
|
|
},
|
|
{
|
|
secondFactor: constants.SecondFactorOTP,
|
|
desc: "invalid OTP value",
|
|
req: &proto.ChangeUserAuthenticationRequest{
|
|
TokenID: validTokenID,
|
|
NewPassword: validPassword,
|
|
NewMFARegisterResponse: &proto.MFARegisterResponse{Response: &proto.MFARegisterResponse_TOTP{
|
|
TOTP: &proto.TOTPRegisterResponse{Code: "invalid"},
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
// set new auth preference settings
|
|
authPreference.SetSecondFactor(tc.secondFactor)
|
|
authPreference, err = as.AuthServer.UpsertAuthPreference(ctx, authPreference)
|
|
require.NoError(t, err)
|
|
|
|
_, err = auth.ChangeUserAuthentication(ctx, as.AuthServer, tc.req)
|
|
require.Error(t, err, "test case %q", tc.desc)
|
|
}
|
|
|
|
authPreference.SetSecondFactor(constants.SecondFactorOff)
|
|
_, err = as.AuthServer.UpsertAuthPreference(ctx, authPreference)
|
|
require.NoError(t, err)
|
|
|
|
_, err = auth.ChangeUserAuthentication(ctx, as.AuthServer, &proto.ChangeUserAuthenticationRequest{
|
|
TokenID: validTokenID,
|
|
NewPassword: validPassword,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// invite token cannot be reused
|
|
_, err = auth.ChangeUserAuthentication(ctx, as.AuthServer, &proto.ChangeUserAuthenticationRequest{
|
|
TokenID: validTokenID,
|
|
NewPassword: validPassword,
|
|
})
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestResetPassword(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
as, err := authtest.NewAuthServer(authtest.AuthServerConfig{
|
|
Dir: t.TempDir(),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, as.Close()) })
|
|
|
|
_, _, err = authtest.CreateUserAndRole(as.AuthServer, "dave", []string{"dave"}, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Using the Identity service makes it easier to set up the test case.
|
|
err = as.AuthServer.UpsertPassword("dave", []byte("it's full of stars!"))
|
|
require.NoError(t, err)
|
|
|
|
// Reset password.
|
|
ctx := context.Background()
|
|
err = as.AuthServer.ResetPassword(ctx, "dave")
|
|
require.NoError(t, err)
|
|
|
|
// Make sure that the password has been reset.
|
|
u, err := as.AuthServer.IdentityInternal.GetUser(ctx, "dave", true /* withSecrets */)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, u.GetLocalAuth(), "user LocalAuth not nil")
|
|
assert.Equal(t, types.PasswordState_PASSWORD_STATE_UNSET, u.GetPasswordState())
|
|
|
|
// Make sure that we can reset once again (i.e. we don't complain if there's
|
|
// no password).
|
|
err = as.AuthServer.ResetPassword(ctx, "dave")
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func shouldLockAfterFailedAttempts(t *testing.T, as *auth.Server, req *proto.ChangePasswordRequest) {
|
|
loginAttempts, _ := as.GetUserLoginAttempts(req.User)
|
|
require.Empty(t, loginAttempts)
|
|
for i := range defaults.MaxLoginAttempts {
|
|
err := as.ChangePassword(t.Context(), req)
|
|
require.Error(t, err)
|
|
loginAttempts, _ = as.GetUserLoginAttempts(req.User)
|
|
require.Len(t, loginAttempts, i+1)
|
|
}
|
|
|
|
err := as.ChangePassword(t.Context(), req)
|
|
require.True(t, trace.IsAccessDenied(err))
|
|
}
|
|
|
|
func prepareForPasswordChange(as *auth.Server, user string, pass []byte, secondFactorType constants.SecondFactorType) (*proto.ChangePasswordRequest, error) {
|
|
ctx := context.Background()
|
|
req := &proto.ChangePasswordRequest{
|
|
User: user,
|
|
OldPassword: pass,
|
|
}
|
|
|
|
userCA, err := authcatest.NewCA(types.UserCA, "me.localhost")
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
if err := as.UpsertCertAuthority(ctx, userCA); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
hostCA, err := authcatest.NewCA(types.HostCA, "me.localhost")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := as.UpsertCertAuthority(ctx, hostCA); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ap, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: secondFactorType,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if _, err := as.UpsertAuthPreference(ctx, ap); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if _, _, err := authtest.CreateUserAndRole(as, user, []string{user}, nil); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := as.UpsertPassword(user, pass); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return req, nil
|
|
}
|