diff --git a/internal/handlers/handler_register_webauthn.go b/internal/handlers/handler_register_webauthn.go index 72798f138..dbd9f4d7d 100644 --- a/internal/handlers/handler_register_webauthn.go +++ b/internal/handlers/handler_register_webauthn.go @@ -30,10 +30,15 @@ var WebauthnIdentityFinish = middlewares.IdentityVerificationFinish( middlewares.IdentityVerificationFinishArgs{ ActionClaim: ActionWebauthnRegistration, IsTokenUserValidFunc: isTokenUserValidFor2FARegistration, - }, SecondFactorWebauthnAttestationGET) + }, WebauthnAttestationGET) -// SecondFactorWebauthnAttestationGET returns the attestation challenge from the server. -func SecondFactorWebauthnAttestationGET(ctx *middlewares.AutheliaCtx, _ string) { +// WebauthnAttestationGET returns the attestation challenge from the server. +func WebauthnAttestationGET(ctx *middlewares.AutheliaCtx, _ string) { + WebauthnRegistrationGET(ctx) +} + +// WebauthnRegistrationGET returns the attestation challenge from the server. +func WebauthnRegistrationGET(ctx *middlewares.AutheliaCtx) { var ( w *webauthn.WebAuthn user *model.WebauthnUser @@ -92,13 +97,8 @@ func SecondFactorWebauthnAttestationGET(ctx *middlewares.AutheliaCtx, _ string) } } -// WebauthnAttestationPOST processes the attestation challenge response from the client. -func WebauthnAttestationPOST(ctx *middlewares.AutheliaCtx) { - type requestPostData struct { - Credential json.RawMessage `json:"credential"` - Description string `json:"description"` - } - +// WebauthnRegistrationPOST processes the attestation challenge response from the client. +func WebauthnRegistrationPOST(ctx *middlewares.AutheliaCtx) { var ( err error w *webauthn.WebAuthn @@ -106,9 +106,10 @@ func WebauthnAttestationPOST(ctx *middlewares.AutheliaCtx) { userSession session.UserSession - attestationResponse *protocol.ParsedCredentialCreationData - credential *webauthn.Credential - postData *requestPostData + response *protocol.ParsedCredentialCreationData + + credential *webauthn.Credential + bodyJSON bodyRegisterWebauthnRequest ) if userSession, err = ctx.GetSession(); err != nil { @@ -135,8 +136,7 @@ func WebauthnAttestationPOST(ctx *middlewares.AutheliaCtx) { return } - err = json.Unmarshal(ctx.PostBody(), &postData) - if err != nil { + if err = json.Unmarshal(ctx.PostBody(), &bodyJSON); err != nil { ctx.Logger.Errorf("Unable to parse %s assertion request data for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) respondUnauthorized(ctx, messageMFAValidationFailed) @@ -144,7 +144,7 @@ func WebauthnAttestationPOST(ctx *middlewares.AutheliaCtx) { return } - if attestationResponse, err = protocol.ParseCredentialCreationResponseBody(bytes.NewReader(postData.Credential)); err != nil { + if response, err = protocol.ParseCredentialCreationResponseBody(bytes.NewReader(bodyJSON.Response)); err != nil { ctx.Logger.Errorf("Unable to parse %s assertion for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) respondUnauthorized(ctx, messageMFAValidationFailed) @@ -152,6 +152,8 @@ func WebauthnAttestationPOST(ctx *middlewares.AutheliaCtx) { return } + ctx.Logger.WithField("att_format", response.Response.AttestationObject.Format).Debug("Response Data") + if user, err = getWebAuthnUser(ctx, userSession); err != nil { ctx.Logger.Errorf("Unable to load %s devices for assertion challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) @@ -160,7 +162,7 @@ func WebauthnAttestationPOST(ctx *middlewares.AutheliaCtx) { return } - if credential, err = w.CreateCredential(user, *userSession.Webauthn, attestationResponse); err != nil { + if credential, err = w.CreateCredential(user, *userSession.Webauthn, response); err != nil { ctx.Logger.Errorf("Unable to load %s devices for assertion challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) respondUnauthorized(ctx, messageMFAValidationFailed) @@ -168,6 +170,8 @@ func WebauthnAttestationPOST(ctx *middlewares.AutheliaCtx) { return } + ctx.Logger.WithField("att_type", credential.AttestationType).Debug("Credential Data") + devices, err := ctx.Providers.StorageProvider.LoadWebauthnDevicesByUsername(ctx, 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) @@ -178,8 +182,8 @@ func WebauthnAttestationPOST(ctx *middlewares.AutheliaCtx) { } for _, existingDevice := range devices { - if existingDevice.Description == postData.Description { - ctx.Logger.Errorf("%s device for for user '%s' with name '%s' already exists", regulation.AuthTypeWebauthn, userSession.Username, postData.Description) + 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) @@ -189,7 +193,7 @@ func WebauthnAttestationPOST(ctx *middlewares.AutheliaCtx) { } } - device := model.NewWebauthnDeviceFromCredential(w.Config.RPID, userSession.Username, postData.Description, credential) + device := model.NewWebauthnDeviceFromCredential(w.Config.RPID, userSession.Username, bodyJSON.Description, credential) if err = ctx.Providers.StorageProvider.SaveWebauthnDevice(ctx, device); err != nil { ctx.Logger.Errorf("Unable to load %s devices for assertion challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) @@ -207,5 +211,5 @@ func WebauthnAttestationPOST(ctx *middlewares.AutheliaCtx) { ctx.ReplyOK() 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": postData.Description}) + ctxLogEvent(ctx, userSession.Username, "Second Factor Method Added", map[string]any{"Action": "Second Factor Method Added", "Category": "Webauthn Credential", "Credential Description": bodyJSON.Description}) } diff --git a/internal/handlers/handler_sign_webauthn.go b/internal/handlers/handler_sign_webauthn.go index da7b88f82..435002929 100644 --- a/internal/handlers/handler_sign_webauthn.go +++ b/internal/handlers/handler_sign_webauthn.go @@ -137,7 +137,7 @@ func WebauthnAssertionPOST(ctx *middlewares.AutheliaCtx) { user *model.WebauthnUser ) - if assertionResponse, err = protocol.ParseCredentialRequestResponseBody(bytes.NewReader(ctx.PostBody())); err != nil { + if assertionResponse, err = protocol.ParseCredentialRequestResponseBody(bytes.NewReader(bodyJSON.Response)); err != nil { ctx.Logger.Errorf("Unable to parse %s assertionfor user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) respondUnauthorized(ctx, messageMFAValidationFailed) diff --git a/internal/handlers/types.go b/internal/handlers/types.go index 51d33db0b..7ca95047b 100644 --- a/internal/handlers/types.go +++ b/internal/handlers/types.go @@ -1,6 +1,7 @@ package handlers import ( + "encoding/json" "net/http" "net/url" @@ -35,6 +36,14 @@ type bodySignWebauthnRequest struct { TargetURL string `json:"targetURL"` Workflow string `json:"workflow"` WorkflowID string `json:"workflowID"` + + Response json.RawMessage `json:"response"` +} + +type bodyRegisterWebauthnRequest struct { + Description string `json:"description"` + + Response json.RawMessage `json:"response"` } type bodyEditWebauthnDeviceRequest struct { diff --git a/internal/middlewares/identity_verification.go b/internal/middlewares/identity_verification.go index 825918e72..125ff138a 100644 --- a/internal/middlewares/identity_verification.go +++ b/internal/middlewares/identity_verification.go @@ -96,23 +96,21 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim } func identityVerificationValidateToken(ctx *AutheliaCtx) (*jwt.Token, error) { - var finishBody IdentityVerificationFinishBody + var bodyJSON IdentityVerificationFinishBody - b := ctx.PostBody() - - err := json.Unmarshal(b, &finishBody) + err := json.Unmarshal(ctx.PostBody(), &bodyJSON) if err != nil { ctx.Error(err, messageOperationFailed) return nil, err } - if finishBody.Token == "" { + if bodyJSON.Token == "" { ctx.Error(fmt.Errorf("No token provided"), messageOperationFailed) return nil, err } - token, err := jwt.ParseWithClaims(finishBody.Token, &model.IdentityVerificationClaim{}, + token, err := jwt.ParseWithClaims(bodyJSON.Token, &model.IdentityVerificationClaim{}, func(token *jwt.Token) (any, error) { return []byte(ctx.Configuration.JWTSecret), nil }) @@ -185,8 +183,7 @@ func IdentityVerificationFinish(args IdentityVerificationFinishArgs, next func(c return } - err = ctx.Providers.StorageProvider.ConsumeIdentityVerification(ctx, claims.ID, model.NewNullIP(ctx.RemoteIP())) - if err != nil { + if err = ctx.Providers.StorageProvider.ConsumeIdentityVerification(ctx, claims.ID, model.NewNullIP(ctx.RemoteIP())); err != nil { ctx.Error(err, messageOperationFailed) return } diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 47e5217d7..645fa2a2e 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -238,7 +238,11 @@ func handleRouter(config schema.Configuration, providers middlewares.Providers) // Webauthn Endpoints. r.POST("/api/secondfactor/webauthn/identity/start", middleware1FA(handlers.WebauthnIdentityStart)) r.POST("/api/secondfactor/webauthn/identity/finish", middleware1FA(handlers.WebauthnIdentityFinish)) - r.POST("/api/secondfactor/webauthn/attestation", middleware1FA(handlers.WebauthnAttestationPOST)) + + r.GET("/api/secondfactor/register/webauthn", middleware1FA(handlers.WebauthnRegistrationGET)) + r.POST("/api/secondfactor/register/webauthn", middleware1FA(handlers.WebauthnRegistrationPOST)) + + r.POST("/api/secondfactor/webauthn/attestation", middleware1FA(handlers.WebauthnRegistrationPOST)) r.GET("/api/secondfactor/webauthn/assertion", middleware1FA(handlers.WebauthnAssertionGET)) r.POST("/api/secondfactor/webauthn/assertion", middleware1FA(handlers.WebauthnAssertionPOST)) diff --git a/web/package.json b/web/package.json index fc27d2ef9..7c1b7b1ee 100644 --- a/web/package.json +++ b/web/package.json @@ -28,6 +28,8 @@ "@mui/icons-material": "5.11.0", "@mui/material": "5.11.6", "@mui/styles": "5.11.2", + "@simplewebauthn/browser": "^7.0.1", + "@simplewebauthn/typescript-types": "^7.0.0", "axios": "1.2.6", "broadcast-channel": "4.20.2", "classnames": "2.3.2", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index adfc120c5..f3d9d3822 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -14,6 +14,8 @@ specifiers: '@mui/icons-material': 5.11.0 '@mui/material': 5.11.6 '@mui/styles': 5.11.2 + '@simplewebauthn/browser': ^7.0.1 + '@simplewebauthn/typescript-types': ^7.0.0 '@testing-library/jest-dom': 5.16.5 '@testing-library/react': 13.4.0 '@types/jest': 29.4.0 @@ -76,6 +78,8 @@ dependencies: '@mui/icons-material': 5.11.0_j5wvuqirnhynb4halegp2mqooy '@mui/material': 5.11.6_rqh7qj4464ntrqrt6banhaqg4q '@mui/styles': 5.11.2_3stiutgnnbnfnf3uowm5cip22i + '@simplewebauthn/browser': 7.0.1 + '@simplewebauthn/typescript-types': 7.0.0 axios: 1.2.6 broadcast-channel: 4.20.2 classnames: 2.3.2 @@ -3395,6 +3399,14 @@ packages: resolution: {integrity: sha512-LwzQKA4vzIct1zNZzBmRKI9QuNpLgTQMEjsQLf3BXuGYb3QPTP4Yjf6mkdX+X1mYttZ808QpOwAzZjv28kq7DA==} dev: true + /@simplewebauthn/browser/7.0.1: + resolution: {integrity: sha512-zFTTQojH8FWAvzBeB9WHMkZz5XrHaNQwKmdpGDfyRtXHJSLlWUjZN1egD2vWqU+gE0CKl0NRZJ56uLugBs9AUg==} + dev: false + + /@simplewebauthn/typescript-types/7.0.0: + resolution: {integrity: sha512-bV+xACCFTsrLR/23ozHO06ZllHZaxC8LlI5YCo79GvU2BrN+rePDU2yXwZIYndNWcMQwRdndRdAhpafOh9AC/g==} + dev: false + /@sinclair/typebox/0.25.21: resolution: {integrity: sha512-gFukHN4t8K4+wVC+ECqeqwzBDeFeTzBXroBTqE6vcWrQGbEUpHO7LYdG0f4xnvYq4VOEwITSlHlp0JBAIFMS/g==} dev: true diff --git a/web/src/components/WebauthnTryIcon.tsx b/web/src/components/WebauthnTryIcon.tsx index 1a96bf163..a7c9fd1d6 100644 --- a/web/src/components/WebauthnTryIcon.tsx +++ b/web/src/components/WebauthnTryIcon.tsx @@ -11,7 +11,6 @@ import { WebauthnTouchState } from "@models/Webauthn"; import IconWithContext from "@views/LoginPortal/SecondFactor/IconWithContext"; interface Props { - timer: number; onRetryClick: () => void; webauthnTouchState: WebauthnTouchState; } @@ -61,7 +60,7 @@ export default function WebauthnTryIcon(props: Props) { ); return ( - + {touch} {failure} diff --git a/web/src/models/Webauthn.ts b/web/src/models/Webauthn.ts index 6eb9dd285..c26a87246 100644 --- a/web/src/models/Webauthn.ts +++ b/web/src/models/Webauthn.ts @@ -1,5 +1,12 @@ +import { + AuthenticationResponseJSON, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + RegistrationResponseJSON, +} from "@simplewebauthn/typescript-types"; + export interface PublicKeyCredentialCreationOptionsStatus { - options?: PublicKeyCredentialCreationOptions; + options?: PublicKeyCredentialCreationOptionsJSON; status: number; } @@ -7,15 +14,8 @@ export interface CredentialCreation { publicKey: PublicKeyCredentialCreationOptionsJSON; } -export interface PublicKeyCredentialCreationOptionsJSON - extends Omit { - challenge: string; - excludeCredentials?: PublicKeyCredentialDescriptorJSON[]; - user: PublicKeyCredentialUserEntityJSON; -} - export interface PublicKeyCredentialRequestOptionsStatus { - options?: PublicKeyCredentialRequestOptions; + options?: PublicKeyCredentialRequestOptionsJSON; status: number; } @@ -23,63 +23,6 @@ export interface CredentialRequest { publicKey: PublicKeyCredentialRequestOptionsJSON; } -export interface PublicKeyCredentialRequestOptionsJSON - extends Omit { - allowCredentials?: PublicKeyCredentialDescriptorJSON[]; - challenge: string; -} - -export interface PublicKeyCredentialDescriptorJSON extends Omit { - id: string; -} - -export interface PublicKeyCredentialUserEntityJSON extends Omit { - id: string; -} - -export interface AuthenticatorAssertionResponseJSON - extends Omit { - authenticatorData: string; - clientDataJSON: string; - signature: string; - userHandle: string; -} - -export interface AuthenticatorAttestationResponseFuture extends AuthenticatorAttestationResponse { - getTransports?: () => AuthenticatorTransport[]; - getAuthenticatorData?: () => ArrayBuffer; - getPublicKey?: () => ArrayBuffer; - getPublicKeyAlgorithm?: () => COSEAlgorithmIdentifier[]; -} - -export interface AttestationPublicKeyCredential extends PublicKeyCredential { - response: AuthenticatorAttestationResponseFuture; -} - -export interface AuthenticatorAttestationResponseJSON - extends Omit { - clientDataJSON: string; - attestationObject: string; -} - -export interface AttestationPublicKeyCredentialJSON - extends Omit { - rawId: string; - response: AuthenticatorAttestationResponseJSON; - clientExtensionResults: AuthenticationExtensionsClientOutputs; - transports?: AuthenticatorTransport[]; -} - -export interface PublicKeyCredentialJSON - extends Omit { - rawId: string; - clientExtensionResults: AuthenticationExtensionsClientOutputs; - response: AuthenticatorAssertionResponseJSON; - targetURL?: string; - workflow?: string; - workflowID?: string; -} - export enum AttestationResult { Success = 1, Failure, @@ -93,21 +36,11 @@ export enum AttestationResult { FailureToken, } -export interface AttestationPublicKeyCredentialResult { - credential?: AttestationPublicKeyCredential; +export interface RegistrationResult { + response?: RegistrationResponseJSON; result: AttestationResult; } -export interface AttestationPublicKeyCredentialResultJSON { - credential?: AttestationPublicKeyCredentialJSON; - result: AttestationResult; -} - -export interface AttestationFinishResult { - result: AttestationResult; - message: string; -} - export enum AssertionResult { Success = 1, Failure, @@ -121,18 +54,54 @@ export enum AssertionResult { FailureUnrecognized, } -export interface DiscoverableAssertionResult { - result: AssertionResult; - username: string; +export function AssertionResultFailureString(result: AssertionResult) { + switch (result) { + case AssertionResult.Success: + return ""; + case AssertionResult.FailureUserConsent: + return "You cancelled the assertion request."; + case AssertionResult.FailureU2FFacetID: + return "The server responded with an invalid Facet ID for the URL."; + case AssertionResult.FailureSyntax: + return "The assertion challenge was rejected as malformed or incompatible by your browser."; + case AssertionResult.FailureWebauthnNotSupported: + return "Your browser does not support the WebAuthN protocol."; + case AssertionResult.FailureUnrecognized: + return "This device is not registered."; + case AssertionResult.FailureUnknownSecurity: + return "An unknown security error occurred."; + case AssertionResult.FailureUnknown: + return "An unknown error occurred."; + default: + return "An unexpected error occurred."; + } } -export interface AssertionPublicKeyCredentialResult { - credential?: PublicKeyCredential; - result: AssertionResult; +export function AttestationResultFailureString(result: AttestationResult) { + switch (result) { + case AttestationResult.FailureToken: + return "You must open the link from the same device and browser that initiated the registration process."; + case AttestationResult.FailureSupport: + return "Your browser does not appear to support the configuration."; + case AttestationResult.FailureSyntax: + return "The attestation challenge was rejected as malformed or incompatible by your browser."; + case AttestationResult.FailureWebauthnNotSupported: + return "Your browser does not support the WebAuthN protocol."; + case AttestationResult.FailureUserConsent: + return "You cancelled the attestation request."; + case AttestationResult.FailureUserVerificationOrResidentKey: + return "Your device does not support user verification or resident keys but this was required."; + case AttestationResult.FailureExcluded: + return "You have registered this device already."; + case AttestationResult.FailureUnknown: + return "An unknown error occurred."; + } + + return ""; } -export interface AssertionPublicKeyCredentialResultJSON { - credential?: PublicKeyCredentialJSON; +export interface AuthenticationResult { + response?: AuthenticationResponseJSON; result: AssertionResult; } @@ -156,7 +125,3 @@ export enum WebauthnTouchState { InProgress = 2, Failure = 3, } - -export interface WebauthnDeviceUpdateRequest { - description: string; -} diff --git a/web/src/services/Api.ts b/web/src/services/Api.ts index f2a0e1eb6..338963bf3 100644 --- a/web/src/services/Api.ts +++ b/web/src/services/Api.ts @@ -12,8 +12,7 @@ export const InitiateTOTPRegistrationPath = basePath + "/api/secondfactor/totp/i export const CompleteTOTPRegistrationPath = basePath + "/api/secondfactor/totp/identity/finish"; export const WebauthnIdentityStartPath = basePath + "/api/secondfactor/webauthn/identity/start"; -export const WebauthnIdentityFinishPath = basePath + "/api/secondfactor/webauthn/identity/finish"; -export const WebauthnAttestationPath = basePath + "/api/secondfactor/webauthn/attestation"; +export const WebauthnRegistrationPath = basePath + "/api/secondfactor/register/webauthn"; export const WebauthnAssertionPath = basePath + "/api/secondfactor/webauthn/assertion"; diff --git a/web/src/services/Webauthn.ts b/web/src/services/Webauthn.ts index ea354123a..c1409f119 100644 --- a/web/src/services/Webauthn.ts +++ b/web/src/services/Webauthn.ts @@ -1,35 +1,32 @@ +import { startAuthentication, startRegistration } from "@simplewebauthn/browser"; +import { + AuthenticationResponseJSON, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + RegistrationResponseJSON, +} from "@simplewebauthn/typescript-types"; import axios, { AxiosError, AxiosResponse } from "axios"; import { - AssertionPublicKeyCredentialResult, AssertionResult, - AttestationFinishResult, - AttestationPublicKeyCredential, - AttestationPublicKeyCredentialJSON, - AttestationPublicKeyCredentialResult, AttestationResult, - AuthenticatorAttestationResponseFuture, + AuthenticationResult, CredentialCreation, CredentialRequest, - PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialCreationOptionsStatus, - PublicKeyCredentialDescriptorJSON, - PublicKeyCredentialJSON, - PublicKeyCredentialRequestOptionsJSON, PublicKeyCredentialRequestOptionsStatus, + RegistrationResult, } from "@models/Webauthn"; import { AuthenticationOKResponse, OptionalDataServiceResponse, ServiceResponse, WebauthnAssertionPath, - WebauthnAttestationPath, WebauthnDevicePath, - WebauthnIdentityFinishPath, + WebauthnRegistrationPath, validateStatusAuthentication, } from "@services/Api"; import { SignInResponse } from "@services/SignIn"; -import { getBase64WebEncodingFromBytes, getBytesFromBase64 } from "@utils/Base64"; export function isWebauthnSecure(): boolean { if (window.isSecureContext) { @@ -51,120 +48,6 @@ export async function isWebauthnPlatformAuthenticatorAvailable(): Promise { +export async function getAttestationCreationOptions(): Promise { let response: AxiosResponse>; - response = await axios.post>(WebauthnIdentityFinishPath, { - token: token, - }); + response = await axios.get>(WebauthnRegistrationPath); if (response.data.status !== "OK" || response.data.data == null) { return { @@ -237,12 +116,12 @@ export async function getAttestationCreationOptions( } return { - options: decodePublicKeyCredentialCreationOptions(response.data.data.publicKey), + options: response.data.data.publicKey, status: response.status, }; } -export async function getAssertionRequestOptions(): Promise { +export async function getAuthenticationOptions(): Promise { let response: AxiosResponse>; response = await axios.get>(WebauthnAssertionPath); @@ -254,65 +133,55 @@ export async function getAssertionRequestOptions(): Promise { - const result: AttestationPublicKeyCredentialResult = { - result: AttestationResult.Success, +export async function startWebauthnRegistration(options: PublicKeyCredentialCreationOptionsJSON) { + const result: RegistrationResult = { + result: AttestationResult.Failure, }; try { - result.credential = (await navigator.credentials.create({ - publicKey: creationOptions, - })) as AttestationPublicKeyCredential; + result.response = await startRegistration(options); } catch (e) { - result.result = AttestationResult.Failure; - const exception = e as DOMException; if (exception !== undefined) { result.result = getAttestationResultFromDOMException(exception); return result; } else { - console.error(`Unhandled exception occurred during WebAuthN attestation: ${e}`); + console.error(`Unhandled exception occurred during WebAuthn attestation: ${e}`); } } - if (result.credential != null) { + if (result.response != null) { result.result = AttestationResult.Success; } return result; } -export async function getAssertionPublicKeyCredentialResult( - requestOptions: PublicKeyCredentialRequestOptions, -): Promise { - const result: AssertionPublicKeyCredentialResult = { +export async function getAuthenticationResult(options: PublicKeyCredentialRequestOptionsJSON) { + const result: AuthenticationResult = { result: AssertionResult.Success, }; try { - result.credential = (await navigator.credentials.get({ publicKey: requestOptions })) as PublicKeyCredential; + result.response = await startAuthentication(options); } catch (e) { - result.result = AssertionResult.Failure; - const exception = e as DOMException; if (exception !== undefined) { - result.result = getAssertionResultFromDOMException(exception, requestOptions); + result.result = getAssertionResultFromDOMException(exception, options); return result; } else { - console.error(`Unhandled exception occurred during WebAuthN assertion: ${e}`); + console.error(`Unhandled exception occurred during WebAuthn authentication: ${e}`); } } - if (result.credential == null) { + if (result.response == null) { result.result = AssertionResult.Failure; } else { result.result = AssertionResult.Success; @@ -321,84 +190,53 @@ export async function getAssertionPublicKeyCredentialResult( return result; } -async function postAttestationPublicKeyCredentialResult( - credential: AttestationPublicKeyCredential, +async function postRegistrationResponse( + response: RegistrationResponseJSON, description: string, ): Promise>> { - const credentialJSON = encodeAttestationPublicKeyCredential(credential); - const postBody = { - credential: credentialJSON, + return axios.post>(WebauthnRegistrationPath, { + response: response, description: description, - }; - return axios.post>(WebauthnAttestationPath, postBody); + }); } -export async function postAssertionPublicKeyCredentialResult( - credential: PublicKeyCredential, +export async function postAuthenticationResponse( + response: AuthenticationResponseJSON, targetURL: string | undefined, workflow?: string, workflowID?: string, -): Promise>> { - const credentialJSON = encodeAssertionPublicKeyCredential(credential, targetURL, workflow, workflowID); - return axios.post>(WebauthnAssertionPath, credentialJSON); +) { + return axios.post>(WebauthnAssertionPath, { + response: response, + targetURL: targetURL, + workflow: workflow, + workflowID: workflowID, + }); } -export async function finishAttestationCeremony( - credential: AttestationPublicKeyCredential, - description: string, -): Promise { +export async function finishRegistration(response: RegistrationResponseJSON, description: string) { let result = { status: AttestationResult.Failure, message: "Device registration failed.", - } as AttestationResult; + }; + try { - const response = await postAttestationPublicKeyCredentialResult(credential, description); - if (response.data.status === "OK" && (response.status === 200 || response.status === 201)) { + const resp = await postRegistrationResponse(response, description); + if (resp.data.status === "OK" && (resp.status === 200 || resp.status === 201)) { return { status: AttestationResult.Success, - } as AttestationFinishResult; + message: "", + }; } } catch (error) { - if (error instanceof AxiosError) { + if (error instanceof AxiosError && error.response !== undefined) { result.message = error.response.data.message; } } + return result; } -export async function performAssertionCeremony( - targetURL?: string, - workflow?: string, - workflowID?: string, -): Promise { - const assertionRequestOpts = await getAssertionRequestOptions(); - - if (assertionRequestOpts.status !== 200 || assertionRequestOpts.options == null) { - return AssertionResult.FailureChallenge; - } - - const assertionResult = await getAssertionPublicKeyCredentialResult(assertionRequestOpts.options); - - if (assertionResult.result !== AssertionResult.Success) { - return assertionResult.result; - } else if (assertionResult.credential == null) { - return AssertionResult.Failure; - } - - const response = await postAssertionPublicKeyCredentialResult( - assertionResult.credential, - targetURL, - workflow, - workflowID, - ); - - if (response.data.status === "OK" && response.status === 200) { - return AssertionResult.Success; - } - - return AssertionResult.Failure; -} - export async function deleteDevice(deviceID: string) { return await axios({ method: "DELETE", diff --git a/web/src/utils/Base64.ts b/web/src/utils/Base64.ts deleted file mode 100644 index e66dfaf77..000000000 --- a/web/src/utils/Base64.ts +++ /dev/null @@ -1,209 +0,0 @@ -/* - -This file is a work taken from the following location: https://gist.github.com/enepomnyaschih/72c423f727d395eeaa09697058238727 - -MIT License - -Copyright (c) 2020 Egor Nepomnyaschih - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ - -/* -// This constant can also be computed with the following algorithm: -const base64Chars = [], - A = "A".charCodeAt(0), - a = "a".charCodeAt(0), - n = "0".charCodeAt(0); -for (let i = 0; i < 26; ++i) { - base64Chars.push(String.fromCharCode(A + i)); -} -for (let i = 0; i < 26; ++i) { - base64Chars.push(String.fromCharCode(a + i)); -} -for (let i = 0; i < 10; ++i) { - base64Chars.push(String.fromCharCode(n + i)); -} -base64Chars.push("+"); -base64Chars.push("/"); -*/ - -const base64Chars = [ - "A", - "B", - "C", - "D", - "E", - "F", - "G", - "H", - "I", - "J", - "K", - "L", - "M", - "N", - "O", - "P", - "Q", - "R", - "S", - "T", - "U", - "V", - "W", - "X", - "Y", - "Z", - "a", - "b", - "c", - "d", - "e", - "f", - "g", - "h", - "i", - "j", - "k", - "l", - "m", - "n", - "o", - "p", - "q", - "r", - "s", - "t", - "u", - "v", - "w", - "x", - "y", - "z", - "0", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "+", - "/", -]; - -/* -// This constant can also be computed with the following algorithm: -const l = 256, base64codes = new Uint8Array(l); -for (let i = 0; i < l; ++i) { - base64codes[i] = 255; // invalid character -} -base64Chars.forEach((char, index) => { - base64codes[char.charCodeAt(0)] = index; -}); -base64codes["=".charCodeAt(0)] = 0; // ignored anyway, so we just need to prevent an error -*/ - -const base64Codes = [ - 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, - 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 62, 255, 255, - 255, 63, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 255, 255, 255, 0, 255, 255, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, - 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 255, 255, 255, 255, 255, 255, 26, 27, 28, 29, 30, 31, - 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -]; - -function getBase64Code(charCode: number) { - if (charCode >= base64Codes.length) { - throw new Error("Unable to parse base64 string."); - } - - const code = base64Codes[charCode]; - if (code === 255) { - throw new Error("Unable to parse base64 string."); - } - - return code; -} - -export function getBase64FromBytes(bytes: number[] | Uint8Array): string { - let result = "", - i, - l = bytes.length; - - for (i = 2; i < l; i += 3) { - result += base64Chars[bytes[i - 2] >> 2]; - result += base64Chars[((bytes[i - 2] & 0x03) << 4) | (bytes[i - 1] >> 4)]; - result += base64Chars[((bytes[i - 1] & 0x0f) << 2) | (bytes[i] >> 6)]; - result += base64Chars[bytes[i] & 0x3f]; - } - - if (i === l + 1) { - // 1 octet yet to write - result += base64Chars[bytes[i - 2] >> 2]; - result += base64Chars[(bytes[i - 2] & 0x03) << 4]; - result += "=="; - } - - if (i === l) { - // 2 octets yet to write - result += base64Chars[bytes[i - 2] >> 2]; - result += base64Chars[((bytes[i - 2] & 0x03) << 4) | (bytes[i - 1] >> 4)]; - result += base64Chars[(bytes[i - 1] & 0x0f) << 2]; - result += "="; - } - - return result; -} - -export function getBase64WebEncodingFromBytes(bytes: number[] | Uint8Array): string { - return getBase64FromBytes(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); -} - -export function getBytesFromBase64(str: string): Uint8Array { - if (str.length % 4 !== 0) { - throw new Error("Unable to parse base64 string."); - } - - const index = str.indexOf("="); - - if (index !== -1 && index < str.length - 2) { - throw new Error("Unable to parse base64 string."); - } - - let missingOctets = str.endsWith("==") ? 2 : str.endsWith("=") ? 1 : 0, - n = str.length, - result = new Uint8Array(3 * (n / 4)), - buffer; - - for (let i = 0, j = 0; i < n; i += 4, j += 3) { - buffer = - (getBase64Code(str.charCodeAt(i)) << 18) | - (getBase64Code(str.charCodeAt(i + 1)) << 12) | - (getBase64Code(str.charCodeAt(i + 2)) << 6) | - getBase64Code(str.charCodeAt(i + 3)); - result[j] = buffer >> 16; - result[j + 1] = (buffer >> 8) & 0xff; - result[j + 2] = buffer & 0xff; - } - - return result.subarray(0, result.length - missingOctets); -} diff --git a/web/src/views/DeviceRegistration/RegisterWebauthn.tsx b/web/src/views/DeviceRegistration/RegisterWebauthn.tsx index 0d4ff852b..8a630088e 100644 --- a/web/src/views/DeviceRegistration/RegisterWebauthn.tsx +++ b/web/src/views/DeviceRegistration/RegisterWebauthn.tsx @@ -2,6 +2,7 @@ import React, { Fragment, MutableRefObject, useCallback, useEffect, useRef, useS import { Box, Button, Grid, Stack, Step, StepLabel, Stepper, Theme, Typography } from "@mui/material"; import makeStyles from "@mui/styles/makeStyles"; +import { PublicKeyCredentialCreationOptionsJSON } from "@simplewebauthn/typescript-types"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -10,16 +11,15 @@ import InformationIcon from "@components/InformationIcon"; import SuccessIcon from "@components/SuccessIcon"; import WebauthnTryIcon from "@components/WebauthnTryIcon"; import { SettingsRoute, SettingsTwoFactorAuthenticationSubRoute } from "@constants/Routes"; -import { IdentityToken } from "@constants/SearchParams"; import { useNotifications } from "@hooks/NotificationsContext"; -import { useQueryParam } from "@hooks/QueryParam"; import LoginLayout from "@layouts/LoginLayout"; -import { AttestationPublicKeyCredential, AttestationResult, WebauthnTouchState } from "@models/Webauthn"; import { - finishAttestationCeremony, - getAttestationCreationOptions, - getAttestationPublicKeyCredentialResult, -} from "@services/Webauthn"; + AttestationResult, + AttestationResultFailureString, + RegistrationResult, + WebauthnTouchState, +} from "@models/Webauthn"; +import { finishRegistration, getAttestationCreationOptions, startWebauthnRegistration } from "@services/Webauthn"; const steps = ["Confirm device", "Choose name"]; @@ -33,83 +33,60 @@ const RegisterWebauthn = function (props: Props) { const { createErrorNotification } = useNotifications(); const [activeStep, setActiveStep] = React.useState(0); - const [credential, setCredential] = React.useState(null as null | AttestationPublicKeyCredential); - const [creationOptions, setCreationOptions] = useState(null as null | PublicKeyCredentialCreationOptions); + const [result, setResult] = React.useState(null as null | RegistrationResult); + const [options, setOptions] = useState(null as null | PublicKeyCredentialCreationOptionsJSON); const [deviceName, setName] = useState(""); const nameRef = useRef() as MutableRefObject; const [nameError, setNameError] = useState(false); - const processToken = useQueryParam(IdentityToken); - const handleBackClick = () => { navigate(`${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`); }; const finishAttestation = async () => { - if (!credential) { + if (!result || !result.response) { return; } + if (!deviceName.length) { setNameError(true); return; } - const result = await finishAttestationCeremony(credential, deviceName); - switch (result.status) { + + const res = await finishRegistration(result.response, deviceName); + switch (res.status) { case AttestationResult.Success: setActiveStep(2); navigate(`${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`); break; case AttestationResult.Failure: - createErrorNotification(result.message); + createErrorNotification(res.message); } }; - const startAttestation = useCallback(async () => { + const startRegistration = useCallback(async () => { + if (options === null) { + return; + } + try { setState(WebauthnTouchState.WaitTouch); setActiveStep(0); - const startResult = await getAttestationPublicKeyCredentialResult(creationOptions); + const res = await startWebauthnRegistration(options); - switch (startResult.result) { - case AttestationResult.Success: - if (startResult.credential == null) { - throw new Error("Attestation request succeeded but credential is empty"); - } - setCredential(startResult.credential); - setActiveStep(1); - return; - case AttestationResult.FailureToken: - createErrorNotification( - "You must open the link from the same device and browser that initiated the registration process.", - ); - break; - case AttestationResult.FailureSupport: - createErrorNotification("Your browser does not appear to support the configuration."); - break; - case AttestationResult.FailureSyntax: - createErrorNotification( - "The attestation challenge was rejected as malformed or incompatible by your browser.", - ); - break; - case AttestationResult.FailureWebauthnNotSupported: - createErrorNotification("Your browser does not support the WebAuthN protocol."); - break; - case AttestationResult.FailureUserConsent: - createErrorNotification("You cancelled the attestation request."); - break; - case AttestationResult.FailureUserVerificationOrResidentKey: - createErrorNotification( - "Your device does not support user verification or resident keys but this was required.", - ); - break; - case AttestationResult.FailureExcluded: - createErrorNotification("You have registered this device already."); - break; - case AttestationResult.FailureUnknown: - createErrorNotification("An unknown error occurred."); - break; + if (res.result === AttestationResult.Success) { + if (res.response == null) { + throw new Error("Attestation request succeeded but credential is empty"); + } + + setResult(res); + setActiveStep(1); + + return; } + + createErrorNotification(AttestationResultFailureString(res.result)); setState(WebauthnTouchState.Failure); } catch (err) { console.error(err); @@ -117,26 +94,26 @@ const RegisterWebauthn = function (props: Props) { "Failed to register your device. The identity verification process might have timed out.", ); } - }, [creationOptions, createErrorNotification]); + }, [options, createErrorNotification]); useEffect(() => { - if (creationOptions !== null) { - startAttestation(); + if (options !== null) { + startRegistration(); } - }, [creationOptions, startAttestation]); + }, [options, startRegistration]); useEffect(() => { (async () => { - const result = await getAttestationCreationOptions(processToken); - if (result.status !== 200 || !result.options) { + const res = await getAttestationCreationOptions(); + if (res.status !== 200 || !res.options) { createErrorNotification( "You must open the link from the same device and browser that initiated the registration process.", ); return; } - setCreationOptions(result.options); + setOptions(res.options); })(); - }, [processToken, setCreationOptions, createErrorNotification]); + }, [setOptions, createErrorNotification]); function renderStep(step: number) { switch (step) { @@ -144,10 +121,10 @@ const RegisterWebauthn = function (props: Props) { return (
- +
Touch the token on your security key - +