388 lines
20 KiB
Go
Vendored
388 lines
20 KiB
Go
Vendored
package webauthn
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"net/http"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
|
||
"github.com/go-webauthn/webauthn/protocol"
|
||
)
|
||
|
||
// LoginOption is a functional option that modifies the [protocol.PublicKeyCredentialRequestOptions] sent to the
|
||
// client during a login ceremony. Use the With* functions in this package (i.e. [WithUserVerification],
|
||
// [WithAllowedCredentials]) to create login options.
|
||
type LoginOption func(*protocol.PublicKeyCredentialRequestOptions)
|
||
|
||
// DiscoverableUserHandler is a callback function that the Relying Party must provide when performing a discoverable
|
||
// (passkey) login. It is called with the rawID of the credential and the userHandle from the authenticator response,
|
||
// and must return the [User] who owns the credential. This is necessary because in discoverable login flows, the
|
||
// Relying Party does not know which user is authenticating until the authenticator response is received.
|
||
type DiscoverableUserHandler func(rawID, userHandle []byte) (user User, err error)
|
||
|
||
// BeginLogin creates the [*protocol.CredentialAssertion] data payload that should be sent to the user agent for beginning
|
||
// the login/assertion process. This function is used to perform a login when the identity of the user is known such as
|
||
// multifactor authentications, to specify a conditional mediation requirement use [WebAuthn.BeginMediatedLogin], to
|
||
// perform a login when the identity of the user is not known see [WebAuthn.BeginDiscoverableLogin] and
|
||
// [WebAuthn.BeginDiscoverableMediatedLogin] instead. The format of this data can be seen in §5.5 of the WebAuthn
|
||
// specification. These default values can be amended by providing additional [LoginOption] parameters. This function
|
||
// also returns [SessionData], that must be stored by the RP in a secure manner and then provided to the
|
||
// [WebAuthn.FinishLogin] function. This data helps us verify the ownership of the credential being retrieved.
|
||
//
|
||
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dictionary-assertion-options)
|
||
func (webauthn *WebAuthn) BeginLogin(user User, opts ...LoginOption) (*protocol.CredentialAssertion, *SessionData, error) {
|
||
return webauthn.BeginMediatedLogin(user, protocol.MediationDefault, opts...)
|
||
}
|
||
|
||
// BeginDiscoverableLogin creates the [*protocol.CredentialAssertion] data payload that should be sent to the user agent
|
||
// for beginning the login/assertion process. This function is used to perform a client-side discoverable login when the
|
||
// identity of the user is not known such as passwordless or usernameless authentication, to specify a conditional
|
||
// mediation requirement use [WebAuthn.BeginDiscoverableMediatedLogin], to perform logins where the identity of the user
|
||
// is known such as multifactor authentication see [WebAuthn.BeginLogin] and [WebAuthn.BeginMediatedLogin] instead.
|
||
// The format of this data can be seen in §5.5 of the WebAuthn specification. These default values can be amended by
|
||
// providing additional [LoginOption] parameters. This function also returns [SessionData], that
|
||
// must be stored by the RP in a secure manner and then provided to the [WebAuthn.FinishLogin] function. This data helps
|
||
// us verify the ownership of the credential being retrieved.
|
||
//
|
||
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dictionary-assertion-options)
|
||
func (webauthn *WebAuthn) BeginDiscoverableLogin(opts ...LoginOption) (*protocol.CredentialAssertion, *SessionData, error) {
|
||
return webauthn.beginLogin(nil, nil, protocol.MediationDefault, opts...)
|
||
}
|
||
|
||
// BeginMediatedLogin is similar to [WebAuthn.BeginLogin] however it also allows specifying a credential mediation
|
||
// requirement.
|
||
func (webauthn *WebAuthn) BeginMediatedLogin(user User, mediation protocol.CredentialMediationRequirement, opts ...LoginOption) (*protocol.CredentialAssertion, *SessionData, error) {
|
||
credentials := user.WebAuthnCredentials()
|
||
|
||
if len(credentials) == 0 { // If the user does not have any credentials, we cannot perform an assertion.
|
||
return nil, nil, protocol.ErrBadRequest.WithDetails("Found no credentials for user")
|
||
}
|
||
|
||
var allowedCredentials = make([]protocol.CredentialDescriptor, len(credentials))
|
||
|
||
for i, credential := range credentials {
|
||
allowedCredentials[i] = credential.Descriptor()
|
||
}
|
||
|
||
return webauthn.beginLogin(user.WebAuthnID(), allowedCredentials, mediation, opts...)
|
||
}
|
||
|
||
// BeginDiscoverableMediatedLogin is similar to [WebAuthn.BeginDiscoverableLogin] however it also allows specifying a
|
||
// credential mediation requirement.
|
||
func (webauthn *WebAuthn) BeginDiscoverableMediatedLogin(mediation protocol.CredentialMediationRequirement, opts ...LoginOption) (*protocol.CredentialAssertion, *SessionData, error) {
|
||
return webauthn.beginLogin(nil, nil, mediation, opts...)
|
||
}
|
||
|
||
func (webauthn *WebAuthn) beginLogin(userID []byte, allowedCredentials []protocol.CredentialDescriptor, mediation protocol.CredentialMediationRequirement, opts ...LoginOption) (assertion *protocol.CredentialAssertion, session *SessionData, err error) {
|
||
if err = webauthn.Config.validate(); err != nil {
|
||
return nil, nil, fmt.Errorf(errFmtConfigValidate, err)
|
||
}
|
||
|
||
assertion = &protocol.CredentialAssertion{
|
||
Response: protocol.PublicKeyCredentialRequestOptions{
|
||
RelyingPartyID: webauthn.Config.RPID,
|
||
UserVerification: webauthn.Config.AuthenticatorSelection.UserVerification,
|
||
AllowedCredentials: allowedCredentials,
|
||
},
|
||
Mediation: mediation,
|
||
}
|
||
|
||
for _, opt := range opts {
|
||
opt(&assertion.Response)
|
||
}
|
||
|
||
if len(assertion.Response.Challenge) == 0 {
|
||
var challenge protocol.URLEncodedBase64
|
||
if challenge, err = protocol.CreateChallenge(); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
assertion.Response.Challenge = challenge
|
||
}
|
||
|
||
if len(assertion.Response.Challenge) < protocol.MinimumChallengeLength {
|
||
return nil, nil, fmt.Errorf("error generating assertion: the challenge must be at least 16 bytes")
|
||
}
|
||
|
||
if len(assertion.Response.RelyingPartyID) == 0 {
|
||
return nil, nil, fmt.Errorf("error generating assertion: the relying party id must be provided via the configuration or a functional option for a login")
|
||
} else if err = protocol.ValidateRPID(assertion.Response.RelyingPartyID); err != nil {
|
||
return nil, nil, fmt.Errorf("error generating assertion: the relying party id failed to validate as it's not a valid domain string with error: %w", err)
|
||
}
|
||
|
||
if assertion.Response.Timeout == 0 {
|
||
switch assertion.Response.UserVerification {
|
||
case protocol.VerificationDiscouraged:
|
||
assertion.Response.Timeout = int(webauthn.Config.Timeouts.Login.TimeoutUVD.Milliseconds())
|
||
default:
|
||
assertion.Response.Timeout = int(webauthn.Config.Timeouts.Login.Timeout.Milliseconds())
|
||
}
|
||
}
|
||
|
||
session = &SessionData{
|
||
Challenge: assertion.Response.Challenge.String(),
|
||
RelyingPartyID: assertion.Response.RelyingPartyID,
|
||
UserID: userID,
|
||
AllowedCredentialIDs: assertion.Response.GetAllowedCredentialIDs(),
|
||
UserVerification: assertion.Response.UserVerification,
|
||
Extensions: assertion.Response.Extensions,
|
||
}
|
||
|
||
if webauthn.Config.Timeouts.Login.Enforce {
|
||
session.Expires = time.Now().Add(time.Millisecond * time.Duration(assertion.Response.Timeout))
|
||
}
|
||
|
||
return assertion, session, nil
|
||
}
|
||
|
||
// FinishLogin takes the response from the client and validates it against the user credentials and stored session data.
|
||
//
|
||
// As with all Finish functions, this function requires a [*http.Request] but you can perform the same steps with the
|
||
// [protocol.ParseCredentialRequestResponseBody] or [protocol.ParseCredentialRequestResponseBytes] which require an
|
||
// [io.Reader] or byte array respectively, you can also use an arbitrary [*protocol.ParsedCredentialAssertionData] which is
|
||
// returned from all of these functions i.e. by implementing a custom parser. The [*SessionData],
|
||
// and [*protocol.ParsedCredentialAssertionData] can then be used with the [WebAuthn.ValidateLogin] function.
|
||
//
|
||
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] provided does not contain
|
||
// a [Credential] with the same ID byte array provided all [Credential]'s in the [SessionData] exist in the [User]'s
|
||
// [Credential] list.
|
||
func (webauthn *WebAuthn) FinishLogin(user User, session SessionData, response *http.Request) (credential *Credential, err error) {
|
||
var parsedResponse *protocol.ParsedCredentialAssertionData
|
||
|
||
if parsedResponse, err = protocol.ParseCredentialRequestResponse(response); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return webauthn.ValidateLogin(user, session, parsedResponse)
|
||
}
|
||
|
||
// FinishDiscoverableLogin takes the response from the client and validates it against the handler and stored session data.
|
||
// The handler helps to find out which user must be used to validate the response. This is a function defined in your
|
||
// business code that will retrieve the user from your persistent data.
|
||
//
|
||
// As with all Finish functions, this function requires a [*http.Request] but you can perform the same steps with the
|
||
// [protocol.ParseCredentialRequestResponseBody] or [protocol.ParseCredentialRequestResponseBytes] which require an
|
||
// [io.Reader] or byte array respectively, you can also use an arbitrary [*protocol.ParsedCredentialAssertionData] which is
|
||
// returned from all of these functions i.e. by implementing a custom parser. The [DiscoverableUserHandler], [*SessionData],
|
||
// and [*protocol.ParsedCredentialAssertionData] can then be used with the [WebAuthn.ValidatePasskeyLogin] function.
|
||
//
|
||
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] returned by the
|
||
// handler does not contain a [Credential] with the same ID byte array provided all [Credential]'s
|
||
// in the [SessionData] exist in the [User]'s [Credential] list.
|
||
func (webauthn *WebAuthn) FinishDiscoverableLogin(handler DiscoverableUserHandler, session SessionData, response *http.Request) (credential *Credential, err error) {
|
||
var parsedResponse *protocol.ParsedCredentialAssertionData
|
||
|
||
if parsedResponse, err = protocol.ParseCredentialRequestResponse(response); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return webauthn.ValidateDiscoverableLogin(handler, session, parsedResponse)
|
||
}
|
||
|
||
// FinishPasskeyLogin takes the response from the client and validate it against the handler and stored session data.
|
||
// The handler helps to find out which user must be used to validate the response. This is a function defined in your
|
||
// business code that will retrieve the user from your persistent data.
|
||
//
|
||
// As with all Finish functions this function requires a [*http.Request] but you can perform the same steps with the
|
||
// [protocol.ParseCredentialRequestResponseBody] or [protocol.ParseCredentialRequestResponseBytes] which require an
|
||
// io.Reader or byte array respectively, you can also use an arbitrary [*protocol.ParsedCredentialAssertionData] which is
|
||
// returned from all of these functions i.e. by implementing a custom parser. The [DiscoverableUserHandler], [*SessionData],
|
||
// and [*protocol.ParsedCredentialAssertionData] can then be used with the [WebAuthn.ValidatePasskeyLogin] function.
|
||
//
|
||
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] returned by the
|
||
// handler does not contain a [Credential] with the same ID byte array provided all [Credential]'s
|
||
// in the [SessionData] exist in the [User]'s [Credential] list.
|
||
func (webauthn *WebAuthn) FinishPasskeyLogin(handler DiscoverableUserHandler, session SessionData, response *http.Request) (user User, credential *Credential, err error) {
|
||
var parsedResponse *protocol.ParsedCredentialAssertionData
|
||
|
||
if parsedResponse, err = protocol.ParseCredentialRequestResponse(response); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
return webauthn.ValidatePasskeyLogin(handler, session, parsedResponse)
|
||
}
|
||
|
||
// ValidateLogin takes a parsed response and validates it against the user credentials and session data.
|
||
//
|
||
// If you wish to skip performing the step required to parse the *protocol.ParsedCredentialAssertionData and
|
||
// you're using net/http then you can use [WebAuthn.FinishLogin] instead.
|
||
//
|
||
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] provided does not contain
|
||
// a [Credential] with the same ID byte array provided all [Credential]'s in the [SessionData] exist in
|
||
// the [User]'s [Credential] list.
|
||
func (webauthn *WebAuthn) ValidateLogin(user User, session SessionData, parsedResponse *protocol.ParsedCredentialAssertionData) (credential *Credential, err error) {
|
||
if !bytes.Equal(user.WebAuthnID(), session.UserID) {
|
||
return nil, protocol.ErrBadRequest.WithDetails("ID mismatch for User and Session")
|
||
}
|
||
|
||
if !session.Expires.IsZero() && session.Expires.Before(time.Now()) {
|
||
return nil, protocol.ErrBadRequest.WithDetails("Session has Expired")
|
||
}
|
||
|
||
return webauthn.validateLogin(user, session, parsedResponse)
|
||
}
|
||
|
||
// ValidateDiscoverableLogin is similar to [WebAuthn.ValidateLogin] that allows for discoverable credentials. It's
|
||
// recommended that [WebAuthn.ValidatePasskeyLogin] is used instead.
|
||
//
|
||
// If you wish to skip performing the step required to parse the [*protocol.ParsedCredentialAssertionData] and
|
||
// you're using net/http then you can use [WebAuthn.FinishDiscoverableLogin] instead.
|
||
//
|
||
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] returned by the
|
||
// handler does not contain a [Credential] with the same ID byte array provided all [Credential]'s
|
||
// in the [SessionData] exist in the [User]'s [Credential] list.
|
||
//
|
||
// Note: this is just a backwards compatibility layer over [WebAuthn.ValidatePasskeyLogin] which returns more information.
|
||
func (webauthn *WebAuthn) ValidateDiscoverableLogin(handler DiscoverableUserHandler, session SessionData, parsedResponse *protocol.ParsedCredentialAssertionData) (credential *Credential, err error) {
|
||
_, credential, err = webauthn.ValidatePasskeyLogin(handler, session, parsedResponse)
|
||
|
||
return credential, err
|
||
}
|
||
|
||
// ValidatePasskeyLogin is similar to [WebAuthn.ValidateLogin] that allows for discoverable credentials.
|
||
//
|
||
// If you wish to skip performing the step required to parse the [*protocol.ParsedCredentialAssertionData] and
|
||
// you're using net/http then you can use [WebAuthn.FinishPasskeyLogin] instead.
|
||
//
|
||
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] returned by the
|
||
// handler does not contain a [Credential] with the same ID byte array provided all [Credential]'s
|
||
// in the [SessionData] exist in the [User]'s [Credential] list.
|
||
func (webauthn *WebAuthn) ValidatePasskeyLogin(handler DiscoverableUserHandler, session SessionData, parsedResponse *protocol.ParsedCredentialAssertionData) (user User, credential *Credential, err error) {
|
||
if len(session.UserID) != 0 {
|
||
return nil, nil, protocol.ErrBadRequest.WithDetails("Session was not initiated as a client-side discoverable login")
|
||
}
|
||
|
||
if !session.Expires.IsZero() && session.Expires.Before(time.Now()) {
|
||
return nil, nil, protocol.ErrBadRequest.WithDetails("Session has Expired")
|
||
}
|
||
|
||
if len(parsedResponse.Response.UserHandle) == 0 {
|
||
return nil, nil, protocol.ErrBadRequest.WithDetails("Client-side Discoverable Assertion was attempted with a blank User Handle")
|
||
}
|
||
|
||
if user, err = handler(parsedResponse.RawID, parsedResponse.Response.UserHandle); err != nil {
|
||
return nil, nil, protocol.ErrBadRequest.WithDetails(fmt.Sprintf("Failed to lookup Client-side Discoverable Credential: %s", err)).WithError(err)
|
||
}
|
||
|
||
if user == nil {
|
||
return nil, nil, protocol.ErrBadRequest.WithDetails("Failed to lookup Client-side Discoverable Credential: handler returned a nil user")
|
||
}
|
||
|
||
if credential, err = webauthn.validateLogin(user, session, parsedResponse); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
return user, credential, nil
|
||
}
|
||
|
||
// validateLogin takes a parsed response and validates it against the user credentials and session data.
|
||
//
|
||
//nolint:gocyclo
|
||
func (webauthn *WebAuthn) validateLogin(user User, session SessionData, parsedResponse *protocol.ParsedCredentialAssertionData) (*Credential, error) {
|
||
// Step 1. If the allowCredentials option was given when this authentication ceremony was initiated,
|
||
// verify that credential.id identifies one of the public key credentials that were listed in
|
||
// allowCredentials.
|
||
|
||
// NON-NORMATIVE Prior Step: Verify that the allowCredentials for the session are owned by the user provided.
|
||
credentials := user.WebAuthnCredentials()
|
||
|
||
if len(session.AllowedCredentialIDs) > 0 {
|
||
if !isCredentialsAllowedMatchingOwned(session.AllowedCredentialIDs, credentials) {
|
||
return nil, protocol.ErrBadRequest.WithDetails("User does not own all credentials from the allowed credential list")
|
||
}
|
||
|
||
if !isCredentialIDInCredentials(parsedResponse.RawID, credentials) {
|
||
return nil, &protocol.ErrorUnknownCredential{Err: protocol.ErrBadRequest.WithDetails("The credential ID provided is not owned by the user")}
|
||
}
|
||
|
||
if !isByteArrayInSlice(parsedResponse.RawID, session.AllowedCredentialIDs...) {
|
||
return nil, protocol.ErrBadRequest.WithDetails("The credential ID provided is not in the sessions allowed credential list")
|
||
}
|
||
}
|
||
|
||
// Step 2. If credential.response.userHandle is present, verify that the user identified by this value is
|
||
// the owner of the public key credential identified by credential.id. This is in part handled by our Step 1.
|
||
userHandle := parsedResponse.Response.UserHandle
|
||
if len(userHandle) > 0 {
|
||
if !bytes.Equal(userHandle, user.WebAuthnID()) {
|
||
return nil, protocol.ErrBadRequest.WithDetails("User handle and User ID do not match")
|
||
}
|
||
}
|
||
|
||
var (
|
||
found bool
|
||
credential Credential
|
||
)
|
||
|
||
// Step 3. Using credential’s id attribute (or the corresponding rawId, if base64url encoding is inappropriate
|
||
// for your use case), look up the corresponding credential public key.
|
||
for _, credential = range credentials {
|
||
if bytes.Equal(credential.ID, parsedResponse.RawID) {
|
||
found = true
|
||
|
||
break
|
||
}
|
||
}
|
||
|
||
if !found {
|
||
return nil, protocol.ErrBadRequest.WithDetails("Unable to find the credential for the returned credential ID")
|
||
}
|
||
|
||
var (
|
||
appID string
|
||
err error
|
||
)
|
||
|
||
// Ensure authenticators with a bad status are not used.
|
||
if webauthn.Config.MDS != nil {
|
||
var aaguid uuid.UUID
|
||
|
||
if len(credential.Authenticator.AAGUID) == 0 {
|
||
aaguid = uuid.Nil
|
||
} else if aaguid, err = uuid.FromBytes(credential.Authenticator.AAGUID); err != nil {
|
||
return nil, protocol.ErrBadRequest.WithDetails("Failed to decode AAGUID").WithInfo(fmt.Sprintf("Error occurred decoding AAGUID from the credential record: %s", err)).WithError(err)
|
||
}
|
||
|
||
if e := protocol.ValidateMetadata(context.Background(), webauthn.Config.MDS, aaguid, credential.AttestationType, credential.AttestationFormat, nil); e != nil {
|
||
return nil, protocol.ErrBadRequest.WithDetails("Failed to validate credential record metadata").WithInfo(e.DevInfo).WithError(e)
|
||
}
|
||
}
|
||
|
||
shouldVerifyUser := session.UserVerification == protocol.VerificationRequired
|
||
shouldVerifyUserPresence := true
|
||
|
||
rpID := webauthn.Config.RPID
|
||
rpOrigins := webauthn.Config.RPOrigins
|
||
rpTopOrigins := webauthn.Config.RPTopOrigins
|
||
|
||
if appID, err = parsedResponse.GetAppID(session.Extensions, credential.AttestationFormat); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Handle steps 4 through 16.
|
||
if err = parsedResponse.Verify(session.Challenge, rpID, appID, rpOrigins, rpTopOrigins, webauthn.Config.RPTopOriginVerificationMode, webauthn.Config.RPAllowCrossOrigin, shouldVerifyUser, shouldVerifyUserPresence, credential.PublicKey); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Check if the BackupEligible flag has changed.
|
||
if credential.Flags.BackupEligible != parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible() {
|
||
return nil, protocol.ErrBadRequest.WithDetails("Backup Eligible flag inconsistency detected during login validation")
|
||
}
|
||
|
||
// Check for the invalid combination BE=0 and BS=1.
|
||
if !parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible() && parsedResponse.Response.AuthenticatorData.Flags.HasBackupState() {
|
||
return nil, protocol.ErrBadRequest.WithDetails("Backup State Flag is true but Backup Eligible flag is false which is invalid")
|
||
}
|
||
|
||
// Handle step 17.
|
||
credential.Authenticator.UpdateCounter(parsedResponse.Response.AuthenticatorData.Counter)
|
||
|
||
// Update flags from response data.
|
||
credential.Flags = NewCredentialFlags(parsedResponse.Response.AuthenticatorData.Flags)
|
||
|
||
return &credential, nil
|
||
}
|