Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions lib/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import (
"github.com/gravitational/teleport/api/constants"
apidefaults "github.com/gravitational/teleport/api/defaults"
"github.com/gravitational/teleport/api/gen/proto/go/assist/v1"
mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1"
"github.com/gravitational/teleport/api/internalutils/stream"
"github.com/gravitational/teleport/api/metadata"
"github.com/gravitational/teleport/api/types"
Expand Down Expand Up @@ -5806,7 +5807,12 @@ func (a *Server) mfaAuthChallenge(ctx context.Context, user string, passwordless
Webauthn: webConfig,
Identity: wanlib.WithDevices(a.Services, groupedDevs.Webauthn),
}
assertion, err := webLogin.Begin(ctx, user)
// TODO(Joerger): Get extensions from caller.
ext := &mfav1.ChallengeExtensions{
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_UNSPECIFIED,
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_NO,
}
assertion, err := webLogin.Begin(ctx, user, ext)
if err != nil {
return nil, trace.Wrap(err)
}
Expand Down Expand Up @@ -5919,25 +5925,32 @@ func (a *Server) ValidateMFAAuthResponse(ctx context.Context, resp *proto.MFAAut
}

assertionResp := wantypes.CredentialAssertionResponseFromProto(res.Webauthn)
var dev *types.MFADevice
var loginData *wanlib.LoginData
if passwordless {
webLogin := &wanlib.PasswordlessFlow{
Webauthn: webConfig,
Identity: a.Services,
}
dev, user, err = webLogin.Finish(ctx, assertionResp)
loginData, err = webLogin.Finish(ctx, assertionResp)
} else {
webLogin := &wanlib.LoginFlow{
U2F: u2f,
Webauthn: webConfig,
Identity: a.Services,
}
dev, err = webLogin.Finish(ctx, user, wantypes.CredentialAssertionResponseFromProto(res.Webauthn))
// TODO(Joerger): Get extensions from caller.
ext := &mfav1.ChallengeExtensions{
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_UNSPECIFIED,
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_NO,
}
loginData, err = webLogin.Finish(ctx, user, wantypes.CredentialAssertionResponseFromProto(res.Webauthn), ext)
}
if err != nil {
return nil, "", trace.AccessDenied("MFA response validation failed: %v", err)
}
return dev, user, nil
// TODO(Joerger): Refactor Validate to also return AllowReuse
// for further validation by caller when necessary.
return loginData.Device, loginData.User, nil

case *proto.MFAAuthenticateResponse_TOTP:
dev, err := a.checkOTP(user, res.TOTP.Code)
Expand Down
94 changes: 72 additions & 22 deletions lib/auth/webauthn/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"github.com/gravitational/trace"
log "github.com/sirupsen/logrus"

mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1"
"github.com/gravitational/teleport/api/types"
wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes"
)
Expand Down Expand Up @@ -66,7 +67,16 @@ type loginFlow struct {
sessionData sessionIdentity
}

func (f *loginFlow) begin(ctx context.Context, user string, passwordless bool) (*wantypes.CredentialAssertion, error) {
func (f *loginFlow) begin(ctx context.Context, user string, challengeExtensions *mfav1.ChallengeExtensions) (*wantypes.CredentialAssertion, error) {
if challengeExtensions == nil {
return nil, trace.BadParameter("requested challenge extensions must be supplied.")
}

if challengeExtensions.AllowReuse == mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_YES && challengeExtensions.Scope != mfav1.ChallengeScope_CHALLENGE_SCOPE_ADMIN_ACTION {
return nil, trace.BadParameter("mfa challenges with scope %s cannot allow reuse", challengeExtensions.Scope)
}

passwordless := challengeExtensions.Scope == mfav1.ChallengeScope_CHALLENGE_SCOPE_PASSWORDLESS_LOGIN
if user == "" && !passwordless {
return nil, trace.BadParameter("user required")
}
Expand Down Expand Up @@ -166,7 +176,7 @@ func (f *loginFlow) begin(ctx context.Context, user string, passwordless bool) (
if err != nil {
return nil, trace.Wrap(err)
}
// TODO(Joerger): set challenge extensions from caller
sd.ChallengeExtensions = challengeExtensions

if err := f.sessionData.Upsert(ctx, user, sd); err != nil {
return nil, trace.Wrap(err)
Expand All @@ -186,56 +196,73 @@ func (f *loginFlow) getWebID(ctx context.Context, user string) ([]byte, error) {
return wla.UserID, nil
}

func (f *loginFlow) finish(ctx context.Context, user string, resp *wantypes.CredentialAssertionResponse, passwordless bool) (*types.MFADevice, string, error) {
// LoginData is data gathered from a successful webauthn login.
type LoginData struct {
// User is the Teleport user.
User string
// Device is the MFA device used to authenticate the user.
Device *types.MFADevice
// AllowReuse is whether the webauthn challenge used for this login
// can be reused by the user for subsequent logins, until it expires.
AllowReuse mfav1.ChallengeAllowReuse
}

func (f *loginFlow) finish(ctx context.Context, user string, resp *wantypes.CredentialAssertionResponse, requiredExtensions *mfav1.ChallengeExtensions) (*LoginData, error) {
if requiredExtensions == nil {
return nil, trace.BadParameter("requested challenge extensions must be supplied.")
}

passwordless := requiredExtensions.Scope == mfav1.ChallengeScope_CHALLENGE_SCOPE_PASSWORDLESS_LOGIN

switch {
case user == "" && !passwordless:
return nil, "", trace.BadParameter("user required")
return nil, trace.BadParameter("user required")
case resp == nil:
// resp != nil is good enough to proceed, we leave remaining validations to
// duo-labs/webauthn.
return nil, "", trace.BadParameter("credential assertion response required")
return nil, trace.BadParameter("credential assertion response required")
}

parsedResp, err := parseCredentialResponse(resp)
if err != nil {
return nil, "", trace.Wrap(err)
return nil, trace.Wrap(err)
}

origin := parsedResp.Response.CollectedClientData.Origin
if err := validateOrigin(origin, f.Webauthn.RPID); err != nil {
log.WithError(err).Debugf("WebAuthn: origin validation failed")
return nil, "", trace.Wrap(err)
return nil, trace.Wrap(err)
}

var webID []byte
if passwordless {
webID = parsedResp.Response.UserHandle
if len(webID) == 0 {
return nil, "", trace.BadParameter("webauthn user handle required for passwordless")
return nil, trace.BadParameter("webauthn user handle required for passwordless")
}

// Fetch user from WebAuthn UserHandle (aka User ID).
teleportUser, err := f.identity.GetTeleportUserByWebauthnID(ctx, webID)
if err != nil {
return nil, "", trace.Wrap(err)
return nil, trace.Wrap(err)
}
user = teleportUser
} else {
webID, err = f.getWebID(ctx, user)
if err != nil {
return nil, "", trace.Wrap(err)
return nil, trace.Wrap(err)
}
}

// Find the device used to sign the credentials. It must be a previously
// registered device.
devices, err := f.identity.GetMFADevices(ctx, user, false /* withSecrets */)
if err != nil {
return nil, "", trace.Wrap(err)
return nil, trace.Wrap(err)
}
dev, ok := findDeviceByID(devices, parsedResp.RawID)
if !ok {
return nil, "", trace.BadParameter(
return nil, trace.BadParameter(
"unknown device credential: %q", base64.RawURLEncoding.EncodeToString(parsedResp.RawID))
}

Expand All @@ -246,7 +273,7 @@ func (f *loginFlow) finish(ctx context.Context, user string, resp *wantypes.Cred
rpID := f.Webauthn.RPID
switch {
case dev.GetU2F() != nil && f.U2F == nil:
return nil, "", trace.BadParameter("U2F device attempted login, but U2F configuration not present")
return nil, trace.BadParameter("U2F device attempted login, but U2F configuration not present")
case dev.GetU2F() != nil:
rpID = f.U2F.AppID
}
Expand All @@ -257,8 +284,23 @@ func (f *loginFlow) finish(ctx context.Context, user string, resp *wantypes.Cred
challenge := parsedResp.Response.CollectedClientData.Challenge
sd, err := f.sessionData.Get(ctx, user, challenge)
if err != nil {
return nil, "", trace.Wrap(err)
return nil, trace.Wrap(err)
}

// Check if the given scope is satisfied by the challenge scope.
if requiredExtensions.Scope != sd.ChallengeExtensions.Scope && requiredExtensions.Scope != mfav1.ChallengeScope_CHALLENGE_SCOPE_UNSPECIFIED {
// old clients do not yet provide a scope, so we only enforce scope opportunistically.
// TODO(Joerger): DELETE IN v16.0.0
if sd.ChallengeExtensions.Scope != mfav1.ChallengeScope_CHALLENGE_SCOPE_UNSPECIFIED {
return nil, trace.AccessDenied("required scope %q is not satisfied by the given webauthn session with scope %q", requiredExtensions.Scope, sd.ChallengeExtensions.Scope)
}
}

// If this session is reusable, but this login forbids reusable sessions, return an error.
if requiredExtensions.AllowReuse == mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_NO && sd.ChallengeExtensions.AllowReuse == mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_YES {
return nil, trace.AccessDenied("the given webauthn session allows reuse, but reuse is not permitted in this context")
}

sessionData := wantypes.SessionDataToProtocol(sd)

// Make sure _all_ credentials in the session are accounted for by the user.
Expand All @@ -281,7 +323,7 @@ func (f *loginFlow) finish(ctx context.Context, user string, resp *wantypes.Cred
requireUserVerification: passwordless,
})
if err != nil {
return nil, "", trace.Wrap(err)
return nil, trace.Wrap(err)
}

var credential *wan.Credential
Expand All @@ -292,7 +334,7 @@ func (f *loginFlow) finish(ctx context.Context, user string, resp *wantypes.Cred
credential, err = web.ValidateLogin(u, *sessionData, parsedResp)
}
if err != nil {
return nil, "", trace.Wrap(err)
return nil, trace.Wrap(err)
}
if credential.Authenticator.CloneWarning {
log.Warnf(
Expand All @@ -301,7 +343,7 @@ func (f *loginFlow) finish(ctx context.Context, user string, resp *wantypes.Cred

// Update last used timestamp and device counter.
if err := setCounterAndTimestamps(dev, credential); err != nil {
return nil, "", trace.Wrap(err)
return nil, trace.Wrap(err)
}
// Retroactively write the credential RPID, now that it cleared authn.
if webDev := dev.GetWebauthn(); webDev != nil && webDev.CredentialRpId == "" {
Expand All @@ -310,16 +352,24 @@ func (f *loginFlow) finish(ctx context.Context, user string, resp *wantypes.Cred
}

if err := f.identity.UpsertMFADevice(ctx, user, dev); err != nil {
return nil, "", trace.Wrap(err)
return nil, trace.Wrap(err)
}

// The user just solved the challenge, so let's make sure it won't be used
// again.
if err := f.sessionData.Delete(ctx, user, challenge); err != nil {
log.Warnf("WebAuthn: failed to delete login SessionData for user %v (passwordless = %v)", user, passwordless)
// again, unless reuse is explicitly allowed.
// Note that even reusable sessions are deleted when their expiration time
// passes.
if sd.ChallengeExtensions.AllowReuse != mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_YES {
if err := f.sessionData.Delete(ctx, user, challenge); err != nil {
log.Warnf("WebAuthn: failed to delete login SessionData for user %v (scope = %s)", user, sd.ChallengeExtensions.Scope)
}
}

return dev, user, nil
return &LoginData{
User: user,
Device: dev,
AllowReuse: sd.ChallengeExtensions.AllowReuse,
}, nil
}

func parseCredentialResponse(resp *wantypes.CredentialAssertionResponse) (*protocol.ParsedCredentialAssertionData, error) {
Expand Down
24 changes: 14 additions & 10 deletions lib/auth/webauthn/login_mfa.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ import (
"context"
"errors"

"github.com/gravitational/trace"

mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1"
"github.com/gravitational/teleport/api/types"
wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes"
)
Expand Down Expand Up @@ -93,29 +92,34 @@ type LoginFlow struct {
// assertion.
// As a side effect Begin may assign (and record in storage) a WebAuthn ID for
// the user.
func (f *LoginFlow) Begin(ctx context.Context, user string) (*wantypes.CredentialAssertion, error) {
// Requested challenge extensions will be stored on the stored webauthn challenge
// record. These extensions indicate additional rules/properties of the webauthn
// challenge that can be validated in the final login step.
func (f *LoginFlow) Begin(ctx context.Context, user string, challengeExtensions *mfav1.ChallengeExtensions) (*wantypes.CredentialAssertion, error) {
lf := &loginFlow{
U2F: f.U2F,
Webauthn: f.Webauthn,
identity: mfaIdentity{f.Identity},
sessionData: (*userSessionStorage)(f),
}
return lf.begin(ctx, user, false /* passwordless */)
return lf.begin(ctx, user, challengeExtensions)
}

// Finish is the second and last step of the LoginFlow.
// It returns the MFADevice used to solve the challenge. If login is successful,
// Finish has the side effect of updating the counter and last used timestamp of
// the returned device.
func (f *LoginFlow) Finish(ctx context.Context, user string, resp *wantypes.CredentialAssertionResponse) (*types.MFADevice, error) {
// Expected challenge extensions will be validated against the stored webauthn
// challenge record.
// It returns the MFADevice used to solve the challenge, the associated Teleport
// user name, and other login properties. If login is successful, Finish has the
// side effect of updating the counter and last used timestamp of the MFADevice
// used.
func (f *LoginFlow) Finish(ctx context.Context, user string, resp *wantypes.CredentialAssertionResponse, requiredExtensions *mfav1.ChallengeExtensions) (*LoginData, error) {
lf := &loginFlow{
U2F: f.U2F,
Webauthn: f.Webauthn,
identity: mfaIdentity{f.Identity},
sessionData: (*userSessionStorage)(f),
}
dev, _, err := lf.finish(ctx, user, resp, false /* passwordless */)
return dev, trace.Wrap(err)
return lf.finish(ctx, user, resp, requiredExtensions)
}

type mfaIdentity struct {
Expand Down
15 changes: 12 additions & 3 deletions lib/auth/webauthn/login_passwordless.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"encoding/base64"
"errors"

mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1"
"github.com/gravitational/teleport/api/types"
wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes"
)
Expand Down Expand Up @@ -54,19 +55,27 @@ func (f *PasswordlessFlow) Begin(ctx context.Context) (*wantypes.CredentialAsser
identity: passwordlessIdentity{f.Identity},
sessionData: (*globalSessionStorage)(f),
}
return lf.begin(ctx, "" /* user */, true /* passwordless */)
chalExt := &mfav1.ChallengeExtensions{
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_PASSWORDLESS_LOGIN,
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_NO,
}
return lf.begin(ctx, "" /* user */, chalExt)
}

// Finish is the last step of the passwordless login flow.
// It works similarly to LoginFlow.Finish, but the user identity is established
// via the response UserHandle, instead of an explicit Teleport username.
func (f *PasswordlessFlow) Finish(ctx context.Context, resp *wantypes.CredentialAssertionResponse) (*types.MFADevice, string, error) {
func (f *PasswordlessFlow) Finish(ctx context.Context, resp *wantypes.CredentialAssertionResponse) (*LoginData, error) {
lf := &loginFlow{
Webauthn: f.Webauthn,
identity: passwordlessIdentity{f.Identity},
sessionData: (*globalSessionStorage)(f),
}
return lf.finish(ctx, "" /* user */, resp, true /* passwordless */)
requiredExt := &mfav1.ChallengeExtensions{
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_PASSWORDLESS_LOGIN,
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_NO,
}
return lf.finish(ctx, "" /* user */, resp, requiredExt)
}

type passwordlessIdentity struct {
Expand Down
Loading