refactor: sql updates

feat-otp-verification
James Elliott 2023-02-14 13:53:57 +11:00
parent 236fcb1e37
commit e84ca4956a
No known key found for this signature in database
GPG Key ID: 0F1C4A096E857E49
32 changed files with 560 additions and 321 deletions

2
go.mod
View File

@ -15,7 +15,7 @@ require (
github.com/go-ldap/ldap/v3 v3.4.4 github.com/go-ldap/ldap/v3 v3.4.4
github.com/go-rod/rod v0.112.5 github.com/go-rod/rod v0.112.5
github.com/go-sql-driver/mysql v1.7.0 github.com/go-sql-driver/mysql v1.7.0
github.com/go-webauthn/webauthn v0.7.1 github.com/go-webauthn/webauthn v0.7.2-0.20230214122838-1ee3a4aecef1
github.com/golang-jwt/jwt/v4 v4.4.3 github.com/golang-jwt/jwt/v4 v4.4.3
github.com/golang/mock v1.6.0 github.com/golang/mock v1.6.0
github.com/google/uuid v1.3.0 github.com/google/uuid v1.3.0

4
go.sum
View File

@ -192,8 +192,8 @@ github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg78
github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/go-webauthn/revoke v0.1.9 h1:gSJ1ckA9VaKA2GN4Ukp+kiGTk1/EXtaDb1YE8RknbS0= github.com/go-webauthn/revoke v0.1.9 h1:gSJ1ckA9VaKA2GN4Ukp+kiGTk1/EXtaDb1YE8RknbS0=
github.com/go-webauthn/revoke v0.1.9/go.mod h1:j6WKPnv0HovtEs++paan9g3ar46gm1NarktkXBaPR+w= github.com/go-webauthn/revoke v0.1.9/go.mod h1:j6WKPnv0HovtEs++paan9g3ar46gm1NarktkXBaPR+w=
github.com/go-webauthn/webauthn v0.7.1 h1:b1/HP1bkqsW+DIO22WyG7BP9dL0rN151VpruH6cxADA= github.com/go-webauthn/webauthn v0.7.2-0.20230214122838-1ee3a4aecef1 h1:q8OgN8xHBoJpZ5+ZrRwGmLVCPVhgW+kMx87AkwVGYfA=
github.com/go-webauthn/webauthn v0.7.1/go.mod h1:22OJd+TV8oHrjjXmPHtcPR82lR/yR5m5ilGiF8yPFrE= github.com/go-webauthn/webauthn v0.7.2-0.20230214122838-1ee3a4aecef1/go.mod h1:22OJd+TV8oHrjjXmPHtcPR82lR/yR5m5ilGiF8yPFrE=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=

View File

@ -562,7 +562,7 @@ func (ctx *CmdCtx) StorageUserWebauthnListRunE(cmd *cobra.Command, args []string
fmt.Printf("ID\tKID\tDescription\n") fmt.Printf("ID\tKID\tDescription\n")
for _, device := range devices { for _, device := range devices {
fmt.Printf("%d\t%s\t%s", device.ID, device.KID, device.Description) fmt.Printf("%d\t%s\t%s", device.ID, device.KID, device.DisplayName)
} }
} }
@ -595,7 +595,7 @@ func (ctx *CmdCtx) StorageUserWebauthnListAllRunE(_ *cobra.Command, _ []string)
} }
for _, device := range devices { for _, device := range devices {
output.WriteString(fmt.Sprintf("%d\t%s\t%s\t%s\n", device.ID, device.KID, device.Description, device.Username)) output.WriteString(fmt.Sprintf("%d\t%s\t%s\t%s\n", device.ID, device.KID, device.DisplayName, device.Username))
} }
if len(devices) < limit { if len(devices) < limit {

View File

@ -721,7 +721,7 @@ func TestValidateIdentityProvidersShouldSetDefaultValues(t *testing.T) {
}, },
{ {
ID: "b-client", ID: "b-client",
Description: "Normal Description", Description: "Normal DisplayName",
Secret: MustDecodeSecret("$plaintext$b-client-secret"), Secret: MustDecodeSecret("$plaintext$b-client-secret"),
Policy: policyOneFactor, Policy: policyOneFactor,
UserinfoSigningAlgorithm: "RS256", UserinfoSigningAlgorithm: "RS256",
@ -783,9 +783,9 @@ func TestValidateIdentityProvidersShouldSetDefaultValues(t *testing.T) {
assert.Equal(t, "none", config.OIDC.Clients[0].UserinfoSigningAlgorithm) assert.Equal(t, "none", config.OIDC.Clients[0].UserinfoSigningAlgorithm)
assert.Equal(t, "RS256", config.OIDC.Clients[1].UserinfoSigningAlgorithm) assert.Equal(t, "RS256", config.OIDC.Clients[1].UserinfoSigningAlgorithm)
// Assert Clients[0] Description is set to the Clients[0] ID, and Clients[1]'s Description is not overridden. // Assert Clients[0] DisplayName is set to the Clients[0] ID, and Clients[1]'s DisplayName is not overridden.
assert.Equal(t, config.OIDC.Clients[0].ID, config.OIDC.Clients[0].Description) assert.Equal(t, config.OIDC.Clients[0].ID, config.OIDC.Clients[0].Description)
assert.Equal(t, "Normal Description", config.OIDC.Clients[1].Description) assert.Equal(t, "Normal DisplayName", config.OIDC.Clients[1].Description)
// Assert Clients[0] ends up configured with the default Scopes. // Assert Clients[0] ends up configured with the default Scopes.
require.Len(t, config.OIDC.Clients[0].Scopes, 4) require.Len(t, config.OIDC.Clients[0].Scopes, 4)

View File

@ -66,7 +66,7 @@ const (
messageAuthenticationFailed = "Authentication failed. Check your credentials." messageAuthenticationFailed = "Authentication failed. Check your credentials."
messageUnableToRegisterOneTimePassword = "Unable to set up one-time passwords." //nolint:gosec messageUnableToRegisterOneTimePassword = "Unable to set up one-time passwords." //nolint:gosec
messageUnableToRegisterSecurityKey = "Unable to register your security key." messageUnableToRegisterSecurityKey = "Unable to register your security key."
messageSecurityKeyDuplicateName = "Another one of your security keys is already registered with that name." messageSecurityKeyDuplicateName = "Another one of your security keys is already registered with that display name."
messageUnableToResetPassword = "Unable to reset your password." messageUnableToResetPassword = "Unable to reset your password."
messageMFAValidationFailed = "Authentication failed, please retry later." messageMFAValidationFailed = "Authentication failed, please retry later."
messagePasswordWeak = "Your supplied password does not meet the password policy requirements" messagePasswordWeak = "Your supplied password does not meet the password policy requirements"

View File

@ -3,6 +3,7 @@ package handlers
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"strings"
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
@ -15,17 +16,18 @@ import (
"github.com/authelia/authelia/v4/internal/storage" "github.com/authelia/authelia/v4/internal/storage"
) )
// WebauthnRegistrationGET returns the attestation challenge from the server. // WebauthnRegistrationPUT returns the attestation challenge from the server.
func WebauthnRegistrationGET(ctx *middlewares.AutheliaCtx) { func WebauthnRegistrationPUT(ctx *middlewares.AutheliaCtx) {
var ( var (
w *webauthn.WebAuthn w *webauthn.WebAuthn
user *model.WebauthnUser user *model.WebauthnUser
userSession session.UserSession userSession session.UserSession
bodyJSON bodyRegisterWebauthnPUTRequest
err error err error
) )
if userSession, err = ctx.GetSession(); err != nil { if userSession, err = ctx.GetSession(); err != nil {
ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s attestation challenge", regulation.AuthTypeWebauthn) ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s registration challenge", regulation.AuthTypeWebauthn)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
@ -33,40 +35,84 @@ func WebauthnRegistrationGET(ctx *middlewares.AutheliaCtx) {
} }
if w, err = newWebauthn(ctx); err != nil { if w, err = newWebauthn(ctx); err != nil {
ctx.Logger.Errorf("Unable to create %s attestation challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) ctx.Logger.Errorf("Unable to create provider to generate %s registration challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return return
} }
if user, err = getWebauthnUserByRPID(ctx, userSession, w.Config.RPID); err != nil { if err = json.Unmarshal(ctx.PostBody(), &bodyJSON); err != nil {
ctx.Logger.Errorf("Unable to load %s devices for assertion challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) ctx.Logger.Errorf("Unable to parse %s registration request PUT data for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed)
return
}
var credentialCreation *protocol.CredentialCreation
if credentialCreation, userSession.Webauthn, err = w.BeginRegistration(user, webauthn.WithExclusions(user.WebAuthnCredentialDescriptors())); err != nil {
ctx.Logger.Errorf("Unable to create %s attestation challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return return
} }
if length := len(bodyJSON.DisplayName); length == 0 || length > 64 {
ctx.Logger.Errorf("Failed to validate the user chosen display name for during %s registration for user '%s': the value has a length of %d but must be between 1 and 64", regulation.AuthTypeWebauthn, userSession.Username, length)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return
}
devices, err := ctx.Providers.StorageProvider.LoadWebauthnDevicesByUsername(ctx, w.Config.RPID, userSession.Username)
if err != nil && err != storage.ErrNoWebauthnDevice {
ctx.Logger.Errorf("Unable to load existing %s devices for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return
}
for _, device := range devices {
if strings.EqualFold(device.DisplayName, bodyJSON.DisplayName) {
ctx.Logger.Errorf("Unable to generate %s registration challenge: device for for user '%s' with display name '%s' already exists", regulation.AuthTypeWebauthn, userSession.Username, bodyJSON.DisplayName)
ctx.SetStatusCode(fasthttp.StatusConflict)
ctx.SetJSONError(messageSecurityKeyDuplicateName)
return
}
}
if user, err = getWebauthnUserByRPID(ctx, userSession.Username, bodyJSON.DisplayName, w.Config.RPID); err != nil {
ctx.Logger.Errorf("Unable to load %s devices for registration challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return
}
var (
opts *protocol.CredentialCreation
)
data := session.Webauthn{
DisplayName: bodyJSON.DisplayName,
}
if opts, data.SessionData, err = w.BeginRegistration(user, webauthn.WithExclusions(user.WebAuthnCredentialDescriptors())); err != nil {
ctx.Logger.Errorf("Unable to create %s registration challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return
}
userSession.Webauthn = &data
if err = ctx.SaveSession(userSession); err != nil { if err = ctx.SaveSession(userSession); err != nil {
ctx.Logger.Errorf(logFmtErrSessionSave, "attestation challenge", regulation.AuthTypeWebauthn, userSession.Username, err) ctx.Logger.Errorf(logFmtErrSessionSave, "registration challenge", regulation.AuthTypeWebauthn, userSession.Username, err)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return return
} }
if err = ctx.SetJSONBody(credentialCreation); err != nil { if err = ctx.SetJSONBody(opts); err != nil {
ctx.Logger.Errorf(logFmtErrWriteResponseBody, regulation.AuthTypeWebauthn, userSession.Username, err) ctx.Logger.Errorf(logFmtErrWriteResponseBody, regulation.AuthTypeWebauthn, userSession.Username, err)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
@ -87,7 +133,6 @@ func WebauthnRegistrationPOST(ctx *middlewares.AutheliaCtx) {
response *protocol.ParsedCredentialCreationData response *protocol.ParsedCredentialCreationData
credential *webauthn.Credential credential *webauthn.Credential
bodyJSON bodyRegisterWebauthnRequest
) )
if userSession, err = ctx.GetSession(); err != nil { if userSession, err = ctx.GetSession(); err != nil {
@ -98,10 +143,10 @@ func WebauthnRegistrationPOST(ctx *middlewares.AutheliaCtx) {
return return
} }
if userSession.Webauthn == nil { if userSession.Webauthn == nil || userSession.Webauthn.SessionData == nil {
ctx.Logger.Errorf("Webauthn session data is not present in order to handle %s registration for user '%s'. This could indicate a user trying to POST to the wrong endpoint, or the session data is not present for the browser they used.", regulation.AuthTypeWebauthn, userSession.Username) ctx.Logger.Errorf("Webauthn session data is not present in order to handle %s registration for user '%s'. This could indicate a user trying to POST to the wrong endpoint, or the session data is not present for the browser they used.", regulation.AuthTypeWebauthn, userSession.Username)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return return
} }
@ -114,71 +159,41 @@ func WebauthnRegistrationPOST(ctx *middlewares.AutheliaCtx) {
return return
} }
if err = json.Unmarshal(ctx.PostBody(), &bodyJSON); err != nil { if response, err = protocol.ParseCredentialCreationResponseBody(bytes.NewReader(ctx.PostBody())); err != nil {
ctx.Logger.Errorf("Unable to parse %s registration request POST data for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) switch e := err.(type) {
case *protocol.Error:
respondUnauthorized(ctx, messageMFAValidationFailed) ctx.Logger.Errorf("Unable to parse %s registration for user '%s': %+v (%s)", regulation.AuthTypeWebauthn, userSession.Username, err, e.DevInfo)
default:
return
}
if response, err = protocol.ParseCredentialCreationResponseBody(bytes.NewReader(bodyJSON.Response)); err != nil {
ctx.Logger.Errorf("Unable to parse %s registration for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) ctx.Logger.Errorf("Unable to parse %s registration for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err)
}
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return return
} }
ctx.Logger.WithField("att_format", response.Response.AttestationObject.Format).Debug("Response Data")
if user, err = getWebauthnUser(ctx, userSession); err != nil { if user, err = getWebauthnUser(ctx, userSession); err != nil {
ctx.Logger.Errorf("Unable to load %s user details for registration for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) ctx.Logger.Errorf("Unable to load %s user details for registration for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return return
} }
if credential, err = w.CreateCredential(user, *userSession.Webauthn, response); err != nil { if credential, err = w.CreateCredential(user, *userSession.Webauthn.SessionData, response); err != nil {
switch e := err.(type) {
case *protocol.Error:
ctx.Logger.Errorf("Unable to create %s credential for user '%s': %+v (%s)", regulation.AuthTypeWebauthn, userSession.Username, err, e.DevInfo)
default:
ctx.Logger.Errorf("Unable to create %s credential for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) ctx.Logger.Errorf("Unable to create %s credential for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed)
return
} }
ctx.Logger.WithField("att_type", credential.AttestationType).Debug("Credential Data")
devices, err := ctx.Providers.StorageProvider.LoadWebauthnDevicesByUsername(ctx, w.Config.RPID, userSession.Username)
if err != nil && err != storage.ErrNoWebauthnDevice {
ctx.Logger.Errorf("Unable to load existing %s devices for for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return return
} }
for _, existingDevice := range devices { device := model.NewWebauthnDeviceFromCredential(w.Config.RPID, userSession.Username, userSession.Webauthn.DisplayName, credential)
if existingDevice.Description == bodyJSON.Description {
ctx.Logger.Errorf("%s device for for user '%s' with name '%s' already exists", regulation.AuthTypeWebauthn, userSession.Username, bodyJSON.Description)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
ctx.SetStatusCode(fasthttp.StatusConflict)
ctx.SetJSONError(messageSecurityKeyDuplicateName)
return
}
}
device := model.NewWebauthnDeviceFromCredential(w.Config.RPID, userSession.Username, bodyJSON.Description, credential)
ctx.Logger.WithFields(map[string]any{
"RPID": device.RPID,
"ID": device.ID,
"KID": device.KID.String(),
"User": device.Username,
}).Debug("Registering New Device")
if err = ctx.Providers.StorageProvider.SaveWebauthnDevice(ctx, device); err != nil { if err = ctx.Providers.StorageProvider.SaveWebauthnDevice(ctx, device); err != nil {
ctx.Logger.Errorf("Unable to save %s device registration for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) ctx.Logger.Errorf("Unable to save %s device registration for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err)
@ -189,6 +204,7 @@ func WebauthnRegistrationPOST(ctx *middlewares.AutheliaCtx) {
} }
userSession.Webauthn = nil userSession.Webauthn = nil
if err = ctx.SaveSession(userSession); err != nil { if err = ctx.SaveSession(userSession); err != nil {
ctx.Logger.Errorf(logFmtErrSessionSave, "removal of the registration challenge", regulation.AuthTypeWebauthn, userSession.Username, err) ctx.Logger.Errorf(logFmtErrSessionSave, "removal of the registration challenge", regulation.AuthTypeWebauthn, userSession.Username, err)
} }
@ -196,5 +212,5 @@ func WebauthnRegistrationPOST(ctx *middlewares.AutheliaCtx) {
ctx.ReplyOK() ctx.ReplyOK()
ctx.SetStatusCode(fasthttp.StatusCreated) ctx.SetStatusCode(fasthttp.StatusCreated)
ctxLogEvent(ctx, userSession.Username, "Second Factor Method Added", map[string]any{"Action": "Second Factor Method Added", "Category": "Webauthn Credential", "Credential Description": bodyJSON.Description}) ctxLogEvent(ctx, userSession.Username, "Second Factor Method Added", map[string]any{"Action": "Second Factor Method Added", "Category": "Webauthn Credential", "Credential Display Name": device.DisplayName})
} }

View File

@ -37,7 +37,7 @@ func WebauthnAssertionGET(ctx *middlewares.AutheliaCtx) {
return return
} }
if user, err = getWebauthnUserByRPID(ctx, userSession, w.Config.RPID); err != nil { if user, err = getWebauthnUserByRPID(ctx, userSession.Username, userSession.DisplayName, w.Config.RPID); err != nil {
ctx.Logger.Errorf("Unable to load %s user details during authentication challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) ctx.Logger.Errorf("Unable to load %s user details during authentication challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageMFAValidationFailed)
@ -61,7 +61,9 @@ func WebauthnAssertionGET(ctx *middlewares.AutheliaCtx) {
var assertion *protocol.CredentialAssertion var assertion *protocol.CredentialAssertion
if assertion, userSession.Webauthn, err = w.BeginLogin(user, opts...); err != nil { data := session.Webauthn{}
if assertion, data.SessionData, err = w.BeginLogin(user, opts...); err != nil {
ctx.Logger.Errorf("Unable to create %s authentication challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) ctx.Logger.Errorf("Unable to create %s authentication challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageMFAValidationFailed)
@ -69,6 +71,8 @@ func WebauthnAssertionGET(ctx *middlewares.AutheliaCtx) {
return return
} }
userSession.Webauthn = &data
if err = ctx.SaveSession(userSession); err != nil { if err = ctx.SaveSession(userSession); err != nil {
ctx.Logger.Errorf(logFmtErrSessionSave, "assertion challenge", regulation.AuthTypeWebauthn, userSession.Username, err) ctx.Logger.Errorf(logFmtErrSessionSave, "assertion challenge", regulation.AuthTypeWebauthn, userSession.Username, err)
@ -115,7 +119,7 @@ func WebauthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
return return
} }
if userSession.Webauthn == nil { if userSession.Webauthn == nil || userSession.Webauthn.SessionData == nil {
ctx.Logger.Errorf("Webauthn session data is not present in order to handle authentication challenge for user '%s'. This could indicate a user trying to POST to the wrong endpoint, or the session data is not present for the browser they used.", userSession.Username) ctx.Logger.Errorf("Webauthn session data is not present in order to handle authentication challenge for user '%s'. This could indicate a user trying to POST to the wrong endpoint, or the session data is not present for the browser they used.", userSession.Username)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageMFAValidationFailed)
@ -145,7 +149,7 @@ func WebauthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
return return
} }
if user, err = getWebauthnUserByRPID(ctx, userSession, w.Config.RPID); err != nil { if user, err = getWebauthnUserByRPID(ctx, userSession.Username, userSession.DisplayName, w.Config.RPID); err != nil {
ctx.Logger.Errorf("Unable to load %s credentials for authentication challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) ctx.Logger.Errorf("Unable to load %s credentials for authentication challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageMFAValidationFailed)
@ -153,7 +157,7 @@ func WebauthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
return return
} }
if credential, err = w.ValidateLogin(user, *userSession.Webauthn, assertionResponse); err != nil { if credential, err = w.ValidateLogin(user, *userSession.Webauthn.SessionData, assertionResponse); err != nil {
_ = markAuthenticationAttempt(ctx, false, nil, userSession.Username, regulation.AuthTypeWebauthn, err) _ = markAuthenticationAttempt(ctx, false, nil, userSession.Username, regulation.AuthTypeWebauthn, err)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageMFAValidationFailed)
@ -169,7 +173,7 @@ func WebauthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
found = true found = true
if err = ctx.Providers.StorageProvider.UpdateWebauthnDeviceSignIn(ctx, device.ID, device.RPID, device.LastUsedAt, device.SignCount, device.CloneWarning); err != nil { if err = ctx.Providers.StorageProvider.UpdateWebauthnDeviceSignIn(ctx, device); err != nil {
ctx.Logger.Errorf("Unable to save %s device signin count for authentication challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) ctx.Logger.Errorf("Unable to save %s device signin count for authentication challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err)
respondUnauthorized(ctx, messageMFAValidationFailed) respondUnauthorized(ctx, messageMFAValidationFailed)

View File

@ -40,10 +40,8 @@ type bodySignWebauthnRequest struct {
Response json.RawMessage `json:"response"` Response json.RawMessage `json:"response"`
} }
type bodyRegisterWebauthnRequest struct { type bodyRegisterWebauthnPUTRequest struct {
Description string `json:"description"` DisplayName string `json:"displayname"`
Response json.RawMessage `json:"response"`
} }
type bodyEditWebauthnDeviceRequest struct { type bodyEditWebauthnDeviceRequest struct {

View File

@ -13,20 +13,20 @@ import (
) )
func getWebauthnUser(ctx *middlewares.AutheliaCtx, userSession session.UserSession) (user *model.WebauthnUser, err error) { func getWebauthnUser(ctx *middlewares.AutheliaCtx, userSession session.UserSession) (user *model.WebauthnUser, err error) {
return getWebauthnUserByRPID(ctx, userSession, "") return getWebauthnUserByRPID(ctx, userSession.Username, userSession.DisplayName, "")
} }
func getWebauthnUserByRPID(ctx *middlewares.AutheliaCtx, userSession session.UserSession, rpid string) (user *model.WebauthnUser, err error) { func getWebauthnUserByRPID(ctx *middlewares.AutheliaCtx, username, displayname string, rpid string) (user *model.WebauthnUser, err error) {
user = &model.WebauthnUser{ user = &model.WebauthnUser{
Username: userSession.Username, Username: username,
DisplayName: userSession.DisplayName, DisplayName: displayname,
} }
if user.DisplayName == "" { if user.DisplayName == "" {
user.DisplayName = user.Username user.DisplayName = user.Username
} }
if user.Devices, err = ctx.Providers.StorageProvider.LoadWebauthnDevicesByUsername(ctx, rpid, userSession.Username); err != nil { if user.Devices, err = ctx.Providers.StorageProvider.LoadWebauthnDevicesByUsername(ctx, rpid, user.Username); err != nil {
return nil, err return nil, err
} }

View File

@ -26,7 +26,7 @@ func TestWebauthnGetUser(t *testing.T) {
ID: 1, ID: 1,
RPID: "example.com", RPID: "example.com",
Username: "john", Username: "john",
Description: "Primary", DisplayName: "Primary",
KID: model.NewBase64([]byte("abc123")), KID: model.NewBase64([]byte("abc123")),
AttestationType: "fido-u2f", AttestationType: "fido-u2f",
PublicKey: []byte("data"), PublicKey: []byte("data"),
@ -37,7 +37,7 @@ func TestWebauthnGetUser(t *testing.T) {
ID: 2, ID: 2,
RPID: "example.com", RPID: "example.com",
Username: "john", Username: "john",
Description: "Secondary", DisplayName: "Secondary",
KID: model.NewBase64([]byte("123abc")), KID: model.NewBase64([]byte("123abc")),
AttestationType: "packed", AttestationType: "packed",
Transport: "usb,nfc", Transport: "usb,nfc",
@ -47,7 +47,7 @@ func TestWebauthnGetUser(t *testing.T) {
}, },
}, nil) }, nil)
user, err := getWebauthnUserByRPID(ctx.Ctx, userSession, "example.com") user, err := getWebauthnUserByRPID(ctx.Ctx, userSession.Username, userSession.DisplayName, "example.com")
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, user) require.NotNil(t, user)
@ -66,7 +66,7 @@ func TestWebauthnGetUser(t *testing.T) {
assert.Equal(t, 1, user.Devices[0].ID) assert.Equal(t, 1, user.Devices[0].ID)
assert.Equal(t, "example.com", user.Devices[0].RPID) assert.Equal(t, "example.com", user.Devices[0].RPID)
assert.Equal(t, "john", user.Devices[0].Username) assert.Equal(t, "john", user.Devices[0].Username)
assert.Equal(t, "Primary", user.Devices[0].Description) assert.Equal(t, "Primary", user.Devices[0].DisplayName)
assert.Equal(t, "", user.Devices[0].Transport) assert.Equal(t, "", user.Devices[0].Transport)
assert.Equal(t, "fido-u2f", user.Devices[0].AttestationType) assert.Equal(t, "fido-u2f", user.Devices[0].AttestationType)
assert.Equal(t, []byte("data"), user.Devices[0].PublicKey) assert.Equal(t, []byte("data"), user.Devices[0].PublicKey)
@ -83,7 +83,7 @@ func TestWebauthnGetUser(t *testing.T) {
assert.Equal(t, 2, user.Devices[1].ID) assert.Equal(t, 2, user.Devices[1].ID)
assert.Equal(t, "example.com", user.Devices[1].RPID) assert.Equal(t, "example.com", user.Devices[1].RPID)
assert.Equal(t, "john", user.Devices[1].Username) assert.Equal(t, "john", user.Devices[1].Username)
assert.Equal(t, "Secondary", user.Devices[1].Description) assert.Equal(t, "Secondary", user.Devices[1].DisplayName)
assert.Equal(t, "usb,nfc", user.Devices[1].Transport) assert.Equal(t, "usb,nfc", user.Devices[1].Transport)
assert.Equal(t, "packed", user.Devices[1].AttestationType) assert.Equal(t, "packed", user.Devices[1].AttestationType)
assert.Equal(t, []byte("data"), user.Devices[1].PublicKey) assert.Equal(t, []byte("data"), user.Devices[1].PublicKey)
@ -111,7 +111,7 @@ func TestWebauthnGetUserWithoutDisplayName(t *testing.T) {
ID: 1, ID: 1,
RPID: "example.com", RPID: "example.com",
Username: "john", Username: "john",
Description: "Primary", DisplayName: "Primary",
KID: model.NewBase64([]byte("abc123")), KID: model.NewBase64([]byte("abc123")),
AttestationType: "fido-u2f", AttestationType: "fido-u2f",
PublicKey: []byte("data"), PublicKey: []byte("data"),
@ -120,7 +120,7 @@ func TestWebauthnGetUserWithoutDisplayName(t *testing.T) {
}, },
}, nil) }, nil)
user, err := getWebauthnUserByRPID(ctx.Ctx, userSession, "example.com") user, err := getWebauthnUserByRPID(ctx.Ctx, userSession.Username, userSession.DisplayName, "example.com")
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, user) require.NotNil(t, user)
@ -138,7 +138,7 @@ func TestWebauthnGetUserWithErr(t *testing.T) {
ctx.StorageMock.EXPECT().LoadWebauthnDevicesByUsername(ctx.Ctx, "example.com", "john").Return(nil, errors.New("not found")) ctx.StorageMock.EXPECT().LoadWebauthnDevicesByUsername(ctx.Ctx, "example.com", "john").Return(nil, errors.New("not found"))
user, err := getWebauthnUserByRPID(ctx.Ctx, userSession, "example.com") user, err := getWebauthnUserByRPID(ctx.Ctx, userSession.Username, userSession.DisplayName, "example.com")
assert.EqualError(t, err, "not found") assert.EqualError(t, err, "not found")
assert.Nil(t, user) assert.Nil(t, user)

View File

@ -850,15 +850,15 @@ func (mr *MockStorageMockRecorder) UpdateWebauthnDeviceDescription(arg0, arg1, a
} }
// UpdateWebauthnDeviceSignIn mocks base method. // UpdateWebauthnDeviceSignIn mocks base method.
func (m *MockStorage) UpdateWebauthnDeviceSignIn(arg0 context.Context, arg1 int, arg2 string, arg3 sql.NullTime, arg4 uint32, arg5 bool) error { func (m *MockStorage) UpdateWebauthnDeviceSignIn(arg0 context.Context, arg1 model.WebauthnDevice) error {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateWebauthnDeviceSignIn", arg0, arg1, arg2, arg3, arg4, arg5) ret := m.ctrl.Call(m, "UpdateWebauthnDeviceSignIn", arg0, arg1)
ret0, _ := ret[0].(error) ret0, _ := ret[0].(error)
return ret0 return ret0
} }
// UpdateWebauthnDeviceSignIn indicates an expected call of UpdateWebauthnDeviceSignIn. // UpdateWebauthnDeviceSignIn indicates an expected call of UpdateWebauthnDeviceSignIn.
func (mr *MockStorageMockRecorder) UpdateWebauthnDeviceSignIn(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) *gomock.Call { func (mr *MockStorageMockRecorder) UpdateWebauthnDeviceSignIn(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper() mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWebauthnDeviceSignIn", reflect.TypeOf((*MockStorage)(nil).UpdateWebauthnDeviceSignIn), arg0, arg1, arg2, arg3, arg4, arg5) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWebauthnDeviceSignIn", reflect.TypeOf((*MockStorage)(nil).UpdateWebauthnDeviceSignIn), arg0, arg1)
} }

View File

@ -72,10 +72,17 @@ func (w WebauthnUser) WebAuthnCredentials() (credentials []webauthn.Credential)
ID: device.KID.Bytes(), ID: device.KID.Bytes(),
PublicKey: device.PublicKey, PublicKey: device.PublicKey,
AttestationType: device.AttestationType, AttestationType: device.AttestationType,
Flags: webauthn.CredentialFlags{
UserPresent: device.Present,
UserVerified: device.Verified,
BackupEligible: device.BackupEligible,
BackupState: device.BackupState,
},
Authenticator: webauthn.Authenticator{ Authenticator: webauthn.Authenticator{
AAGUID: aaguid, AAGUID: aaguid,
SignCount: device.SignCount, SignCount: device.SignCount,
CloneWarning: device.CloneWarning, CloneWarning: device.CloneWarning,
Attachment: protocol.AuthenticatorAttachment(device.Attachment),
}, },
} }
@ -110,7 +117,7 @@ func (w WebauthnUser) WebAuthnCredentialDescriptors() (descriptors []protocol.Cr
} }
// NewWebauthnDeviceFromCredential creates a WebauthnDevice from a webauthn.Credential. // NewWebauthnDeviceFromCredential creates a WebauthnDevice from a webauthn.Credential.
func NewWebauthnDeviceFromCredential(rpid, username, description string, credential *webauthn.Credential) (device WebauthnDevice) { func NewWebauthnDeviceFromCredential(rpid, username, displayname string, credential *webauthn.Credential) (device WebauthnDevice) {
transport := make([]string, len(credential.Transport)) transport := make([]string, len(credential.Transport))
for i, t := range credential.Transport { for i, t := range credential.Transport {
@ -121,13 +128,19 @@ func NewWebauthnDeviceFromCredential(rpid, username, description string, credent
RPID: rpid, RPID: rpid,
Username: username, Username: username,
CreatedAt: time.Now(), CreatedAt: time.Now(),
Description: description, DisplayName: displayname,
KID: NewBase64(credential.ID), KID: NewBase64(credential.ID),
PublicKey: credential.PublicKey,
AttestationType: credential.AttestationType, AttestationType: credential.AttestationType,
Attachment: string(credential.Authenticator.Attachment),
Transport: strings.Join(transport, ","),
SignCount: credential.Authenticator.SignCount, SignCount: credential.Authenticator.SignCount,
CloneWarning: credential.Authenticator.CloneWarning, CloneWarning: credential.Authenticator.CloneWarning,
Transport: strings.Join(transport, ","), Discoverable: false,
Present: credential.Flags.UserPresent,
Verified: credential.Flags.UserVerified,
BackupEligible: credential.Flags.BackupEligible,
BackupState: credential.Flags.BackupState,
PublicKey: credential.PublicKey,
} }
aaguid, err := uuid.Parse(hex.EncodeToString(credential.Authenticator.AAGUID)) aaguid, err := uuid.Parse(hex.EncodeToString(credential.Authenticator.AAGUID))
@ -144,14 +157,20 @@ type WebauthnDeviceJSON struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"` LastUsedAt *time.Time `json:"last_used_at,omitempty"`
RPID string `json:"rpid"` RPID string `json:"rpid"`
Description string `json:"description"` DisplayName string `json:"displayname"`
KID []byte `json:"kid"` KID []byte `json:"kid"`
PublicKey []byte `json:"public_key"` AAGUID string `json:"aaguid,omitempty"`
Attachment string `json:"attachment"`
AttestationType string `json:"attestation_type"` AttestationType string `json:"attestation_type"`
Transports []string `json:"transports"` Transports []string `json:"transports"`
AAGUID string `json:"aaguid,omitempty"`
SignCount uint32 `json:"sign_count"` SignCount uint32 `json:"sign_count"`
CloneWarning bool `json:"clone_warning"` CloneWarning bool `json:"clone_warning"`
Discoverable bool `json:"discoverable"`
Present bool `json:"present"`
Verified bool `json:"verified"`
BackupEligible bool `json:"backup_eligible"`
BackupState bool `json:"backup_state"`
PublicKey []byte `json:"public_key"`
} }
// WebauthnDevice represents a Webauthn Device in the database storage. // WebauthnDevice represents a Webauthn Device in the database storage.
@ -161,14 +180,20 @@ type WebauthnDevice struct {
LastUsedAt sql.NullTime `db:"last_used_at"` LastUsedAt sql.NullTime `db:"last_used_at"`
RPID string `db:"rpid"` RPID string `db:"rpid"`
Username string `db:"username"` Username string `db:"username"`
Description string `db:"description"` DisplayName string `db:"displayname"`
KID Base64 `db:"kid"` KID Base64 `db:"kid"`
PublicKey []byte `db:"public_key"`
AttestationType string `db:"attestation_type"`
Transport string `db:"transport"`
AAGUID uuid.NullUUID `db:"aaguid"` AAGUID uuid.NullUUID `db:"aaguid"`
AttestationType string `db:"attestation_type"`
Attachment string `db:"attachment"`
Transport string `db:"transport"`
SignCount uint32 `db:"sign_count"` SignCount uint32 `db:"sign_count"`
CloneWarning bool `db:"clone_warning"` CloneWarning bool `db:"clone_warning"`
Discoverable bool `db:"discoverable"`
Present bool `db:"present"`
Verified bool `db:"verified"`
BackupEligible bool `db:"backup_eligible"`
BackupState bool `db:"backup_state"`
PublicKey []byte `db:"public_key"`
} }
// MarshalJSON returns the WebauthnDevice in a JSON friendly manner. // MarshalJSON returns the WebauthnDevice in a JSON friendly manner.
@ -177,13 +202,19 @@ func (w *WebauthnDevice) MarshalJSON() (data []byte, err error) {
ID: w.ID, ID: w.ID,
CreatedAt: w.CreatedAt, CreatedAt: w.CreatedAt,
RPID: w.RPID, RPID: w.RPID,
Description: w.Description, DisplayName: w.DisplayName,
KID: w.KID.data, KID: w.KID.data,
PublicKey: w.PublicKey,
AttestationType: w.AttestationType, AttestationType: w.AttestationType,
Attachment: w.Attachment,
Transports: []string{}, Transports: []string{},
SignCount: w.SignCount, SignCount: w.SignCount,
CloneWarning: w.CloneWarning, CloneWarning: w.CloneWarning,
Discoverable: w.Discoverable,
Present: w.Present,
Verified: w.Verified,
BackupEligible: w.BackupEligible,
BackupState: w.BackupState,
PublicKey: w.PublicKey,
} }
if w.AAGUID.Valid { if w.AAGUID.Valid {
@ -234,14 +265,19 @@ func (d *WebauthnDevice) MarshalYAML() (any, error) {
LastUsedAt: d.LastUsed(), LastUsedAt: d.LastUsed(),
RPID: d.RPID, RPID: d.RPID,
Username: d.Username, Username: d.Username,
Description: d.Description, DisplayName: d.DisplayName,
KID: d.KID.String(), KID: d.KID.String(),
PublicKey: base64.StdEncoding.EncodeToString(d.PublicKey),
AttestationType: d.AttestationType,
Transport: d.Transport,
AAGUID: d.AAGUID.UUID.String(), AAGUID: d.AAGUID.UUID.String(),
AttestationType: d.AttestationType,
Attachment: d.Attachment,
Transport: d.Transport,
SignCount: d.SignCount, SignCount: d.SignCount,
CloneWarning: d.CloneWarning, CloneWarning: d.CloneWarning,
Present: d.Present,
Verified: d.Verified,
BackupEligible: d.BackupEligible,
BackupState: d.BackupState,
PublicKey: base64.StdEncoding.EncodeToString(d.PublicKey),
} }
return yaml.Marshal(o) return yaml.Marshal(o)
@ -280,11 +316,17 @@ func (d *WebauthnDevice) UnmarshalYAML(value *yaml.Node) (err error) {
d.CreatedAt = o.CreatedAt d.CreatedAt = o.CreatedAt
d.RPID = o.RPID d.RPID = o.RPID
d.Username = o.Username d.Username = o.Username
d.Description = o.Description d.DisplayName = o.DisplayName
d.AttestationType = o.AttestationType d.AttestationType = o.AttestationType
d.Attachment = o.Attachment
d.Transport = o.Transport d.Transport = o.Transport
d.SignCount = o.SignCount d.SignCount = o.SignCount
d.CloneWarning = o.CloneWarning d.CloneWarning = o.CloneWarning
d.Discoverable = o.Discoverable
d.Present = o.Present
d.Verified = o.Verified
d.BackupEligible = o.BackupEligible
d.BackupState = o.BackupState
if o.LastUsedAt != nil { if o.LastUsedAt != nil {
d.LastUsedAt = sql.NullTime{Valid: true, Time: *o.LastUsedAt} d.LastUsedAt = sql.NullTime{Valid: true, Time: *o.LastUsedAt}
@ -299,14 +341,20 @@ type WebauthnDeviceData struct {
LastUsedAt *time.Time `yaml:"last_used_at"` LastUsedAt *time.Time `yaml:"last_used_at"`
RPID string `yaml:"rpid"` RPID string `yaml:"rpid"`
Username string `yaml:"username"` Username string `yaml:"username"`
Description string `yaml:"description"` DisplayName string `yaml:"displayname"`
KID string `yaml:"kid"` KID string `yaml:"kid"`
PublicKey string `yaml:"public_key"`
AttestationType string `yaml:"attestation_type"`
Transport string `yaml:"transport"`
AAGUID string `yaml:"aaguid"` AAGUID string `yaml:"aaguid"`
AttestationType string `yaml:"attestation_type"`
Attachment string `yaml:"attachment"`
Transport string `yaml:"transport"`
SignCount uint32 `yaml:"sign_count"` SignCount uint32 `yaml:"sign_count"`
CloneWarning bool `yaml:"clone_warning"` CloneWarning bool `yaml:"clone_warning"`
Discoverable bool `yaml:"discoverable"`
Present bool `yaml:"present"`
Verified bool `yaml:"verified"`
BackupEligible bool `yaml:"backup_eligible"`
BackupState bool `yaml:"backup_state"`
PublicKey string `yaml:"public_key"`
} }
// WebauthnDeviceExport represents a WebauthnDevice export file. // WebauthnDeviceExport represents a WebauthnDevice export file.

View File

@ -70,7 +70,7 @@ func TestOpenIDConnectProvider_NewOpenIDConnectProvider_GoodConfiguration(t *tes
}, },
{ {
ID: "b-client", ID: "b-client",
Description: "Normal Description", Description: "Normal DisplayName",
Secret: MustDecodeSecret("$plaintext$b-client-secret"), Secret: MustDecodeSecret("$plaintext$b-client-secret"),
Policy: "two_factor", Policy: "two_factor",
RedirectURIs: []string{ RedirectURIs: []string{

View File

@ -240,7 +240,7 @@ func handleRouter(config *schema.Configuration, providers middlewares.Providers)
// Management of the webauthn devices. // Management of the webauthn devices.
r.GET("/api/secondfactor/webauthn/credentials", middleware1FA(handlers.WebauthnDevicesGET)) r.GET("/api/secondfactor/webauthn/credentials", middleware1FA(handlers.WebauthnDevicesGET))
r.GET("/api/secondfactor/webauthn/credential/register", middleware1FA(handlers.WebauthnRegistrationGET)) r.PUT("/api/secondfactor/webauthn/credential/register", middleware1FA(handlers.WebauthnRegistrationPUT))
r.POST("/api/secondfactor/webauthn/credential/register", middleware1FA(handlers.WebauthnRegistrationPOST)) r.POST("/api/secondfactor/webauthn/credential/register", middleware1FA(handlers.WebauthnRegistrationPOST))
r.PUT("/api/secondfactor/webauthn/credential/{deviceID}", middleware2FA(handlers.WebauthnDevicePUT)) r.PUT("/api/secondfactor/webauthn/credential/{deviceID}", middleware2FA(handlers.WebauthnDevicePUT))
r.DELETE("/api/secondfactor/webauthn/credential/{deviceID}", middleware2FA(handlers.WebauthnDeviceDELETE)) r.DELETE("/api/secondfactor/webauthn/credential/{deviceID}", middleware2FA(handlers.WebauthnDeviceDELETE))

View File

@ -5,7 +5,7 @@
"Added": "Added {{when, datetime}}", "Added": "Added {{when, datetime}}",
"Are you sure you want to remove the Webauthn credential from from your account": "Are you sure you want to remove the Webauthn credential {{description}} from your account?", "Are you sure you want to remove the Webauthn credential from from your account": "Are you sure you want to remove the Webauthn credential {{description}} from your account?",
"Attestation Type": "Attestation Type", "Attestation Type": "Attestation Type",
"Authenticator Attestation GUID": "Authenticator Attestation GUID", "Authenticator GUID": "Authenticator GUID",
"Cancel": "Cancel", "Cancel": "Cancel",
"Click to add a Webauthn credential to your account": "Click to add a Webauthn credential to your account", "Click to add a Webauthn credential to your account": "Click to add a Webauthn credential to your account",
"Click to copy the": "Click to copy the", "Click to copy the": "Click to copy the",
@ -19,7 +19,8 @@
"Edit Webauthn Credential": "Edit Webauthn Credential", "Edit Webauthn Credential": "Edit Webauthn Credential",
"Enabled": "Enabled", "Enabled": "Enabled",
"Enter a new name for this Webauthn credential": "Enter a new name for this Webauthn credential:", "Enter a new name for this Webauthn credential": "Enter a new name for this Webauthn credential:",
"Extended Webauthn credential information for security key": "Extended Webauthn credential information for security key {{description}}", "Enter a display name for this credential": "Enter a display name for this credential",
"Extended Webauthn credential information for security key": "Extended Webauthn credential information for security key {{displayname}}",
"Identifier": "Identifier", "Identifier": "Identifier",
"Last Used": "Last Used {{when, datetime}}", "Last Used": "Last Used {{when, datetime}}",
"Manage your security keys": "Manage your security keys", "Manage your security keys": "Manage your security keys",
@ -28,7 +29,7 @@
"No Registered Webauthn Credentials": "No Registered Webauthn Credentials", "No Registered Webauthn Credentials": "No Registered Webauthn Credentials",
"Overview": "Overview", "Overview": "Overview",
"Provide the details for the new security key": "Provide the details for the new security key", "Provide the details for the new security key": "Provide the details for the new security key",
"Register Webauthn Credential (Security Key)": "Register Webauthn Credential (Security Key)", "Register Webauthn Credential": "Register Webauthn Credential",
"Relying Party ID": "Relying Party ID", "Relying Party ID": "Relying Party ID",
"Remove": "Remove", "Remove": "Remove",
"Remove this Webauthn credential": "Remove this Webauthn credential", "Remove this Webauthn credential": "Remove this Webauthn credential",

View File

@ -36,7 +36,7 @@ type UserSession struct {
AuthenticationMethodRefs oidc.AuthenticationMethodsReferences AuthenticationMethodRefs oidc.AuthenticationMethodsReferences
// Webauthn holds the session registration data for this session. // Webauthn holds the session registration data for this session.
Webauthn *webauthn.SessionData Webauthn *Webauthn
// This boolean is set to true after identity verification and checked // This boolean is set to true after identity verification and checked
// while doing the query actually updating the password. // while doing the query actually updating the password.
@ -45,6 +45,11 @@ type UserSession struct {
RefreshTTL time.Time RefreshTTL time.Time
} }
type Webauthn struct {
*webauthn.SessionData
DisplayName string
}
// Identity identity of the user who is being verified. // Identity identity of the user who is being verified.
type Identity struct { type Identity struct {
Username string Username string

View File

@ -1,3 +1,33 @@
DROP INDEX webauthn_devices_lookup_key ON webauthn_devices; ALTER TABLE webauthn_devices
ALTER TABLE webauthn_devices MODIFY COLUMN rpid VARCHAR(512); RENAME _bkp_UP_V0008_webauthn_devices;
CREATE UNIQUE INDEX webauthn_devices_lookup_key ON webauthn_devices (rpid, username, description);
CREATE TABLE IF NOT EXISTS webauthn_devices (
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP NULL DEFAULT NULL,
rpid VARCHAR(512) NOT NULL,
username VARCHAR(100) NOT NULL,
displayname VARCHAR(30) NOT NULL,
kid VARCHAR(512) NOT NULL,
aaguid CHAR(36) NOT NULL,
attestation_type VARCHAR(32),
attachment VARCHAR(64) NOT NULL,
transport VARCHAR(20) DEFAULT '',
sign_count INTEGER DEFAULT 0,
clone_warning BOOLEAN NOT NULL DEFAULT FALSE,
discoverable BOOLEAN NOT NULL,
present BOOLEAN NOT NULL DEFAULT FALSE,
verified BOOLEAN NOT NULL DEFAULT FALSE,
backup_eligible BOOLEAN NOT NULL DEFAULT FALSE,
backup_state BOOLEAN NOT NULL DEFAULT FALSE,
public_key BLOB NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
CREATE UNIQUE INDEX webauthn_devices_kid_key ON webauthn_devices (kid);
CREATE UNIQUE INDEX webauthn_devices_lookup_key ON webauthn_devices (rpid, username, displayname);
INSERT INTO webauthn_devices (created_at, last_used_at, rpid, username, displayname, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key)
SELECT id, created_at, last_used_at, CAST(rpid AS CHAR) AS rpid, username, description, kid, aaguid, attestation_type, 'cross-platform', transport, sign_count, clone_warning, FALSE, FALSE, FALSE, FALSE, public_key
FROM _bkp_UP_V0008_webauthn_devices;
DROP TABLE IF EXISTS _bkp_UP_V0008_webauthn_devices;

View File

@ -1,3 +1,33 @@
DROP INDEX webauthn_devices_lookup_key; ALTER TABLE webauthn_devices
ALTER TABLE webauthn_devices ALTER COLUMN rpid SET DATA TYPE VARCHAR(512); RENAME TO _bkp_UP_V0008_webauthn_devices;
CREATE UNIQUE INDEX webauthn_devices_lookup_key ON webauthn_devices (rpid, username, description);
CREATE TABLE IF NOT EXISTS webauthn_devices (
id SERIAL CONSTRAINT webauthn_devices_pkey PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP WITH TIME ZONE NULL DEFAULT NULL,
rpid VARCHAR(512) NOT NULL,
username VARCHAR(100) NOT NULL,
displayname VARCHAR(64) NOT NULL,
kid VARCHAR(512) NOT NULL,
aaguid CHAR(36) NOT NULL,
attestation_type VARCHAR(32),
attachment VARCHAR(64) NOT NULL,
transport VARCHAR(20) DEFAULT '',
sign_count INTEGER DEFAULT 0,
clone_warning BOOLEAN NOT NULL DEFAULT FALSE,
discoverable BOOLEAN NOT NULL,
present BOOLEAN NOT NULL DEFAULT FALSE,
verified BOOLEAN NOT NULL DEFAULT FALSE,
backup_eligible BOOLEAN NOT NULL DEFAULT FALSE,
backup_state BOOLEAN NOT NULL DEFAULT FALSE,
public_key BYTEA NOT NULL
);
CREATE UNIQUE INDEX webauthn_devices_kid_key ON webauthn_devices (kid);
CREATE UNIQUE INDEX webauthn_devices_lookup_key ON webauthn_devices (rpid, username, displayname);
INSERT INTO webauthn_devices (created_at, last_used_at, rpid, username, displayname, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key)
SELECT created_at, last_used_at, rpid, username, description, kid, aaguid, attestation_type, 'cross-platform', transport, sign_count, clone_warning, FALSE, FALSE, FALSE, FALSE, FALSE, public_key
FROM _bkp_UP_V0008_webauthn_devices;
DROP TABLE IF EXISTS _bkp_UP_V0008_webauthn_devices;

View File

@ -1,2 +1,36 @@
DROP INDEX webauthn_devices_lookup_key; DROP INDEX IF EXISTS webauthn_devices_lookup_key;
CREATE UNIQUE INDEX webauthn_devices_lookup_key ON webauthn_devices (rpid, username, description); DROP INDEX IF EXISTS webauthn_devices_kid_key;
ALTER TABLE webauthn_devices
RENAME TO _bkp_UP_V0008_webauthn_devices;
CREATE TABLE IF NOT EXISTS webauthn_devices (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME NULL DEFAULT NULL,
rpid VARCHAR(512) NOT NULL,
username VARCHAR(100) NOT NULL,
displayname VARCHAR(64) NOT NULL,
kid VARCHAR(512) NOT NULL,
aaguid CHAR(36) NULL,
attestation_type VARCHAR(32),
attachment VARCHAR(64),
transport VARCHAR(20) DEFAULT '',
sign_count INTEGER DEFAULT 0,
clone_warning BOOLEAN NOT NULL DEFAULT FALSE,
discoverable BOOLEAN NOT NULL,
present BOOLEAN NOT NULL DEFAULT FALSE,
verified BOOLEAN NOT NULL DEFAULT FALSE,
backup_eligible BOOLEAN NOT NULL DEFAULT FALSE,
backup_state BOOLEAN NOT NULL DEFAULT FALSE,
public_key BLOB NOT NULL
);
CREATE UNIQUE INDEX webauthn_devices_kid_key ON webauthn_devices (kid);
CREATE UNIQUE INDEX webauthn_devices_lookup_key ON webauthn_devices (rpid, username, displayname);
INSERT INTO webauthn_devices (created_at, last_used_at, rpid, username, displayname, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key)
SELECT created_at, last_used_at, rpid, username, description, kid, aaguid, attestation_type, 'cross-platform', transport, sign_count, clone_warning, FALSE, FALSE, FALSE, FALSE, FALSE, public_key
FROM _bkp_UP_V0008_webauthn_devices;
DROP TABLE IF EXISTS _bkp_UP_V0008_webauthn_devices;

View File

@ -40,7 +40,7 @@ type Provider interface {
SaveWebauthnDevice(ctx context.Context, device model.WebauthnDevice) (err error) SaveWebauthnDevice(ctx context.Context, device model.WebauthnDevice) (err error)
UpdateWebauthnDeviceDescription(ctx context.Context, username string, deviceID int, description string) (err error) UpdateWebauthnDeviceDescription(ctx context.Context, username string, deviceID int, description string) (err error)
UpdateWebauthnDeviceSignIn(ctx context.Context, id int, rpid string, lastUsedAt sql.NullTime, signCount uint32, cloneWarning bool) (err error) UpdateWebauthnDeviceSignIn(ctx context.Context, device model.WebauthnDevice) (err error)
DeleteWebauthnDevice(ctx context.Context, kid string) (err error) DeleteWebauthnDevice(ctx context.Context, kid string) (err error)
DeleteWebauthnDeviceByUsername(ctx context.Context, username, description string) (err error) DeleteWebauthnDeviceByUsername(ctx context.Context, username, description string) (err error)
LoadWebauthnDevices(ctx context.Context, limit, page int) (devices []model.WebauthnDevice, err error) LoadWebauthnDevices(ctx context.Context, limit, page int) (devices []model.WebauthnDevice, err error)

View File

@ -51,11 +51,11 @@ func NewSQLProvider(config *schema.Configuration, name, driverName, dataSourceNa
sqlSelectWebauthnDevicesByUsername: fmt.Sprintf(queryFmtSelectWebauthnDevicesByUsername, tableWebauthnDevices), sqlSelectWebauthnDevicesByUsername: fmt.Sprintf(queryFmtSelectWebauthnDevicesByUsername, tableWebauthnDevices),
sqlSelectWebauthnDevicesByRPIDByUsername: fmt.Sprintf(queryFmtSelectWebauthnDevicesByRPIDByUsername, tableWebauthnDevices), sqlSelectWebauthnDevicesByRPIDByUsername: fmt.Sprintf(queryFmtSelectWebauthnDevicesByRPIDByUsername, tableWebauthnDevices),
sqlSelectWebauthnDeviceByID: fmt.Sprintf(queryFmtSelectWebauthnDeviceByID, tableWebauthnDevices), sqlSelectWebauthnDeviceByID: fmt.Sprintf(queryFmtSelectWebauthnDeviceByID, tableWebauthnDevices),
sqlUpdateWebauthnDeviceDescriptionByUsernameAndID: fmt.Sprintf(queryFmtUpdateUpdateWebauthnDeviceDescriptionByUsernameAndID, tableWebauthnDevices), sqlUpdateWebauthnDeviceDescriptionByUsernameAndID: fmt.Sprintf(queryFmtUpdateUpdateWebauthnDeviceDisplayNameByUsernameAndID, tableWebauthnDevices),
sqlUpdateWebauthnDeviceRecordSignIn: fmt.Sprintf(queryFmtUpdateWebauthnDeviceRecordSignIn, tableWebauthnDevices), sqlUpdateWebauthnDeviceRecordSignIn: fmt.Sprintf(queryFmtUpdateWebauthnDeviceRecordSignIn, tableWebauthnDevices),
sqlDeleteWebauthnDevice: fmt.Sprintf(queryFmtDeleteWebauthnDevice, tableWebauthnDevices), sqlDeleteWebauthnDevice: fmt.Sprintf(queryFmtDeleteWebauthnDevice, tableWebauthnDevices),
sqlDeleteWebauthnDeviceByUsername: fmt.Sprintf(queryFmtDeleteWebauthnDeviceByUsername, tableWebauthnDevices), sqlDeleteWebauthnDeviceByUsername: fmt.Sprintf(queryFmtDeleteWebauthnDeviceByUsername, tableWebauthnDevices),
sqlDeleteWebauthnDeviceByUsernameAndDescription: fmt.Sprintf(queryFmtDeleteWebauthnDeviceByUsernameAndDescription, tableWebauthnDevices), sqlDeleteWebauthnDeviceByUsernameAndDisplayName: fmt.Sprintf(queryFmtDeleteWebauthnDeviceByUsernameAndDisplayName, tableWebauthnDevices),
sqlUpsertDuoDevice: fmt.Sprintf(queryFmtUpsertDuoDevice, tableDuoDevices), sqlUpsertDuoDevice: fmt.Sprintf(queryFmtUpsertDuoDevice, tableDuoDevices),
sqlDeleteDuoDevice: fmt.Sprintf(queryFmtDeleteDuoDevice, tableDuoDevices), sqlDeleteDuoDevice: fmt.Sprintf(queryFmtDeleteDuoDevice, tableDuoDevices),
@ -172,7 +172,7 @@ type SQLProvider struct {
sqlDeleteWebauthnDevice string sqlDeleteWebauthnDevice string
sqlDeleteWebauthnDeviceByUsername string sqlDeleteWebauthnDeviceByUsername string
sqlDeleteWebauthnDeviceByUsernameAndDescription string sqlDeleteWebauthnDeviceByUsernameAndDisplayName string
// Table: duo_devices. // Table: duo_devices.
sqlUpsertDuoDevice string sqlUpsertDuoDevice string
@ -842,10 +842,10 @@ func (p *SQLProvider) SaveWebauthnDevice(ctx context.Context, device model.Webau
} }
if _, err = p.db.ExecContext(ctx, p.sqlInsertWebauthnDevice, if _, err = p.db.ExecContext(ctx, p.sqlInsertWebauthnDevice,
device.CreatedAt, device.LastUsedAt, device.CreatedAt, device.LastUsedAt, device.RPID, device.Username, device.DisplayName,
device.RPID, device.Username, device.Description, device.KID, device.AAGUID, device.AttestationType, device.Attachment, device.Transport,
device.KID, device.PublicKey, device.SignCount, device.CloneWarning, device.Discoverable, device.Present, device.Verified,
device.AttestationType, device.Transport, device.AAGUID, device.SignCount, device.CloneWarning, device.BackupEligible, device.BackupState, device.PublicKey,
); err != nil { ); err != nil {
return fmt.Errorf("error upserting Webauthn device for user '%s' kid '%x': %w", device.Username, device.KID, err) return fmt.Errorf("error upserting Webauthn device for user '%s' kid '%x': %w", device.Username, device.KID, err)
} }
@ -863,9 +863,12 @@ func (p *SQLProvider) UpdateWebauthnDeviceDescription(ctx context.Context, usern
} }
// UpdateWebauthnDeviceSignIn updates a registered Webauthn devices sign in information. // UpdateWebauthnDeviceSignIn updates a registered Webauthn devices sign in information.
func (p *SQLProvider) UpdateWebauthnDeviceSignIn(ctx context.Context, id int, rpid string, lastUsedAt sql.NullTime, signCount uint32, cloneWarning bool) (err error) { func (p *SQLProvider) UpdateWebauthnDeviceSignIn(ctx context.Context, device model.WebauthnDevice) (err error) {
if _, err = p.db.ExecContext(ctx, p.sqlUpdateWebauthnDeviceRecordSignIn, rpid, lastUsedAt, signCount, cloneWarning, id); err != nil { if _, err = p.db.ExecContext(ctx, p.sqlUpdateWebauthnDeviceRecordSignIn,
return fmt.Errorf("error updating Webauthn signin metadata for id '%x': %w", id, err) device.RPID, device.LastUsedAt, device.SignCount, device.Discoverable, device.Present, device.Verified,
device.BackupEligible, device.BackupState, device.CloneWarning, device.ID,
); err != nil {
return fmt.Errorf("error updating Webauthn signin metadata for id '%x': %w", device.ID, err)
} }
return nil return nil
@ -881,18 +884,18 @@ func (p *SQLProvider) DeleteWebauthnDevice(ctx context.Context, kid string) (err
} }
// DeleteWebauthnDeviceByUsername deletes registered Webauthn devices by username or username and description. // DeleteWebauthnDeviceByUsername deletes registered Webauthn devices by username or username and description.
func (p *SQLProvider) DeleteWebauthnDeviceByUsername(ctx context.Context, username, description string) (err error) { func (p *SQLProvider) DeleteWebauthnDeviceByUsername(ctx context.Context, username, displayname string) (err error) {
if len(username) == 0 { if len(username) == 0 {
return fmt.Errorf("error deleting webauthn device with username '%s' and description '%s': username must not be empty", username, description) return fmt.Errorf("error deleting webauthn device with username '%s' and displayname '%s': username must not be empty", username, displayname)
} }
if len(description) == 0 { if len(displayname) == 0 {
if _, err = p.db.ExecContext(ctx, p.sqlDeleteWebauthnDeviceByUsername, username); err != nil { if _, err = p.db.ExecContext(ctx, p.sqlDeleteWebauthnDeviceByUsername, username); err != nil {
return fmt.Errorf("error deleting webauthn devices for username '%s': %w", username, err) return fmt.Errorf("error deleting webauthn devices for username '%s': %w", username, err)
} }
} else { } else {
if _, err = p.db.ExecContext(ctx, p.sqlDeleteWebauthnDeviceByUsernameAndDescription, username, description); err != nil { if _, err = p.db.ExecContext(ctx, p.sqlDeleteWebauthnDeviceByUsernameAndDisplayName, username, displayname); err != nil {
return fmt.Errorf("error deleting webauthn device with username '%s' and description '%s': %w", username, description, err) return fmt.Errorf("error deleting webauthn device with username '%s' and displayname '%s': %w", username, displayname, err)
} }
} }

View File

@ -67,7 +67,7 @@ func NewPostgreSQLProvider(config *schema.Configuration, caCertPool *x509.CertPo
provider.sqlUpdateWebauthnDeviceRecordSignIn = provider.db.Rebind(provider.sqlUpdateWebauthnDeviceRecordSignIn) provider.sqlUpdateWebauthnDeviceRecordSignIn = provider.db.Rebind(provider.sqlUpdateWebauthnDeviceRecordSignIn)
provider.sqlDeleteWebauthnDevice = provider.db.Rebind(provider.sqlDeleteWebauthnDevice) provider.sqlDeleteWebauthnDevice = provider.db.Rebind(provider.sqlDeleteWebauthnDevice)
provider.sqlDeleteWebauthnDeviceByUsername = provider.db.Rebind(provider.sqlDeleteWebauthnDeviceByUsername) provider.sqlDeleteWebauthnDeviceByUsername = provider.db.Rebind(provider.sqlDeleteWebauthnDeviceByUsername)
provider.sqlDeleteWebauthnDeviceByUsernameAndDescription = provider.db.Rebind(provider.sqlDeleteWebauthnDeviceByUsernameAndDescription) provider.sqlDeleteWebauthnDeviceByUsernameAndDisplayName = provider.db.Rebind(provider.sqlDeleteWebauthnDeviceByUsernameAndDisplayName)
provider.sqlSelectDuoDevice = provider.db.Rebind(provider.sqlSelectDuoDevice) provider.sqlSelectDuoDevice = provider.db.Rebind(provider.sqlSelectDuoDevice)
provider.sqlDeleteDuoDevice = provider.db.Rebind(provider.sqlDeleteDuoDevice) provider.sqlDeleteDuoDevice = provider.db.Rebind(provider.sqlDeleteDuoDevice)

View File

@ -174,7 +174,7 @@ func schemaEncryptionChangeKeyWebauthn(ctx context.Context, provider *SQLProvide
return fmt.Errorf("error selecting Webauthn devices: %w", err) return fmt.Errorf("error selecting Webauthn devices: %w", err)
} }
query := provider.db.Rebind(fmt.Sprintf(queryFmtUpdateWebauthnDevicePublicKey, tableWebauthnDevices)) query := provider.db.Rebind(fmt.Sprintf(queryFmtUpdateWebauthnDevicesEncryptedData, tableWebauthnDevices))
for _, d := range devices { for _, d := range devices {
if d.PublicKey, err = provider.decrypt(d.PublicKey); err != nil { if d.PublicKey, err = provider.decrypt(d.PublicKey); err != nil {

View File

@ -120,50 +120,41 @@ const (
const ( const (
queryFmtSelectWebauthnDevices = ` queryFmtSelectWebauthnDevices = `
SELECT id, created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning SELECT id, created_at, last_used_at, rpid, username, displayname, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key
FROM %s FROM %s
LIMIT ? LIMIT ?
OFFSET ?;` OFFSET ?;`
queryFmtSelectWebauthnDevicesEncryptedData = `
SELECT id, public_key
FROM %s;`
queryFmtSelectWebauthnDevicesByUsername = ` queryFmtSelectWebauthnDevicesByUsername = `
SELECT id, created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning SELECT id, created_at, last_used_at, rpid, username, displayname, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key
FROM %s FROM %s
WHERE username = ?;` WHERE username = ?;`
queryFmtSelectWebauthnDevicesByRPIDByUsername = ` queryFmtSelectWebauthnDevicesByRPIDByUsername = `
SELECT id, created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning SELECT id, created_at, last_used_at, rpid, username, displayname, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key
FROM %s FROM %s
WHERE rpid = ? AND username = ?;` WHERE rpid = ? AND username = ?;`
queryFmtSelectWebauthnDeviceByID = ` queryFmtSelectWebauthnDeviceByID = `
SELECT id, created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning SELECT id, created_at, last_used_at, rpid, username, displayname, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key
FROM %s FROM %s
WHERE id = ?;` WHERE id = ?;`
queryFmtUpdateWebauthnDevicePublicKey = ` queryFmtUpdateUpdateWebauthnDeviceDisplayNameByUsernameAndID = `
UPDATE %s UPDATE %s
SET public_key = ? SET displayname = ?
WHERE id = ?;`
queryFmtUpdateUpdateWebauthnDeviceDescriptionByUsernameAndID = `
UPDATE %s
SET description = ?
WHERE username = ? AND id = ?;` WHERE username = ? AND id = ?;`
queryFmtUpdateWebauthnDeviceRecordSignIn = ` queryFmtUpdateWebauthnDeviceRecordSignIn = `
UPDATE %s UPDATE %s
SET SET
rpid = ?, last_used_at = ?, sign_count = ?, rpid = ?, last_used_at = ?, sign_count = ?, discoverable = ?, present = ?, verified = ?, backup_eligible = ?, backup_state = ?,
clone_warning = CASE clone_warning WHEN TRUE THEN TRUE ELSE ? END clone_warning = CASE clone_warning WHEN TRUE THEN TRUE ELSE ? END
WHERE id = ?;` WHERE id = ?;`
queryFmtUpsertInsertDevice = ` queryFmtUpsertInsertDevice = `
INSERT INTO %s (created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning) INSERT INTO %s (created_at, last_used_at, rpid, username, displayname, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`
queryFmtDeleteWebauthnDevice = ` queryFmtDeleteWebauthnDevice = `
DELETE FROM %s DELETE FROM %s
@ -173,9 +164,18 @@ const (
DELETE FROM %s DELETE FROM %s
WHERE username = ?;` WHERE username = ?;`
queryFmtDeleteWebauthnDeviceByUsernameAndDescription = ` queryFmtDeleteWebauthnDeviceByUsernameAndDisplayName = `
DELETE FROM %s DELETE FROM %s
WHERE username = ? AND description = ?;` WHERE username = ? AND displayname = ?;`
queryFmtSelectWebauthnDevicesEncryptedData = `
SELECT id, public_key
FROM %s;`
queryFmtUpdateWebauthnDevicesEncryptedData = `
UPDATE %s
SET public_key = ?
WHERE id = ?;`
) )
const ( const (

View File

@ -34,7 +34,7 @@
"@mui/styles": "5.11.7", "@mui/styles": "5.11.7",
"@simplewebauthn/browser": "7.1.0", "@simplewebauthn/browser": "7.1.0",
"@simplewebauthn/typescript-types": "7.0.0", "@simplewebauthn/typescript-types": "7.0.0",
"axios": "1.3.2", "axios": "1.3.3",
"broadcast-channel": "4.20.2", "broadcast-channel": "4.20.2",
"classnames": "2.3.2", "classnames": "2.3.2",
"date-fns": "2.29.3", "date-fns": "2.29.3",

View File

@ -27,7 +27,7 @@ specifiers:
'@typescript-eslint/eslint-plugin': 5.51.0 '@typescript-eslint/eslint-plugin': 5.51.0
'@typescript-eslint/parser': 5.51.0 '@typescript-eslint/parser': 5.51.0
'@vitejs/plugin-react': 3.1.0 '@vitejs/plugin-react': 3.1.0
axios: 1.3.2 axios: 1.3.3
broadcast-channel: 4.20.2 broadcast-channel: 4.20.2
classnames: 2.3.2 classnames: 2.3.2
date-fns: 2.29.3 date-fns: 2.29.3
@ -81,7 +81,7 @@ dependencies:
'@mui/styles': 5.11.7_pmekkgnqduwlme35zpnqhenc34 '@mui/styles': 5.11.7_pmekkgnqduwlme35zpnqhenc34
'@simplewebauthn/browser': 7.1.0 '@simplewebauthn/browser': 7.1.0
'@simplewebauthn/typescript-types': 7.0.0 '@simplewebauthn/typescript-types': 7.0.0
axios: 1.3.2 axios: 1.3.3
broadcast-channel: 4.20.2 broadcast-channel: 4.20.2
classnames: 2.3.2 classnames: 2.3.2
date-fns: 2.29.3 date-fns: 2.29.3
@ -4313,8 +4313,8 @@ packages:
engines: {node: '>=4'} engines: {node: '>=4'}
dev: true dev: true
/axios/1.3.2: /axios/1.3.3:
resolution: {integrity: sha512-1M3O703bYqYuPhbHeya5bnhpYVsDDRyQSabNja04mZtboLNSuZ4YrltestrLXfHgmzua4TpUqRiVKbiQuo2epw==} resolution: {integrity: sha512-eYq77dYIFS77AQlhzEL937yUBSepBfPIe8FcgEDN35vMNZKMrs81pgnyrQpwfy4NF4b4XWX1Zgx7yX+25w8QJA==}
dependencies: dependencies:
follow-redirects: 1.15.1 follow-redirects: 1.15.1
form-data: 4.0.0 form-data: 4.0.0

View File

@ -110,14 +110,34 @@ export interface WebauthnDevice {
created_at: string; created_at: string;
last_used_at?: string; last_used_at?: string;
rpid: string; rpid: string;
description: string; displayname: string;
kid: Uint8Array; kid: Uint8Array;
public_key: Uint8Array;
attestation_type: string;
transports: string[];
aaguid?: string; aaguid?: string;
attestation_type: string;
attachment: string;
transports: string[];
sign_count: number; sign_count: number;
clone_warning: boolean; clone_warning: boolean;
discoverable: boolean;
present: boolean;
verified: boolean;
backup_eligible: boolean;
backup_state: boolean;
public_key: Uint8Array;
}
export function toTransportName(transport: string) {
switch (transport.toLowerCase()) {
case "internal":
return "Internal";
case "ble":
return "Bluetooth";
case "nfc":
case "usb":
return transport.toUpperCase();
default:
return transport;
}
} }
export enum WebauthnTouchState { export enum WebauthnTouchState {

View File

@ -104,10 +104,20 @@ function getAssertionResultFromDOMException(
} }
} }
export async function getAttestationCreationOptions(): Promise<PublicKeyCredentialCreationOptionsStatus> { export async function getAttestationCreationOptions(
let response: AxiosResponse<ServiceResponse<CredentialCreation>>; displayname: string,
): Promise<PublicKeyCredentialCreationOptionsStatus> {
response = await axios.get<ServiceResponse<CredentialCreation>>(WebauthnRegistrationPath); const response = await axios.put<ServiceResponse<CredentialCreation>>(
WebauthnRegistrationPath,
{
displayname: displayname,
},
{
validateStatus: function (status) {
return status < 300 || status === 409;
},
},
);
if (response.data.status !== "OK" || response.data.data == null) { if (response.data.status !== "OK" || response.data.data == null) {
return { return {
@ -194,12 +204,8 @@ export async function getAuthenticationResult(options: PublicKeyCredentialReques
async function postRegistrationResponse( async function postRegistrationResponse(
response: RegistrationResponseJSON, response: RegistrationResponseJSON,
description: string,
): Promise<AxiosResponse<OptionalDataServiceResponse<any>>> { ): Promise<AxiosResponse<OptionalDataServiceResponse<any>>> {
return axios.post<OptionalDataServiceResponse<any>>(WebauthnRegistrationPath, { return axios.post<OptionalDataServiceResponse<any>>(WebauthnRegistrationPath, response);
response: response,
description: description,
});
} }
export async function postAuthenticationResponse( export async function postAuthenticationResponse(
@ -216,14 +222,14 @@ export async function postAuthenticationResponse(
}); });
} }
export async function finishRegistration(response: RegistrationResponseJSON, description: string) { export async function finishRegistration(response: RegistrationResponseJSON) {
let result = { let result = {
status: AttestationResult.Failure, status: AttestationResult.Failure,
message: "Device registration failed.", message: "Device registration failed.",
}; };
try { try {
const resp = await postRegistrationResponse(response, description); const resp = await postRegistrationResponse(response);
if (resp.data.status === "OK" && (resp.status === 200 || resp.status === 201)) { if (resp.data.status === "OK" && (resp.status === 200 || resp.status === 201)) {
return { return {
status: AttestationResult.Success, status: AttestationResult.Success,

View File

@ -24,7 +24,7 @@ export default function WebauthnDeviceDeleteDialog(props: Props) {
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>
{translate("Are you sure you want to remove the Webauthn credential from from your account", { {translate("Are you sure you want to remove the Webauthn credential from from your account", {
description: props.device.description, description: props.device.displayname,
})} })}
</DialogContentText> </DialogContentText>
</DialogContent> </DialogContent>

View File

@ -16,7 +16,7 @@ import {
} from "@mui/material"; } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { WebauthnDevice } from "@models/Webauthn"; import { WebauthnDevice, toTransportName } from "@models/Webauthn";
interface Props { interface Props {
open: boolean; open: boolean;
@ -33,7 +33,7 @@ export default function WebauthnDetailsDeleteDialog(props: Props) {
<DialogContent> <DialogContent>
<DialogContentText sx={{ mb: 3 }}> <DialogContentText sx={{ mb: 3 }}>
{translate("Extended Webauthn credential information for security key", { {translate("Extended Webauthn credential information for security key", {
description: props.device.description, displayname: props.device.displayname,
})} })}
</DialogContentText> </DialogContentText>
<Stack spacing={0} sx={{ minWidth: 400 }}> <Stack spacing={0} sx={{ minWidth: 400 }}>
@ -46,16 +46,39 @@ export default function WebauthnDetailsDeleteDialog(props: Props) {
/> />
</Stack> </Stack>
</Box> </Box>
<PropertyText name={translate("Description")} value={props.device.description} /> <PropertyText name={translate("Display Name")} value={props.device.displayname} />
<PropertyText name={translate("Relying Party ID")} value={props.device.rpid} /> <PropertyText name={translate("Relying Party ID")} value={props.device.rpid} />
<PropertyText <PropertyText
name={translate("Authenticator Attestation GUID")} name={translate("Authenticator GUID")}
value={props.device.aaguid === undefined ? "N/A" : props.device.aaguid} value={props.device.aaguid === undefined ? "N/A" : props.device.aaguid}
/> />
<PropertyText name={translate("Attestation Type")} value={props.device.attestation_type} /> <PropertyText name={translate("Attestation Type")} value={props.device.attestation_type} />
<PropertyText name={translate("Attachment")} value={props.device.attachment} />
<PropertyText
name={translate("Discoverable")}
value={props.device.discoverable ? translate("Yes") : translate("No")}
/>
<PropertyText
name={translate("User Verified")}
value={props.device.verified ? translate("Yes") : translate("No")}
/>
<PropertyText
name={translate("Backup State")}
value={
props.device.backup_eligible
? props.device.backup_state
? translate("Backed Up")
: translate("Eligible")
: translate("Not Eligible")
}
/>
<PropertyText <PropertyText
name={translate("Transports")} name={translate("Transports")}
value={props.device.transports.length === 0 ? "N/A" : props.device.transports.join(", ")} value={
props.device.transports.length === 0
? "N/A"
: props.device.transports.map((transport) => toTransportName(transport)).join(", ")
}
/> />
<PropertyText <PropertyText
name={translate("Clone Warning")} name={translate("Clone Warning")}

View File

@ -118,7 +118,7 @@ export default function WebauthnDeviceItem(props: Props) {
<Stack spacing={0} sx={{ minWidth: 400 }}> <Stack spacing={0} sx={{ minWidth: 400 }}>
<Box> <Box>
<Typography display="inline" sx={{ fontWeight: "bold" }}> <Typography display="inline" sx={{ fontWeight: "bold" }}>
{props.device.description} {props.device.displayname}
</Typography> </Typography>
<Typography <Typography
display="inline" display="inline"

View File

@ -6,9 +6,9 @@ import {
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogContentText,
DialogTitle, DialogTitle,
Grid, Grid,
Stack,
Step, Step,
StepLabel, StepLabel,
Stepper, Stepper,
@ -23,15 +23,10 @@ import { useTranslation } from "react-i18next";
import InformationIcon from "@components/InformationIcon"; import InformationIcon from "@components/InformationIcon";
import WebauthnRegisterIcon from "@components/WebauthnRegisterIcon"; import WebauthnRegisterIcon from "@components/WebauthnRegisterIcon";
import { useNotifications } from "@hooks/NotificationsContext"; import { useNotifications } from "@hooks/NotificationsContext";
import { import { AttestationResult, AttestationResultFailureString, WebauthnTouchState } from "@models/Webauthn";
AttestationResult,
AttestationResultFailureString,
RegistrationResult,
WebauthnTouchState,
} from "@models/Webauthn";
import { finishRegistration, getAttestationCreationOptions, startWebauthnRegistration } from "@services/Webauthn"; import { finishRegistration, getAttestationCreationOptions, startWebauthnRegistration } from "@services/Webauthn";
const steps = ["Confirm device", "Choose name"]; const steps = ["Display Name", "Verification"];
interface Props { interface Props {
open: boolean; open: boolean;
@ -47,21 +42,20 @@ const WebauthnDeviceRegisterDialog = function (props: Props) {
const [state, setState] = useState(WebauthnTouchState.WaitTouch); const [state, setState] = useState(WebauthnTouchState.WaitTouch);
const [activeStep, setActiveStep] = useState(0); const [activeStep, setActiveStep] = useState(0);
const [result, setResult] = useState<RegistrationResult | null>(null);
const [options, setOptions] = useState<PublicKeyCredentialCreationOptionsJSON | null>(null); const [options, setOptions] = useState<PublicKeyCredentialCreationOptionsJSON | null>(null);
const [timeout, setTimeout] = useState<number | null>(null); const [timeout, setTimeout] = useState<number | null>(null);
const [deviceName, setName] = useState(""); const [credentialDisplayName, setCredentialDisplayName] = useState("");
const [errorDisplayName, setErrorDisplayName] = useState(false);
const nameRef = useRef() as MutableRefObject<HTMLInputElement>; const nameRef = useRef() as MutableRefObject<HTMLInputElement>;
const [nameError, setNameError] = useState(false);
const resetStates = () => { const resetStates = () => {
setState(WebauthnTouchState.WaitTouch); setState(WebauthnTouchState.WaitTouch);
setActiveStep(0);
setResult(null);
setOptions(null); setOptions(null);
setActiveStep(0);
setTimeout(null); setTimeout(null);
setName(""); setCredentialDisplayName("");
setErrorDisplayName(false);
}; };
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@ -70,53 +64,41 @@ const WebauthnDeviceRegisterDialog = function (props: Props) {
props.setCancelled(); props.setCancelled();
}, [props]); }, [props]);
const finishAttestation = async () => { const performCredentialCreation = useCallback(async () => {
if (!result || !result.response) {
return;
}
if (!deviceName.length) {
setNameError(true);
return;
}
const res = await finishRegistration(result.response, deviceName);
switch (res.status) {
case AttestationResult.Success:
handleClose();
break;
case AttestationResult.Failure:
createErrorNotification(res.message);
}
};
const startRegistration = useCallback(async () => {
if (options === null) { if (options === null) {
return; return;
} }
setTimeout(options.timeout ? options.timeout : null); setTimeout(options.timeout ? options.timeout : null);
setActiveStep(1);
try { try {
setState(WebauthnTouchState.WaitTouch); setState(WebauthnTouchState.WaitTouch);
setActiveStep(0);
const res = await startWebauthnRegistration(options); const resultCredentialCreation = await startWebauthnRegistration(options);
setTimeout(null); setTimeout(null);
if (res.result === AttestationResult.Success) { if (resultCredentialCreation.result === AttestationResult.Success) {
if (res.response == null) { if (resultCredentialCreation.response == null) {
throw new Error("Attestation request succeeded but credential is empty"); throw new Error("Credential Creation Request succeeded but Registration Response is empty.");
} }
setResult(res); const response = await finishRegistration(resultCredentialCreation.response);
setActiveStep(1);
switch (response.status) {
case AttestationResult.Success:
handleClose();
break;
case AttestationResult.Failure:
createErrorNotification(response.message);
break;
}
return; return;
} }
createErrorNotification(AttestationResultFailureString(res.result)); createErrorNotification(AttestationResultFailureString(resultCredentialCreation.result));
setState(WebauthnTouchState.Failure); setState(WebauthnTouchState.Failure);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -124,7 +106,7 @@ const WebauthnDeviceRegisterDialog = function (props: Props) {
"Failed to register your device. The identity verification process might have timed out.", "Failed to register your device. The identity verification process might have timed out.",
); );
} }
}, [options, createErrorNotification]); }, [options, createErrorNotification, handleClose]);
useEffect(() => { useEffect(() => {
if (state !== WebauthnTouchState.Failure || activeStep !== 0 || !props.open) { if (state !== WebauthnTouchState.Failure || activeStep !== 0 || !props.open) {
@ -135,35 +117,100 @@ const WebauthnDeviceRegisterDialog = function (props: Props) {
}, [props, state, activeStep, handleClose]); }, [props, state, activeStep, handleClose]);
useEffect(() => { useEffect(() => {
(async () => { (async function () {
if (options === null || !props.open || activeStep !== 0) { if (!props.open || activeStep !== 0 || options === null) {
return; return;
} }
await startRegistration(); await performCredentialCreation();
})(); })();
}, [options, props.open, activeStep, startRegistration]); }, [props.open, activeStep, options, performCredentialCreation]);
useEffect(() => { const handleNext = useCallback(async () => {
(async () => { if (credentialDisplayName.length === 0 || credentialDisplayName.length > 64) {
if (!props.open || activeStep !== 0) { setErrorDisplayName(true);
return;
}
const res = await getAttestationCreationOptions();
if (res.status !== 200 || !res.options) {
createErrorNotification( createErrorNotification(
"You must open the link from the same device and browser that initiated the registration process.", translate("The Display Name must be more than 1 character and less than 64 characters."),
); );
return; return;
} }
const res = await getAttestationCreationOptions(credentialDisplayName);
switch (res.status) {
case 200:
if (res.options) {
setOptions(res.options); setOptions(res.options);
})(); } else {
}, [setOptions, createErrorNotification, props.open, activeStep]); throw new Error(
"Credential Creation Options Request succeeded but Credential Creation Options is empty.",
);
}
break;
case 409:
setErrorDisplayName(true);
createErrorNotification(translate("A Webauthn Credential with that Display Name already exists."));
break;
default:
createErrorNotification(
translate("Error occurred obtaining the Webauthn Credential creation options."),
);
}
}, [createErrorNotification, credentialDisplayName, performCredentialCreation, translate]);
const handleCredentialDisplayName = useCallback(
(displayname: string) => {
setCredentialDisplayName(displayname);
if (errorDisplayName) {
setErrorDisplayName(false);
}
},
[errorDisplayName],
);
function renderStep(step: number) { function renderStep(step: number) {
switch (step) { switch (step) {
case 0: case 0:
return (
<Box>
<Box className={styles.icon}>
<InformationIcon />
</Box>
<Typography className={styles.instruction}>
{translate("Enter a display name for this credential")}
</Typography>
<Grid container spacing={1}>
<Grid item xs={12}>
<TextField
inputRef={nameRef}
id="name-textfield"
label={translate("Display Name")}
variant="outlined"
required
value={credentialDisplayName}
error={errorDisplayName}
disabled={false}
onChange={(v) => handleCredentialDisplayName(v.target.value)}
autoCapitalize="none"
onKeyDown={(ev) => {
if (ev.key === "Enter") {
(async () => {
await handleNext();
})();
ev.preventDefault();
}
}}
/>
</Grid>
</Grid>
</Box>
);
case 1:
return ( return (
<Fragment> <Fragment>
<Box className={styles.icon}> <Box className={styles.icon}>
@ -174,52 +221,6 @@ const WebauthnDeviceRegisterDialog = function (props: Props) {
</Typography> </Typography>
</Fragment> </Fragment>
); );
case 1:
return (
<Box id="webauthn-registration-name">
<Box className={styles.icon}>
<InformationIcon />
</Box>
<Typography className={styles.instruction}>{translate("Enter a name for this key")}</Typography>
<Grid container spacing={1}>
<Grid item xs={12}>
<TextField
inputRef={nameRef}
id="name-textfield"
label={translate("Name")}
variant="outlined"
required
value={deviceName}
error={nameError}
disabled={false}
onChange={(v) => setName(v.target.value.substring(0, 30))}
onFocus={() => setNameError(false)}
autoCapitalize="none"
autoComplete="webauthn-name"
onKeyDown={(ev) => {
if (ev.key === "Enter") {
if (!deviceName.length) {
setNameError(true);
} else {
(async () => {
await finishAttestation();
})();
}
ev.preventDefault();
}
}}
/>
</Grid>
<Grid item xs={12}>
<Stack direction="row" spacing={1} justifyContent="center" paddingTop={1}>
<Button color="primary" variant="contained" onClick={finishAttestation}>
{translate("Finish")}
</Button>
</Stack>
</Grid>
</Grid>
</Box>
);
} }
} }
@ -233,8 +234,13 @@ const WebauthnDeviceRegisterDialog = function (props: Props) {
return ( return (
<Dialog open={props.open} onClose={handleOnClose} maxWidth={"xs"} fullWidth={true}> <Dialog open={props.open} onClose={handleOnClose} maxWidth={"xs"} fullWidth={true}>
<DialogTitle>{translate("Register Webauthn Credential (Security Key)")}</DialogTitle> <DialogTitle>{translate("Register Webauthn Credential")}</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText sx={{ mb: 3 }}>
{translate(
"This page allows registration of a new Security Key backed by modern Webauthn Credential technology.",
)}
</DialogContentText>
<Grid container spacing={0} alignItems={"center"} justifyContent={"center"} textAlign={"center"}> <Grid container spacing={0} alignItems={"center"} justifyContent={"center"} textAlign={"center"}>
<Grid item xs={12}> <Grid item xs={12}>
<Stepper activeStep={activeStep}> <Stepper activeStep={activeStep}>
@ -257,9 +263,24 @@ const WebauthnDeviceRegisterDialog = function (props: Props) {
</Grid> </Grid>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleClose} disabled={activeStep === 0 && state !== WebauthnTouchState.Failure}> <Button
color={activeStep === 1 && state !== WebauthnTouchState.Failure ? "primary" : "error"}
disabled={activeStep === 1 && state !== WebauthnTouchState.Failure}
onClick={handleClose}
>
{translate("Cancel")} {translate("Cancel")}
</Button> </Button>
{activeStep === 0 ? (
<Button
color={credentialDisplayName.length !== 0 ? "success" : "primary"}
disabled={activeStep !== 0}
onClick={async () => {
await handleNext();
}}
>
{translate("Next")}
</Button>
) : null}
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); );