diff --git a/internal/handlers/webauthn_test.go b/internal/handlers/webauthn_test.go index 434ed496b..9678ff389 100644 --- a/internal/handlers/webauthn_test.go +++ b/internal/handlers/webauthn_test.go @@ -52,7 +52,7 @@ func TestWebAuthnGetUser(t *testing.T) { require.NoError(t, err) require.NotNil(t, user) - assert.Equal(t, []byte("john"), user.WebAuthnID()) + assert.Equal(t, []byte{}, user.WebAuthnID()) assert.Equal(t, "john", user.WebAuthnName()) assert.Equal(t, "john", user.Username) @@ -109,7 +109,7 @@ func TestWebAuthnGetUserWithoutDisplayName(t *testing.T) { ctx.StorageMock.EXPECT().LoadWebAuthnDevicesByUsername(ctx.Ctx, "john").Return([]model.WebAuthnDevice{ { ID: 1, - RPID: "https://example.com", + RPID: "example.com", Username: "john", Description: "Primary", KID: model.NewBase64([]byte("abc123")), diff --git a/internal/model/u2f_device.go b/internal/model/u2f_device.go deleted file mode 100644 index 331f43d75..000000000 --- a/internal/model/u2f_device.go +++ /dev/null @@ -1,10 +0,0 @@ -package model - -// U2FDevice represents a users U2F device row in the database. -type U2FDevice struct { - ID int `db:"id"` - Username string `db:"username"` - Description string `db:"description"` - KeyHandle []byte `db:"key_handle"` - PublicKey []byte `db:"public_key"` -} diff --git a/internal/model/webauthn.go b/internal/model/webauthn.go index 0921ccd24..be2f8c370 100644 --- a/internal/model/webauthn.go +++ b/internal/model/webauthn.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/base64" "encoding/hex" + "encoding/json" "strings" "time" @@ -19,9 +20,13 @@ const ( // WebAuthnUser is an object to represent a user for the WebAuthn lib. type WebAuthnUser struct { - Username string - DisplayName string - Devices []WebAuthnDevice + ID int `db:"id"` + RPID string `db:"rpid"` + Username string `db:"username"` + UserID string `db:"userid"` + DisplayName string `db:"-"` + + Devices []WebAuthnDevice `db:"-"` } // HasFIDOU2F returns true if the user has any attestation type `fido-u2f` devices. @@ -37,7 +42,7 @@ func (w WebAuthnUser) HasFIDOU2F() bool { // WebAuthnID implements the webauthn.User interface. func (w WebAuthnUser) WebAuthnID() []byte { - return []byte(w.Username) + return []byte(w.UserID) } // WebAuthnName implements the webauthn.User interface. @@ -122,11 +127,11 @@ func NewWebAuthnDeviceFromCredential(rpid, username, description string, credent CreatedAt: time.Now(), Description: description, KID: NewBase64(credential.ID), - PublicKey: credential.PublicKey, AttestationType: credential.AttestationType, + Transport: strings.Join(transport, ","), SignCount: credential.Authenticator.SignCount, CloneWarning: credential.Authenticator.CloneWarning, - Transport: strings.Join(transport, ","), + PublicKey: credential.PublicKey, } aaguid, err := uuid.Parse(hex.EncodeToString(credential.Authenticator.AAGUID)) @@ -146,12 +151,12 @@ type WebAuthnDevice struct { Username string `db:"username"` Description string `db:"description"` KID Base64 `db:"kid"` - PublicKey []byte `db:"public_key"` + AAGUID uuid.NullUUID `db:"aaguid"` AttestationType string `db:"attestation_type"` Transport string `db:"transport"` - AAGUID uuid.NullUUID `db:"aaguid"` SignCount uint32 `db:"sign_count"` CloneWarning bool `db:"clone_warning"` + PublicKey []byte `db:"public_key"` } // UpdateSignInInfo adjusts the values of the WebAuthnDevice after a sign in. @@ -172,8 +177,8 @@ func (d *WebAuthnDevice) UpdateSignInInfo(config *webauthn.Config, now time.Time } } -// LastUsed provides LastUsedAt as a *time.Time instead of sql.NullTime. -func (d *WebAuthnDevice) LastUsed() *time.Time { +// DataValueLastUsedAt provides LastUsedAt as a *time.Time instead of sql.NullTime. +func (d *WebAuthnDevice) DataValueLastUsedAt() *time.Time { if d.LastUsedAt.Valid { value := time.Unix(d.LastUsedAt.Time.Unix(), int64(d.LastUsedAt.Time.Nanosecond())) @@ -183,22 +188,43 @@ func (d *WebAuthnDevice) LastUsed() *time.Time { return nil } -// ToData converts this WebAuthnDevice into the data format for exporting etc. +// DataValueAAGUID provides AAGUID as a *string instead of uuid.NullUUID. +func (d *WebAuthnDevice) DataValueAAGUID() *string { + if d.AAGUID.Valid { + value := d.AAGUID.UUID.String() + + return &value + } + + return nil +} + func (d *WebAuthnDevice) ToData() WebAuthnDeviceData { - return WebAuthnDeviceData{ + o := WebAuthnDeviceData{ + ID: d.ID, CreatedAt: d.CreatedAt, - LastUsedAt: d.LastUsed(), + LastUsedAt: d.DataValueLastUsedAt(), RPID: d.RPID, Username: d.Username, Description: d.Description, KID: d.KID.String(), - PublicKey: base64.StdEncoding.EncodeToString(d.PublicKey), + AAGUID: d.DataValueAAGUID(), AttestationType: d.AttestationType, - Transport: d.Transport, - AAGUID: d.AAGUID.UUID.String(), SignCount: d.SignCount, CloneWarning: d.CloneWarning, + PublicKey: base64.StdEncoding.EncodeToString(d.PublicKey), } + + if d.Transport != "" { + o.Transports = strings.Split(d.Transport, ",") + } + + return o +} + +// MarshalJSON returns the WebAuthnDevice in a JSON friendly manner. +func (d *WebAuthnDevice) MarshalJSON() (data []byte, err error) { + return json.Marshal(d.ToData()) } // MarshalYAML marshals this model into YAML. @@ -220,12 +246,14 @@ func (d *WebAuthnDevice) UnmarshalYAML(value *yaml.Node) (err error) { var aaguid uuid.UUID - if aaguid, err = uuid.Parse(o.AAGUID); err != nil { - return err - } + if o.AAGUID != nil { + if aaguid, err = uuid.Parse(*o.AAGUID); err != nil { + return err + } - if aaguid.ID() != 0 { - d.AAGUID = uuid.NullUUID{Valid: true, UUID: aaguid} + if aaguid.ID() != 0 { + d.AAGUID = uuid.NullUUID{Valid: true, UUID: aaguid} + } } var kid []byte @@ -241,7 +269,7 @@ func (d *WebAuthnDevice) UnmarshalYAML(value *yaml.Node) (err error) { d.Username = o.Username d.Description = o.Description d.AttestationType = o.AttestationType - d.Transport = o.Transport + d.Transport = strings.Join(o.Transports, ",") d.SignCount = o.SignCount d.CloneWarning = o.CloneWarning @@ -254,18 +282,62 @@ func (d *WebAuthnDevice) UnmarshalYAML(value *yaml.Node) (err error) { // WebAuthnDeviceData represents a WebAuthn Device in the database storage. type WebAuthnDeviceData struct { - CreatedAt time.Time `yaml:"created_at"` - LastUsedAt *time.Time `yaml:"last_used_at"` - RPID string `yaml:"rpid"` - Username string `yaml:"username"` - Description string `yaml:"description"` - KID string `yaml:"kid"` - PublicKey string `yaml:"public_key"` - AttestationType string `yaml:"attestation_type"` - Transport string `yaml:"transport"` - AAGUID string `yaml:"aaguid"` - SignCount uint32 `yaml:"sign_count"` - CloneWarning bool `yaml:"clone_warning"` + ID int `json:"id" yaml:"-"` + CreatedAt time.Time `json:"created_at" yaml:"created_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty" yaml:"last_used_at,omitempty"` + RPID string `json:"rpid" yaml:"rpid"` + Username string `json:"-" yaml:"username"` + Description string `json:"description" yaml:"description"` + KID string `json:"kid" yaml:"kid"` + AAGUID *string `json:"aaguid,omitempty" yaml:"aaguid,omitempty"` + AttestationType string `json:"attestation_type" yaml:"attestation_type"` + Transports []string `json:"transports" yaml:"transports"` + SignCount uint32 `json:"sign_count" yaml:"sign_count"` + CloneWarning bool `json:"clone_warning" yaml:"clone_warning"` + PublicKey string `json:"public_key" yaml:"public_key"` +} + +func (d *WebAuthnDeviceData) ToDevice() (device *WebAuthnDevice, err error) { + device = &WebAuthnDevice{ + CreatedAt: d.CreatedAt, + RPID: d.RPID, + Username: d.Username, + Description: d.Description, + AttestationType: d.AttestationType, + Transport: strings.Join(d.Transports, ","), + SignCount: d.SignCount, + CloneWarning: d.CloneWarning, + } + + if device.PublicKey, err = base64.StdEncoding.DecodeString(d.PublicKey); err != nil { + return nil, err + } + + var aaguid uuid.UUID + + if d.AAGUID != nil { + if aaguid, err = uuid.Parse(*d.AAGUID); err != nil { + return nil, err + } + + if aaguid.ID() != 0 { + device.AAGUID = uuid.NullUUID{Valid: true, UUID: aaguid} + } + } + + var kid []byte + + if kid, err = base64.StdEncoding.DecodeString(d.KID); err != nil { + return nil, err + } + + device.KID = NewBase64(kid) + + if d.LastUsedAt != nil { + device.LastUsedAt = sql.NullTime{Valid: true, Time: *d.LastUsedAt} + } + + return device, nil } // WebAuthnDeviceExport represents a WebAuthnDevice export file. diff --git a/internal/storage/migrations/V0002.Webauthn.mysql.down.sql b/internal/storage/migrations/V0002.WebAuthn.mysql.down.sql similarity index 100% rename from internal/storage/migrations/V0002.Webauthn.mysql.down.sql rename to internal/storage/migrations/V0002.WebAuthn.mysql.down.sql diff --git a/internal/storage/migrations/V0002.Webauthn.mysql.up.sql b/internal/storage/migrations/V0002.WebAuthn.mysql.up.sql similarity index 100% rename from internal/storage/migrations/V0002.Webauthn.mysql.up.sql rename to internal/storage/migrations/V0002.WebAuthn.mysql.up.sql diff --git a/internal/storage/migrations/V0002.Webauthn.postgres.down.sql b/internal/storage/migrations/V0002.WebAuthn.postgres.down.sql similarity index 100% rename from internal/storage/migrations/V0002.Webauthn.postgres.down.sql rename to internal/storage/migrations/V0002.WebAuthn.postgres.down.sql diff --git a/internal/storage/migrations/V0002.Webauthn.postgres.up.sql b/internal/storage/migrations/V0002.WebAuthn.postgres.up.sql similarity index 100% rename from internal/storage/migrations/V0002.Webauthn.postgres.up.sql rename to internal/storage/migrations/V0002.WebAuthn.postgres.up.sql diff --git a/internal/storage/migrations/V0002.Webauthn.sqlite.down.sql b/internal/storage/migrations/V0002.WebAuthn.sqlite.down.sql similarity index 100% rename from internal/storage/migrations/V0002.Webauthn.sqlite.down.sql rename to internal/storage/migrations/V0002.WebAuthn.sqlite.down.sql diff --git a/internal/storage/migrations/V0002.Webauthn.sqlite.up.sql b/internal/storage/migrations/V0002.WebAuthn.sqlite.up.sql similarity index 100% rename from internal/storage/migrations/V0002.Webauthn.sqlite.up.sql rename to internal/storage/migrations/V0002.WebAuthn.sqlite.up.sql diff --git a/internal/storage/migrations/V0003.WebauthnKIDLength.all.down.sql b/internal/storage/migrations/V0003.WebAuthnKIDLength.all.down.sql similarity index 100% rename from internal/storage/migrations/V0003.WebauthnKIDLength.all.down.sql rename to internal/storage/migrations/V0003.WebAuthnKIDLength.all.down.sql diff --git a/internal/storage/migrations/V0003.WebauthnKIDLength.mysql.up.sql b/internal/storage/migrations/V0003.WebAuthnKIDLength.mysql.up.sql similarity index 100% rename from internal/storage/migrations/V0003.WebauthnKIDLength.mysql.up.sql rename to internal/storage/migrations/V0003.WebAuthnKIDLength.mysql.up.sql diff --git a/internal/storage/migrations/V0003.WebauthnKIDLength.postgres.up.sql b/internal/storage/migrations/V0003.WebAuthnKIDLength.postgres.up.sql similarity index 100% rename from internal/storage/migrations/V0003.WebauthnKIDLength.postgres.up.sql rename to internal/storage/migrations/V0003.WebAuthnKIDLength.postgres.up.sql diff --git a/internal/storage/migrations/V0003.WebauthnKIDLength.sqlite.up.sql b/internal/storage/migrations/V0003.WebAuthnKIDLength.sqlite.up.sql similarity index 100% rename from internal/storage/migrations/V0003.WebauthnKIDLength.sqlite.up.sql rename to internal/storage/migrations/V0003.WebAuthnKIDLength.sqlite.up.sql diff --git a/web/src/models/Methods.ts b/web/src/models/Methods.ts index d1c0c52e1..baa0cb342 100644 --- a/web/src/models/Methods.ts +++ b/web/src/models/Methods.ts @@ -1,5 +1,5 @@ export enum SecondFactorMethod { TOTP = 1, - Webauthn, + WebAuthn, MobilePush, } diff --git a/web/src/models/Webauthn.ts b/web/src/models/WebAuthn.ts similarity index 98% rename from web/src/models/Webauthn.ts rename to web/src/models/WebAuthn.ts index bbd75503d..404197d2b 100644 --- a/web/src/models/Webauthn.ts +++ b/web/src/models/WebAuthn.ts @@ -89,7 +89,7 @@ export enum AttestationResult { FailureSyntax, FailureSupport, FailureUnknown, - FailureWebauthnNotSupported, + FailureWebAuthnNotSupported, FailureToken, } @@ -111,7 +111,7 @@ export enum AssertionResult { FailureSyntax, FailureUnknown, FailureUnknownSecurity, - FailureWebauthnNotSupported, + FailureWebAuthnNotSupported, FailureChallenge, } diff --git a/web/src/services/UserInfo.ts b/web/src/services/UserInfo.ts index ea2971172..04e81fa5e 100644 --- a/web/src/services/UserInfo.ts +++ b/web/src/services/UserInfo.ts @@ -22,7 +22,7 @@ export function toEnum(method: Method2FA): SecondFactorMethod { case "totp": return SecondFactorMethod.TOTP; case "webauthn": - return SecondFactorMethod.Webauthn; + return SecondFactorMethod.WebAuthn; case "mobile_push": return SecondFactorMethod.MobilePush; } @@ -32,7 +32,7 @@ export function toString(method: SecondFactorMethod): Method2FA { switch (method) { case SecondFactorMethod.TOTP: return "totp"; - case SecondFactorMethod.Webauthn: + case SecondFactorMethod.WebAuthn: return "webauthn"; case SecondFactorMethod.MobilePush: return "mobile_push"; diff --git a/web/src/services/Webauthn.ts b/web/src/services/WebAuthn.ts similarity index 99% rename from web/src/services/Webauthn.ts rename to web/src/services/WebAuthn.ts index ecd2dbfe8..6e113578c 100644 --- a/web/src/services/Webauthn.ts +++ b/web/src/services/WebAuthn.ts @@ -16,7 +16,7 @@ import { PublicKeyCredentialJSON, PublicKeyCredentialRequestOptionsJSON, PublicKeyCredentialRequestOptionsStatus, -} from "@models/Webauthn"; +} from "@models/WebAuthn"; import { OptionalDataServiceResponse, ServiceResponse, diff --git a/web/src/views/DeviceRegistration/RegisterWebAuthn.tsx b/web/src/views/DeviceRegistration/RegisterWebAuthn.tsx index 33a93ef6a..338fb86c4 100644 --- a/web/src/views/DeviceRegistration/RegisterWebAuthn.tsx +++ b/web/src/views/DeviceRegistration/RegisterWebAuthn.tsx @@ -9,9 +9,9 @@ import { IdentityToken } from "@constants/SearchParams"; import { useNotifications } from "@hooks/NotificationsContext"; import { useQueryParam } from "@hooks/QueryParam"; import LoginLayout from "@layouts/LoginLayout"; -import { AttestationResult } from "@models/Webauthn"; +import { AttestationResult } from "@models/WebAuthn"; import { FirstFactorPath } from "@services/Api"; -import { performAttestationCeremony } from "@services/Webauthn"; +import { performAttestationCeremony } from "@services/WebAuthn"; const RegisterWebAuthn = function () { const styles = useStyles(); @@ -53,7 +53,7 @@ const RegisterWebAuthn = function () { "The attestation challenge was rejected as malformed or incompatible by your browser.", ); break; - case AttestationResult.FailureWebauthnNotSupported: + case AttestationResult.FailureWebAuthnNotSupported: createErrorNotification("Your browser does not support the WebAuthN protocol."); break; case AttestationResult.FailureUserConsent: diff --git a/web/src/views/LoginPortal/LoginPortal.tsx b/web/src/views/LoginPortal/LoginPortal.tsx index 9a3b9accc..cd143376a 100644 --- a/web/src/views/LoginPortal/LoginPortal.tsx +++ b/web/src/views/LoginPortal/LoginPortal.tsx @@ -143,7 +143,7 @@ const LoginPortal = function (props: Props) { if (configuration.available_methods.size === 0) { redirect(AuthenticatedRoute, false); } else { - if (userInfo.method === SecondFactorMethod.Webauthn) { + if (userInfo.method === SecondFactorMethod.WebAuthn) { redirect(`${SecondFactorRoute}${SecondFactorWebAuthnSubRoute}`); } else if (userInfo.method === SecondFactorMethod.MobilePush) { redirect(`${SecondFactorRoute}${SecondFactorPushSubRoute}`); diff --git a/web/src/views/LoginPortal/SecondFactor/MethodSelectionDialog.tsx b/web/src/views/LoginPortal/SecondFactor/MethodSelectionDialog.tsx index 93a85c0d9..8ec82f231 100644 --- a/web/src/views/LoginPortal/SecondFactor/MethodSelectionDialog.tsx +++ b/web/src/views/LoginPortal/SecondFactor/MethodSelectionDialog.tsx @@ -39,12 +39,12 @@ const MethodSelectionDialog = function (props: Props) { onClick={() => props.onClick(SecondFactorMethod.TOTP)} /> ) : null} - {props.methods.has(SecondFactorMethod.Webauthn) && props.webauthnSupported ? ( + {props.methods.has(SecondFactorMethod.WebAuthn) && props.webauthnSupported ? ( } - onClick={() => props.onClick(SecondFactorMethod.Webauthn)} + onClick={() => props.onClick(SecondFactorMethod.WebAuthn)} /> ) : null} {props.methods.has(SecondFactorMethod.MobilePush) ? ( diff --git a/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx b/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx index e86b1afd0..6d9b9873e 100644 --- a/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx +++ b/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx @@ -19,11 +19,11 @@ import { UserInfo } from "@models/UserInfo"; import { initiateTOTPRegistrationProcess, initiateWebAuthnRegistrationProcess } from "@services/RegisterDevice"; import { AuthenticationLevel } from "@services/State"; import { setPreferred2FAMethod } from "@services/UserInfo"; -import { isWebAuthnSupported } from "@services/Webauthn"; +import { isWebAuthnSupported } from "@services/WebAuthn"; import MethodSelectionDialog from "@views/LoginPortal/SecondFactor/MethodSelectionDialog"; import OneTimePasswordMethod from "@views/LoginPortal/SecondFactor/OneTimePasswordMethod"; import PushNotificationMethod from "@views/LoginPortal/SecondFactor/PushNotificationMethod"; -import WebauthnMethod from "@views/LoginPortal/SecondFactor/WebauthnMethod"; +import WebAuthnMethod from "@views/LoginPortal/SecondFactor/WebAuthnMethod"; export interface Props { authenticationLevel: AuthenticationLevel; @@ -41,12 +41,12 @@ const SecondFactorForm = function (props: Props) { const [methodSelectionOpen, setMethodSelectionOpen] = useState(false); const { createInfoNotification, createErrorNotification } = useNotifications(); const [registrationInProgress, setRegistrationInProgress] = useState(false); - const [webauthnSupported, setWebauthnSupported] = useState(false); + const [stateWebAuthnSupported, setStateWebAuthnSupported] = useState(false); const { t: translate } = useTranslation(); useEffect(() => { - setWebauthnSupported(isWebAuthnSupported()); - }, [setWebauthnSupported]); + setStateWebAuthnSupported(isWebAuthnSupported()); + }, [setStateWebAuthnSupported]); const initiateRegistration = (initiateRegistrationFunc: () => Promise) => { return async () => { @@ -90,7 +90,7 @@ const SecondFactorForm = function (props: Props) { setMethodSelectionOpen(false)} onClick={handleMethodSelected} /> @@ -126,10 +126,10 @@ const SecondFactorForm = function (props: Props) { createErrorNotification(err.message)} diff --git a/web/src/views/LoginPortal/SecondFactor/WebauthnMethod.tsx b/web/src/views/LoginPortal/SecondFactor/WebAuthnMethod.tsx similarity index 97% rename from web/src/views/LoginPortal/SecondFactor/WebauthnMethod.tsx rename to web/src/views/LoginPortal/SecondFactor/WebAuthnMethod.tsx index 6cd064e5e..f68dbe9ce 100644 --- a/web/src/views/LoginPortal/SecondFactor/WebauthnMethod.tsx +++ b/web/src/views/LoginPortal/SecondFactor/WebAuthnMethod.tsx @@ -11,13 +11,13 @@ import { useIsMountedRef } from "@hooks/Mounted"; import { useQueryParam } from "@hooks/QueryParam"; import { useTimer } from "@hooks/Timer"; import { useWorkflow } from "@hooks/Workflow"; -import { AssertionResult } from "@models/Webauthn"; +import { AssertionResult } from "@models/WebAuthn"; import { AuthenticationLevel } from "@services/State"; import { getAssertionPublicKeyCredentialResult, getAssertionRequestOptions, postAssertionPublicKeyCredentialResult, -} from "@services/Webauthn"; +} from "@services/WebAuthn"; import IconWithContext from "@views/LoginPortal/SecondFactor/IconWithContext"; import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer"; @@ -37,7 +37,7 @@ export interface Props { onSignInSuccess: (redirectURL: string | undefined) => void; } -const WebauthnMethod = function (props: Props) { +const WebAuthnMethod = function (props: Props) { const signInTimeout = 30; const [state, setState] = useState(State.WaitTouch); const styles = useStyles(); @@ -86,7 +86,7 @@ const WebauthnMethod = function (props: Props) { ), ); break; - case AssertionResult.FailureWebauthnNotSupported: + case AssertionResult.FailureWebAuthnNotSupported: onSignInErrorCallback(new Error("Your browser does not support the WebAuthN protocol.")); break; case AssertionResult.FailureUnknownSecurity: @@ -179,7 +179,7 @@ const WebauthnMethod = function (props: Props) { ); }; -export default WebauthnMethod; +export default WebAuthnMethod; const useStyles = makeStyles((theme: Theme) => ({ icon: {