refactor(web): webauthn references (#5244)

Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
pull/5246/head
James Elliott 2023-04-15 02:54:24 +10:00 committed by GitHub
parent 2733fc040c
commit 370585d1de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 133 additions and 71 deletions

View File

@ -52,7 +52,7 @@ func TestWebAuthnGetUser(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, user) 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.WebAuthnName())
assert.Equal(t, "john", user.Username) 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{ ctx.StorageMock.EXPECT().LoadWebAuthnDevicesByUsername(ctx.Ctx, "john").Return([]model.WebAuthnDevice{
{ {
ID: 1, ID: 1,
RPID: "https://example.com", RPID: "example.com",
Username: "john", Username: "john",
Description: "Primary", Description: "Primary",
KID: model.NewBase64([]byte("abc123")), KID: model.NewBase64([]byte("abc123")),

View File

@ -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"`
}

View File

@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json"
"strings" "strings"
"time" "time"
@ -19,9 +20,13 @@ const (
// WebAuthnUser is an object to represent a user for the WebAuthn lib. // WebAuthnUser is an object to represent a user for the WebAuthn lib.
type WebAuthnUser struct { type WebAuthnUser struct {
Username string ID int `db:"id"`
DisplayName string RPID string `db:"rpid"`
Devices []WebAuthnDevice 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. // 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. // WebAuthnID implements the webauthn.User interface.
func (w WebAuthnUser) WebAuthnID() []byte { func (w WebAuthnUser) WebAuthnID() []byte {
return []byte(w.Username) return []byte(w.UserID)
} }
// WebAuthnName implements the webauthn.User interface. // WebAuthnName implements the webauthn.User interface.
@ -122,11 +127,11 @@ func NewWebAuthnDeviceFromCredential(rpid, username, description string, credent
CreatedAt: time.Now(), CreatedAt: time.Now(),
Description: description, Description: description,
KID: NewBase64(credential.ID), KID: NewBase64(credential.ID),
PublicKey: credential.PublicKey,
AttestationType: credential.AttestationType, AttestationType: credential.AttestationType,
Transport: strings.Join(transport, ","),
SignCount: credential.Authenticator.SignCount, SignCount: credential.Authenticator.SignCount,
CloneWarning: credential.Authenticator.CloneWarning, CloneWarning: credential.Authenticator.CloneWarning,
Transport: strings.Join(transport, ","), PublicKey: credential.PublicKey,
} }
aaguid, err := uuid.Parse(hex.EncodeToString(credential.Authenticator.AAGUID)) aaguid, err := uuid.Parse(hex.EncodeToString(credential.Authenticator.AAGUID))
@ -146,12 +151,12 @@ type WebAuthnDevice struct {
Username string `db:"username"` Username string `db:"username"`
Description string `db:"description"` Description string `db:"description"`
KID Base64 `db:"kid"` KID Base64 `db:"kid"`
PublicKey []byte `db:"public_key"` AAGUID uuid.NullUUID `db:"aaguid"`
AttestationType string `db:"attestation_type"` AttestationType string `db:"attestation_type"`
Transport string `db:"transport"` Transport string `db:"transport"`
AAGUID uuid.NullUUID `db:"aaguid"`
SignCount uint32 `db:"sign_count"` SignCount uint32 `db:"sign_count"`
CloneWarning bool `db:"clone_warning"` CloneWarning bool `db:"clone_warning"`
PublicKey []byte `db:"public_key"`
} }
// UpdateSignInInfo adjusts the values of the WebAuthnDevice after a sign in. // 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. // DataValueLastUsedAt provides LastUsedAt as a *time.Time instead of sql.NullTime.
func (d *WebAuthnDevice) LastUsed() *time.Time { func (d *WebAuthnDevice) DataValueLastUsedAt() *time.Time {
if d.LastUsedAt.Valid { if d.LastUsedAt.Valid {
value := time.Unix(d.LastUsedAt.Time.Unix(), int64(d.LastUsedAt.Time.Nanosecond())) value := time.Unix(d.LastUsedAt.Time.Unix(), int64(d.LastUsedAt.Time.Nanosecond()))
@ -183,22 +188,43 @@ func (d *WebAuthnDevice) LastUsed() *time.Time {
return nil 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 { func (d *WebAuthnDevice) ToData() WebAuthnDeviceData {
return WebAuthnDeviceData{ o := WebAuthnDeviceData{
ID: d.ID,
CreatedAt: d.CreatedAt, CreatedAt: d.CreatedAt,
LastUsedAt: d.LastUsed(), LastUsedAt: d.DataValueLastUsedAt(),
RPID: d.RPID, RPID: d.RPID,
Username: d.Username, Username: d.Username,
Description: d.Description, Description: d.Description,
KID: d.KID.String(), KID: d.KID.String(),
PublicKey: base64.StdEncoding.EncodeToString(d.PublicKey), AAGUID: d.DataValueAAGUID(),
AttestationType: d.AttestationType, AttestationType: d.AttestationType,
Transport: d.Transport,
AAGUID: d.AAGUID.UUID.String(),
SignCount: d.SignCount, SignCount: d.SignCount,
CloneWarning: d.CloneWarning, 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. // MarshalYAML marshals this model into YAML.
@ -220,12 +246,14 @@ func (d *WebAuthnDevice) UnmarshalYAML(value *yaml.Node) (err error) {
var aaguid uuid.UUID var aaguid uuid.UUID
if aaguid, err = uuid.Parse(o.AAGUID); err != nil { if o.AAGUID != nil {
return err if aaguid, err = uuid.Parse(*o.AAGUID); err != nil {
} return err
}
if aaguid.ID() != 0 { if aaguid.ID() != 0 {
d.AAGUID = uuid.NullUUID{Valid: true, UUID: aaguid} d.AAGUID = uuid.NullUUID{Valid: true, UUID: aaguid}
}
} }
var kid []byte var kid []byte
@ -241,7 +269,7 @@ func (d *WebAuthnDevice) UnmarshalYAML(value *yaml.Node) (err error) {
d.Username = o.Username d.Username = o.Username
d.Description = o.Description d.Description = o.Description
d.AttestationType = o.AttestationType d.AttestationType = o.AttestationType
d.Transport = o.Transport d.Transport = strings.Join(o.Transports, ",")
d.SignCount = o.SignCount d.SignCount = o.SignCount
d.CloneWarning = o.CloneWarning 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. // WebAuthnDeviceData represents a WebAuthn Device in the database storage.
type WebAuthnDeviceData struct { type WebAuthnDeviceData struct {
CreatedAt time.Time `yaml:"created_at"` ID int `json:"id" yaml:"-"`
LastUsedAt *time.Time `yaml:"last_used_at"` CreatedAt time.Time `json:"created_at" yaml:"created_at"`
RPID string `yaml:"rpid"` LastUsedAt *time.Time `json:"last_used_at,omitempty" yaml:"last_used_at,omitempty"`
Username string `yaml:"username"` RPID string `json:"rpid" yaml:"rpid"`
Description string `yaml:"description"` Username string `json:"-" yaml:"username"`
KID string `yaml:"kid"` Description string `json:"description" yaml:"description"`
PublicKey string `yaml:"public_key"` KID string `json:"kid" yaml:"kid"`
AttestationType string `yaml:"attestation_type"` AAGUID *string `json:"aaguid,omitempty" yaml:"aaguid,omitempty"`
Transport string `yaml:"transport"` AttestationType string `json:"attestation_type" yaml:"attestation_type"`
AAGUID string `yaml:"aaguid"` Transports []string `json:"transports" yaml:"transports"`
SignCount uint32 `yaml:"sign_count"` SignCount uint32 `json:"sign_count" yaml:"sign_count"`
CloneWarning bool `yaml:"clone_warning"` 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. // WebAuthnDeviceExport represents a WebAuthnDevice export file.

View File

@ -1,5 +1,5 @@
export enum SecondFactorMethod { export enum SecondFactorMethod {
TOTP = 1, TOTP = 1,
Webauthn, WebAuthn,
MobilePush, MobilePush,
} }

View File

@ -89,7 +89,7 @@ export enum AttestationResult {
FailureSyntax, FailureSyntax,
FailureSupport, FailureSupport,
FailureUnknown, FailureUnknown,
FailureWebauthnNotSupported, FailureWebAuthnNotSupported,
FailureToken, FailureToken,
} }
@ -111,7 +111,7 @@ export enum AssertionResult {
FailureSyntax, FailureSyntax,
FailureUnknown, FailureUnknown,
FailureUnknownSecurity, FailureUnknownSecurity,
FailureWebauthnNotSupported, FailureWebAuthnNotSupported,
FailureChallenge, FailureChallenge,
} }

View File

@ -22,7 +22,7 @@ export function toEnum(method: Method2FA): SecondFactorMethod {
case "totp": case "totp":
return SecondFactorMethod.TOTP; return SecondFactorMethod.TOTP;
case "webauthn": case "webauthn":
return SecondFactorMethod.Webauthn; return SecondFactorMethod.WebAuthn;
case "mobile_push": case "mobile_push":
return SecondFactorMethod.MobilePush; return SecondFactorMethod.MobilePush;
} }
@ -32,7 +32,7 @@ export function toString(method: SecondFactorMethod): Method2FA {
switch (method) { switch (method) {
case SecondFactorMethod.TOTP: case SecondFactorMethod.TOTP:
return "totp"; return "totp";
case SecondFactorMethod.Webauthn: case SecondFactorMethod.WebAuthn:
return "webauthn"; return "webauthn";
case SecondFactorMethod.MobilePush: case SecondFactorMethod.MobilePush:
return "mobile_push"; return "mobile_push";

View File

@ -16,7 +16,7 @@ import {
PublicKeyCredentialJSON, PublicKeyCredentialJSON,
PublicKeyCredentialRequestOptionsJSON, PublicKeyCredentialRequestOptionsJSON,
PublicKeyCredentialRequestOptionsStatus, PublicKeyCredentialRequestOptionsStatus,
} from "@models/Webauthn"; } from "@models/WebAuthn";
import { import {
OptionalDataServiceResponse, OptionalDataServiceResponse,
ServiceResponse, ServiceResponse,

View File

@ -9,9 +9,9 @@ import { IdentityToken } from "@constants/SearchParams";
import { useNotifications } from "@hooks/NotificationsContext"; import { useNotifications } from "@hooks/NotificationsContext";
import { useQueryParam } from "@hooks/QueryParam"; import { useQueryParam } from "@hooks/QueryParam";
import LoginLayout from "@layouts/LoginLayout"; import LoginLayout from "@layouts/LoginLayout";
import { AttestationResult } from "@models/Webauthn"; import { AttestationResult } from "@models/WebAuthn";
import { FirstFactorPath } from "@services/Api"; import { FirstFactorPath } from "@services/Api";
import { performAttestationCeremony } from "@services/Webauthn"; import { performAttestationCeremony } from "@services/WebAuthn";
const RegisterWebAuthn = function () { const RegisterWebAuthn = function () {
const styles = useStyles(); const styles = useStyles();
@ -53,7 +53,7 @@ const RegisterWebAuthn = function () {
"The attestation challenge was rejected as malformed or incompatible by your browser.", "The attestation challenge was rejected as malformed or incompatible by your browser.",
); );
break; break;
case AttestationResult.FailureWebauthnNotSupported: case AttestationResult.FailureWebAuthnNotSupported:
createErrorNotification("Your browser does not support the WebAuthN protocol."); createErrorNotification("Your browser does not support the WebAuthN protocol.");
break; break;
case AttestationResult.FailureUserConsent: case AttestationResult.FailureUserConsent:

View File

@ -143,7 +143,7 @@ const LoginPortal = function (props: Props) {
if (configuration.available_methods.size === 0) { if (configuration.available_methods.size === 0) {
redirect(AuthenticatedRoute, false); redirect(AuthenticatedRoute, false);
} else { } else {
if (userInfo.method === SecondFactorMethod.Webauthn) { if (userInfo.method === SecondFactorMethod.WebAuthn) {
redirect(`${SecondFactorRoute}${SecondFactorWebAuthnSubRoute}`); redirect(`${SecondFactorRoute}${SecondFactorWebAuthnSubRoute}`);
} else if (userInfo.method === SecondFactorMethod.MobilePush) { } else if (userInfo.method === SecondFactorMethod.MobilePush) {
redirect(`${SecondFactorRoute}${SecondFactorPushSubRoute}`); redirect(`${SecondFactorRoute}${SecondFactorPushSubRoute}`);

View File

@ -39,12 +39,12 @@ const MethodSelectionDialog = function (props: Props) {
onClick={() => props.onClick(SecondFactorMethod.TOTP)} onClick={() => props.onClick(SecondFactorMethod.TOTP)}
/> />
) : null} ) : null}
{props.methods.has(SecondFactorMethod.Webauthn) && props.webauthnSupported ? ( {props.methods.has(SecondFactorMethod.WebAuthn) && props.webauthnSupported ? (
<MethodItem <MethodItem
id="webauthn-option" id="webauthn-option"
method={translate("Security Key - WebAuthN")} method={translate("Security Key - WebAuthN")}
icon={<FingerTouchIcon size={32} />} icon={<FingerTouchIcon size={32} />}
onClick={() => props.onClick(SecondFactorMethod.Webauthn)} onClick={() => props.onClick(SecondFactorMethod.WebAuthn)}
/> />
) : null} ) : null}
{props.methods.has(SecondFactorMethod.MobilePush) ? ( {props.methods.has(SecondFactorMethod.MobilePush) ? (

View File

@ -19,11 +19,11 @@ import { UserInfo } from "@models/UserInfo";
import { initiateTOTPRegistrationProcess, initiateWebAuthnRegistrationProcess } from "@services/RegisterDevice"; import { initiateTOTPRegistrationProcess, initiateWebAuthnRegistrationProcess } from "@services/RegisterDevice";
import { AuthenticationLevel } from "@services/State"; import { AuthenticationLevel } from "@services/State";
import { setPreferred2FAMethod } from "@services/UserInfo"; import { setPreferred2FAMethod } from "@services/UserInfo";
import { isWebAuthnSupported } from "@services/Webauthn"; import { isWebAuthnSupported } from "@services/WebAuthn";
import MethodSelectionDialog from "@views/LoginPortal/SecondFactor/MethodSelectionDialog"; import MethodSelectionDialog from "@views/LoginPortal/SecondFactor/MethodSelectionDialog";
import OneTimePasswordMethod from "@views/LoginPortal/SecondFactor/OneTimePasswordMethod"; import OneTimePasswordMethod from "@views/LoginPortal/SecondFactor/OneTimePasswordMethod";
import PushNotificationMethod from "@views/LoginPortal/SecondFactor/PushNotificationMethod"; import PushNotificationMethod from "@views/LoginPortal/SecondFactor/PushNotificationMethod";
import WebauthnMethod from "@views/LoginPortal/SecondFactor/WebauthnMethod"; import WebAuthnMethod from "@views/LoginPortal/SecondFactor/WebAuthnMethod";
export interface Props { export interface Props {
authenticationLevel: AuthenticationLevel; authenticationLevel: AuthenticationLevel;
@ -41,12 +41,12 @@ const SecondFactorForm = function (props: Props) {
const [methodSelectionOpen, setMethodSelectionOpen] = useState(false); const [methodSelectionOpen, setMethodSelectionOpen] = useState(false);
const { createInfoNotification, createErrorNotification } = useNotifications(); const { createInfoNotification, createErrorNotification } = useNotifications();
const [registrationInProgress, setRegistrationInProgress] = useState(false); const [registrationInProgress, setRegistrationInProgress] = useState(false);
const [webauthnSupported, setWebauthnSupported] = useState(false); const [stateWebAuthnSupported, setStateWebAuthnSupported] = useState(false);
const { t: translate } = useTranslation(); const { t: translate } = useTranslation();
useEffect(() => { useEffect(() => {
setWebauthnSupported(isWebAuthnSupported()); setStateWebAuthnSupported(isWebAuthnSupported());
}, [setWebauthnSupported]); }, [setStateWebAuthnSupported]);
const initiateRegistration = (initiateRegistrationFunc: () => Promise<void>) => { const initiateRegistration = (initiateRegistrationFunc: () => Promise<void>) => {
return async () => { return async () => {
@ -90,7 +90,7 @@ const SecondFactorForm = function (props: Props) {
<MethodSelectionDialog <MethodSelectionDialog
open={methodSelectionOpen} open={methodSelectionOpen}
methods={props.configuration.available_methods} methods={props.configuration.available_methods}
webauthnSupported={webauthnSupported} webauthnSupported={stateWebAuthnSupported}
onClose={() => setMethodSelectionOpen(false)} onClose={() => setMethodSelectionOpen(false)}
onClick={handleMethodSelected} onClick={handleMethodSelected}
/> />
@ -126,10 +126,10 @@ const SecondFactorForm = function (props: Props) {
<Route <Route
path={SecondFactorWebAuthnSubRoute} path={SecondFactorWebAuthnSubRoute}
element={ element={
<WebauthnMethod <WebAuthnMethod
id="webauthn-method" id="webauthn-method"
authenticationLevel={props.authenticationLevel} authenticationLevel={props.authenticationLevel}
// Whether the user has a Webauthn device registered already // Whether the user has a WebAuthn device registered already
registered={props.userInfo.has_webauthn} registered={props.userInfo.has_webauthn}
onRegisterClick={initiateRegistration(initiateWebAuthnRegistrationProcess)} onRegisterClick={initiateRegistration(initiateWebAuthnRegistrationProcess)}
onSignInError={(err) => createErrorNotification(err.message)} onSignInError={(err) => createErrorNotification(err.message)}

View File

@ -11,13 +11,13 @@ import { useIsMountedRef } from "@hooks/Mounted";
import { useQueryParam } from "@hooks/QueryParam"; import { useQueryParam } from "@hooks/QueryParam";
import { useTimer } from "@hooks/Timer"; import { useTimer } from "@hooks/Timer";
import { useWorkflow } from "@hooks/Workflow"; import { useWorkflow } from "@hooks/Workflow";
import { AssertionResult } from "@models/Webauthn"; import { AssertionResult } from "@models/WebAuthn";
import { AuthenticationLevel } from "@services/State"; import { AuthenticationLevel } from "@services/State";
import { import {
getAssertionPublicKeyCredentialResult, getAssertionPublicKeyCredentialResult,
getAssertionRequestOptions, getAssertionRequestOptions,
postAssertionPublicKeyCredentialResult, postAssertionPublicKeyCredentialResult,
} from "@services/Webauthn"; } from "@services/WebAuthn";
import IconWithContext from "@views/LoginPortal/SecondFactor/IconWithContext"; import IconWithContext from "@views/LoginPortal/SecondFactor/IconWithContext";
import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer"; import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer";
@ -37,7 +37,7 @@ export interface Props {
onSignInSuccess: (redirectURL: string | undefined) => void; onSignInSuccess: (redirectURL: string | undefined) => void;
} }
const WebauthnMethod = function (props: Props) { const WebAuthnMethod = function (props: Props) {
const signInTimeout = 30; const signInTimeout = 30;
const [state, setState] = useState(State.WaitTouch); const [state, setState] = useState(State.WaitTouch);
const styles = useStyles(); const styles = useStyles();
@ -86,7 +86,7 @@ const WebauthnMethod = function (props: Props) {
), ),
); );
break; break;
case AssertionResult.FailureWebauthnNotSupported: case AssertionResult.FailureWebAuthnNotSupported:
onSignInErrorCallback(new Error("Your browser does not support the WebAuthN protocol.")); onSignInErrorCallback(new Error("Your browser does not support the WebAuthN protocol."));
break; break;
case AssertionResult.FailureUnknownSecurity: case AssertionResult.FailureUnknownSecurity:
@ -179,7 +179,7 @@ const WebauthnMethod = function (props: Props) {
); );
}; };
export default WebauthnMethod; export default WebAuthnMethod;
const useStyles = makeStyles((theme: Theme) => ({ const useStyles = makeStyles((theme: Theme) => ({
icon: { icon: {