Compare commits
101 Commits
master
...
feat-setti
Author | SHA1 | Date |
---|---|---|
James Elliott | 53d3cdb271 | |
James Elliott | b4083df061 | |
James Elliott | 1334e9e007 | |
James Elliott | 91ebdd6bc6 | |
James Elliott | ecbd6511e1 | |
James Elliott | 5e9fe907c8 | |
James Elliott | bb563f4baa | |
James Elliott | 5faffbe46b | |
James Elliott | 12443920e6 | |
James Elliott | 873749a28f | |
James Elliott | 79973414e4 | |
James Elliott | 7c69152a86 | |
James Elliott | c3e785872d | |
James Elliott | a8a8089f33 | |
James Elliott | 29ddc73012 | |
James Elliott | e464295c8b | |
James Elliott | 1341ef79d6 | |
James Elliott | 53668e0c60 | |
James Elliott | 6c89ee1f9c | |
James Elliott | 86b525ce21 | |
James Elliott | d97c0eb0ea | |
James Elliott | f549afd480 | |
James Elliott | f35e49a1fd | |
James Elliott | 774f64a932 | |
James Elliott | f3d447d76a | |
James Elliott | 51e1f41620 | |
James Elliott | 7fdcc351d4 | |
James Elliott | 928df8a698 | |
James Elliott | 904b659fcb | |
James Elliott | 54509bcc60 | |
James Elliott | 1ba4f705f0 | |
James Elliott | 4f46514fdf | |
James Elliott | e584e0c4a3 | |
James Elliott | 7ef1ba23df | |
James Elliott | b6883a337f | |
James Elliott | e64661af3f | |
James Elliott | 8b8d6ce417 | |
James Elliott | e6ef74fd8e | |
James Elliott | f2e40a72e7 | |
James Elliott | ea2350f0e4 | |
James Elliott | a3d7212f23 | |
James Elliott | 257bd2a25a | |
James Elliott | 3e53ae7b2e | |
James Elliott | a6cc022e5c | |
James Elliott | a13a3c45f2 | |
James Elliott | e5cdb175b4 | |
James Elliott | 5be5de02d8 | |
James Elliott | e84ca4956a | |
James Elliott | 236fcb1e37 | |
James Elliott | ee56740f46 | |
James Elliott | 130a28a430 | |
James Elliott | 526dd8347d | |
James Elliott | ba1ed1252c | |
James Elliott | 515309c10e | |
James Elliott | 7e56cf2d15 | |
James Elliott | d0160edc70 | |
James Elliott | be21d73c72 | |
James Elliott | 40e247fcee | |
James Elliott | f920ef9dd9 | |
James Elliott | 62fa7a6244 | |
James Elliott | 3b6f5482b8 | |
James Elliott | 8c057f65a5 | |
James Elliott | 852dc808bd | |
James Elliott | 1f1210c6ac | |
James Elliott | dc334234a8 | |
James Elliott | 9e5aa1c1a9 | |
James Elliott | d7be1c1359 | |
James Elliott | 3af20a7daf | |
James Elliott | a36c45f1e1 | |
James Elliott | 4bed5d2461 | |
James Elliott | 7d17c39c52 | |
James Elliott | 25244c42f1 | |
James Elliott | bd279900ca | |
James Elliott | 49d421e910 | |
James Elliott | 917ac89e38 | |
James Elliott | dd781ffc51 | |
James Elliott | 4239db6171 | |
James Elliott | f2ee86472d | |
James Elliott | 0e2770e72d | |
James Elliott | 4a2fd3dea7 | |
James Elliott | a186dca3bf | |
James Elliott | 67381b1318 | |
Stephen Kent | 326ed60a65 | |
James Elliott | 133f1626ab | |
Stephen Kent | d6f1365d42 | |
Stephen Kent | 33520daa10 | |
Stephen Kent | 24d947624b | |
Stephen Kent | b842a22236 | |
Stephen Kent | 2967500401 | |
James Elliott | 6f8b6adfb5 | |
James Elliott | 5d1b840e2b | |
Stephen Kent | 2584e3d328 | |
James Elliott | ff26673659 | |
James Elliott | 0f8de33f2f | |
Stephen Kent | dcd65515fc | |
James Elliott | 164fc5e80d | |
James Elliott | 1a1b85489c | |
Stephen Kent | 92b3a5804b | |
James Elliott | bbc9e6422e | |
James Elliott | 9b66bb4fe2 | |
Clément Michaud | a69ba22f46 |
|
@ -849,6 +849,45 @@ paths:
|
|||
$ref: '#/components/schemas/middlewares.OkResponse'
|
||||
security:
|
||||
- authelia_auth: []
|
||||
/api/secondfactor/webauthn/devices/{deviceID}:
|
||||
delete:
|
||||
tags:
|
||||
- Second Factor
|
||||
summary: WebAuthn Device Deletion
|
||||
description: This endpoint deletes the specified WebAuthn credential.
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/middlewares.OkResponse'
|
||||
security:
|
||||
- authelia_auth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/deviceID'
|
||||
put:
|
||||
tags:
|
||||
- Second Factor
|
||||
summary: WebAuthn Device Update
|
||||
description: This endpoint updates the description of the specified WebAuthn credential.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/webauthn.DeviceUpdateRequest'
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/middlewares.OkResponse'
|
||||
security:
|
||||
- authelia_auth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/deviceID'
|
||||
{{- end }}
|
||||
{{- if .Duo }}
|
||||
/api/secondfactor/duo:
|
||||
|
@ -1433,6 +1472,13 @@ paths:
|
|||
{{- end }}
|
||||
components:
|
||||
parameters:
|
||||
deviceID:
|
||||
in: path
|
||||
name: deviceID
|
||||
schema:
|
||||
type: integer
|
||||
required: true
|
||||
description: Numeric WebAuthn Device ID
|
||||
originalMethodParam:
|
||||
name: X-Original-Method
|
||||
in: header
|
||||
|
@ -1917,6 +1963,9 @@ components:
|
|||
type: string
|
||||
format: byte
|
||||
webauthn.CredentialAttestationResponse:
|
||||
type: object
|
||||
properties:
|
||||
credential:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/webauthn.PublicKeyCredential'
|
||||
- type: object
|
||||
|
@ -1934,6 +1983,8 @@ components:
|
|||
attestationObject:
|
||||
type: string
|
||||
format: byte
|
||||
description:
|
||||
type: string
|
||||
webauthn.CredentialAssertionResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/webauthn.PublicKeyCredential'
|
||||
|
@ -1971,6 +2022,11 @@ components:
|
|||
format: uuid
|
||||
pattern: '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$'
|
||||
example: '3ebcfbc5-b0fd-4ee0-9d3c-080ae1e7298c'
|
||||
webauthn.DeviceUpdateRequest:
|
||||
type: object
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
webauthn.PublicKeyCredentialCreationOptions:
|
||||
type: object
|
||||
properties:
|
||||
|
|
|
@ -38,3 +38,4 @@ this instance if you wanted to downgrade to pre1 you would need to use an Authel
|
|||
| 7 | 4.37.3 | Fixed some schema inconsistencies most notably the MySQL/MariaDB Engine and Collation |
|
||||
| 8 | 4.38.0 | OpenID Connect 1.0 Pushed Authorization Requests |
|
||||
| 9 | 4.38.0 | Fix a PostgreSQL NOT NULL constraint issue on the `aaguid` column of the `webauthn_devices` table |
|
||||
| 10 | 4.38.0 | WebAuthn adjustments for multi-cookie domain changes |
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
title: "Database Schema"
|
||||
description: "Authelia Development Database Schema Guidelines"
|
||||
lead: "This section covers the database schema guidelines we use for development."
|
||||
date: 2022-11-19T16:47:09+11:00
|
||||
date: 2022-11-19T17:42:03+11:00
|
||||
draft: false
|
||||
images: []
|
||||
menu:
|
||||
|
|
|
@ -45,14 +45,14 @@ Easy, right?!
|
|||
|
||||
## Frequently Asked Questions
|
||||
|
||||
### Can I register multiple FIDO2 WebAuthn devices?
|
||||
### Can I register multiple FIDO2 WebAuthn credentials?
|
||||
|
||||
At present this is not possible in the frontend. However the backend technically supports it. We plan to add this to the
|
||||
frontend in the near future. Subscribe to [this issue](https://github.com/authelia/authelia/issues/275) for updates.
|
||||
Yes, as of v4.38.0 and above Authelia supprots registering multiple WebAuthn credentials as per the
|
||||
[roadmap](../../../roadmap/active/webauthn.md#multi-device-registration).
|
||||
|
||||
### Can I perform a passwordless login?
|
||||
|
||||
Not at this time. We will tackle this at a later date.
|
||||
Not at this time. We will tackle this at a later date as per the [roadmap](../../../roadmap/active/webauthn.md#passwordless-login).
|
||||
|
||||
### Why don't I have access to the *Security Key* option?
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
title: "Database Integrations"
|
||||
description: "A database integration reference guide"
|
||||
lead: "This section contains a database integration reference guide for Authelia."
|
||||
date: 2022-11-19T16:47:09+11:00
|
||||
date: 2022-11-19T17:42:03+11:00
|
||||
draft: false
|
||||
images: []
|
||||
menu:
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
title: "Integrations"
|
||||
description: "A collection of integration reference guides"
|
||||
lead: "This section contains integration reference guides for Authelia."
|
||||
date: 2022-11-19T16:47:09+11:00
|
||||
date: 2022-11-19T17:42:03+11:00
|
||||
draft: false
|
||||
images: []
|
||||
menu:
|
||||
|
|
|
@ -1 +1 @@
|
|||
{"defaults":{"language":{"display":"English","locale":"en"},"namespace":"portal"},"namespaces":["portal"],"languages":[{"display":"English","locale":"en","namespaces":["portal"],"fallbacks":["en"]},{"display":"Arabic","locale":"ar","namespaces":["portal"],"fallbacks":["en"]},{"display":"Arabic (Saudi Arabia)","locale":"ar-SA","namespaces":["portal"],"fallbacks":["ar","en"]},{"display":"Czech","locale":"cs","namespaces":["portal"],"fallbacks":["en"]},{"display":"Czech (Czechia)","locale":"cs-CZ","namespaces":["portal"],"fallbacks":["cs","en"]},{"display":"Danish","locale":"da","namespaces":["portal"],"fallbacks":["en"]},{"display":"Danish (Denmark)","locale":"da-DK","namespaces":["portal"],"fallbacks":["da","en"]},{"display":"German","locale":"de","namespaces":["portal"],"fallbacks":["en"]},{"display":"Greek","locale":"el","namespaces":["portal"],"fallbacks":["en"]},{"display":"Greek (Greece)","locale":"el-GR","namespaces":["portal"],"fallbacks":["el","en"]},{"display":"Spanish","locale":"es","namespaces":["portal"],"fallbacks":["en"]},{"display":"Finnish","locale":"fi","namespaces":["portal"],"fallbacks":["en"]},{"display":"French","locale":"fr","namespaces":["portal"],"fallbacks":["en"]},{"display":"Hungarian","locale":"hu","namespaces":["portal"],"fallbacks":["en"]},{"display":"Italian","locale":"it","namespaces":["portal"],"fallbacks":["en"]},{"display":"Japanese","locale":"ja","namespaces":["portal"],"fallbacks":["en"]},{"display":"Japanese (Japan)","locale":"ja-JP","namespaces":["portal"],"fallbacks":["ja","en"]},{"display":"Norwegian Bokmål","locale":"nb","namespaces":["portal"],"fallbacks":["en"]},{"display":"Norwegian Bokmål (Norway)","locale":"nb-NO","namespaces":["portal"],"fallbacks":["nb","en"]},{"display":"Dutch","locale":"nl","namespaces":["portal"],"fallbacks":["en"]},{"display":"Norwegian Bokmål","locale":"no","namespaces":["portal"],"fallbacks":["en"]},{"display":"Polish","locale":"pl","namespaces":["portal"],"fallbacks":["en"]},{"display":"Portuguese","locale":"pt","namespaces":["portal"],"fallbacks":["en"]},{"display":"Brazilian Portuguese","locale":"pt-BR","namespaces":["portal"],"fallbacks":["en"]},{"display":"Romanian","locale":"ro","namespaces":["portal"],"fallbacks":["en"]},{"display":"Russian","locale":"ru","namespaces":["portal"],"fallbacks":["en"]},{"display":"Slovenian","locale":"sl","namespaces":["portal"],"fallbacks":["en"]},{"display":"Slovenian (Slovenia)","locale":"sl-SI","namespaces":["portal"],"fallbacks":["sl","en"]},{"display":"Swedish","locale":"sv","namespaces":["portal"],"fallbacks":["en"]},{"display":"Swedish (Sweden)","locale":"sv-SE","namespaces":["portal"],"fallbacks":["sv","en"]},{"display":"Ukrainian","locale":"uk","namespaces":["portal"],"fallbacks":["en"]},{"display":"Ukrainian (Ukraine)","locale":"uk-UA","namespaces":["portal"],"fallbacks":["uk","en"]},{"display":"Chinese","locale":"zh","namespaces":["portal"],"fallbacks":["en"]},{"display":"Chinese (China)","locale":"zh-CN","namespaces":["portal"],"fallbacks":["zh","en"]},{"display":"Chinese (Taiwan)","locale":"zh-TW","namespaces":["portal"],"fallbacks":["en"]}]}
|
||||
{"defaults":{"language":{"display":"English","locale":"en"},"namespace":"portal"},"namespaces":["portal","settings"],"languages":[{"display":"English","locale":"en","namespaces":["portal","settings"],"fallbacks":["en"]},{"display":"Arabic","locale":"ar","namespaces":["portal"],"fallbacks":["en"]},{"display":"Arabic (Saudi Arabia)","locale":"ar-SA","namespaces":["portal"],"fallbacks":["ar","en"]},{"display":"Czech","locale":"cs","namespaces":["portal"],"fallbacks":["en"]},{"display":"Czech (Czechia)","locale":"cs-CZ","namespaces":["portal"],"fallbacks":["cs","en"]},{"display":"Danish","locale":"da","namespaces":["portal"],"fallbacks":["en"]},{"display":"Danish (Denmark)","locale":"da-DK","namespaces":["portal"],"fallbacks":["da","en"]},{"display":"German","locale":"de","namespaces":["portal"],"fallbacks":["en"]},{"display":"Greek","locale":"el","namespaces":["portal"],"fallbacks":["en"]},{"display":"Greek (Greece)","locale":"el-GR","namespaces":["portal"],"fallbacks":["el","en"]},{"display":"Spanish","locale":"es","namespaces":["portal"],"fallbacks":["en"]},{"display":"Finnish","locale":"fi","namespaces":["portal"],"fallbacks":["en"]},{"display":"French","locale":"fr","namespaces":["portal"],"fallbacks":["en"]},{"display":"Hungarian","locale":"hu","namespaces":["portal"],"fallbacks":["en"]},{"display":"Italian","locale":"it","namespaces":["portal"],"fallbacks":["en"]},{"display":"Japanese","locale":"ja","namespaces":["portal"],"fallbacks":["en"]},{"display":"Japanese (Japan)","locale":"ja-JP","namespaces":["portal"],"fallbacks":["ja","en"]},{"display":"Norwegian Bokmål","locale":"nb","namespaces":["portal"],"fallbacks":["en"]},{"display":"Norwegian Bokmål (Norway)","locale":"nb-NO","namespaces":["portal"],"fallbacks":["nb","en"]},{"display":"Dutch","locale":"nl","namespaces":["portal"],"fallbacks":["en"]},{"display":"Norwegian Bokmål","locale":"no","namespaces":["portal"],"fallbacks":["en"]},{"display":"Polish","locale":"pl","namespaces":["portal"],"fallbacks":["en"]},{"display":"Portuguese","locale":"pt","namespaces":["portal"],"fallbacks":["en"]},{"display":"Brazilian Portuguese","locale":"pt-BR","namespaces":["portal"],"fallbacks":["en"]},{"display":"Romanian","locale":"ro","namespaces":["portal"],"fallbacks":["en"]},{"display":"Russian","locale":"ru","namespaces":["portal"],"fallbacks":["en"]},{"display":"Slovenian","locale":"sl","namespaces":["portal"],"fallbacks":["en"]},{"display":"Slovenian (Slovenia)","locale":"sl-SI","namespaces":["portal"],"fallbacks":["sl","en"]},{"display":"Swedish","locale":"sv","namespaces":["portal"],"fallbacks":["en"]},{"display":"Swedish (Sweden)","locale":"sv-SE","namespaces":["portal"],"fallbacks":["sv","en"]},{"display":"Ukrainian","locale":"uk","namespaces":["portal"],"fallbacks":["en"]},{"display":"Ukrainian (Ukraine)","locale":"uk-UA","namespaces":["portal"],"fallbacks":["uk","en"]},{"display":"Chinese","locale":"zh","namespaces":["portal"],"fallbacks":["en"]},{"display":"Chinese (China)","locale":"zh-CN","namespaces":["portal"],"fallbacks":["zh","en"]},{"display":"Chinese (Taiwan)","locale":"zh-TW","namespaces":["portal"],"fallbacks":["en"]}]}
|
2
go.mod
2
go.mod
|
@ -15,7 +15,7 @@ require (
|
|||
github.com/go-ldap/ldap/v3 v3.4.5-0.20230521105649-cdb0754f6668
|
||||
github.com/go-rod/rod v0.113.1
|
||||
github.com/go-sql-driver/mysql v1.7.1
|
||||
github.com/go-webauthn/webauthn v0.5.0
|
||||
github.com/go-webauthn/webauthn v0.8.2
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0
|
||||
github.com/golang/mock v1.6.0
|
||||
github.com/google/uuid v1.3.0
|
||||
|
|
4
go.sum
4
go.sum
|
@ -152,8 +152,8 @@ github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9
|
|||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
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/webauthn v0.5.0 h1:Tbmp37AGIhYbQmcy2hEffo3U3cgPClqvxJ7cLUnF7Rc=
|
||||
github.com/go-webauthn/webauthn v0.5.0/go.mod h1:0CBq/jNfPS9l033j4AxMk8K8MluiMsde9uGNSPFLEVE=
|
||||
github.com/go-webauthn/webauthn v0.8.2 h1:8KLIbpldjz9KVGHfqEgJNbkhd7bbRXhNw4QWFJE15oA=
|
||||
github.com/go-webauthn/webauthn v0.8.2/go.mod h1:d+ezx/jMCNDiqSMzOchuynKb9CVU1NM9BumOnokfcVQ=
|
||||
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/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||
|
|
|
@ -550,7 +550,7 @@ func (ctx *CmdCtx) StorageUserWebAuthnListRunE(cmd *cobra.Command, args []string
|
|||
|
||||
user := args[0]
|
||||
|
||||
devices, err = ctx.providers.StorageProvider.LoadWebAuthnDevicesByUsername(ctx, user)
|
||||
devices, err = ctx.providers.StorageProvider.LoadWebAuthnDevicesByUsername(ctx, "", user)
|
||||
|
||||
switch {
|
||||
case len(devices) == 0 || (err != nil && errors.Is(err, storage.ErrNoWebAuthnDevice)):
|
||||
|
|
|
@ -68,6 +68,7 @@ const (
|
|||
messageAuthenticationFailed = "Authentication failed. Check your credentials."
|
||||
messageUnableToRegisterOneTimePassword = "Unable to set up one-time passwords." //nolint:gosec
|
||||
messageUnableToRegisterSecurityKey = "Unable to register your security key."
|
||||
messageSecurityKeyDuplicateName = "Another one of your security keys is already registered with that display name."
|
||||
messageUnableToResetPassword = "Unable to reset your password."
|
||||
messageMFAValidationFailed = "Authentication failed, please retry later."
|
||||
messagePasswordWeak = "Your supplied password does not meet the password policy requirements"
|
||||
|
|
|
@ -2,6 +2,8 @@ package handlers
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
|
@ -11,35 +13,21 @@ import (
|
|||
"github.com/authelia/authelia/v4/internal/model"
|
||||
"github.com/authelia/authelia/v4/internal/regulation"
|
||||
"github.com/authelia/authelia/v4/internal/session"
|
||||
"github.com/authelia/authelia/v4/internal/storage"
|
||||
)
|
||||
|
||||
// WebauthnIdentityStart the handler for initiating the identity validation.
|
||||
var WebauthnIdentityStart = middlewares.IdentityVerificationStart(middlewares.IdentityVerificationStartArgs{
|
||||
MailTitle: "Register your key",
|
||||
MailButtonContent: "Register",
|
||||
TargetEndpoint: "/webauthn/register",
|
||||
ActionClaim: ActionWebAuthnRegistration,
|
||||
IdentityRetrieverFunc: identityRetrieverFromSession,
|
||||
}, nil)
|
||||
|
||||
// WebauthnIdentityFinish the handler for finishing the identity validation.
|
||||
var WebauthnIdentityFinish = middlewares.IdentityVerificationFinish(
|
||||
middlewares.IdentityVerificationFinishArgs{
|
||||
ActionClaim: ActionWebAuthnRegistration,
|
||||
IsTokenUserValidFunc: isTokenUserValidFor2FARegistration,
|
||||
}, SecondFactorWebAuthnAttestationGET)
|
||||
|
||||
// SecondFactorWebAuthnAttestationGET returns the attestation challenge from the server.
|
||||
func SecondFactorWebAuthnAttestationGET(ctx *middlewares.AutheliaCtx, _ string) {
|
||||
// WebAuthnRegistrationPUT returns the attestation challenge from the server.
|
||||
func WebAuthnRegistrationPUT(ctx *middlewares.AutheliaCtx) {
|
||||
var (
|
||||
w *webauthn.WebAuthn
|
||||
user *model.WebAuthnUser
|
||||
userSession session.UserSession
|
||||
bodyJSON bodyRegisterWebAuthnPUTRequest
|
||||
err error
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
|
@ -47,40 +35,90 @@ func SecondFactorWebAuthnAttestationGET(ctx *middlewares.AutheliaCtx, _ string)
|
|||
}
|
||||
|
||||
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)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
respondUnauthorized(ctx, messageMFAValidationFailed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var credentialCreation *protocol.CredentialCreation
|
||||
|
||||
if credentialCreation, userSession.WebAuthn, err = w.BeginRegistration(user); err != nil {
|
||||
ctx.Logger.Errorf("Unable to create %s attestation challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
if err = json.Unmarshal(ctx.PostBody(), &bodyJSON); err != nil {
|
||||
ctx.Logger.Errorf("Unable to parse %s registration request PUT data for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
|
||||
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if length := len(bodyJSON.Description); 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.Description, bodyJSON.Description) {
|
||||
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.Description)
|
||||
|
||||
ctx.SetStatusCode(fasthttp.StatusConflict)
|
||||
ctx.SetJSONError(messageSecurityKeyDuplicateName)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if user, err = getWebAuthnUserByRPID(ctx, userSession.Username, userSession.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 (
|
||||
creation *protocol.CredentialCreation
|
||||
)
|
||||
|
||||
opts := []webauthn.RegistrationOption{
|
||||
webauthn.WithExclusions(user.WebAuthnCredentialDescriptors()),
|
||||
webauthn.WithExtensions(map[string]any{"credProps": true}),
|
||||
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementDiscouraged),
|
||||
}
|
||||
|
||||
data := session.WebAuthn{
|
||||
Description: bodyJSON.Description,
|
||||
}
|
||||
|
||||
if creation, data.SessionData, err = w.BeginRegistration(user, opts...); 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 {
|
||||
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)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err = ctx.SetJSONBody(credentialCreation); err != nil {
|
||||
if err = ctx.SetJSONBody(creation); err != nil {
|
||||
ctx.Logger.Errorf(logFmtErrWriteResponseBody, regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
|
||||
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
|
||||
|
@ -89,8 +127,8 @@ func SecondFactorWebAuthnAttestationGET(ctx *middlewares.AutheliaCtx, _ string)
|
|||
}
|
||||
}
|
||||
|
||||
// WebAuthnAttestationPOST processes the attestation challenge response from the client.
|
||||
func WebAuthnAttestationPOST(ctx *middlewares.AutheliaCtx) {
|
||||
// WebAuthnRegistrationPOST processes the attestation challenge response from the client.
|
||||
func WebAuthnRegistrationPOST(ctx *middlewares.AutheliaCtx) {
|
||||
var (
|
||||
err error
|
||||
w *webauthn.WebAuthn
|
||||
|
@ -98,75 +136,89 @@ func WebAuthnAttestationPOST(ctx *middlewares.AutheliaCtx) {
|
|||
|
||||
userSession session.UserSession
|
||||
|
||||
attestationResponse *protocol.ParsedCredentialCreationData
|
||||
response *protocol.ParsedCredentialCreationData
|
||||
|
||||
credential *webauthn.Credential
|
||||
)
|
||||
|
||||
if userSession, err = ctx.GetSession(); err != nil {
|
||||
ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s attestation response", regulation.AuthTypeWebAuthn)
|
||||
ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s registration response", regulation.AuthTypeWebAuthn)
|
||||
|
||||
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if userSession.WebAuthn == nil {
|
||||
ctx.Logger.Errorf("WebAuthn session data is not present in order to handle attestation 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)
|
||||
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)
|
||||
|
||||
respondUnauthorized(ctx, messageMFAValidationFailed)
|
||||
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if w, err = newWebAuthn(ctx); err != nil {
|
||||
ctx.Logger.Errorf("Unable to configure %s during assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
ctx.Logger.Errorf("Unable to configure %s during registration for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
|
||||
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if attestationResponse, err = protocol.ParseCredentialCreationResponseBody(bytes.NewReader(ctx.PostBody())); err != nil {
|
||||
ctx.Logger.Errorf("Unable to parse %s assertionfor user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
if response, err = protocol.ParseCredentialCreationResponseBody(bytes.NewReader(ctx.PostBody())); err != nil {
|
||||
switch e := err.(type) {
|
||||
case *protocol.Error:
|
||||
ctx.Logger.Errorf("Unable to parse %s registration for user '%s': %+v (%s)", regulation.AuthTypeWebAuthn, userSession.Username, err, e.DevInfo)
|
||||
default:
|
||||
ctx.Logger.Errorf("Unable to parse %s registration for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
}
|
||||
|
||||
respondUnauthorized(ctx, messageMFAValidationFailed)
|
||||
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
if user, err = getWebAuthnUserByRPID(ctx, userSession.Username, userSession.DisplayName, w.Config.RPID); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
if credential, err = w.CreateCredential(user, *userSession.WebAuthn, attestationResponse); err != nil {
|
||||
ctx.Logger.Errorf("Unable to load %s devices for assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
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)
|
||||
}
|
||||
|
||||
respondUnauthorized(ctx, messageMFAValidationFailed)
|
||||
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
device := model.NewWebAuthnDeviceFromCredential(w.Config.RPID, userSession.Username, "Primary", credential)
|
||||
device := model.NewWebAuthnDeviceFromCredential(w.Config.RPID, userSession.Username, userSession.WebAuthn.Description, credential)
|
||||
|
||||
device.Discoverable = webauthnCredentialCreationIsDiscoverable(ctx, response)
|
||||
|
||||
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)
|
||||
ctx.Logger.Errorf("Unable to save %s device registration for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
|
||||
respondUnauthorized(ctx, messageMFAValidationFailed)
|
||||
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
userSession.WebAuthn = nil
|
||||
|
||||
if err = ctx.SaveSession(userSession); err != nil {
|
||||
ctx.Logger.Errorf(logFmtErrSessionSave, "removal of the attestation challenge", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
ctx.Logger.Errorf(logFmtErrSessionSave, "removal of the registration challenge", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
}
|
||||
|
||||
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", "Device Name": "Primary"})
|
||||
ctxLogEvent(ctx, userSession.Username, "Second Factor Method Added", map[string]any{"Action": "Second Factor Method Added", "Category": "WebAuthn Credential", "Credential Description": device.Description})
|
||||
}
|
||||
|
|
|
@ -30,45 +30,50 @@ func WebAuthnAssertionGET(ctx *middlewares.AutheliaCtx) {
|
|||
}
|
||||
|
||||
if w, err = newWebAuthn(ctx); err != nil {
|
||||
ctx.Logger.Errorf("Unable to configure %s during assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
ctx.Logger.Errorf("Unable to configure %s during authentication challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
|
||||
respondUnauthorized(ctx, messageMFAValidationFailed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if user, err = getWebAuthnUser(ctx, userSession); err != nil {
|
||||
ctx.Logger.Errorf("Unable to create %s assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
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)
|
||||
|
||||
respondUnauthorized(ctx, messageMFAValidationFailed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
extensions := map[string]any{}
|
||||
|
||||
if user.HasFIDOU2F() {
|
||||
extensions["appid"] = w.Config.RPOrigins[0]
|
||||
}
|
||||
|
||||
var opts = []webauthn.LoginOption{
|
||||
webauthn.WithAllowedCredentials(user.WebAuthnCredentialDescriptors()),
|
||||
}
|
||||
|
||||
extensions := map[string]any{}
|
||||
|
||||
if user.HasFIDOU2F() {
|
||||
extensions["appid"] = w.Config.RPOrigin
|
||||
}
|
||||
|
||||
if len(extensions) != 0 {
|
||||
opts = append(opts, webauthn.WithAssertionExtensions(extensions))
|
||||
}
|
||||
|
||||
var assertion *protocol.CredentialAssertion
|
||||
var (
|
||||
assertion *protocol.CredentialAssertion
|
||||
data session.WebAuthn
|
||||
)
|
||||
|
||||
if assertion, userSession.WebAuthn, err = w.BeginLogin(user, opts...); err != nil {
|
||||
ctx.Logger.Errorf("Unable to create %s assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
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)
|
||||
|
||||
respondUnauthorized(ctx, messageMFAValidationFailed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
userSession.WebAuthn = &data
|
||||
|
||||
if err = ctx.SaveSession(userSession); err != nil {
|
||||
ctx.Logger.Errorf(logFmtErrSessionSave, "assertion challenge", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
|
||||
|
@ -115,8 +120,8 @@ func WebAuthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
|
|||
return
|
||||
}
|
||||
|
||||
if userSession.WebAuthn == nil {
|
||||
ctx.Logger.Errorf("WebAuthn session data is not present in order to handle assertion 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)
|
||||
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)
|
||||
|
||||
respondUnauthorized(ctx, messageMFAValidationFailed)
|
||||
|
||||
|
@ -124,7 +129,7 @@ func WebAuthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
|
|||
}
|
||||
|
||||
if w, err = newWebAuthn(ctx); err != nil {
|
||||
ctx.Logger.Errorf("Unable to configure %s during assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
ctx.Logger.Errorf("Unable to configure %s during authentication challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
|
||||
respondUnauthorized(ctx, messageMFAValidationFailed)
|
||||
|
||||
|
@ -137,23 +142,23 @@ func WebAuthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
|
|||
user *model.WebAuthnUser
|
||||
)
|
||||
|
||||
if assertionResponse, err = protocol.ParseCredentialRequestResponseBody(bytes.NewReader(ctx.PostBody())); err != nil {
|
||||
ctx.Logger.Errorf("Unable to parse %s assertionfor user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
if assertionResponse, err = protocol.ParseCredentialRequestResponseBody(bytes.NewReader(bodyJSON.Response)); err != nil {
|
||||
ctx.Logger.Errorf("Unable to parse %s authentication challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
|
||||
respondUnauthorized(ctx, messageMFAValidationFailed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
respondUnauthorized(ctx, messageMFAValidationFailed)
|
||||
|
||||
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)
|
||||
|
||||
respondUnauthorized(ctx, messageMFAValidationFailed)
|
||||
|
@ -169,8 +174,8 @@ func WebAuthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
|
|||
|
||||
found = true
|
||||
|
||||
if err = ctx.Providers.StorageProvider.UpdateWebAuthnDeviceSignIn(ctx, device.ID, device.RPID, device.LastUsedAt, device.SignCount, device.CloneWarning); err != nil {
|
||||
ctx.Logger.Errorf("Unable to save %s device signin count for assertion challenge for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
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)
|
||||
|
||||
respondUnauthorized(ctx, messageMFAValidationFailed)
|
||||
|
||||
|
@ -182,7 +187,7 @@ func WebAuthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
|
|||
}
|
||||
|
||||
if !found {
|
||||
ctx.Logger.Errorf("Unable to save %s device signin count for assertion challenge for user '%s' device '%x' count '%d': unable to find device", regulation.AuthTypeWebAuthn, userSession.Username, credential.ID, credential.Authenticator.SignCount)
|
||||
ctx.Logger.Errorf("Unable to save %s device signin count for authentication challenge for user '%s' device '%x' count '%d': unable to find device", regulation.AuthTypeWebAuthn, userSession.Username, credential.ID, credential.Authenticator.SignCount)
|
||||
|
||||
respondUnauthorized(ctx, messageMFAValidationFailed)
|
||||
|
||||
|
@ -204,11 +209,11 @@ func WebAuthnAssertionPOST(ctx *middlewares.AutheliaCtx) {
|
|||
}
|
||||
|
||||
userSession.SetTwoFactorWebAuthn(ctx.Clock.Now(),
|
||||
assertionResponse.Response.AuthenticatorData.Flags.UserPresent(),
|
||||
assertionResponse.Response.AuthenticatorData.Flags.UserVerified())
|
||||
assertionResponse.Response.AuthenticatorData.Flags.HasUserPresent(),
|
||||
assertionResponse.Response.AuthenticatorData.Flags.HasUserVerified())
|
||||
|
||||
if err = ctx.SaveSession(userSession); err != nil {
|
||||
ctx.Logger.Errorf(logFmtErrSessionSave, "removal of the assertion challenge and authentication time", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
ctx.Logger.Errorf(logFmtErrSessionSave, "removal of the authentiation challenge and authentication time", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
|
||||
respondUnauthorized(ctx, messageMFAValidationFailed)
|
||||
|
||||
|
|
|
@ -0,0 +1,160 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
|
||||
"github.com/authelia/authelia/v4/internal/middlewares"
|
||||
"github.com/authelia/authelia/v4/internal/model"
|
||||
"github.com/authelia/authelia/v4/internal/regulation"
|
||||
"github.com/authelia/authelia/v4/internal/session"
|
||||
"github.com/authelia/authelia/v4/internal/storage"
|
||||
)
|
||||
|
||||
func getWebAuthnDeviceIDFromContext(ctx *middlewares.AutheliaCtx) (int, error) {
|
||||
deviceIDStr, ok := ctx.UserValue("deviceID").(string)
|
||||
if !ok {
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
return 0, errors.New("Invalid device ID type")
|
||||
}
|
||||
|
||||
deviceID, err := strconv.Atoi(deviceIDStr)
|
||||
if err != nil {
|
||||
ctx.Error(err, messageOperationFailed)
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return deviceID, nil
|
||||
}
|
||||
|
||||
// WebAuthnDevicesGET returns all devices registered for the current user.
|
||||
func WebAuthnDevicesGET(ctx *middlewares.AutheliaCtx) {
|
||||
var (
|
||||
userSession session.UserSession
|
||||
origin *url.URL
|
||||
err error
|
||||
)
|
||||
|
||||
if userSession, err = ctx.GetSession(); err != nil {
|
||||
ctx.Logger.WithError(err).Error("Error occurred retrieving user session")
|
||||
|
||||
ctx.ReplyForbidden()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if origin, err = ctx.GetOrigin(); err != nil {
|
||||
ctx.Logger.WithError(err).Error("Error occurred retrieving origin")
|
||||
|
||||
ctx.ReplyForbidden()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
devices, err := ctx.Providers.StorageProvider.LoadWebAuthnDevicesByUsername(ctx, origin.Hostname(), userSession.Username)
|
||||
|
||||
if err != nil && err != storage.ErrNoWebAuthnDevice {
|
||||
ctx.Error(err, messageOperationFailed)
|
||||
return
|
||||
}
|
||||
|
||||
if err = ctx.SetJSONBody(devices); err != nil {
|
||||
ctx.Error(err, messageOperationFailed)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// WebAuthnDevicePUT updates the description for a specific device for the current user.
|
||||
func WebAuthnDevicePUT(ctx *middlewares.AutheliaCtx) {
|
||||
var (
|
||||
bodyJSON bodyEditWebAuthnDeviceRequest
|
||||
|
||||
id int
|
||||
device *model.WebAuthnDevice
|
||||
userSession session.UserSession
|
||||
|
||||
err error
|
||||
)
|
||||
|
||||
if userSession, err = ctx.GetSession(); err != nil {
|
||||
ctx.Logger.WithError(err).Error("Error occurred retrieving user session")
|
||||
|
||||
ctx.ReplyForbidden()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(ctx.PostBody(), &bodyJSON); err != nil {
|
||||
ctx.Logger.Errorf("Unable to parse %s update request data for user '%s': %+v", regulation.AuthTypeWebAuthn, userSession.Username, err)
|
||||
|
||||
ctx.SetStatusCode(fasthttp.StatusBadRequest)
|
||||
ctx.Error(err, messageOperationFailed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if id, err = getWebAuthnDeviceIDFromContext(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if device, err = ctx.Providers.StorageProvider.LoadWebAuthnDeviceByID(ctx, id); err != nil {
|
||||
ctx.Error(err, messageOperationFailed)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Username != userSession.Username {
|
||||
ctx.Error(fmt.Errorf("user '%s' tried to delete device with id '%d' which belongs to '%s", userSession.Username, device.ID, device.Username), messageOperationFailed)
|
||||
return
|
||||
}
|
||||
|
||||
if err = ctx.Providers.StorageProvider.UpdateWebAuthnDeviceDescription(ctx, userSession.Username, id, bodyJSON.Description); err != nil {
|
||||
ctx.Error(err, messageOperationFailed)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// WebAuthnDeviceDELETE deletes a specific device for the current user.
|
||||
func WebAuthnDeviceDELETE(ctx *middlewares.AutheliaCtx) {
|
||||
var (
|
||||
id int
|
||||
device *model.WebAuthnDevice
|
||||
userSession session.UserSession
|
||||
err error
|
||||
)
|
||||
|
||||
if id, err = getWebAuthnDeviceIDFromContext(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if device, err = ctx.Providers.StorageProvider.LoadWebAuthnDeviceByID(ctx, id); err != nil {
|
||||
ctx.Error(err, messageOperationFailed)
|
||||
return
|
||||
}
|
||||
|
||||
if userSession, err = ctx.GetSession(); err != nil {
|
||||
ctx.Logger.WithError(err).Error("Error occurred retrieving user session")
|
||||
|
||||
ctx.ReplyForbidden()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if device.Username != userSession.Username {
|
||||
ctx.Error(fmt.Errorf("user '%s' tried to delete device with id '%d' which belongs to '%s", userSession.Username, device.ID, device.Username), messageOperationFailed)
|
||||
return
|
||||
}
|
||||
|
||||
if err = ctx.Providers.StorageProvider.DeleteWebAuthnDevice(ctx, device.KID.String()); err != nil {
|
||||
ctx.Error(err, messageOperationFailed)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.ReplyOK()
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
|
@ -35,6 +36,16 @@ type bodySignWebAuthnRequest struct {
|
|||
TargetURL string `json:"targetURL"`
|
||||
Workflow string `json:"workflow"`
|
||||
WorkflowID string `json:"workflowID"`
|
||||
|
||||
Response json.RawMessage `json:"response"`
|
||||
}
|
||||
|
||||
type bodyRegisterWebAuthnPUTRequest struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type bodyEditWebAuthnDeviceRequest struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// bodySignDuoRequest is the model of the request body of Duo 2FA authentication endpoint.
|
||||
|
|
|
@ -1,28 +1,42 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"github.com/authelia/authelia/v4/internal/middlewares"
|
||||
"github.com/authelia/authelia/v4/internal/model"
|
||||
"github.com/authelia/authelia/v4/internal/session"
|
||||
"github.com/authelia/authelia/v4/internal/random"
|
||||
)
|
||||
|
||||
func getWebAuthnUser(ctx *middlewares.AutheliaCtx, userSession session.UserSession) (user *model.WebAuthnUser, err error) {
|
||||
func getWebAuthnUserByRPID(ctx *middlewares.AutheliaCtx, username, displayname string, rpid string) (user *model.WebAuthnUser, err error) {
|
||||
if user, err = ctx.Providers.StorageProvider.LoadWebAuthnUser(ctx, rpid, username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
user = &model.WebAuthnUser{
|
||||
Username: userSession.Username,
|
||||
DisplayName: userSession.DisplayName,
|
||||
RPID: rpid,
|
||||
Username: username,
|
||||
UserID: ctx.Providers.Random.StringCustom(64, random.CharSetASCII),
|
||||
DisplayName: displayname,
|
||||
}
|
||||
|
||||
if err = ctx.Providers.StorageProvider.SaveWebAuthnUser(ctx, *user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
user.DisplayName = displayname
|
||||
}
|
||||
|
||||
if user.DisplayName == "" {
|
||||
user.DisplayName = user.Username
|
||||
}
|
||||
|
||||
if user.Devices, err = ctx.Providers.StorageProvider.LoadWebAuthnDevicesByUsername(ctx, userSession.Username); err != nil {
|
||||
if user.Devices, err = ctx.Providers.StorageProvider.LoadWebAuthnDevicesByUsername(ctx, rpid, user.Username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
@ -31,33 +45,72 @@ func getWebAuthnUser(ctx *middlewares.AutheliaCtx, userSession session.UserSessi
|
|||
|
||||
func newWebAuthn(ctx *middlewares.AutheliaCtx) (w *webauthn.WebAuthn, err error) {
|
||||
var (
|
||||
u *url.URL
|
||||
origin *url.URL
|
||||
)
|
||||
|
||||
if u, err = ctx.GetXOriginalURLOrXForwardedURL(); err != nil {
|
||||
if origin, err = ctx.GetOrigin(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rpID := u.Hostname()
|
||||
origin := fmt.Sprintf("%s://%s", u.Scheme, u.Host)
|
||||
|
||||
config := &webauthn.Config{
|
||||
RPID: origin.Hostname(),
|
||||
RPDisplayName: ctx.Configuration.WebAuthn.DisplayName,
|
||||
RPID: rpID,
|
||||
RPOrigin: origin,
|
||||
RPIcon: "",
|
||||
|
||||
RPOrigins: []string{origin.String()},
|
||||
AttestationPreference: ctx.Configuration.WebAuthn.ConveyancePreference,
|
||||
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
||||
AuthenticatorAttachment: protocol.CrossPlatform,
|
||||
UserVerification: ctx.Configuration.WebAuthn.UserVerification,
|
||||
RequireResidentKey: protocol.ResidentKeyNotRequired(),
|
||||
ResidentKey: protocol.ResidentKeyRequirementDiscouraged,
|
||||
UserVerification: ctx.Configuration.WebAuthn.UserVerification,
|
||||
},
|
||||
Debug: false,
|
||||
EncodeUserIDAsString: true,
|
||||
Timeouts: webauthn.TimeoutsConfig{
|
||||
Login: webauthn.TimeoutConfig{
|
||||
Enforce: true,
|
||||
Timeout: ctx.Configuration.WebAuthn.Timeout,
|
||||
TimeoutUVD: ctx.Configuration.WebAuthn.Timeout,
|
||||
},
|
||||
Registration: webauthn.TimeoutConfig{
|
||||
Enforce: true,
|
||||
Timeout: ctx.Configuration.WebAuthn.Timeout,
|
||||
TimeoutUVD: ctx.Configuration.WebAuthn.Timeout,
|
||||
},
|
||||
},
|
||||
|
||||
Timeout: int(ctx.Configuration.WebAuthn.Timeout.Milliseconds()),
|
||||
}
|
||||
|
||||
ctx.Logger.Tracef("Creating new WebAuthn RP instance with ID %s and Origins %s", config.RPID, config.RPOrigin)
|
||||
ctx.Logger.Tracef("Creating new WebAuthn RP instance with ID %s and Origins %s", config.RPID, strings.Join(config.RPOrigins, ", "))
|
||||
|
||||
return webauthn.New(config)
|
||||
}
|
||||
|
||||
func webauthnCredentialCreationIsDiscoverable(ctx *middlewares.AutheliaCtx, response *protocol.ParsedCredentialCreationData) (discoverable bool) {
|
||||
if value, ok := response.ClientExtensionResults["credProps"]; ok {
|
||||
switch credentialProperties := value.(type) {
|
||||
case map[string]any:
|
||||
var v any
|
||||
|
||||
if v, ok = credentialProperties["rk"]; ok {
|
||||
if discoverable, ok = v.(bool); ok {
|
||||
ctx.Logger.WithFields(map[string]any{"discoverable": discoverable}).Trace("Determined Credential Discoverability via Client Extension Results")
|
||||
|
||||
return discoverable
|
||||
} else {
|
||||
ctx.Logger.WithFields(map[string]any{"discoverable": false}).Trace("Assuming Credential Discoverability is false as the 'rk' field for the 'credProps' extension in the Client Extension Results was not a boolean")
|
||||
}
|
||||
} else {
|
||||
ctx.Logger.WithFields(map[string]any{"discoverable": false}).Trace("Assuming Credential Discoverability is false as the 'rk' field for the 'credProps' extension was missing from the Client Extension Results")
|
||||
}
|
||||
|
||||
return false
|
||||
default:
|
||||
ctx.Logger.WithFields(map[string]any{"discoverable": false}).Trace("Assuming Credential Discoverability is false as the 'credProps' extension in the Client Extension Results does not appear to be a dictionary")
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Logger.WithFields(map[string]any{"discoverable": false}).Trace("Assuming Credential Discoverability is false as the 'credProps' extension is missing from the Client Extension Results")
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -5,12 +5,14 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/valyala/fasthttp"
|
||||
|
||||
"github.com/authelia/authelia/v4/internal/mocks"
|
||||
"github.com/authelia/authelia/v4/internal/model"
|
||||
"github.com/authelia/authelia/v4/internal/random"
|
||||
"github.com/authelia/authelia/v4/internal/session"
|
||||
)
|
||||
|
||||
|
@ -22,10 +24,14 @@ func TestWebAuthnGetUser(t *testing.T) {
|
|||
DisplayName: "John Smith",
|
||||
}
|
||||
|
||||
ctx.StorageMock.EXPECT().LoadWebAuthnDevicesByUsername(ctx.Ctx, "john").Return([]model.WebAuthnDevice{
|
||||
ctx.StorageMock.EXPECT().
|
||||
LoadWebAuthnUser(ctx.Ctx, "example.com", "john").
|
||||
Return(&model.WebAuthnUser{ID: 1, RPID: "example.com", Username: "john", UserID: "john123"}, nil)
|
||||
|
||||
ctx.StorageMock.EXPECT().LoadWebAuthnDevicesByUsername(ctx.Ctx, "example.com", "john").Return([]model.WebAuthnDevice{
|
||||
{
|
||||
ID: 1,
|
||||
RPID: "https://example.com",
|
||||
RPID: "example.com",
|
||||
Username: "john",
|
||||
Description: "Primary",
|
||||
KID: model.NewBase64([]byte("abc123")),
|
||||
|
@ -48,12 +54,12 @@ func TestWebAuthnGetUser(t *testing.T) {
|
|||
},
|
||||
}, nil)
|
||||
|
||||
user, err := getWebAuthnUser(ctx.Ctx, userSession)
|
||||
user, err := getWebAuthnUserByRPID(ctx.Ctx, userSession.Username, userSession.DisplayName, "example.com")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, user)
|
||||
|
||||
assert.Equal(t, []byte{}, user.WebAuthnID())
|
||||
assert.Equal(t, []byte("john123"), user.WebAuthnID())
|
||||
assert.Equal(t, "john", user.WebAuthnName())
|
||||
assert.Equal(t, "john", user.Username)
|
||||
|
||||
|
@ -65,7 +71,107 @@ func TestWebAuthnGetUser(t *testing.T) {
|
|||
require.Len(t, user.Devices, 2)
|
||||
|
||||
assert.Equal(t, 1, user.Devices[0].ID)
|
||||
assert.Equal(t, "https://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, "Primary", user.Devices[0].Description)
|
||||
assert.Equal(t, "", user.Devices[0].Transport)
|
||||
assert.Equal(t, "fido-u2f", user.Devices[0].AttestationType)
|
||||
assert.Equal(t, []byte("data"), user.Devices[0].PublicKey)
|
||||
assert.Equal(t, uint32(0), user.Devices[0].SignCount)
|
||||
assert.False(t, user.Devices[0].CloneWarning)
|
||||
|
||||
descriptors := user.WebAuthnCredentialDescriptors()
|
||||
assert.Equal(t, "fido-u2f", descriptors[0].AttestationType)
|
||||
assert.Equal(t, "abc123", string(descriptors[0].CredentialID))
|
||||
assert.Equal(t, protocol.PublicKeyCredentialType, descriptors[0].Type)
|
||||
|
||||
assert.Len(t, descriptors[0].Transport, 0)
|
||||
|
||||
assert.Equal(t, 2, user.Devices[1].ID)
|
||||
assert.Equal(t, "example.com", user.Devices[1].RPID)
|
||||
assert.Equal(t, "john", user.Devices[1].Username)
|
||||
assert.Equal(t, "Secondary", user.Devices[1].Description)
|
||||
assert.Equal(t, "usb,nfc", user.Devices[1].Transport)
|
||||
assert.Equal(t, "packed", user.Devices[1].AttestationType)
|
||||
assert.Equal(t, []byte("data"), user.Devices[1].PublicKey)
|
||||
assert.Equal(t, uint32(100), user.Devices[1].SignCount)
|
||||
assert.False(t, user.Devices[1].CloneWarning)
|
||||
|
||||
assert.Equal(t, "packed", descriptors[1].AttestationType)
|
||||
assert.Equal(t, "123abc", string(descriptors[1].CredentialID))
|
||||
assert.Equal(t, protocol.PublicKeyCredentialType, descriptors[1].Type)
|
||||
|
||||
assert.Len(t, descriptors[1].Transport, 2)
|
||||
assert.Equal(t, protocol.AuthenticatorTransport("usb"), descriptors[1].Transport[0])
|
||||
assert.Equal(t, protocol.AuthenticatorTransport("nfc"), descriptors[1].Transport[1])
|
||||
}
|
||||
|
||||
func TestWebAuthnGetNewUser(t *testing.T) {
|
||||
ctx := mocks.NewMockAutheliaCtx(t)
|
||||
|
||||
// Use the random mock.
|
||||
ctx.Ctx.Providers.Random = ctx.RandomMock
|
||||
|
||||
userSession := session.UserSession{
|
||||
Username: "john",
|
||||
DisplayName: "John Smith",
|
||||
}
|
||||
|
||||
gomock.InOrder(
|
||||
ctx.StorageMock.EXPECT().
|
||||
LoadWebAuthnUser(ctx.Ctx, "example.com", "john").
|
||||
Return(nil, nil),
|
||||
ctx.RandomMock.EXPECT().
|
||||
StringCustom(64, random.CharSetASCII).
|
||||
Return("=ckBRe.%fp{w#K[qw4)AWMZrAP)(z3NUt5n3g?;>'^Rp>+eE4z>[^.<3?&n;LM#w"),
|
||||
ctx.StorageMock.EXPECT().
|
||||
SaveWebAuthnUser(ctx.Ctx, model.WebAuthnUser{RPID: "example.com", Username: "john", DisplayName: "John Smith", UserID: "=ckBRe.%fp{w#K[qw4)AWMZrAP)(z3NUt5n3g?;>'^Rp>+eE4z>[^.<3?&n;LM#w"}).
|
||||
Return(nil),
|
||||
ctx.StorageMock.EXPECT().LoadWebAuthnDevicesByUsername(ctx.Ctx, "example.com", "john").Return([]model.WebAuthnDevice{
|
||||
{
|
||||
ID: 1,
|
||||
RPID: "example.com",
|
||||
Username: "john",
|
||||
Description: "Primary",
|
||||
KID: model.NewBase64([]byte("abc123")),
|
||||
AttestationType: "fido-u2f",
|
||||
PublicKey: []byte("data"),
|
||||
SignCount: 0,
|
||||
CloneWarning: false,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
RPID: "example.com",
|
||||
Username: "john",
|
||||
Description: "Secondary",
|
||||
KID: model.NewBase64([]byte("123abc")),
|
||||
AttestationType: "packed",
|
||||
Transport: "usb,nfc",
|
||||
PublicKey: []byte("data"),
|
||||
SignCount: 100,
|
||||
CloneWarning: false,
|
||||
},
|
||||
}, nil),
|
||||
)
|
||||
|
||||
user, err := getWebAuthnUserByRPID(ctx.Ctx, userSession.Username, userSession.DisplayName, "example.com")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, user)
|
||||
|
||||
assert.Equal(t, []byte("=ckBRe.%fp{w#K[qw4)AWMZrAP)(z3NUt5n3g?;>'^Rp>+eE4z>[^.<3?&n;LM#w"), user.WebAuthnID())
|
||||
assert.Equal(t, "john", user.WebAuthnName())
|
||||
assert.Equal(t, "john", user.Username)
|
||||
|
||||
assert.Equal(t, "", user.WebAuthnIcon())
|
||||
|
||||
assert.Equal(t, "John Smith", user.WebAuthnDisplayName())
|
||||
assert.Equal(t, "John Smith", user.DisplayName)
|
||||
|
||||
require.Len(t, user.Devices, 2)
|
||||
|
||||
assert.Equal(t, 1, user.Devices[0].ID)
|
||||
assert.Equal(t, "example.com", user.Devices[0].RPID)
|
||||
assert.Equal(t, "john", user.Devices[0].Username)
|
||||
assert.Equal(t, "Primary", user.Devices[0].Description)
|
||||
assert.Equal(t, "", user.Devices[0].Transport)
|
||||
|
@ -107,7 +213,11 @@ func TestWebAuthnGetUserWithoutDisplayName(t *testing.T) {
|
|||
Username: "john",
|
||||
}
|
||||
|
||||
ctx.StorageMock.EXPECT().LoadWebAuthnDevicesByUsername(ctx.Ctx, "john").Return([]model.WebAuthnDevice{
|
||||
ctx.StorageMock.EXPECT().
|
||||
LoadWebAuthnUser(ctx.Ctx, "example.com", "john").
|
||||
Return(&model.WebAuthnUser{ID: 1, RPID: "example.com", Username: "john", UserID: "john123"}, nil)
|
||||
|
||||
ctx.StorageMock.EXPECT().LoadWebAuthnDevicesByUsername(ctx.Ctx, "example.com", "john").Return([]model.WebAuthnDevice{
|
||||
{
|
||||
ID: 1,
|
||||
RPID: "example.com",
|
||||
|
@ -121,7 +231,7 @@ func TestWebAuthnGetUserWithoutDisplayName(t *testing.T) {
|
|||
},
|
||||
}, nil)
|
||||
|
||||
user, err := getWebAuthnUser(ctx.Ctx, userSession)
|
||||
user, err := getWebAuthnUserByRPID(ctx.Ctx, userSession.Username, userSession.DisplayName, "example.com")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, user)
|
||||
|
@ -137,9 +247,15 @@ func TestWebAuthnGetUserWithErr(t *testing.T) {
|
|||
Username: "john",
|
||||
}
|
||||
|
||||
ctx.StorageMock.EXPECT().LoadWebAuthnDevicesByUsername(ctx.Ctx, "john").Return(nil, errors.New("not found"))
|
||||
ctx.StorageMock.EXPECT().
|
||||
LoadWebAuthnUser(ctx.Ctx, "example.com", "john").
|
||||
Return(&model.WebAuthnUser{ID: 1, RPID: "example.com", Username: "john", UserID: "john123"}, nil)
|
||||
|
||||
user, err := getWebAuthnUser(ctx.Ctx, userSession)
|
||||
ctx.StorageMock.EXPECT().
|
||||
LoadWebAuthnDevicesByUsername(ctx.Ctx, "example.com", "john").
|
||||
Return(nil, errors.New("not found"))
|
||||
|
||||
user, err := getWebAuthnUserByRPID(ctx.Ctx, userSession.Username, userSession.DisplayName, "example.com")
|
||||
|
||||
assert.EqualError(t, err, "not found")
|
||||
assert.Nil(t, user)
|
||||
|
@ -165,5 +281,5 @@ func TestWebAuthnNewWebAuthnShouldReturnErrWhenWebAuthnNotConfigured(t *testing.
|
|||
w, err := newWebAuthn(ctx.Ctx)
|
||||
|
||||
assert.Nil(t, w)
|
||||
assert.EqualError(t, err, "Configuration error: Missing RPDisplayName")
|
||||
assert.EqualError(t, err, "error occurred validating the configuration: the field 'RPDisplayName' must be configured but it is empty")
|
||||
}
|
||||
|
|
|
@ -69,8 +69,19 @@ func (ctx *AutheliaCtx) Error(err error, message string) {
|
|||
|
||||
// SetJSONError sets the body of the response to an JSON error KO message.
|
||||
func (ctx *AutheliaCtx) SetJSONError(message string) {
|
||||
if replyErr := ctx.ReplyJSON(ErrorResponse{Status: "KO", Message: message}, 0); replyErr != nil {
|
||||
ctx.Logger.Error(replyErr)
|
||||
if err := ctx.ReplyJSON(ErrorResponse{Status: "KO", Message: message}, 0); err != nil {
|
||||
ctx.Logger.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// SetAuthenticationErrorJSON sets the body of the response to an JSON error KO message.
|
||||
func (ctx *AutheliaCtx) SetAuthenticationErrorJSON(status int, message string, authentication, elevation bool) {
|
||||
if status > fasthttp.StatusOK {
|
||||
ctx.SetStatusCode(status)
|
||||
}
|
||||
|
||||
if err := ctx.ReplyJSON(AuthenticationErrorResponse{Status: "KO", Message: message, Authentication: authentication, Elevation: elevation}, 0); err != nil {
|
||||
ctx.Logger.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -517,6 +528,18 @@ func (ctx *AutheliaCtx) GetXOriginalURLOrXForwardedURL() (requestURI *url.URL, e
|
|||
}
|
||||
}
|
||||
|
||||
// GetOrigin returns the expected origin for requests from this endpoint.
|
||||
func (ctx *AutheliaCtx) GetOrigin() (origin *url.URL, err error) {
|
||||
if origin, err = ctx.GetXOriginalURLOrXForwardedURL(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
origin.Path = ""
|
||||
origin.RawPath = ""
|
||||
|
||||
return origin, nil
|
||||
}
|
||||
|
||||
// IssuerURL returns the expected Issuer.
|
||||
func (ctx *AutheliaCtx) IssuerURL() (issuerURL *url.URL, err error) {
|
||||
issuerURL = &url.URL{
|
||||
|
|
|
@ -95,26 +95,22 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim
|
|||
}
|
||||
}
|
||||
|
||||
// IdentityVerificationFinish the middleware for finishing the identity validation process.
|
||||
func IdentityVerificationFinish(args IdentityVerificationFinishArgs, next func(ctx *AutheliaCtx, username string)) RequestHandler {
|
||||
return func(ctx *AutheliaCtx) {
|
||||
var finishBody IdentityVerificationFinishBody
|
||||
func identityVerificationValidateToken(ctx *AutheliaCtx) (*jwt.Token, error) {
|
||||
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
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if finishBody.Token == "" {
|
||||
if bodyJSON.Token == "" {
|
||||
ctx.Error(fmt.Errorf("No token provided"), messageOperationFailed)
|
||||
return
|
||||
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
|
||||
})
|
||||
|
@ -124,19 +120,30 @@ func IdentityVerificationFinish(args IdentityVerificationFinishArgs, next func(c
|
|||
switch {
|
||||
case ve.Errors&jwt.ValidationErrorMalformed != 0:
|
||||
ctx.Error(fmt.Errorf("Cannot parse token"), messageOperationFailed)
|
||||
return
|
||||
return nil, err
|
||||
case ve.Errors&(jwt.ValidationErrorExpired|jwt.ValidationErrorNotValidYet) != 0:
|
||||
// Token is either expired or not active yet.
|
||||
ctx.Error(fmt.Errorf("Token expired"), messageIdentityVerificationTokenHasExpired)
|
||||
return
|
||||
return nil, err
|
||||
default:
|
||||
ctx.Error(fmt.Errorf("Cannot handle this token: %s", ve), messageOperationFailed)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Error(err, messageOperationFailed)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// IdentityVerificationFinish the middleware for finishing the identity validation process.
|
||||
func IdentityVerificationFinish(args IdentityVerificationFinishArgs, next func(ctx *AutheliaCtx, username string)) RequestHandler {
|
||||
return func(ctx *AutheliaCtx) {
|
||||
token, err := identityVerificationValidateToken(ctx)
|
||||
if token == nil || err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -176,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
|
||||
}
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
package middlewares
|
||||
|
||||
import (
|
||||
"github.com/valyala/fasthttp"
|
||||
|
||||
"github.com/authelia/authelia/v4/internal/authentication"
|
||||
)
|
||||
|
||||
// Require1FA requires the user to have authenticated with at least one-factor authentication (i.e. password).
|
||||
func Require1FA(next RequestHandler) RequestHandler {
|
||||
return func(ctx *AutheliaCtx) {
|
||||
if session, err := ctx.GetSession(); err != nil || session.AuthenticationLevel < authentication.OneFactor {
|
||||
ctx.ReplyForbidden()
|
||||
return
|
||||
}
|
||||
|
||||
next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Require2FA requires the user to have authenticated with two-factor authentication.
|
||||
func Require2FA(next RequestHandler) RequestHandler {
|
||||
return func(ctx *AutheliaCtx) {
|
||||
if session, err := ctx.GetSession(); err != nil || session.AuthenticationLevel < authentication.TwoFactor {
|
||||
ctx.ReplyForbidden()
|
||||
return
|
||||
}
|
||||
|
||||
next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Require2FAWithAPIResponse requires the user to have authenticated with two-factor authentication.
|
||||
func Require2FAWithAPIResponse(next RequestHandler) RequestHandler {
|
||||
return func(ctx *AutheliaCtx) {
|
||||
if session, err := ctx.GetSession(); err != nil || session.AuthenticationLevel < authentication.TwoFactor {
|
||||
ctx.SetAuthenticationErrorJSON(fasthttp.StatusForbidden, "Authentication Required.", true, false)
|
||||
return
|
||||
}
|
||||
|
||||
next(ctx)
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
package middlewares
|
||||
|
||||
import (
|
||||
"github.com/authelia/authelia/v4/internal/authentication"
|
||||
)
|
||||
|
||||
// Require1FA check if user has enough permissions to execute the next handler.
|
||||
func Require1FA(next RequestHandler) RequestHandler {
|
||||
return func(ctx *AutheliaCtx) {
|
||||
if s, err := ctx.GetSession(); err != nil || s.AuthenticationLevel < authentication.OneFactor {
|
||||
ctx.ReplyForbidden()
|
||||
return
|
||||
}
|
||||
|
||||
next(ctx)
|
||||
}
|
||||
}
|
|
@ -121,3 +121,11 @@ type ErrorResponse struct {
|
|||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// AuthenticationErrorResponse model of an error response.
|
||||
type AuthenticationErrorResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Authentication bool `json:"authentication"`
|
||||
Elevation bool `json:"elevation"`
|
||||
}
|
||||
|
|
|
@ -419,6 +419,21 @@ func (mr *MockStorageMockRecorder) LoadUserOpaqueIdentifiers(arg0 interface{}) *
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadUserOpaqueIdentifiers", reflect.TypeOf((*MockStorage)(nil).LoadUserOpaqueIdentifiers), arg0)
|
||||
}
|
||||
|
||||
// LoadWebAuthnDeviceByID mocks base method.
|
||||
func (m *MockStorage) LoadWebAuthnDeviceByID(arg0 context.Context, arg1 int) (*model.WebAuthnDevice, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "LoadWebAuthnDeviceByID", arg0, arg1)
|
||||
ret0, _ := ret[0].(*model.WebAuthnDevice)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// LoadWebAuthnDeviceByID indicates an expected call of LoadWebAuthnDeviceByID.
|
||||
func (mr *MockStorageMockRecorder) LoadWebAuthnDeviceByID(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadWebAuthnDeviceByID", reflect.TypeOf((*MockStorage)(nil).LoadWebAuthnDeviceByID), arg0, arg1)
|
||||
}
|
||||
|
||||
// LoadWebAuthnDevices mocks base method.
|
||||
func (m *MockStorage) LoadWebAuthnDevices(arg0 context.Context, arg1, arg2 int) ([]model.WebAuthnDevice, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
@ -435,18 +450,33 @@ func (mr *MockStorageMockRecorder) LoadWebAuthnDevices(arg0, arg1, arg2 interfac
|
|||
}
|
||||
|
||||
// LoadWebAuthnDevicesByUsername mocks base method.
|
||||
func (m *MockStorage) LoadWebAuthnDevicesByUsername(arg0 context.Context, arg1 string) ([]model.WebAuthnDevice, error) {
|
||||
func (m *MockStorage) LoadWebAuthnDevicesByUsername(arg0 context.Context, arg1, arg2 string) ([]model.WebAuthnDevice, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "LoadWebAuthnDevicesByUsername", arg0, arg1)
|
||||
ret := m.ctrl.Call(m, "LoadWebAuthnDevicesByUsername", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].([]model.WebAuthnDevice)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// LoadWebAuthnDevicesByUsername indicates an expected call of LoadWebAuthnDevicesByUsername.
|
||||
func (mr *MockStorageMockRecorder) LoadWebAuthnDevicesByUsername(arg0, arg1 interface{}) *gomock.Call {
|
||||
func (mr *MockStorageMockRecorder) LoadWebAuthnDevicesByUsername(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadWebAuthnDevicesByUsername", reflect.TypeOf((*MockStorage)(nil).LoadWebAuthnDevicesByUsername), arg0, arg1)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadWebAuthnDevicesByUsername", reflect.TypeOf((*MockStorage)(nil).LoadWebAuthnDevicesByUsername), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// LoadWebAuthnUser mocks base method.
|
||||
func (m *MockStorage) LoadWebAuthnUser(arg0 context.Context, arg1, arg2 string) (*model.WebAuthnUser, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "LoadWebAuthnUser", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(*model.WebAuthnUser)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// LoadWebAuthnUser indicates an expected call of LoadWebAuthnUser.
|
||||
func (mr *MockStorageMockRecorder) LoadWebAuthnUser(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadWebAuthnUser", reflect.TypeOf((*MockStorage)(nil).LoadWebAuthnUser), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// RevokeOAuth2PARContext mocks base method.
|
||||
|
@ -702,6 +732,20 @@ func (mr *MockStorageMockRecorder) SaveWebAuthnDevice(arg0, arg1 interface{}) *g
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveWebAuthnDevice", reflect.TypeOf((*MockStorage)(nil).SaveWebAuthnDevice), arg0, arg1)
|
||||
}
|
||||
|
||||
// SaveWebAuthnUser mocks base method.
|
||||
func (m *MockStorage) SaveWebAuthnUser(arg0 context.Context, arg1 model.WebAuthnUser) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SaveWebAuthnUser", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// SaveWebAuthnUser indicates an expected call of SaveWebAuthnUser.
|
||||
func (mr *MockStorageMockRecorder) SaveWebAuthnUser(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveWebAuthnUser", reflect.TypeOf((*MockStorage)(nil).SaveWebAuthnUser), arg0, arg1)
|
||||
}
|
||||
|
||||
// SchemaEncryptionChangeKey mocks base method.
|
||||
func (m *MockStorage) SchemaEncryptionChangeKey(arg0 context.Context, arg1 string) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
@ -863,16 +907,30 @@ func (mr *MockStorageMockRecorder) UpdateTOTPConfigurationSignIn(arg0, arg1, arg
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTOTPConfigurationSignIn", reflect.TypeOf((*MockStorage)(nil).UpdateTOTPConfigurationSignIn), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// UpdateWebAuthnDeviceSignIn mocks base method.
|
||||
func (m *MockStorage) UpdateWebAuthnDeviceSignIn(arg0 context.Context, arg1 int, arg2 string, arg3 sql.NullTime, arg4 uint32, arg5 bool) error {
|
||||
// UpdateWebAuthnDeviceDescription mocks base method.
|
||||
func (m *MockStorage) UpdateWebAuthnDeviceDescription(arg0 context.Context, arg1 string, arg2 int, arg3 string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateWebAuthnDeviceSignIn", arg0, arg1, arg2, arg3, arg4, arg5)
|
||||
ret := m.ctrl.Call(m, "UpdateWebAuthnDeviceDescription", arg0, arg1, arg2, arg3)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UpdateWebAuthnDeviceDescription indicates an expected call of UpdateWebAuthnDeviceDescription.
|
||||
func (mr *MockStorageMockRecorder) UpdateWebAuthnDeviceDescription(arg0, arg1, arg2, arg3 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWebAuthnDeviceDescription", reflect.TypeOf((*MockStorage)(nil).UpdateWebAuthnDeviceDescription), arg0, arg1, arg2, arg3)
|
||||
}
|
||||
|
||||
// UpdateWebAuthnDeviceSignIn mocks base method.
|
||||
func (m *MockStorage) UpdateWebAuthnDeviceSignIn(arg0 context.Context, arg1 model.WebAuthnDevice) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateWebAuthnDeviceSignIn", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// 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()
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -76,10 +76,17 @@ func (w WebAuthnUser) WebAuthnCredentials() (credentials []webauthn.Credential)
|
|||
ID: device.KID.Bytes(),
|
||||
PublicKey: device.PublicKey,
|
||||
AttestationType: device.AttestationType,
|
||||
Flags: webauthn.CredentialFlags{
|
||||
UserPresent: device.Present,
|
||||
UserVerified: device.Verified,
|
||||
BackupEligible: device.BackupEligible,
|
||||
BackupState: device.BackupState,
|
||||
},
|
||||
Authenticator: webauthn.Authenticator{
|
||||
AAGUID: aaguid,
|
||||
SignCount: device.SignCount,
|
||||
CloneWarning: device.CloneWarning,
|
||||
Attachment: protocol.AuthenticatorAttachment(device.Attachment),
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -128,9 +135,15 @@ func NewWebAuthnDeviceFromCredential(rpid, username, description string, credent
|
|||
Description: description,
|
||||
KID: NewBase64(credential.ID),
|
||||
AttestationType: credential.AttestationType,
|
||||
Attachment: string(credential.Authenticator.Attachment),
|
||||
Transport: strings.Join(transport, ","),
|
||||
SignCount: credential.Authenticator.SignCount,
|
||||
CloneWarning: credential.Authenticator.CloneWarning,
|
||||
Discoverable: false,
|
||||
Present: credential.Flags.UserPresent,
|
||||
Verified: credential.Flags.UserVerified,
|
||||
BackupEligible: credential.Flags.BackupEligible,
|
||||
BackupState: credential.Flags.BackupState,
|
||||
PublicKey: credential.PublicKey,
|
||||
}
|
||||
|
||||
|
@ -153,9 +166,15 @@ type WebAuthnDevice struct {
|
|||
KID Base64 `db:"kid"`
|
||||
AAGUID uuid.NullUUID `db:"aaguid"`
|
||||
AttestationType string `db:"attestation_type"`
|
||||
Attachment string `db:"attachment"`
|
||||
Transport string `db:"transport"`
|
||||
SignCount uint32 `db:"sign_count"`
|
||||
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"`
|
||||
}
|
||||
|
||||
|
@ -171,7 +190,7 @@ func (d *WebAuthnDevice) UpdateSignInInfo(config *webauthn.Config, now time.Time
|
|||
|
||||
switch d.AttestationType {
|
||||
case attestationTypeFIDOU2F:
|
||||
d.RPID = config.RPOrigin
|
||||
d.RPID = config.RPOrigins[0]
|
||||
default:
|
||||
d.RPID = config.RPID
|
||||
}
|
||||
|
@ -210,8 +229,13 @@ func (d *WebAuthnDevice) ToData() WebAuthnDeviceData {
|
|||
KID: d.KID.String(),
|
||||
AAGUID: d.DataValueAAGUID(),
|
||||
AttestationType: d.AttestationType,
|
||||
Attachment: d.Attachment,
|
||||
SignCount: d.SignCount,
|
||||
CloneWarning: d.CloneWarning,
|
||||
Present: d.Present,
|
||||
Verified: d.Verified,
|
||||
BackupEligible: d.BackupEligible,
|
||||
BackupState: d.BackupState,
|
||||
PublicKey: base64.StdEncoding.EncodeToString(d.PublicKey),
|
||||
}
|
||||
|
||||
|
@ -269,9 +293,15 @@ func (d *WebAuthnDevice) UnmarshalYAML(value *yaml.Node) (err error) {
|
|||
d.Username = o.Username
|
||||
d.Description = o.Description
|
||||
d.AttestationType = o.AttestationType
|
||||
d.Attachment = o.Attachment
|
||||
d.Transport = strings.Join(o.Transports, ",")
|
||||
d.SignCount = o.SignCount
|
||||
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 {
|
||||
d.LastUsedAt = sql.NullTime{Valid: true, Time: *o.LastUsedAt}
|
||||
|
@ -291,9 +321,15 @@ type WebAuthnDeviceData struct {
|
|||
KID string `json:"kid" yaml:"kid"`
|
||||
AAGUID *string `json:"aaguid,omitempty" yaml:"aaguid,omitempty"`
|
||||
AttestationType string `json:"attestation_type" yaml:"attestation_type"`
|
||||
Attachment string `json:"attachment" yaml:"attachment"`
|
||||
Transports []string `json:"transports" yaml:"transports"`
|
||||
SignCount uint32 `json:"sign_count" yaml:"sign_count"`
|
||||
CloneWarning bool `json:"clone_warning" yaml:"clone_warning"`
|
||||
Discoverable bool `json:"discoverable" yaml:"discoverable"`
|
||||
Present bool `json:"present" yaml:"present"`
|
||||
Verified bool `json:"verified" yaml:"verified"`
|
||||
BackupEligible bool `json:"backup_eligible" yaml:"backup_eligible"`
|
||||
BackupState bool `json:"backup_state" yaml:"backup_state"`
|
||||
PublicKey string `json:"public_key" yaml:"public_key"`
|
||||
}
|
||||
|
||||
|
@ -304,9 +340,15 @@ func (d *WebAuthnDeviceData) ToDevice() (device *WebAuthnDevice, err error) {
|
|||
Username: d.Username,
|
||||
Description: d.Description,
|
||||
AttestationType: d.AttestationType,
|
||||
Attachment: d.Attachment,
|
||||
Transport: strings.Join(d.Transports, ","),
|
||||
SignCount: d.SignCount,
|
||||
CloneWarning: d.CloneWarning,
|
||||
Discoverable: d.Discoverable,
|
||||
Present: d.Present,
|
||||
Verified: d.Verified,
|
||||
BackupEligible: d.BackupEligible,
|
||||
BackupState: d.BackupState,
|
||||
}
|
||||
|
||||
if device.PublicKey, err = base64.StdEncoding.DecodeString(d.PublicKey); err != nil {
|
||||
|
|
|
@ -174,6 +174,11 @@ func handleRouter(config *schema.Configuration, providers middlewares.Providers)
|
|||
WithPostMiddlewares(middlewares.Require1FA).
|
||||
Build()
|
||||
|
||||
middleware2FA := middlewares.NewBridgeBuilder(*config, providers).
|
||||
WithPreMiddlewares(middlewares.SecurityHeaders, middlewares.SecurityHeadersNoStore, middlewares.SecurityHeadersCSPNone).
|
||||
WithPostMiddlewares(middlewares.Require2FAWithAPIResponse).
|
||||
Build()
|
||||
|
||||
r.HEAD("/api/health", middlewareAPI(handlers.HealthGET))
|
||||
r.GET("/api/health", middlewareAPI(handlers.HealthGET))
|
||||
|
||||
|
@ -256,13 +261,15 @@ func handleRouter(config *schema.Configuration, providers middlewares.Providers)
|
|||
}
|
||||
|
||||
if !config.WebAuthn.Disable {
|
||||
// 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/webauthn", middleware1FA(handlers.WebAuthnAssertionGET))
|
||||
r.POST("/api/secondfactor/webauthn", middleware1FA(handlers.WebAuthnAssertionPOST))
|
||||
|
||||
r.GET("/api/secondfactor/webauthn/assertion", middleware1FA(handlers.WebAuthnAssertionGET))
|
||||
r.POST("/api/secondfactor/webauthn/assertion", middleware1FA(handlers.WebAuthnAssertionPOST))
|
||||
// Management of the webauthn devices.
|
||||
r.GET("/api/secondfactor/webauthn/credentials", middleware1FA(handlers.WebAuthnDevicesGET))
|
||||
r.PUT("/api/secondfactor/webauthn/credential/register", middleware1FA(handlers.WebAuthnRegistrationPUT))
|
||||
r.POST("/api/secondfactor/webauthn/credential/register", middleware1FA(handlers.WebAuthnRegistrationPOST))
|
||||
r.PUT("/api/secondfactor/webauthn/credential/{deviceID}", middleware2FA(handlers.WebAuthnDevicePUT))
|
||||
r.DELETE("/api/secondfactor/webauthn/credential/{deviceID}", middleware2FA(handlers.WebAuthnDeviceDELETE))
|
||||
}
|
||||
|
||||
// Configure DUO api endpoint only if configuration exists.
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
"Reset": "Reset",
|
||||
"Scan QR Code": "Scan QR Code",
|
||||
"Secret": "سرية",
|
||||
"Security Key - WebAuthN": "مفتاح الأمان - WebAuthN",
|
||||
"Security Key - WebAuthn": "مفتاح الأمان - WebAuthn",
|
||||
"Select a Device": "حدد جهاز",
|
||||
"Sign in": "تسجيل الدخول",
|
||||
"Sign out": "تسجيل الخروج",
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
"Reset": "Reset",
|
||||
"Scan QR Code": "Scan QR Code",
|
||||
"Secret": "Tajný klíč",
|
||||
"Security Key - WebAuthN": "Bezpečnostní klíč - WebAuthN",
|
||||
"Security Key - WebAuthn": "Bezpečnostní klíč - WebAuthn",
|
||||
"Select a Device": "Vybrat zařízení",
|
||||
"Sign in": "Přihlásit se",
|
||||
"Sign out": "Odhlásit se",
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
"Reset": "Reset",
|
||||
"Scan QR Code": "Scan QR Code",
|
||||
"Secret": "Hemmelighed",
|
||||
"Security Key - WebAuthN": "Sikkerhedsnøgle - WebAuthN",
|
||||
"Security Key - WebAuthn": "Sikkerhedsnøgle - WebAuthn",
|
||||
"Select a Device": "Vælg en enhed",
|
||||
"Sign in": "Log ind",
|
||||
"Sign out": "Log ud",
|
||||
|
|
|
@ -51,7 +51,7 @@
|
|||
"Reset": "Zurücksetzen",
|
||||
"Scan QR Code": "QR-Code scannen",
|
||||
"Secret": "Geheimnis",
|
||||
"Security Key - WebAuthN": "Sicherheitsschlüssel - WebAuthN",
|
||||
"Security Key - WebAuthn": "Sicherheitsschlüssel - WebAuthn",
|
||||
"Select a Device": "Gerät auswählen",
|
||||
"Sign in": "Anmelden",
|
||||
"Sign out": "Abmelden",
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
"Reset": "Reset",
|
||||
"Scan QR Code": "Scan QR Code",
|
||||
"Secret": "Μυστικό",
|
||||
"Security Key - WebAuthN": "Κλειδί Ασφαλείας - WebAuthn",
|
||||
"Security Key - WebAuthn": "Κλειδί Ασφαλείας - WebAuthn",
|
||||
"Select a Device": "Επιλέξτε μια συσκευή",
|
||||
"Sign in": "Σύνδεση",
|
||||
"Sign out": "Αποσύνδεση",
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
"Automatically refresh these permissions without user interaction": "Automatically refresh these permissions without user interaction",
|
||||
"Cancel": "Cancel",
|
||||
"Client ID": "Client ID: {{client_id}}",
|
||||
"Close": "Close",
|
||||
"Consent Request": "Consent Request",
|
||||
"Contact your administrator to register a device": "Contact your administrator to register a device.",
|
||||
"Could not obtain user settings": "Could not obtain user settings",
|
||||
|
@ -50,8 +51,9 @@
|
|||
"Reset password?": "Reset password?",
|
||||
"Reset": "Reset",
|
||||
"Scan QR Code": "Scan QR Code",
|
||||
"Scope": "Scope {{name}}",
|
||||
"Secret": "Secret",
|
||||
"Security Key - WebAuthN": "Security Key - WebAuthN",
|
||||
"Security Key - WebAuthn": "Security Key - WebAuthn",
|
||||
"Select a Device": "Select a Device",
|
||||
"Sign in": "Sign in",
|
||||
"Sign out": "Sign out",
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"Actions": "Actions",
|
||||
"Add": "Add",
|
||||
"Add Credential": "Add Credential",
|
||||
"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?",
|
||||
"Attestation Type": "Attestation Type",
|
||||
"Authenticator GUID": "Authenticator GUID",
|
||||
"Cancel": "Cancel",
|
||||
"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",
|
||||
"Clone Warning": "Clone Warning",
|
||||
"Created": "Created",
|
||||
"Delete": "Delete",
|
||||
"Details": "Details",
|
||||
"Display extended information for this WebAuthn credential": "Display extended information for this WebAuthn credential",
|
||||
"Edit": "Edit",
|
||||
"Edit information for this WebAuthn credential": "Edit information for this WebAuthn credential",
|
||||
"Edit WebAuthn Credential": "Edit WebAuthn Credential",
|
||||
"Enabled": "Enabled",
|
||||
"Enter a new name for this WebAuthn credential": "Enter a new name for this WebAuthn credential:",
|
||||
"Enter a description for this credential": "Enter a description for this credential",
|
||||
"Extended WebAuthn credential information for security key": "Extended WebAuthn credential information for security key {{description}}",
|
||||
"Identifier": "Identifier",
|
||||
"Last Used": "Last Used {{when, datetime}}",
|
||||
"Manage your security keys": "Manage your security keys",
|
||||
"Name": "Name",
|
||||
"No": "No",
|
||||
"No Registered WebAuthn Credentials": "No Registered WebAuthn Credentials",
|
||||
"Overview": "Overview",
|
||||
"Provide the details for the new security key": "Provide the details for the new security key",
|
||||
"Register WebAuthn Credential": "Register WebAuthn Credential",
|
||||
"Relying Party ID": "Relying Party ID",
|
||||
"Remove": "Remove",
|
||||
"Remove this WebAuthn credential": "Remove this WebAuthn credential",
|
||||
"Remove WebAuthn Credential": "Remove WebAuthn Credential",
|
||||
"Settings": "Settings",
|
||||
"Successfully deleted the WebAuthn credential": "Successfully deleted the WebAuthn credential",
|
||||
"Successfully updated the WebAuthn credential": "Successfully updated the WebAuthn credential",
|
||||
"There was a problem deleting the WebAuthn credential": "There was a problem deleting the WebAuthn credential",
|
||||
"There was a problem updating the WebAuthn credential": "There was a problem updating the WebAuthn credential",
|
||||
"Transports": "Transports",
|
||||
"Two-Factor Authentication": "Two-Factor Authentication",
|
||||
"Usage Count": "Usage Count",
|
||||
"WebAuthn Credential Details": "WebAuthn Credential Details",
|
||||
"WebAuthn Credentials": "WebAuthn Credentials",
|
||||
"Yes": "Yes",
|
||||
"You must have a higher authentication level to delete WebAuthn credentials": "You must have a higher authentication level to delete WebAuthn credentials",
|
||||
"You must be elevated to delete WebAuthn credentials": "You must be elevated to delete WebAuthn credentials",
|
||||
"You must have a higher authentication level to update WebAuthn credentials": "You must have a higher authentication level to update WebAuthn credentials",
|
||||
"You must be elevated to update WebAuthn credentials": "You must be elevated to update WebAuthn credentials"
|
||||
}
|
|
@ -50,7 +50,7 @@
|
|||
"Reset": "Restablecer",
|
||||
"Scan QR Code": "Escanear Código QR",
|
||||
"Secret": "Secreto",
|
||||
"Security Key - WebAuthN": "Clave de seguridad - WebAuthN",
|
||||
"Security Key - WebAuthn": "Clave de seguridad - WebAuthn",
|
||||
"Select a Device": "Seleccionar Dispositivo",
|
||||
"Sign in": "Iniciar Sesión",
|
||||
"Sign out": "Cerrar Sesión",
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
"Reset": "Nollaa",
|
||||
"Scan QR Code": "Skannaa QR-koodi",
|
||||
"Secret": "Salainen",
|
||||
"Security Key - WebAuthN": "Suojausavain - WebAuthN",
|
||||
"Security Key - WebAuthn": "Suojausavain - WebAuthn",
|
||||
"Select a Device": "Valitse laite",
|
||||
"Sign in": "Kirjaudu sisään",
|
||||
"Sign out": "Kirjaudu ulos",
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
"Reset": "Réinitialiser",
|
||||
"Scan QR Code": "Scannez le QR Code",
|
||||
"Secret": "Secret",
|
||||
"Security Key - WebAuthN": "Clé de sécurité - WebAuthN",
|
||||
"Security Key - WebAuthn": "Clé de sécurité - WebAuthn",
|
||||
"Select a Device": "Sélectionnez un appareil",
|
||||
"Sign in": "Se connecter",
|
||||
"Sign out": "Se déconnecter",
|
||||
|
|
|
@ -51,7 +51,7 @@
|
|||
"Reset": "Reset",
|
||||
"Scan QR Code": "Scan QR Code",
|
||||
"Secret": "Segreto",
|
||||
"Security Key - WebAuthN": "Chiave Di Sicurezza - WebAuthN",
|
||||
"Security Key - WebAuthn": "Chiave Di Sicurezza - WebAuthn",
|
||||
"Select a Device": "Seleziona un dispositivo",
|
||||
"Sign in": "Accedi",
|
||||
"Sign out": "Esci",
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
"Reset": "Reset",
|
||||
"Scan QR Code": "Scan QR Code",
|
||||
"Secret": "シークレット",
|
||||
"Security Key - WebAuthN": "セキュリティキー - WebAuthN",
|
||||
"Security Key - WebAuthn": "セキュリティキー - WebAuthn",
|
||||
"Select a Device": "デバイスを選択",
|
||||
"Sign in": "サインイン",
|
||||
"Sign out": "サインアウト",
|
||||
|
|
|
@ -51,7 +51,7 @@
|
|||
"Reset": "Tilbakestill",
|
||||
"Scan QR Code": "Skann QR Kode",
|
||||
"Secret": "Hemmelig",
|
||||
"Security Key - WebAuthN": "Sikkerhetsnøkkel - WebAuthN",
|
||||
"Security Key - WebAuthn": "Sikkerhetsnøkkel - WebAuthn",
|
||||
"Select a Device": "Velg en enhet",
|
||||
"Sign in": "Logg inn",
|
||||
"Sign out": "Logg ut",
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
"Reset": "Reset",
|
||||
"Scan QR Code": "Scan QR Code",
|
||||
"Secret": "Geheim",
|
||||
"Security Key - WebAuthN": "Beveiligingssleutel - WebAuthN",
|
||||
"Security Key - WebAuthn": "Beveiligingssleutel - WebAuthn",
|
||||
"Select a Device": "Selecteer een apparaat",
|
||||
"Sign in": "Log in",
|
||||
"Sign out": "Log uit",
|
||||
|
|
|
@ -51,7 +51,7 @@
|
|||
"Reset": "Tilbakestill",
|
||||
"Scan QR Code": "Skann QR Kode",
|
||||
"Secret": "Hemmelig",
|
||||
"Security Key - WebAuthN": "Sikkerhetsnøkkel - WebAuthN",
|
||||
"Security Key - WebAuthn": "Sikkerhetsnøkkel - WebAuthn",
|
||||
"Select a Device": "Velg en enhet",
|
||||
"Sign in": "Logg inn",
|
||||
"Sign out": "Logg ut",
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
"Reset": "Reset",
|
||||
"Scan QR Code": "Scan QR Code",
|
||||
"Secret": "Sekretny",
|
||||
"Security Key - WebAuthN": "Klucz bezpieczeństwa - WebAuthN",
|
||||
"Security Key - WebAuthn": "Klucz bezpieczeństwa - WebAuthn",
|
||||
"Select a Device": "Wybierz urządzenie",
|
||||
"Sign in": "Zaloguj się",
|
||||
"Sign out": "Wyloguj się",
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
"Reset": "Reset",
|
||||
"Scan QR Code": "Scan QR Code",
|
||||
"Secret": "Segredo",
|
||||
"Security Key - WebAuthN": "Chave de segurança - WebAuthN",
|
||||
"Security Key - WebAuthn": "Chave de segurança - WebAuthn",
|
||||
"Select a Device": "Selecione um dispositivo",
|
||||
"Sign in": "Iniciar sessão",
|
||||
"Sign out": "Encerrar sessão",
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
"Reset": "Reset",
|
||||
"Scan QR Code": "Ler Código QR",
|
||||
"Secret": "Segredo",
|
||||
"Security Key - WebAuthN": "Chave de Segurança - WebAuthN",
|
||||
"Security Key - WebAuthn": "Chave de Segurança - WebAuthn",
|
||||
"Select a Device": "Selecione um Dispositivo",
|
||||
"Sign in": "Iniciar sessão",
|
||||
"Sign out": "Terminar sessão",
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
"Reset": "Reset",
|
||||
"Scan QR Code": "Scan QR Code",
|
||||
"Secret": "Secret",
|
||||
"Security Key - WebAuthN": "Cheie de securitate - WebAuthN",
|
||||
"Security Key - WebAuthn": "Cheie de securitate - WebAuthn",
|
||||
"Select a Device": "Selectați un dispozitiv",
|
||||
"Sign in": "Autentificare",
|
||||
"Sign out": "Deconectare",
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
"Reset": "Сбросить",
|
||||
"Scan QR Code": "Отсканировать QR Code",
|
||||
"Secret": "Секрет",
|
||||
"Security Key - WebAuthN": "Сектретный ключ - WebAuthN",
|
||||
"Security Key - WebAuthn": "Сектретный ключ - WebAuthn",
|
||||
"Select a Device": "Выберите устройство",
|
||||
"Sign in": "Авторизация",
|
||||
"Sign out": "Выход",
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
"Reset": "Återställ",
|
||||
"Scan QR Code": "Skanna QR koden",
|
||||
"Secret": "Kod",
|
||||
"Security Key - WebAuthN": "Säkerhetsnyckel - WebAuthN",
|
||||
"Security Key - WebAuthn": "Säkerhetsnyckel - WebAuthn",
|
||||
"Select a Device": "Välj en enhet",
|
||||
"Sign in": "Logga in",
|
||||
"Sign out": "Logga ut",
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
"Reset": "Reset",
|
||||
"Scan QR Code": "Scan QR Code",
|
||||
"Secret": "Секрет",
|
||||
"Security Key - WebAuthN": "Ключ безпеки - WebAuthN",
|
||||
"Security Key - WebAuthn": "Ключ безпеки - WebAuthn",
|
||||
"Select a Device": "Оберіть пристрій",
|
||||
"Sign in": "Увійти",
|
||||
"Sign out": "Вийти",
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
"Reset": "Reset",
|
||||
"Scan QR Code": "扫描二维码",
|
||||
"Secret": "密钥",
|
||||
"Security Key - WebAuthN": "安全密钥 - WebAuthN",
|
||||
"Security Key - WebAuthn": "安全密钥 - WebAuthn",
|
||||
"Select a Device": "选择一个设备",
|
||||
"Sign in": "登录",
|
||||
"Sign out": "登出",
|
||||
|
|
|
@ -51,7 +51,7 @@
|
|||
"Reset": "重設",
|
||||
"Scan QR Code": "掃描 QR Code",
|
||||
"Secret": "密錀",
|
||||
"Security Key - WebAuthN": "安全密鑰 - WebAuthN",
|
||||
"Security Key - WebAuthn": "安全密鑰 - WebAuthn",
|
||||
"Select a Device": "選擇裝置",
|
||||
"Sign in": "登入",
|
||||
"Sign out": "登出",
|
||||
|
|
|
@ -36,7 +36,7 @@ type UserSession struct {
|
|||
AuthenticationMethodRefs oidc.AuthenticationMethodsReferences
|
||||
|
||||
// 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
|
||||
// while doing the query actually updating the password.
|
||||
|
@ -45,7 +45,13 @@ type UserSession struct {
|
|||
RefreshTTL time.Time
|
||||
}
|
||||
|
||||
// Identity identity of the user who is being verified.
|
||||
// WebAuthn holds the standard webauthn session data plus some extra.
|
||||
type WebAuthn struct {
|
||||
*webauthn.SessionData
|
||||
Description string
|
||||
}
|
||||
|
||||
// Identity of the user who is being verified.
|
||||
type Identity struct {
|
||||
Username string
|
||||
Email string
|
||||
|
|
|
@ -12,6 +12,7 @@ const (
|
|||
tableUserOpaqueIdentifier = "user_opaque_identifier"
|
||||
tableUserPreferences = "user_preferences"
|
||||
tableWebAuthnDevices = "webauthn_devices"
|
||||
tableWebAuthnUsers = "webauthn_users"
|
||||
|
||||
tableOAuth2BlacklistedJTI = "oauth2_blacklisted_jti"
|
||||
tableOAuth2ConsentSession = "oauth2_consent_session"
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
ALTER TABLE webauthn_devices
|
||||
RENAME _bkp_DOWN_V0008_webauthn_devices;
|
||||
|
||||
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 TEXT,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
description VARCHAR(30) NOT NULL DEFAULT 'Primary',
|
||||
kid VARCHAR(512) NOT NULL,
|
||||
public_key BLOB NOT NULL,
|
||||
attestation_type VARCHAR(32),
|
||||
transport VARCHAR(20) DEFAULT '',
|
||||
aaguid CHAR(36) NOT NULL,
|
||||
sign_count INTEGER DEFAULT 0,
|
||||
clone_warning BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
UNIQUE KEY (username, description),
|
||||
UNIQUE KEY (kid)
|
||||
) 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, description);
|
||||
|
||||
INSERT INTO webauthn_devices (created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning)
|
||||
SELECT created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning
|
||||
FROM _bkp_DOWN_V0008_webauthn_devices
|
||||
WHERE legacy = TRUE;
|
||||
|
||||
DROP TABLE IF EXISTS _bkp_DOWN_V0008_webauthn_devices;
|
||||
DROP TABLE IF EXISTS webauthn_users;
|
|
@ -0,0 +1,43 @@
|
|||
ALTER TABLE webauthn_devices
|
||||
RENAME _bkp_UP_V0008_webauthn_devices;
|
||||
|
||||
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,
|
||||
description 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,
|
||||
legacy 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, description);
|
||||
|
||||
INSERT INTO webauthn_devices (created_at, last_used_at, rpid, username, description, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, legacy, 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, TRUE, FALSE, FALSE, FALSE, FALSE, public_key
|
||||
FROM _bkp_UP_V0008_webauthn_devices;
|
||||
|
||||
DROP TABLE IF EXISTS _bkp_UP_V0008_webauthn_devices;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webauthn_users (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
|
||||
rpid VARCHAR(512) NOT NULL,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
userid CHAR(64) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
|
||||
|
||||
CREATE UNIQUE INDEX webauthn_users_lookup_key ON webauthn_users (rpid, username);
|
|
@ -0,0 +1,36 @@
|
|||
ALTER TABLE webauthn_devices
|
||||
DROP CONSTRAINT IF EXISTS webauthn_devices_pkey;
|
||||
|
||||
DROP INDEX IF EXISTS webauthn_devices_pkey;
|
||||
DROP INDEX IF EXISTS webauthn_devices_kid_key;
|
||||
DROP INDEX IF EXISTS webauthn_devices_lookup_key;
|
||||
|
||||
ALTER TABLE webauthn_devices
|
||||
RENAME TO _bkp_DOWN_V0008_webauthn_devices;
|
||||
|
||||
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 TEXT,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
description VARCHAR(30) NOT NULL DEFAULT 'Primary',
|
||||
kid VARCHAR(512) NOT NULL,
|
||||
public_key BYTEA NOT NULL,
|
||||
attestation_type VARCHAR(32),
|
||||
transport VARCHAR(20) DEFAULT '',
|
||||
aaguid CHAR(36) NOT NULL,
|
||||
sign_count INTEGER DEFAULT 0,
|
||||
clone_warning BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX webauthn_devices_kid_key ON webauthn_devices (kid);
|
||||
CREATE UNIQUE INDEX webauthn_devices_lookup_key ON webauthn_devices (username, description);
|
||||
|
||||
INSERT INTO webauthn_devices (created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning)
|
||||
SELECT created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning
|
||||
FROM _bkp_DOWN_V0008_webauthn_devices
|
||||
WHERE legacy = TRUE;
|
||||
|
||||
DROP TABLE IF EXISTS _bkp_DOWN_V0008_webauthn_devices;
|
||||
DROP TABLE IF EXISTS webauthn_users;
|
|
@ -0,0 +1,50 @@
|
|||
ALTER TABLE webauthn_devices
|
||||
DROP CONSTRAINT IF EXISTS webauthn_devices_pkey;
|
||||
|
||||
DROP INDEX IF EXISTS webauthn_devices_pkey;
|
||||
DROP INDEX IF EXISTS webauthn_devices_kid_key;
|
||||
DROP INDEX IF EXISTS webauthn_devices_lookup_key;
|
||||
|
||||
ALTER TABLE webauthn_devices
|
||||
RENAME TO _bkp_UP_V0008_webauthn_devices;
|
||||
|
||||
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,
|
||||
description 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,
|
||||
legacy 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, description);
|
||||
|
||||
INSERT INTO webauthn_devices (created_at, last_used_at, rpid, username, description, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, legacy, 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, TRUE, FALSE, FALSE, FALSE, FALSE, FALSE, public_key
|
||||
FROM _bkp_UP_V0008_webauthn_devices;
|
||||
|
||||
DROP TABLE IF EXISTS _bkp_UP_V0008_webauthn_devices;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webauthn_users (
|
||||
id SERIAL CONSTRAINT webauthn_users_pkey PRIMARY KEY,
|
||||
rpid VARCHAR(512) NOT NULL,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
userid CHAR(64) NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX webauthn_users_lookup_key ON webauthn_users (rpid, username);
|
|
@ -0,0 +1,32 @@
|
|||
ALTER TABLE webauthn_devices
|
||||
RENAME TO _bkp_DOWN_V0008_webauthn_devices;
|
||||
|
||||
DROP INDEX IF EXISTS webauthn_devices_kid_key;
|
||||
DROP INDEX IF EXISTS webauthn_devices_lookup_key;
|
||||
|
||||
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 TEXT,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
description VARCHAR(30) NOT NULL DEFAULT 'Primary',
|
||||
kid VARCHAR(512) NOT NULL,
|
||||
public_key BLOB NOT NULL,
|
||||
attestation_type VARCHAR(32),
|
||||
transport VARCHAR(20) DEFAULT '',
|
||||
aaguid CHAR(36) NULL,
|
||||
sign_count INTEGER DEFAULT 0,
|
||||
clone_warning BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX webauthn_devices_lookup_key ON webauthn_devices (username, description);
|
||||
CREATE UNIQUE INDEX webauthn_devices_kid_key ON webauthn_devices (kid);
|
||||
|
||||
INSERT INTO webauthn_devices (created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning)
|
||||
SELECT created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning
|
||||
FROM _bkp_DOWN_V0008_webauthn_devices
|
||||
WHERE legacy = TRUE;
|
||||
|
||||
DROP TABLE IF EXISTS _bkp_DOWN_V0008_webauthn_devices;
|
||||
DROP TABLE IF EXISTS webauthn_users;
|
|
@ -0,0 +1,46 @@
|
|||
DROP INDEX IF EXISTS webauthn_devices_lookup_key;
|
||||
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,
|
||||
description 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,
|
||||
legacy 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, description);
|
||||
|
||||
INSERT INTO webauthn_devices (created_at, last_used_at, rpid, username, description, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, legacy, 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, TRUE, FALSE, FALSE, FALSE, FALSE, FALSE, public_key
|
||||
FROM _bkp_UP_V0008_webauthn_devices;
|
||||
|
||||
DROP TABLE IF EXISTS _bkp_UP_V0008_webauthn_devices;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webauthn_users (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
rpid VARCHAR(512) NOT NULL,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
userid CHAR(64) NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX webauthn_users_lookup_key ON webauthn_users (rpid, username);
|
|
@ -9,7 +9,7 @@ import (
|
|||
|
||||
const (
|
||||
// This is the latest schema version for the purpose of tests.
|
||||
LatestVersion = 9
|
||||
LatestVersion = 10
|
||||
)
|
||||
|
||||
func TestShouldObtainCorrectUpMigrations(t *testing.T) {
|
||||
|
|
|
@ -38,12 +38,17 @@ type Provider interface {
|
|||
LoadTOTPConfiguration(ctx context.Context, username string) (config *model.TOTPConfiguration, err error)
|
||||
LoadTOTPConfigurations(ctx context.Context, limit, page int) (configs []model.TOTPConfiguration, err error)
|
||||
|
||||
SaveWebAuthnUser(ctx context.Context, user model.WebAuthnUser) (err error)
|
||||
LoadWebAuthnUser(ctx context.Context, rpid, username string) (user *model.WebAuthnUser, err error)
|
||||
|
||||
SaveWebAuthnDevice(ctx context.Context, device model.WebAuthnDevice) (err error)
|
||||
UpdateWebAuthnDeviceSignIn(ctx context.Context, id int, rpid string, lastUsedAt sql.NullTime, signCount uint32, cloneWarning bool) (err error)
|
||||
UpdateWebAuthnDeviceDescription(ctx context.Context, username string, deviceID int, description string) (err error)
|
||||
UpdateWebAuthnDeviceSignIn(ctx context.Context, device model.WebAuthnDevice) (err error)
|
||||
DeleteWebAuthnDevice(ctx context.Context, kid string) (err error)
|
||||
DeleteWebAuthnDeviceByUsername(ctx context.Context, username, description string) (err error)
|
||||
LoadWebAuthnDevices(ctx context.Context, limit, page int) (devices []model.WebAuthnDevice, err error)
|
||||
LoadWebAuthnDevicesByUsername(ctx context.Context, username string) (devices []model.WebAuthnDevice, err error)
|
||||
LoadWebAuthnDevicesByUsername(ctx context.Context, rpid, username string) (devices []model.WebAuthnDevice, err error)
|
||||
LoadWebAuthnDeviceByID(ctx context.Context, id int) (device *model.WebAuthnDevice, err error)
|
||||
|
||||
SavePreferredDuoDevice(ctx context.Context, device model.DuoDevice) (err error)
|
||||
DeletePreferredDuoDevice(ctx context.Context, username string) (err error)
|
||||
|
|
|
@ -46,16 +46,19 @@ func NewSQLProvider(config *schema.Configuration, name, driverName, dataSourceNa
|
|||
sqlUpdateTOTPConfigRecordSignIn: fmt.Sprintf(queryFmtUpdateTOTPConfigRecordSignIn, tableTOTPConfigurations),
|
||||
sqlUpdateTOTPConfigRecordSignInByUsername: fmt.Sprintf(queryFmtUpdateTOTPConfigRecordSignInByUsername, tableTOTPConfigurations),
|
||||
|
||||
sqlUpsertWebAuthnDevice: fmt.Sprintf(queryFmtUpsertWebAuthnDevice, tableWebAuthnDevices),
|
||||
sqlInsertWebAuthnUser: fmt.Sprintf(queryFmtInsertWebAuthnUser, tableWebAuthnUsers),
|
||||
sqlSelectWebAuthnUser: fmt.Sprintf(queryFmtSelectWebAuthnUser, tableWebAuthnUsers),
|
||||
|
||||
sqlInsertWebAuthnDevice: fmt.Sprintf(queryFmtInsertWebAuthnDevice, tableWebAuthnDevices),
|
||||
sqlSelectWebAuthnDevices: fmt.Sprintf(queryFmtSelectWebAuthnDevices, tableWebAuthnDevices),
|
||||
sqlSelectWebAuthnDevicesByUsername: fmt.Sprintf(queryFmtSelectWebAuthnDevicesByUsername, tableWebAuthnDevices),
|
||||
|
||||
sqlSelectWebAuthnDevicesByRPIDByUsername: fmt.Sprintf(queryFmtSelectWebAuthnDevicesByRPIDByUsername, tableWebAuthnDevices),
|
||||
sqlSelectWebAuthnDeviceByID: fmt.Sprintf(queryFmtSelectWebAuthnDeviceByID, tableWebAuthnDevices),
|
||||
sqlUpdateWebAuthnDeviceDescriptionByUsernameAndID: fmt.Sprintf(queryFmtUpdateUpdateWebAuthnDeviceDescriptionByUsernameAndID, tableWebAuthnDevices),
|
||||
sqlUpdateWebAuthnDeviceRecordSignIn: fmt.Sprintf(queryFmtUpdateWebAuthnDeviceRecordSignIn, tableWebAuthnDevices),
|
||||
sqlUpdateWebAuthnDeviceRecordSignInByUsername: fmt.Sprintf(queryFmtUpdateWebAuthnDeviceRecordSignInByUsername, tableWebAuthnDevices),
|
||||
|
||||
sqlDeleteWebAuthnDevice: fmt.Sprintf(queryFmtDeleteWebAuthnDevice, tableWebAuthnDevices),
|
||||
sqlDeleteWebAuthnDeviceByUsername: fmt.Sprintf(queryFmtDeleteWebAuthnDeviceByUsername, tableWebAuthnDevices),
|
||||
sqlDeleteWebAuthnDeviceByUsernameAndDescription: fmt.Sprintf(queryFmtDeleteWebAuthnDeviceByUsernameAndDescription, tableWebAuthnDevices),
|
||||
sqlDeleteWebAuthnDeviceByUsernameAndDisplayName: fmt.Sprintf(queryFmtDeleteWebAuthnDeviceByUsernameAndDescription, tableWebAuthnDevices),
|
||||
|
||||
sqlUpsertDuoDevice: fmt.Sprintf(queryFmtUpsertDuoDevice, tableDuoDevices),
|
||||
sqlDeleteDuoDevice: fmt.Sprintf(queryFmtDeleteDuoDevice, tableDuoDevices),
|
||||
|
@ -164,17 +167,23 @@ type SQLProvider struct {
|
|||
sqlUpdateTOTPConfigRecordSignIn string
|
||||
sqlUpdateTOTPConfigRecordSignInByUsername string
|
||||
|
||||
// Table: webauthn_users.
|
||||
sqlInsertWebAuthnUser string
|
||||
sqlSelectWebAuthnUser string
|
||||
|
||||
// Table: webauthn_devices.
|
||||
sqlUpsertWebAuthnDevice string
|
||||
sqlInsertWebAuthnDevice string
|
||||
sqlSelectWebAuthnDevices string
|
||||
sqlSelectWebAuthnDevicesByUsername string
|
||||
sqlSelectWebAuthnDevicesByRPIDByUsername string
|
||||
sqlSelectWebAuthnDeviceByID string
|
||||
|
||||
sqlUpdateWebAuthnDeviceDescriptionByUsernameAndID string
|
||||
sqlUpdateWebAuthnDeviceRecordSignIn string
|
||||
sqlUpdateWebAuthnDeviceRecordSignInByUsername string
|
||||
|
||||
sqlDeleteWebAuthnDevice string
|
||||
sqlDeleteWebAuthnDeviceByUsername string
|
||||
sqlDeleteWebAuthnDeviceByUsernameAndDescription string
|
||||
sqlDeleteWebAuthnDeviceByUsernameAndDisplayName string
|
||||
|
||||
// Table: duo_devices.
|
||||
sqlUpsertDuoDevice string
|
||||
|
@ -365,7 +374,7 @@ func (p *SQLProvider) LoadUserOpaqueIdentifier(ctx context.Context, identifier u
|
|||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("error selecting user opaque id with value '%s': %w", identifier.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -404,7 +413,7 @@ func (p *SQLProvider) LoadUserOpaqueIdentifierBySignature(ctx context.Context, s
|
|||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("error selecting user opaque with service '%s' and sector '%s' for username '%s': %w", service, sectorID, username, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -845,7 +854,7 @@ func (p *SQLProvider) DeleteTOTPConfiguration(ctx context.Context, username stri
|
|||
func (p *SQLProvider) LoadTOTPConfiguration(ctx context.Context, username string) (config *model.TOTPConfiguration, err error) {
|
||||
config = &model.TOTPConfiguration{}
|
||||
|
||||
if err = p.db.QueryRowxContext(ctx, p.sqlSelectTOTPConfig, username).StructScan(config); err != nil {
|
||||
if err = p.db.GetContext(ctx, config, p.sqlSelectTOTPConfig, username); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNoTOTPConfiguration
|
||||
}
|
||||
|
@ -881,28 +890,65 @@ func (p *SQLProvider) LoadTOTPConfigurations(ctx context.Context, limit, page in
|
|||
return configs, nil
|
||||
}
|
||||
|
||||
// SaveWebAuthnUser saves a registered WebAuthn user.
|
||||
func (p *SQLProvider) SaveWebAuthnUser(ctx context.Context, user model.WebAuthnUser) (err error) {
|
||||
if _, err = p.db.ExecContext(ctx, p.sqlInsertWebAuthnUser, user.RPID, user.Username, user.UserID); err != nil {
|
||||
return fmt.Errorf("error inserting WebAuthn user '%s' with relying party id '%s': %w", user.Username, user.RPID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadWebAuthnUser loads a registered WebAuthn user.
|
||||
func (p *SQLProvider) LoadWebAuthnUser(ctx context.Context, rpid, username string) (user *model.WebAuthnUser, err error) {
|
||||
user = &model.WebAuthnUser{}
|
||||
|
||||
if err = p.db.GetContext(ctx, user, p.sqlSelectWebAuthnUser, rpid, username); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("error selecting WebAuthn user '%s' with relying party id '%s': %w", user.Username, user.RPID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// SaveWebAuthnDevice saves a registered WebAuthn device.
|
||||
func (p *SQLProvider) SaveWebAuthnDevice(ctx context.Context, device model.WebAuthnDevice) (err error) {
|
||||
if device.PublicKey, err = p.encrypt(device.PublicKey); err != nil {
|
||||
return fmt.Errorf("error encrypting WebAuthn device public key for user '%s' kid '%x': %w", device.Username, device.KID, err)
|
||||
}
|
||||
|
||||
if _, err = p.db.ExecContext(ctx, p.sqlUpsertWebAuthnDevice,
|
||||
device.CreatedAt, device.LastUsedAt,
|
||||
device.RPID, device.Username, device.Description,
|
||||
device.KID, device.PublicKey,
|
||||
device.AttestationType, device.Transport, device.AAGUID, device.SignCount, device.CloneWarning,
|
||||
if _, err = p.db.ExecContext(ctx, p.sqlInsertWebAuthnDevice,
|
||||
device.CreatedAt, device.LastUsedAt, device.RPID, device.Username, device.Description,
|
||||
device.KID, device.AAGUID, device.AttestationType, device.Attachment, device.Transport,
|
||||
device.SignCount, device.CloneWarning, device.Discoverable, device.Present, device.Verified,
|
||||
device.BackupEligible, device.BackupState, device.PublicKey,
|
||||
); err != nil {
|
||||
return fmt.Errorf("error upserting WebAuthn device for user '%s' kid '%x': %w", device.Username, device.KID, err)
|
||||
return fmt.Errorf("error inserting WebAuthn device for user '%s' kid '%x': %w", device.Username, device.KID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateWebAuthnDeviceDescription updates a registered WebAuthn device's description.
|
||||
func (p *SQLProvider) UpdateWebAuthnDeviceDescription(ctx context.Context, username string, deviceID int, description string) (err error) {
|
||||
if _, err = p.db.ExecContext(ctx, p.sqlUpdateWebAuthnDeviceDescriptionByUsernameAndID, description, username, deviceID); err != nil {
|
||||
return fmt.Errorf("error updating WebAuthn device description to '%s' for device id '%d': %w", description, deviceID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if _, err = p.db.ExecContext(ctx, p.sqlUpdateWebAuthnDeviceRecordSignIn, rpid, lastUsedAt, signCount, cloneWarning, id); err != nil {
|
||||
return fmt.Errorf("error updating WebAuthn signin metadata for id '%x': %w", id, err)
|
||||
func (p *SQLProvider) UpdateWebAuthnDeviceSignIn(ctx context.Context, device model.WebAuthnDevice) (err error) {
|
||||
if _, err = p.db.ExecContext(ctx, p.sqlUpdateWebAuthnDeviceRecordSignIn,
|
||||
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 authentication metadata for id '%x': %w", device.ID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -918,18 +964,18 @@ func (p *SQLProvider) DeleteWebAuthnDevice(ctx context.Context, kid string) (err
|
|||
}
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
return fmt.Errorf("error deleting WebAuthn devices for username '%s': %w", username, err)
|
||||
}
|
||||
} else {
|
||||
if _, err = p.db.ExecContext(ctx, p.sqlDeleteWebAuthnDeviceByUsernameAndDescription, username, description); err != nil {
|
||||
return fmt.Errorf("error deleting WebAuthn device with username '%s' and description '%s': %w", username, description, err)
|
||||
if _, err = p.db.ExecContext(ctx, p.sqlDeleteWebAuthnDeviceByUsernameAndDisplayName, username, displayname); err != nil {
|
||||
return fmt.Errorf("error deleting WebAuthn device with username '%s' and displayname '%s': %w", username, displayname, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -957,11 +1003,33 @@ func (p *SQLProvider) LoadWebAuthnDevices(ctx context.Context, limit, page int)
|
|||
return devices, nil
|
||||
}
|
||||
|
||||
// LoadWebAuthnDevicesByUsername loads all WebAuthn devices registration for a given username.
|
||||
func (p *SQLProvider) LoadWebAuthnDevicesByUsername(ctx context.Context, username string) (devices []model.WebAuthnDevice, err error) {
|
||||
if err = p.db.SelectContext(ctx, &devices, p.sqlSelectWebAuthnDevicesByUsername, username); err != nil {
|
||||
// LoadWebAuthnDeviceByID loads a WebAuthn device registration for a given id.
|
||||
func (p *SQLProvider) LoadWebAuthnDeviceByID(ctx context.Context, id int) (device *model.WebAuthnDevice, err error) {
|
||||
device = &model.WebAuthnDevice{}
|
||||
|
||||
if err = p.db.GetContext(ctx, device, p.sqlSelectWebAuthnDeviceByID, id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNoWebAuthnDevice
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("error selecting WebAuthn device with id '%d': %w", id, err)
|
||||
}
|
||||
|
||||
return device, nil
|
||||
}
|
||||
|
||||
// LoadWebAuthnDevicesByUsername loads all WebAuthn devices registration for a given username.
|
||||
func (p *SQLProvider) LoadWebAuthnDevicesByUsername(ctx context.Context, rpid, username string) (devices []model.WebAuthnDevice, err error) {
|
||||
switch len(rpid) {
|
||||
case 0:
|
||||
err = p.db.SelectContext(ctx, &devices, p.sqlSelectWebAuthnDevicesByUsername, username)
|
||||
default:
|
||||
err = p.db.SelectContext(ctx, &devices, p.sqlSelectWebAuthnDevicesByRPIDByUsername, rpid, username)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return devices, ErrNoWebAuthnDevice
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("error selecting WebAuthn devices for user '%s': %w", username, err)
|
||||
|
|
|
@ -30,7 +30,6 @@ func NewPostgreSQLProvider(config *schema.Configuration, caCertPool *x509.CertPo
|
|||
|
||||
// Specific alterations to this provider.
|
||||
// PostgreSQL doesn't have a UPSERT statement but has an ON CONFLICT operation instead.
|
||||
provider.sqlUpsertWebAuthnDevice = fmt.Sprintf(queryFmtUpsertWebAuthnDevicePostgreSQL, tableWebAuthnDevices)
|
||||
provider.sqlUpsertDuoDevice = fmt.Sprintf(queryFmtUpsertDuoDevicePostgreSQL, tableDuoDevices)
|
||||
provider.sqlUpsertTOTPConfig = fmt.Sprintf(queryFmtUpsertTOTPConfigurationPostgreSQL, tableTOTPConfigurations)
|
||||
provider.sqlUpsertPreferred2FAMethod = fmt.Sprintf(queryFmtUpsertPreferred2FAMethodPostgreSQL, tableUserPreferences)
|
||||
|
@ -58,13 +57,19 @@ func NewPostgreSQLProvider(config *schema.Configuration, caCertPool *x509.CertPo
|
|||
provider.sqlDeleteTOTPConfig = provider.db.Rebind(provider.sqlDeleteTOTPConfig)
|
||||
provider.sqlSelectTOTPConfigs = provider.db.Rebind(provider.sqlSelectTOTPConfigs)
|
||||
|
||||
provider.sqlInsertWebAuthnUser = provider.db.Rebind(provider.sqlInsertWebAuthnUser)
|
||||
provider.sqlSelectWebAuthnUser = provider.db.Rebind(provider.sqlSelectWebAuthnUser)
|
||||
|
||||
provider.sqlInsertWebAuthnDevice = provider.db.Rebind(provider.sqlInsertWebAuthnDevice)
|
||||
provider.sqlSelectWebAuthnDevices = provider.db.Rebind(provider.sqlSelectWebAuthnDevices)
|
||||
provider.sqlSelectWebAuthnDevicesByUsername = provider.db.Rebind(provider.sqlSelectWebAuthnDevicesByUsername)
|
||||
provider.sqlSelectWebAuthnDevicesByRPIDByUsername = provider.db.Rebind(provider.sqlSelectWebAuthnDevicesByRPIDByUsername)
|
||||
provider.sqlSelectWebAuthnDeviceByID = provider.db.Rebind(provider.sqlSelectWebAuthnDeviceByID)
|
||||
provider.sqlUpdateWebAuthnDeviceDescriptionByUsernameAndID = provider.db.Rebind(provider.sqlUpdateWebAuthnDeviceDescriptionByUsernameAndID)
|
||||
provider.sqlUpdateWebAuthnDeviceRecordSignIn = provider.db.Rebind(provider.sqlUpdateWebAuthnDeviceRecordSignIn)
|
||||
provider.sqlUpdateWebAuthnDeviceRecordSignInByUsername = provider.db.Rebind(provider.sqlUpdateWebAuthnDeviceRecordSignInByUsername)
|
||||
provider.sqlDeleteWebAuthnDevice = provider.db.Rebind(provider.sqlDeleteWebAuthnDevice)
|
||||
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.sqlDeleteDuoDevice = provider.db.Rebind(provider.sqlDeleteDuoDevice)
|
||||
|
|
|
@ -174,7 +174,7 @@ func schemaEncryptionChangeKeyWebAuthn(ctx context.Context, provider *SQLProvide
|
|||
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 {
|
||||
if d.PublicKey, err = provider.decrypt(d.PublicKey); err != nil {
|
||||
|
|
|
@ -120,48 +120,41 @@ const (
|
|||
|
||||
const (
|
||||
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, description, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key
|
||||
FROM %s
|
||||
LIMIT ?
|
||||
OFFSET ?;`
|
||||
|
||||
queryFmtSelectWebAuthnDevicesEncryptedData = `
|
||||
SELECT id, public_key
|
||||
FROM %s;`
|
||||
|
||||
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, description, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key
|
||||
FROM %s
|
||||
WHERE username = ?;`
|
||||
|
||||
queryFmtUpdateWebAuthnDevicePublicKey = `
|
||||
UPDATE %s
|
||||
SET public_key = ?
|
||||
queryFmtSelectWebAuthnDevicesByRPIDByUsername = `
|
||||
SELECT id, created_at, last_used_at, rpid, username, description, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key
|
||||
FROM %s
|
||||
WHERE rpid = ? AND username = ?;`
|
||||
|
||||
queryFmtSelectWebAuthnDeviceByID = `
|
||||
SELECT id, created_at, last_used_at, rpid, username, description, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key
|
||||
FROM %s
|
||||
WHERE id = ?;`
|
||||
|
||||
queryFmtUpdateUpdateWebAuthnDeviceDescriptionByUsernameAndID = `
|
||||
UPDATE %s
|
||||
SET description = ?
|
||||
WHERE username = ? AND id = ?;`
|
||||
|
||||
queryFmtUpdateWebAuthnDeviceRecordSignIn = `
|
||||
UPDATE %s
|
||||
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
|
||||
WHERE id = ?;`
|
||||
|
||||
queryFmtUpdateWebAuthnDeviceRecordSignInByUsername = `
|
||||
UPDATE %s
|
||||
SET
|
||||
rpid = ?, last_used_at = ?, sign_count = ?,
|
||||
clone_warning = CASE clone_warning WHEN TRUE THEN TRUE ELSE ? END
|
||||
WHERE username = ? AND kid = ?;`
|
||||
|
||||
queryFmtUpsertWebAuthnDevice = `
|
||||
REPLACE INTO %s (created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`
|
||||
|
||||
queryFmtUpsertWebAuthnDevicePostgreSQL = `
|
||||
INSERT INTO %s (created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT (username, description)
|
||||
DO UPDATE SET created_at = $1, last_used_at = $2, rpid = $3, kid = $6, public_key = $7, attestation_type = $8, transport = $9, aaguid = $10, sign_count = $11, clone_warning = $12;`
|
||||
queryFmtInsertWebAuthnDevice = `
|
||||
INSERT INTO %s (created_at, last_used_at, rpid, username, description, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`
|
||||
|
||||
queryFmtDeleteWebAuthnDevice = `
|
||||
DELETE FROM %s
|
||||
|
@ -174,6 +167,26 @@ const (
|
|||
queryFmtDeleteWebAuthnDeviceByUsernameAndDescription = `
|
||||
DELETE FROM %s
|
||||
WHERE username = ? AND description = ?;`
|
||||
|
||||
queryFmtSelectWebAuthnDevicesEncryptedData = `
|
||||
SELECT id, public_key
|
||||
FROM %s;`
|
||||
|
||||
queryFmtUpdateWebAuthnDevicesEncryptedData = `
|
||||
UPDATE %s
|
||||
SET public_key = ?
|
||||
WHERE id = ?;`
|
||||
)
|
||||
|
||||
const (
|
||||
queryFmtInsertWebAuthnUser = `
|
||||
INSERT INTO %s (rpid, username, userid)
|
||||
VALUES (?, ?, ?);`
|
||||
|
||||
queryFmtSelectWebAuthnUser = `
|
||||
SELECT id, rpid, username, userid
|
||||
FROM %s
|
||||
WHERE rpid = ? AND username = ?;`
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
|
@ -49,8 +49,12 @@ func (s *BackendProtectionScenario) AssertRequestStatusCode(method, url string,
|
|||
|
||||
func (s *BackendProtectionScenario) TestProtectionOfBackendEndpoints() {
|
||||
s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/totp", AutheliaBaseURL), fasthttp.StatusForbidden)
|
||||
s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/webauthn/assertion", AutheliaBaseURL), fasthttp.StatusForbidden)
|
||||
s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/webauthn/attestation", AutheliaBaseURL), fasthttp.StatusForbidden)
|
||||
s.AssertRequestStatusCode(fasthttp.MethodGet, fmt.Sprintf("%s/api/secondfactor/webauthn/credentials", AutheliaBaseURL), fasthttp.StatusForbidden)
|
||||
s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/webauthn", AutheliaBaseURL), fasthttp.StatusForbidden)
|
||||
s.AssertRequestStatusCode(fasthttp.MethodPut, fmt.Sprintf("%s/api/secondfactor/webauthn/credential/register", AutheliaBaseURL), fasthttp.StatusForbidden)
|
||||
s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/webauthn/credential/register", AutheliaBaseURL), fasthttp.StatusForbidden)
|
||||
s.AssertRequestStatusCode(fasthttp.MethodDelete, fmt.Sprintf("%s/api/secondfactor/webauthn/credential/1", AutheliaBaseURL), fasthttp.StatusForbidden)
|
||||
s.AssertRequestStatusCode(fasthttp.MethodPut, fmt.Sprintf("%s/api/secondfactor/webauthn/credential/1", AutheliaBaseURL), fasthttp.StatusForbidden)
|
||||
s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/user/info/2fa_method", AutheliaBaseURL), fasthttp.StatusForbidden)
|
||||
|
||||
s.AssertRequestStatusCode(fasthttp.MethodGet, fmt.Sprintf("%s/api/user/info", AutheliaBaseURL), fasthttp.StatusForbidden)
|
||||
|
@ -58,8 +62,6 @@ func (s *BackendProtectionScenario) TestProtectionOfBackendEndpoints() {
|
|||
|
||||
s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/totp/identity/start", AutheliaBaseURL), fasthttp.StatusForbidden)
|
||||
s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/totp/identity/finish", AutheliaBaseURL), fasthttp.StatusForbidden)
|
||||
s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/webauthn/identity/start", AutheliaBaseURL), fasthttp.StatusForbidden)
|
||||
s.AssertRequestStatusCode(fasthttp.MethodPost, fmt.Sprintf("%s/api/secondfactor/webauthn/identity/finish", AutheliaBaseURL), fasthttp.StatusForbidden)
|
||||
}
|
||||
|
||||
func (s *BackendProtectionScenario) TestInvalidEndpointsReturn404() {
|
||||
|
|
|
@ -2,6 +2,10 @@
|
|||
"name": "authelia",
|
||||
"version": "4.37.5",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18.4.0",
|
||||
"pnpm": "8"
|
||||
},
|
||||
"pnpm": {
|
||||
"peerDependencyRules": {
|
||||
"allowedVersions": {
|
||||
|
@ -21,6 +25,8 @@
|
|||
"@mui/icons-material": "5.11.16",
|
||||
"@mui/material": "5.13.2",
|
||||
"@mui/styles": "5.13.2",
|
||||
"@simplewebauthn/browser": "7.2.0",
|
||||
"@simplewebauthn/typescript-types": "7.0.0",
|
||||
"axios": "1.4.0",
|
||||
"broadcast-channel": "5.1.0",
|
||||
"classnames": "2.3.2",
|
||||
|
|
|
@ -31,6 +31,12 @@ dependencies:
|
|||
'@mui/styles':
|
||||
specifier: 5.13.2
|
||||
version: 5.13.2(@types/react@18.2.7)(react@18.2.0)
|
||||
'@simplewebauthn/browser':
|
||||
specifier: 7.2.0
|
||||
version: 7.2.0
|
||||
'@simplewebauthn/typescript-types':
|
||||
specifier: 7.0.0
|
||||
version: 7.0.0
|
||||
axios:
|
||||
specifier: 1.4.0
|
||||
version: 1.4.0
|
||||
|
@ -499,7 +505,6 @@ packages:
|
|||
/@babel/parser@7.21.4:
|
||||
resolution: {integrity: sha512-alVJj7k7zIxqBZ7BTRhz0IqJFxW1VJbm6N8JbcYhQ186df9ZBPbZBmWSqAMXwHGsCJdYks7z/voa3ibiS5bCIw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
'@babel/types': 7.21.4
|
||||
dev: true
|
||||
|
@ -1552,7 +1557,6 @@ packages:
|
|||
/@commitlint/cli@17.6.3:
|
||||
resolution: {integrity: sha512-ItSz2fd4F+CujgIbQOfNNerDF1eFlsBGEfp9QcCb1kxTYMuKTYZzA6Nu1YRRrIaaWwe2E7awUGpIMrPoZkOG3A==}
|
||||
engines: {node: '>=v14'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
'@commitlint/format': 17.4.4
|
||||
'@commitlint/lint': 17.6.3
|
||||
|
@ -2535,6 +2539,16 @@ packages:
|
|||
resolution: {integrity: sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==}
|
||||
dev: true
|
||||
|
||||
/@simplewebauthn/browser@7.2.0:
|
||||
resolution: {integrity: sha512-HHIvRPpqKy0UV/BsGAmx4rQRZuZTUFYLLH65FwpSOslqHruiHx3Ql/bq7A75bjWuJ296a+4BIAq3+SPaII01TQ==}
|
||||
dependencies:
|
||||
'@simplewebauthn/typescript-types': 7.0.0
|
||||
dev: false
|
||||
|
||||
/@simplewebauthn/typescript-types@7.0.0:
|
||||
resolution: {integrity: sha512-bV+xACCFTsrLR/23ozHO06ZllHZaxC8LlI5YCo79GvU2BrN+rePDU2yXwZIYndNWcMQwRdndRdAhpafOh9AC/g==}
|
||||
dev: false
|
||||
|
||||
/@sinclair/typebox@0.25.24:
|
||||
resolution: {integrity: sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==}
|
||||
dev: true
|
||||
|
@ -3185,7 +3199,6 @@ packages:
|
|||
|
||||
/JSONStream@1.3.5:
|
||||
resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
jsonparse: 1.3.1
|
||||
through: 2.3.8
|
||||
|
@ -3215,7 +3228,6 @@ packages:
|
|||
/acorn@8.8.2:
|
||||
resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/ajv@6.12.6:
|
||||
|
@ -3508,7 +3520,6 @@ packages:
|
|||
/browserslist@4.21.5:
|
||||
resolution: {integrity: sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
caniuse-lite: 1.0.30001477
|
||||
electron-to-chromium: 1.4.357
|
||||
|
@ -3710,7 +3721,6 @@ packages:
|
|||
/conventional-commits-parser@3.2.4:
|
||||
resolution: {integrity: sha512-nK7sAtfi+QXbxHCYfhpZsfRtaitZLIA6889kFIouLvz6repszQDgxBu7wf2WbU+Dco7sAnNCJYERCwt54WPC2Q==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
JSONStream: 1.3.5
|
||||
is-text-path: 1.0.1
|
||||
|
@ -4281,7 +4291,6 @@ packages:
|
|||
/esbuild@0.15.18:
|
||||
resolution: {integrity: sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
requiresBuild: true
|
||||
optionalDependencies:
|
||||
'@esbuild/android-arm': 0.15.18
|
||||
|
@ -4311,7 +4320,6 @@ packages:
|
|||
/esbuild@0.17.19:
|
||||
resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
requiresBuild: true
|
||||
optionalDependencies:
|
||||
'@esbuild/android-arm': 0.17.19
|
||||
|
@ -4362,7 +4370,6 @@ packages:
|
|||
|
||||
/eslint-config-prettier@8.8.0(eslint@8.41.0):
|
||||
resolution: {integrity: sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
eslint: '>=7.0.0'
|
||||
dependencies:
|
||||
|
@ -4658,7 +4665,6 @@ packages:
|
|||
/eslint@8.41.0:
|
||||
resolution: {integrity: sha512-WQDQpzGBOP5IrXPo4Hc0814r4/v2rrIsB0rhT7jtunIalgg6gYXWhRMOejVO8yH21T/FGaxjmFjBMNqcIlmH1Q==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.4.0(eslint@8.41.0)
|
||||
'@eslint-community/regexpp': 4.5.0
|
||||
|
@ -4715,7 +4721,6 @@ packages:
|
|||
/esprima@4.0.1:
|
||||
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/esquery@1.5.0:
|
||||
|
@ -5047,7 +5052,6 @@ packages:
|
|||
/git-raw-commits@2.0.11:
|
||||
resolution: {integrity: sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
dargs: 7.0.0
|
||||
lodash: 4.17.21
|
||||
|
@ -5259,7 +5263,6 @@ packages:
|
|||
/husky@8.0.3:
|
||||
resolution: {integrity: sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/hyphenate-style-name@1.0.4:
|
||||
|
@ -5402,7 +5405,6 @@ packages:
|
|||
/is-docker@2.2.1:
|
||||
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/is-extglob@2.1.1:
|
||||
|
@ -5657,7 +5659,6 @@ packages:
|
|||
|
||||
/js-yaml@3.14.1:
|
||||
resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
argparse: 1.0.10
|
||||
esprima: 4.0.1
|
||||
|
@ -5665,20 +5666,17 @@ packages:
|
|||
|
||||
/js-yaml@4.1.0:
|
||||
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
dev: true
|
||||
|
||||
/jsesc@0.5.0:
|
||||
resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/jsesc@2.5.2:
|
||||
resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/json-parse-even-better-errors@2.3.1:
|
||||
|
@ -5698,7 +5696,6 @@ packages:
|
|||
|
||||
/json5@1.0.2:
|
||||
resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
minimist: 1.2.8
|
||||
dev: true
|
||||
|
@ -5706,7 +5703,6 @@ packages:
|
|||
/json5@2.2.3:
|
||||
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/jsonc-parser@3.2.0:
|
||||
|
@ -5891,7 +5887,6 @@ packages:
|
|||
|
||||
/loose-envify@1.4.0:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
|
@ -5916,7 +5911,6 @@ packages:
|
|||
|
||||
/lz-string@1.5.0:
|
||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/magic-string@0.30.0:
|
||||
|
@ -6015,7 +6009,6 @@ packages:
|
|||
/mime@1.6.0:
|
||||
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/mimic-fn@2.1.0:
|
||||
|
@ -6070,7 +6063,6 @@ packages:
|
|||
/nanoid@3.3.6:
|
||||
resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/natural-compare-lite@1.4.0:
|
||||
|
@ -6400,7 +6392,6 @@ packages:
|
|||
/prettier@2.8.8:
|
||||
resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/pretty-format@27.5.1:
|
||||
|
@ -6696,7 +6687,6 @@ packages:
|
|||
|
||||
/regjsparser@0.9.1:
|
||||
resolution: {integrity: sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
jsesc: 0.5.0
|
||||
dev: true
|
||||
|
@ -6729,7 +6719,6 @@ packages:
|
|||
|
||||
/resolve@1.22.2:
|
||||
resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
is-core-module: 2.12.0
|
||||
path-parse: 1.0.7
|
||||
|
@ -6737,7 +6726,6 @@ packages:
|
|||
|
||||
/resolve@2.0.0-next.4:
|
||||
resolution: {integrity: sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
is-core-module: 2.12.0
|
||||
path-parse: 1.0.7
|
||||
|
@ -6751,14 +6739,12 @@ packages:
|
|||
|
||||
/rimraf@3.0.2:
|
||||
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
|
||||
/rollup@2.79.1:
|
||||
resolution: {integrity: sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
hasBin: true
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
dev: true
|
||||
|
@ -6766,7 +6752,6 @@ packages:
|
|||
/rollup@3.21.0:
|
||||
resolution: {integrity: sha512-ANPhVcyeHvYdQMUyCbczy33nbLzI7RzrBje4uvNiTDJGIMtlKoOStmympwr9OtS1LZxiDmE2wvxHyVhoLtf1KQ==}
|
||||
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
dev: true
|
||||
|
@ -6800,18 +6785,15 @@ packages:
|
|||
|
||||
/semver@5.7.1:
|
||||
resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/semver@6.3.0:
|
||||
resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/semver@7.5.0:
|
||||
resolution: {integrity: sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
lru-cache: 6.0.0
|
||||
dev: true
|
||||
|
@ -7180,7 +7162,6 @@ packages:
|
|||
|
||||
/ts-node@10.9.1(@types/node@20.2.5)(typescript@5.0.4):
|
||||
resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@swc/core': '>=1.2.50'
|
||||
'@swc/wasm': '>=1.2.50'
|
||||
|
@ -7212,7 +7193,6 @@ packages:
|
|||
/tsconfck@2.1.1(typescript@5.0.4):
|
||||
resolution: {integrity: sha512-ZPCkJBKASZBmBUNqGHmRhdhM8pJYDdOXp4nRgj/O0JwUwsMq50lCDRQP/M5GBNAA0elPrq4gAeu4dkaVCuKWww==}
|
||||
engines: {node: ^14.13.1 || ^16 || >=18}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: ^4.3.5 || ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
|
@ -7300,7 +7280,6 @@ packages:
|
|||
/typescript@5.0.4:
|
||||
resolution: {integrity: sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==}
|
||||
engines: {node: '>=12.20'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/ufo@1.1.1:
|
||||
|
@ -7355,7 +7334,6 @@ packages:
|
|||
|
||||
/update-browserslist-db@1.0.10(browserslist@4.21.5):
|
||||
resolution: {integrity: sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
browserslist: '>= 4.21.0'
|
||||
dependencies:
|
||||
|
@ -7398,7 +7376,6 @@ packages:
|
|||
/vite-node@0.31.1(@types/node@20.2.5):
|
||||
resolution: {integrity: sha512-BajE/IsNQ6JyizPzu9zRgHrBwczkAs0erQf/JRpgTIESpKvNj9/Gd0vxX905klLkb0I0SJVCKbdrl5c6FnqYKA==}
|
||||
engines: {node: '>=v14.18.0'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.3.4
|
||||
|
@ -7477,7 +7454,6 @@ packages:
|
|||
/vite@3.2.5(@types/node@18.16.5):
|
||||
resolution: {integrity: sha512-4mVEpXpSOgrssFZAOmGIr85wPHKvaDAcXqxVxVRZhljkJOMZi1ibLibzjLHzJvcok8BMguLc7g1W6W/GqZbLdQ==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': '>= 14'
|
||||
less: '*'
|
||||
|
@ -7511,7 +7487,6 @@ packages:
|
|||
/vite@4.3.9(@types/node@20.2.5):
|
||||
resolution: {integrity: sha512-qsTNZjO9NoJNW7KnOrgYwczm0WctJ8m/yqYAMAK9Lxt4SoySUfS5S8ia9K7JHpa3KEeMfyF8LoJ3c5NeBJy6pg==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': '>= 14'
|
||||
less: '*'
|
||||
|
@ -7543,7 +7518,6 @@ packages:
|
|||
|
||||
/vitest-preview@0.0.1:
|
||||
resolution: {integrity: sha512-rKh+rzW54HYfgYjCU/9n8t0V8rnxYiH67uJGYUKKqW5L87Cl8NESDzNe2BbD6WmNvM4ojQdc0VqLXv6QsDt1Jw==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
'@types/express': 4.17.17
|
||||
'@types/node': 18.16.5
|
||||
|
@ -7562,7 +7536,6 @@ packages:
|
|||
/vitest@0.31.1(happy-dom@9.20.3):
|
||||
resolution: {integrity: sha512-/dOoOgzoFk/5pTvg1E65WVaobknWREN15+HF+0ucudo3dDG/vCZoXTQrjIfEaWvQXmqScwkRodrTbM/ScMpRcQ==}
|
||||
engines: {node: '>=v14.18.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@vitest/browser': '*'
|
||||
|
@ -7697,7 +7670,6 @@ packages:
|
|||
/which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
dev: true
|
||||
|
@ -7705,7 +7677,6 @@ packages:
|
|||
/why-is-node-running@2.2.2:
|
||||
resolution: {integrity: sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
|
|
@ -12,9 +12,9 @@ import {
|
|||
IndexRoute,
|
||||
LogoutRoute,
|
||||
RegisterOneTimePasswordRoute,
|
||||
RegisterWebAuthnRoute,
|
||||
ResetPasswordStep1Route,
|
||||
ResetPasswordStep2Route,
|
||||
SettingsRoute,
|
||||
} from "@constants/Routes";
|
||||
import NotificationsContext from "@hooks/NotificationsContext";
|
||||
import { Notification } from "@models/Notifications";
|
||||
|
@ -28,13 +28,13 @@ import {
|
|||
getTheme,
|
||||
} from "@utils/Configuration";
|
||||
import RegisterOneTimePassword from "@views/DeviceRegistration/RegisterOneTimePassword";
|
||||
import RegisterWebAuthn from "@views/DeviceRegistration/RegisterWebAuthn";
|
||||
import BaseLoadingPage from "@views/LoadingPage/BaseLoadingPage";
|
||||
import ConsentView from "@views/LoginPortal/ConsentView/ConsentView";
|
||||
import LoginPortal from "@views/LoginPortal/LoginPortal";
|
||||
import SignOut from "@views/LoginPortal/SignOut/SignOut";
|
||||
import ResetPasswordStep1 from "@views/ResetPassword/ResetPasswordStep1";
|
||||
import ResetPasswordStep2 from "@views/ResetPassword/ResetPasswordStep2";
|
||||
import SettingsRouter from "@views/Settings/SettingsRouter";
|
||||
|
||||
import "@fortawesome/fontawesome-svg-core/styles.css";
|
||||
|
||||
|
@ -89,10 +89,10 @@ const App: React.FC<Props> = (props: Props) => {
|
|||
<Routes>
|
||||
<Route path={ResetPasswordStep1Route} element={<ResetPasswordStep1 />} />
|
||||
<Route path={ResetPasswordStep2Route} element={<ResetPasswordStep2 />} />
|
||||
<Route path={RegisterWebAuthnRoute} element={<RegisterWebAuthn />} />
|
||||
<Route path={RegisterOneTimePasswordRoute} element={<RegisterOneTimePassword />} />
|
||||
<Route path={LogoutRoute} element={<SignOut />} />
|
||||
<Route path={ConsentRoute} element={<ConsentView />} />
|
||||
<Route path={`${SettingsRoute}/*`} element={<SettingsRouter />} />
|
||||
<Route
|
||||
path={`${IndexRoute}*`}
|
||||
element={
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
import React, { Fragment } from "react";
|
||||
|
||||
import { Divider, Grid, Link, Theme } from "@mui/material";
|
||||
import { grey } from "@mui/material/colors";
|
||||
import makeStyles from "@mui/styles/makeStyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import PrivacyPolicyLink from "@components/PrivacyPolicyLink";
|
||||
import { getPrivacyPolicyEnabled } from "@utils/Configuration";
|
||||
|
||||
export interface Props {}
|
||||
|
||||
const url = "https://www.authelia.com";
|
||||
|
||||
const Brand = function (props: Props) {
|
||||
const { t: translate } = useTranslation();
|
||||
|
||||
const styles = useStyles();
|
||||
const privacyEnabled = getPrivacyPolicyEnabled();
|
||||
|
||||
return (
|
||||
<Grid item container xs={12} alignItems="center" justifyContent="center">
|
||||
<Grid item xs={4}>
|
||||
<Link href={url} target="_blank" underline="hover" className={styles.links}>
|
||||
{translate("Powered by")} Authelia
|
||||
</Link>
|
||||
</Grid>
|
||||
{privacyEnabled ? (
|
||||
<Fragment>
|
||||
<Divider orientation="vertical" flexItem variant="middle" />
|
||||
<Grid item xs={4}>
|
||||
<PrivacyPolicyLink className={styles.links} />
|
||||
</Grid>
|
||||
</Fragment>
|
||||
) : null}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default Brand;
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => ({
|
||||
links: {
|
||||
fontSize: "0.7em",
|
||||
color: grey[500],
|
||||
},
|
||||
}));
|
|
@ -19,7 +19,7 @@ const PasswordMeter = function (props: Props) {
|
|||
const [progressColor] = useState(["#D32F2F", "#FF5722", "#FFEB3B", "#AFB42B", "#62D32F"]);
|
||||
const [passwordScore, setPasswordScore] = useState(0);
|
||||
const [maxScores, setMaxScores] = useState(0);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const password = props.value;
|
||||
|
@ -114,7 +114,7 @@ const PasswordMeter = function (props: Props) {
|
|||
|
||||
return (
|
||||
<Box className={styles.progressContainer}>
|
||||
<Box title={feedback} className={classnames(styles.progressBar)} />
|
||||
<Box title={feedback === null ? "" : feedback} className={classnames(styles.progressBar)} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -6,10 +6,11 @@ import { usePersistentStorageValue } from "@hooks/PersistentStorage";
|
|||
import { getPrivacyPolicyEnabled, getPrivacyPolicyRequireAccept } from "@utils/Configuration";
|
||||
|
||||
const PrivacyPolicyDrawer = function (props: DrawerProps) {
|
||||
const { t: translate } = useTranslation();
|
||||
|
||||
const privacyEnabled = getPrivacyPolicyEnabled();
|
||||
const privacyRequireAccept = getPrivacyPolicyRequireAccept();
|
||||
const [accepted, setAccepted] = usePersistentStorageValue<boolean>("privacy-policy-accepted", false);
|
||||
const { t: translate } = useTranslation();
|
||||
|
||||
return privacyEnabled && privacyRequireAccept && !accepted ? (
|
||||
<Drawer {...props} anchor="bottom" open={!accepted}>
|
||||
|
|
|
@ -6,10 +6,10 @@ import { useTranslation } from "react-i18next";
|
|||
import { getPrivacyPolicyURL } from "@utils/Configuration";
|
||||
|
||||
const PrivacyPolicyLink = function (props: LinkProps) {
|
||||
const hrefPrivacyPolicy = getPrivacyPolicyURL();
|
||||
|
||||
const { t: translate } = useTranslation();
|
||||
|
||||
const hrefPrivacyPolicy = getPrivacyPolicyURL();
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Link {...props} href={hrefPrivacyPolicy} target="_blank" rel="noopener" underline="hover">
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
import React, { useEffect } from "react";
|
||||
|
||||
import { Box, Theme, useTheme } from "@mui/material";
|
||||
import makeStyles from "@mui/styles/makeStyles";
|
||||
|
||||
import FingerTouchIcon from "@components/FingerTouchIcon";
|
||||
import LinearProgressBar from "@components/LinearProgressBar";
|
||||
import { useTimer } from "@hooks/Timer";
|
||||
import IconWithContext from "@views/LoginPortal/SecondFactor/IconWithContext";
|
||||
|
||||
interface Props {
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
export default function WebAuthnRegisterIcon(props: Props) {
|
||||
const theme = useTheme();
|
||||
const [timerPercent, triggerTimer] = useTimer(props.timeout);
|
||||
|
||||
const styles = makeStyles((theme: Theme) => ({
|
||||
icon: {
|
||||
display: "inline-block",
|
||||
},
|
||||
progressBar: {
|
||||
marginTop: theme.spacing(),
|
||||
},
|
||||
}))();
|
||||
|
||||
useEffect(() => {
|
||||
triggerTimer();
|
||||
}, [triggerTimer]);
|
||||
|
||||
return (
|
||||
<Box className={styles.icon} sx={{ minHeight: 101 }}>
|
||||
<IconWithContext icon={<FingerTouchIcon size={64} animated strong />}>
|
||||
<LinearProgressBar value={timerPercent} className={styles.progressBar} height={theme.spacing(2)} />
|
||||
</IconWithContext>
|
||||
</Box>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
import React, { useEffect } from "react";
|
||||
|
||||
import { Box, Button, Theme, useTheme } from "@mui/material";
|
||||
import makeStyles from "@mui/styles/makeStyles";
|
||||
|
||||
import FailureIcon from "@components/FailureIcon";
|
||||
import FingerTouchIcon from "@components/FingerTouchIcon";
|
||||
import LinearProgressBar from "@components/LinearProgressBar";
|
||||
import { useTimer } from "@hooks/Timer";
|
||||
import { WebAuthnTouchState } from "@models/WebAuthn";
|
||||
import IconWithContext from "@views/LoginPortal/SecondFactor/IconWithContext";
|
||||
|
||||
interface Props {
|
||||
onRetryClick: () => void;
|
||||
webauthnTouchState: WebAuthnTouchState;
|
||||
}
|
||||
|
||||
export default function WebAuthnTryIcon(props: Props) {
|
||||
const touchTimeout = 30;
|
||||
const theme = useTheme();
|
||||
const [timerPercent, triggerTimer, clearTimer] = useTimer(touchTimeout * 1000 - 500);
|
||||
|
||||
const styles = makeStyles((theme: Theme) => ({
|
||||
icon: {
|
||||
display: "inline-block",
|
||||
},
|
||||
progressBar: {
|
||||
marginTop: theme.spacing(),
|
||||
},
|
||||
}))();
|
||||
|
||||
const handleRetryClick = () => {
|
||||
clearTimer();
|
||||
triggerTimer();
|
||||
props.onRetryClick();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
triggerTimer();
|
||||
}, [triggerTimer]);
|
||||
|
||||
const touch = (
|
||||
<IconWithContext
|
||||
icon={<FingerTouchIcon size={64} animated strong />}
|
||||
className={props.webauthnTouchState === WebAuthnTouchState.WaitTouch ? undefined : "hidden"}
|
||||
>
|
||||
<LinearProgressBar value={timerPercent} className={styles.progressBar} height={theme.spacing(2)} />
|
||||
</IconWithContext>
|
||||
);
|
||||
|
||||
const failure = (
|
||||
<IconWithContext
|
||||
icon={<FailureIcon />}
|
||||
className={props.webauthnTouchState === WebAuthnTouchState.Failure ? undefined : "hidden"}
|
||||
>
|
||||
<Button color="secondary" onClick={handleRetryClick}>
|
||||
Retry
|
||||
</Button>
|
||||
</IconWithContext>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box className={styles.icon} sx={{ minHeight: 101 }}>
|
||||
{touch}
|
||||
{failure}
|
||||
</Box>
|
||||
);
|
||||
}
|
|
@ -2,13 +2,15 @@ export const IndexRoute: string = "/";
|
|||
export const AuthenticatedRoute: string = "/authenticated";
|
||||
export const ConsentRoute: string = "/consent";
|
||||
|
||||
export const SecondFactorRoute: string = "/2fa/";
|
||||
export const SecondFactorWebAuthnSubRoute: string = "webauthn";
|
||||
export const SecondFactorTOTPSubRoute: string = "one-time-password";
|
||||
export const SecondFactorPushSubRoute: string = "push-notification";
|
||||
export const SecondFactorRoute: string = "/2fa";
|
||||
export const SecondFactorWebAuthnSubRoute: string = "/webauthn";
|
||||
export const SecondFactorTOTPSubRoute: string = "/one-time-password";
|
||||
export const SecondFactorPushSubRoute: string = "/push-notification";
|
||||
|
||||
export const ResetPasswordStep1Route: string = "/reset-password/step1";
|
||||
export const ResetPasswordStep2Route: string = "/reset-password/step2";
|
||||
export const RegisterWebAuthnRoute: string = "/webauthn/register";
|
||||
export const RegisterOneTimePasswordRoute: string = "/one-time-password/register";
|
||||
export const LogoutRoute: string = "/logout";
|
||||
|
||||
export const SettingsRoute: string = "/settings";
|
||||
export const SettingsTwoFactorAuthenticationSubRoute: string = "/two-factor-authentication";
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import { useCallback } from "react";
|
||||
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
export function useRouterNavigate() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
return useCallback(
|
||||
(
|
||||
pathname: string,
|
||||
preserveSearchParams: boolean = true,
|
||||
searchParamsOverride: URLSearchParams | undefined = undefined,
|
||||
) => {
|
||||
if (searchParamsOverride && URLSearchParamsHasValues(searchParamsOverride)) {
|
||||
navigate({ pathname: pathname, search: `?${searchParamsOverride.toString()}` });
|
||||
} else if (preserveSearchParams && URLSearchParamsHasValues(searchParams)) {
|
||||
navigate({ pathname: pathname, search: `?${searchParams.toString()}` });
|
||||
} else {
|
||||
navigate({ pathname: pathname });
|
||||
}
|
||||
},
|
||||
[navigate, searchParams],
|
||||
);
|
||||
}
|
||||
|
||||
function URLSearchParamsHasValues(params?: URLSearchParams) {
|
||||
return params ? !params.entries().next().done : false;
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { useRemoteCall } from "@hooks/RemoteCall";
|
||||
import { getUserWebAuthnDevices } from "@services/UserWebAuthnDevices";
|
||||
|
||||
export function useUserWebAuthnDevices() {
|
||||
return useRemoteCall(getUserWebAuthnDevices, []);
|
||||
}
|
|
@ -31,7 +31,7 @@ i18n.use(Backend)
|
|||
loadPath: basePath + "/locales/{{lng}}/{{ns}}.json",
|
||||
},
|
||||
load: "all",
|
||||
ns: ["portal"],
|
||||
ns: ["portal", "settings"],
|
||||
defaultNS: "portal",
|
||||
fallbackLng: {
|
||||
default: ["en"],
|
||||
|
|
|
@ -1,46 +1,78 @@
|
|||
import React, { Fragment, ReactNode, useEffect } from "react";
|
||||
import React, { ReactNode, useEffect } from "react";
|
||||
|
||||
import { Container, Divider, Grid, Link, Theme } from "@mui/material";
|
||||
import { grey } from "@mui/material/colors";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import { AppBar, Box, Container, Grid, IconButton, Theme, Toolbar, Typography } from "@mui/material";
|
||||
import makeStyles from "@mui/styles/makeStyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ReactComponent as UserSvg } from "@assets/images/user.svg";
|
||||
import Brand from "@components/Brand";
|
||||
import PrivacyPolicyDrawer from "@components/PrivacyPolicyDrawer";
|
||||
import PrivacyPolicyLink from "@components/PrivacyPolicyLink";
|
||||
import TypographyWithTooltip from "@components/TypographyWithTooltip";
|
||||
import { getLogoOverride, getPrivacyPolicyEnabled } from "@utils/Configuration";
|
||||
import { SettingsRoute } from "@constants/Routes";
|
||||
import { getLogoOverride } from "@utils/Configuration";
|
||||
|
||||
export interface Props {
|
||||
id?: string;
|
||||
children?: ReactNode;
|
||||
title?: string;
|
||||
titleTooltip?: string;
|
||||
subtitle?: string;
|
||||
subtitleTooltip?: string;
|
||||
title?: string | null;
|
||||
titleTooltip?: string | null;
|
||||
subtitle?: string | null;
|
||||
subtitleTooltip?: string | null;
|
||||
showBrand?: boolean;
|
||||
showSettings?: boolean;
|
||||
}
|
||||
|
||||
const url = "https://www.authelia.com";
|
||||
|
||||
const LoginLayout = function (props: Props) {
|
||||
const styles = useStyles();
|
||||
const { t: translate } = useTranslation();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const styles = useStyles();
|
||||
|
||||
const logo = getLogoOverride() ? (
|
||||
<img src="./static/media/logo.png" alt="Logo" className={styles.icon} />
|
||||
) : (
|
||||
<UserSvg className={styles.icon} />
|
||||
);
|
||||
|
||||
const privacyEnabled = getPrivacyPolicyEnabled();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${translate("Login")} - Authelia`;
|
||||
}, [translate]);
|
||||
|
||||
const handleSettingsClick = () => {
|
||||
navigate({
|
||||
pathname: SettingsRoute,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid id={props.id} className={styles.root} container spacing={0} alignItems="center" justifyContent="center">
|
||||
<Box>
|
||||
<AppBar position="static" color="transparent" elevation={0}>
|
||||
<Toolbar variant="dense">
|
||||
<Typography style={{ flexGrow: 1 }} />
|
||||
{props.showSettings ? (
|
||||
<IconButton
|
||||
size="large"
|
||||
edge="start"
|
||||
color="inherit"
|
||||
aria-label="menu"
|
||||
sx={{ mr: 2 }}
|
||||
onClick={handleSettingsClick}
|
||||
>
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
) : null}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Grid
|
||||
id={props.id}
|
||||
className={styles.root}
|
||||
container
|
||||
spacing={0}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Container maxWidth="xs" className={styles.rootContainer}>
|
||||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
|
@ -48,7 +80,11 @@ const LoginLayout = function (props: Props) {
|
|||
</Grid>
|
||||
{props.title ? (
|
||||
<Grid item xs={12}>
|
||||
<TypographyWithTooltip variant={"h5"} value={props.title} tooltip={props.titleTooltip} />
|
||||
<TypographyWithTooltip
|
||||
variant={"h5"}
|
||||
value={props.title}
|
||||
tooltip={props.titleTooltip !== null ? props.titleTooltip : undefined}
|
||||
/>
|
||||
</Grid>
|
||||
) : null}
|
||||
{props.subtitle ? (
|
||||
|
@ -56,34 +92,19 @@ const LoginLayout = function (props: Props) {
|
|||
<TypographyWithTooltip
|
||||
variant={"h6"}
|
||||
value={props.subtitle}
|
||||
tooltip={props.subtitleTooltip}
|
||||
tooltip={props.subtitleTooltip !== null ? props.subtitleTooltip : undefined}
|
||||
/>
|
||||
</Grid>
|
||||
) : null}
|
||||
<Grid item xs={12} className={styles.body}>
|
||||
{props.children}
|
||||
</Grid>
|
||||
{props.showBrand ? (
|
||||
<Grid item container xs={12} alignItems="center" justifyContent="center">
|
||||
<Grid item xs={4}>
|
||||
<Link href={url} target="_blank" underline="hover" className={styles.footerLinks}>
|
||||
{translate("Powered by")} Authelia
|
||||
</Link>
|
||||
</Grid>
|
||||
{privacyEnabled ? (
|
||||
<Fragment>
|
||||
<Divider orientation="vertical" flexItem variant="middle" />
|
||||
<Grid item xs={4}>
|
||||
<PrivacyPolicyLink className={styles.footerLinks} />
|
||||
</Grid>
|
||||
</Fragment>
|
||||
) : null}
|
||||
</Grid>
|
||||
) : null}
|
||||
{props.showBrand ? <Brand /> : null}
|
||||
</Grid>
|
||||
</Container>
|
||||
<PrivacyPolicyDrawer />
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -110,8 +131,4 @@ const useStyles = makeStyles((theme: Theme) => ({
|
|||
paddingTop: theme.spacing(),
|
||||
paddingBottom: theme.spacing(),
|
||||
},
|
||||
footerLinks: {
|
||||
fontSize: "0.7em",
|
||||
color: grey[500],
|
||||
},
|
||||
}));
|
||||
|
|
|
@ -0,0 +1,125 @@
|
|||
import React, { ReactNode, useEffect } from "react";
|
||||
|
||||
import { Dashboard } from "@mui/icons-material";
|
||||
import SystemSecurityUpdateGoodIcon from "@mui/icons-material/SystemSecurityUpdateGood";
|
||||
import {
|
||||
AppBar,
|
||||
Box,
|
||||
Button,
|
||||
Drawer,
|
||||
Grid,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Toolbar,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { IndexRoute, SettingsRoute, SettingsTwoFactorAuthenticationSubRoute } from "@constants/Routes";
|
||||
import { useRouterNavigate } from "@hooks/RouterNavigate";
|
||||
|
||||
export interface Props {
|
||||
id?: string;
|
||||
children?: ReactNode;
|
||||
title?: string;
|
||||
titlePrefix?: string;
|
||||
drawerWidth?: number;
|
||||
}
|
||||
|
||||
const defaultDrawerWidth = 240;
|
||||
|
||||
const SettingsLayout = function (props: Props) {
|
||||
const { t: translate } = useTranslation("settings");
|
||||
|
||||
const navigate = useRouterNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (props.title) {
|
||||
if (props.titlePrefix) {
|
||||
document.title = `${props.titlePrefix} - ${props.title} - Authelia`;
|
||||
} else {
|
||||
document.title = `${props.title} - Authelia`;
|
||||
}
|
||||
} else {
|
||||
if (props.titlePrefix) {
|
||||
document.title = `${props.titlePrefix} - ${translate("Settings")} - Authelia`;
|
||||
} else {
|
||||
document.title = `${translate("Settings")} - Authelia`;
|
||||
}
|
||||
}
|
||||
}, [props.title, props.titlePrefix, translate]);
|
||||
|
||||
const drawerWidth = props.drawerWidth === undefined ? defaultDrawerWidth : props.drawerWidth;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex" }}>
|
||||
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}>
|
||||
<Toolbar variant="dense">
|
||||
<Typography style={{ flexGrow: 1 }}>Authelia {translate("Settings")}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
onClick={() => {
|
||||
navigate(IndexRoute);
|
||||
}}
|
||||
>
|
||||
{translate("Close")}
|
||||
</Button>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
[`& .MuiDrawer-paper`]: { width: drawerWidth, boxSizing: "border-box" },
|
||||
}}
|
||||
>
|
||||
<Toolbar variant="dense" />
|
||||
<Box sx={{ overflow: "auto" }}>
|
||||
<List>
|
||||
<SettingsMenuItem pathname={SettingsRoute} text={translate("Overview")} icon={<Dashboard />} />
|
||||
<SettingsMenuItem
|
||||
pathname={`${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`}
|
||||
text={translate("Two-Factor Authentication")}
|
||||
icon={<SystemSecurityUpdateGoodIcon />}
|
||||
/>
|
||||
</List>
|
||||
</Box>
|
||||
</Drawer>
|
||||
<Grid container id={props.id} spacing={0}>
|
||||
<Grid item xs={12}>
|
||||
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
|
||||
<Toolbar variant="dense" />
|
||||
{props.children}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsLayout;
|
||||
|
||||
interface SettingsMenuItemProps {
|
||||
pathname: string;
|
||||
text: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
const SettingsMenuItem = function (props: SettingsMenuItemProps) {
|
||||
const selected = window.location.pathname === props.pathname || window.location.pathname === props.pathname + "/";
|
||||
const navigate = useRouterNavigate();
|
||||
|
||||
return (
|
||||
<ListItem disablePadding onClick={!selected ? () => navigate(props.pathname) : undefined}>
|
||||
<ListItemButton selected={selected}>
|
||||
<ListItemIcon>{props.icon}</ListItemIcon>
|
||||
<ListItemText primary={props.text} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
|
@ -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<PublicKeyCredentialCreationOptions, "challenge" | "excludeCredentials" | "user"> {
|
||||
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<PublicKeyCredentialRequestOptions, "allowCredentials" | "challenge"> {
|
||||
allowCredentials?: PublicKeyCredentialDescriptorJSON[];
|
||||
challenge: string;
|
||||
}
|
||||
|
||||
export interface PublicKeyCredentialDescriptorJSON extends Omit<PublicKeyCredentialDescriptor, "id"> {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface PublicKeyCredentialUserEntityJSON extends Omit<PublicKeyCredentialUserEntity, "id"> {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AuthenticatorAssertionResponseJSON
|
||||
extends Omit<AuthenticatorAssertionResponse, "authenticatorData" | "clientDataJSON" | "signature" | "userHandle"> {
|
||||
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<AuthenticatorAttestationResponseFuture, "clientDataJSON" | "attestationObject"> {
|
||||
clientDataJSON: string;
|
||||
attestationObject: string;
|
||||
}
|
||||
|
||||
export interface AttestationPublicKeyCredentialJSON
|
||||
extends Omit<AttestationPublicKeyCredential, "response" | "rawId" | "getClientExtensionResults"> {
|
||||
rawId: string;
|
||||
response: AuthenticatorAttestationResponseJSON;
|
||||
clientExtensionResults: AuthenticationExtensionsClientOutputs;
|
||||
transports?: AuthenticatorTransport[];
|
||||
}
|
||||
|
||||
export interface PublicKeyCredentialJSON
|
||||
extends Omit<PublicKeyCredential, "rawId" | "response" | "getClientExtensionResults"> {
|
||||
rawId: string;
|
||||
clientExtensionResults: AuthenticationExtensionsClientOutputs;
|
||||
response: AuthenticatorAssertionResponseJSON;
|
||||
targetURL?: string;
|
||||
workflow?: string;
|
||||
workflowID?: string;
|
||||
}
|
||||
|
||||
export enum AttestationResult {
|
||||
Success = 1,
|
||||
Failure,
|
||||
|
@ -93,13 +36,8 @@ export enum AttestationResult {
|
|||
FailureToken,
|
||||
}
|
||||
|
||||
export interface AttestationPublicKeyCredentialResult {
|
||||
credential?: AttestationPublicKeyCredential;
|
||||
result: AttestationResult;
|
||||
}
|
||||
|
||||
export interface AttestationPublicKeyCredentialResultJSON {
|
||||
credential?: AttestationPublicKeyCredentialJSON;
|
||||
export interface RegistrationResult {
|
||||
response?: RegistrationResponseJSON;
|
||||
result: AttestationResult;
|
||||
}
|
||||
|
||||
|
@ -113,19 +51,97 @@ export enum AssertionResult {
|
|||
FailureUnknownSecurity,
|
||||
FailureWebAuthnNotSupported,
|
||||
FailureChallenge,
|
||||
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;
|
||||
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 AuthenticationResult {
|
||||
response?: AuthenticationResponseJSON;
|
||||
result: AssertionResult;
|
||||
}
|
||||
|
||||
export interface AssertionPublicKeyCredentialResultJSON {
|
||||
credential?: PublicKeyCredentialJSON;
|
||||
result: AssertionResult;
|
||||
export interface WebAuthnDevice {
|
||||
id: string;
|
||||
created_at: string;
|
||||
last_used_at?: string;
|
||||
rpid: string;
|
||||
description: string;
|
||||
kid: Uint8Array;
|
||||
aaguid?: string;
|
||||
attestation_type: string;
|
||||
attachment: string;
|
||||
transports: null | string[];
|
||||
sign_count: number;
|
||||
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 {
|
||||
WaitTouch = 1,
|
||||
InProgress = 2,
|
||||
Failure = 3,
|
||||
}
|
||||
|
|
|
@ -11,11 +11,12 @@ export const FirstFactorPath = basePath + "/api/firstfactor";
|
|||
export const InitiateTOTPRegistrationPath = basePath + "/api/secondfactor/totp/identity/start";
|
||||
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/webauthn/credential/register";
|
||||
|
||||
export const WebAuthnAssertionPath = basePath + "/api/secondfactor/webauthn/assertion";
|
||||
export const WebAuthnAssertionPath = basePath + "/api/secondfactor/webauthn";
|
||||
|
||||
export const WebAuthnDevicesPath = basePath + "/api/secondfactor/webauthn/credentials";
|
||||
export const WebAuthnDevicePath = basePath + "/api/secondfactor/webauthn/credential";
|
||||
|
||||
export const InitiateDuoDeviceSelectionPath = basePath + "/api/secondfactor/duo_devices";
|
||||
export const CompleteDuoDeviceSelectionPath = basePath + "/api/secondfactor/duo_device";
|
||||
|
@ -39,21 +40,30 @@ export const UserInfoTOTPConfigurationPath = basePath + "/api/user/info/totp";
|
|||
export const ConfigurationPath = basePath + "/api/configuration";
|
||||
export const PasswordPolicyConfigurationPath = basePath + "/api/configuration/password-policy";
|
||||
|
||||
export interface AuthenticationErrorResponse extends ErrorResponse {
|
||||
authentication: boolean;
|
||||
elevation: boolean;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
status: "KO";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface Response<T> {
|
||||
status: "OK";
|
||||
export interface Response<T> extends OKResponse {
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface OptionalDataResponse<T> {
|
||||
status: "OK";
|
||||
export interface OptionalDataResponse<T> extends OKResponse {
|
||||
data?: T;
|
||||
}
|
||||
|
||||
export interface OKResponse {
|
||||
status: "OK";
|
||||
}
|
||||
|
||||
export type AuthenticationResponse<T> = Response<T> | AuthenticationErrorResponse;
|
||||
export type AuthenticationOKResponse = OKResponse | AuthenticationErrorResponse;
|
||||
export type OptionalDataServiceResponse<T> = OptionalDataResponse<T> | ErrorResponse;
|
||||
export type ServiceResponse<T> = Response<T> | ErrorResponse;
|
||||
|
||||
|
@ -78,3 +88,7 @@ export function hasServiceError<T>(resp: AxiosResponse<ServiceResponse<T>>) {
|
|||
}
|
||||
return { errored: false, message: null };
|
||||
}
|
||||
|
||||
export function validateStatusAuthentication(status: number): boolean {
|
||||
return (status >= 200 && status < 300) || status === 401 || status === 403;
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ export async function PostWithOptionalResponse<T = undefined>(path: string, body
|
|||
if (res.status !== 200 || hasServiceError(res).errored) {
|
||||
throw new Error(`Failed POST to ${path}. Code: ${res.status}. Message: ${hasServiceError(res).message}`);
|
||||
}
|
||||
|
||||
return toData<T>(res);
|
||||
}
|
||||
|
||||
|
@ -32,3 +33,21 @@ export async function Get<T = undefined>(path: string): Promise<T> {
|
|||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
export async function GetWithOptionalData<T = undefined>(path: string): Promise<T | null> {
|
||||
const res = await axios.get<ServiceResponse<T>>(path);
|
||||
|
||||
if (res.status !== 200 || hasServiceError(res).errored) {
|
||||
throw new Error(`Failed GET from ${path}. Code: ${res.status}.`);
|
||||
}
|
||||
|
||||
const d = toData<T>(res);
|
||||
if (d === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!d) {
|
||||
throw new Error("unexpected type of response");
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { CompleteTOTPRegistrationPath, InitiateTOTPRegistrationPath, WebAuthnIdentityStartPath } from "@services/Api";
|
||||
import { CompleteTOTPRegistrationPath, InitiateTOTPRegistrationPath } from "@services/Api";
|
||||
import { Post, PostWithOptionalResponse } from "@services/Client";
|
||||
|
||||
export async function initiateTOTPRegistrationProcess() {
|
||||
|
@ -13,7 +13,3 @@ interface CompleteTOTPRegistrationResponse {
|
|||
export async function completeTOTPRegistrationProcess(processToken: string) {
|
||||
return Post<CompleteTOTPRegistrationResponse>(CompleteTOTPRegistrationPath, { token: processToken });
|
||||
}
|
||||
|
||||
export async function initiateWebAuthnRegistrationProcess() {
|
||||
return PostWithOptionalResponse(WebAuthnIdentityStartPath);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import { WebAuthnDevice } from "@models/WebAuthn";
|
||||
import { WebAuthnDevicesPath } from "@services/Api";
|
||||
import { GetWithOptionalData } from "@services/Client";
|
||||
|
||||
export async function getUserWebAuthnDevices(): Promise<WebAuthnDevice[]> {
|
||||
const res = await GetWithOptionalData<WebAuthnDevice[] | null>(WebAuthnDevicesPath);
|
||||
|
||||
if (res === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
|
@ -1,31 +1,32 @@
|
|||
import axios, { AxiosResponse } from "axios";
|
||||
import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
|
||||
import {
|
||||
AuthenticationResponseJSON,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from "@simplewebauthn/typescript-types";
|
||||
import axios, { AxiosError, AxiosResponse } from "axios";
|
||||
|
||||
import {
|
||||
AssertionPublicKeyCredentialResult,
|
||||
AssertionResult,
|
||||
AttestationPublicKeyCredential,
|
||||
AttestationPublicKeyCredentialJSON,
|
||||
AttestationPublicKeyCredentialResult,
|
||||
AttestationResult,
|
||||
AuthenticatorAttestationResponseFuture,
|
||||
AuthenticationResult,
|
||||
CredentialCreation,
|
||||
CredentialRequest,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialCreationOptionsStatus,
|
||||
PublicKeyCredentialDescriptorJSON,
|
||||
PublicKeyCredentialJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsStatus,
|
||||
RegistrationResult,
|
||||
} from "@models/WebAuthn";
|
||||
import {
|
||||
AuthenticationOKResponse,
|
||||
OptionalDataServiceResponse,
|
||||
ServiceResponse,
|
||||
WebAuthnAssertionPath,
|
||||
WebAuthnAttestationPath,
|
||||
WebAuthnIdentityFinishPath,
|
||||
WebAuthnDevicePath,
|
||||
WebAuthnRegistrationPath,
|
||||
validateStatusAuthentication,
|
||||
} from "@services/Api";
|
||||
import { SignInResponse } from "@services/SignIn";
|
||||
import { getBase64WebEncodingFromBytes, getBytesFromBase64 } from "@utils/Base64";
|
||||
|
||||
export function isWebAuthnSecure(): boolean {
|
||||
if (window.isSecureContext) {
|
||||
|
@ -47,120 +48,6 @@ export async function isWebAuthnPlatformAuthenticatorAvailable(): Promise<boolea
|
|||
return window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
||||
}
|
||||
|
||||
function arrayBufferEncode(value: ArrayBuffer): string {
|
||||
return getBase64WebEncodingFromBytes(new Uint8Array(value));
|
||||
}
|
||||
|
||||
function arrayBufferDecode(value: string): ArrayBuffer {
|
||||
return getBytesFromBase64(value);
|
||||
}
|
||||
|
||||
function decodePublicKeyCredentialDescriptor(
|
||||
descriptor: PublicKeyCredentialDescriptorJSON,
|
||||
): PublicKeyCredentialDescriptor {
|
||||
return {
|
||||
id: arrayBufferDecode(descriptor.id),
|
||||
type: descriptor.type,
|
||||
transports: descriptor.transports,
|
||||
};
|
||||
}
|
||||
|
||||
function decodePublicKeyCredentialCreationOptions(
|
||||
options: PublicKeyCredentialCreationOptionsJSON,
|
||||
): PublicKeyCredentialCreationOptions {
|
||||
return {
|
||||
attestation: options.attestation,
|
||||
authenticatorSelection: options.authenticatorSelection,
|
||||
challenge: arrayBufferDecode(options.challenge),
|
||||
excludeCredentials: options.excludeCredentials?.map(decodePublicKeyCredentialDescriptor),
|
||||
extensions: options.extensions,
|
||||
pubKeyCredParams: options.pubKeyCredParams,
|
||||
rp: options.rp,
|
||||
timeout: options.timeout,
|
||||
user: {
|
||||
displayName: options.user.displayName,
|
||||
id: arrayBufferDecode(options.user.id),
|
||||
name: options.user.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decodePublicKeyCredentialRequestOptions(
|
||||
options: PublicKeyCredentialRequestOptionsJSON,
|
||||
): PublicKeyCredentialRequestOptions {
|
||||
let allowCredentials: PublicKeyCredentialDescriptor[] | undefined = undefined;
|
||||
|
||||
if (options.allowCredentials?.length !== 0) {
|
||||
allowCredentials = options.allowCredentials?.map(decodePublicKeyCredentialDescriptor);
|
||||
}
|
||||
|
||||
return {
|
||||
allowCredentials: allowCredentials,
|
||||
challenge: arrayBufferDecode(options.challenge),
|
||||
extensions: options.extensions,
|
||||
rpId: options.rpId,
|
||||
timeout: options.timeout,
|
||||
userVerification: options.userVerification,
|
||||
};
|
||||
}
|
||||
|
||||
function encodeAttestationPublicKeyCredential(
|
||||
credential: AttestationPublicKeyCredential,
|
||||
): AttestationPublicKeyCredentialJSON {
|
||||
const response = credential.response as AuthenticatorAttestationResponseFuture;
|
||||
|
||||
let transports: AuthenticatorTransport[] | undefined;
|
||||
|
||||
if (response?.getTransports !== undefined && typeof response.getTransports === "function") {
|
||||
transports = response.getTransports();
|
||||
}
|
||||
|
||||
return {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: arrayBufferEncode(credential.rawId),
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
response: {
|
||||
attestationObject: arrayBufferEncode(response.attestationObject),
|
||||
clientDataJSON: arrayBufferEncode(response.clientDataJSON),
|
||||
},
|
||||
transports: transports,
|
||||
};
|
||||
}
|
||||
|
||||
function encodeAssertionPublicKeyCredential(
|
||||
credential: PublicKeyCredential,
|
||||
targetURL: string | undefined,
|
||||
workflow: string | undefined,
|
||||
workflowID: string | undefined,
|
||||
): PublicKeyCredentialJSON {
|
||||
const response = credential.response as AuthenticatorAssertionResponse;
|
||||
|
||||
let userHandle: string;
|
||||
|
||||
if (response.userHandle == null) {
|
||||
userHandle = "";
|
||||
} else {
|
||||
userHandle = arrayBufferEncode(response.userHandle);
|
||||
}
|
||||
|
||||
return {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: arrayBufferEncode(credential.rawId),
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
response: {
|
||||
authenticatorData: arrayBufferEncode(response.authenticatorData),
|
||||
clientDataJSON: arrayBufferEncode(response.clientDataJSON),
|
||||
signature: arrayBufferEncode(response.signature),
|
||||
userHandle: userHandle,
|
||||
},
|
||||
targetURL: targetURL,
|
||||
workflow: workflow,
|
||||
workflowID: workflowID,
|
||||
};
|
||||
}
|
||||
|
||||
function getAttestationResultFromDOMException(exception: DOMException): AttestationResult {
|
||||
// Docs for this section:
|
||||
// https://w3c.github.io/webauthn/#sctn-op-make-cred
|
||||
|
@ -174,6 +61,7 @@ function getAttestationResultFromDOMException(exception: DOMException): Attestat
|
|||
case "InvalidStateError":
|
||||
// § 6.3.2 Step 3.
|
||||
return AttestationResult.FailureExcluded;
|
||||
case "AbortError":
|
||||
case "NotAllowedError":
|
||||
// § 6.3.2 Step 3 and Step 6.
|
||||
return AttestationResult.FailureUserConsent;
|
||||
|
@ -181,14 +69,14 @@ function getAttestationResultFromDOMException(exception: DOMException): Attestat
|
|||
// § 6.3.2 Step 4.
|
||||
return AttestationResult.FailureUserVerificationOrResidentKey;
|
||||
default:
|
||||
console.error(`Unhandled DOMException occurred during WebAuthN attestation: ${exception}`);
|
||||
console.error(`Unhandled DOMException occurred during WebAuthn attestation: ${exception}`);
|
||||
return AttestationResult.FailureUnknown;
|
||||
}
|
||||
}
|
||||
|
||||
function getAssertionResultFromDOMException(
|
||||
exception: DOMException,
|
||||
requestOptions: PublicKeyCredentialRequestOptions,
|
||||
options: PublicKeyCredentialRequestOptionsJSON,
|
||||
): AssertionResult {
|
||||
// Docs for this section:
|
||||
// https://w3c.github.io/webauthn/#sctn-op-get-assertion
|
||||
|
@ -196,28 +84,40 @@ function getAssertionResultFromDOMException(
|
|||
case "UnknownError":
|
||||
// § 6.3.3 Step 1 and Step 12.
|
||||
return AssertionResult.FailureSyntax;
|
||||
case "InvalidStateError":
|
||||
// § 6.3.2 Step 3.
|
||||
return AssertionResult.FailureUnrecognized;
|
||||
case "AbortError":
|
||||
case "NotAllowedError":
|
||||
// § 6.3.3 Step 6 and Step 7.
|
||||
return AssertionResult.FailureUserConsent;
|
||||
case "SecurityError":
|
||||
if (requestOptions.extensions?.appid !== undefined) {
|
||||
if (options.extensions?.appid !== undefined) {
|
||||
// § 10.1 and 10.2 Step 3.
|
||||
return AssertionResult.FailureU2FFacetID;
|
||||
} else {
|
||||
return AssertionResult.FailureUnknownSecurity;
|
||||
}
|
||||
default:
|
||||
console.error(`Unhandled DOMException occurred during WebAuthN assertion: ${exception}`);
|
||||
console.error(`Unhandled DOMException occurred during WebAuthn assertion: ${exception}`);
|
||||
return AssertionResult.FailureUnknown;
|
||||
}
|
||||
}
|
||||
|
||||
async function getAttestationCreationOptions(token: string): Promise<PublicKeyCredentialCreationOptionsStatus> {
|
||||
let response: AxiosResponse<ServiceResponse<CredentialCreation>>;
|
||||
|
||||
response = await axios.post<ServiceResponse<CredentialCreation>>(WebAuthnIdentityFinishPath, {
|
||||
token: token,
|
||||
});
|
||||
export async function getAttestationCreationOptions(
|
||||
description: string,
|
||||
): Promise<PublicKeyCredentialCreationOptionsStatus> {
|
||||
const response = await axios.put<ServiceResponse<CredentialCreation>>(
|
||||
WebAuthnRegistrationPath,
|
||||
{
|
||||
description: description,
|
||||
},
|
||||
{
|
||||
validateStatus: function (status) {
|
||||
return status < 300 || status === 409;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data.status !== "OK" || response.data.data == null) {
|
||||
return {
|
||||
|
@ -226,12 +126,12 @@ async function getAttestationCreationOptions(token: string): Promise<PublicKeyCr
|
|||
}
|
||||
|
||||
return {
|
||||
options: decodePublicKeyCredentialCreationOptions(response.data.data.publicKey),
|
||||
options: response.data.data.publicKey,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAssertionRequestOptions(): Promise<PublicKeyCredentialRequestOptionsStatus> {
|
||||
export async function getAuthenticationOptions(): Promise<PublicKeyCredentialRequestOptionsStatus> {
|
||||
let response: AxiosResponse<ServiceResponse<CredentialRequest>>;
|
||||
|
||||
response = await axios.get<ServiceResponse<CredentialRequest>>(WebAuthnAssertionPath);
|
||||
|
@ -243,67 +143,57 @@ export async function getAssertionRequestOptions(): Promise<PublicKeyCredentialR
|
|||
}
|
||||
|
||||
return {
|
||||
options: decodePublicKeyCredentialRequestOptions(response.data.data.publicKey),
|
||||
options: response.data.data.publicKey,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
async function getAttestationPublicKeyCredentialResult(
|
||||
creationOptions: PublicKeyCredentialCreationOptions,
|
||||
): Promise<AttestationPublicKeyCredentialResult> {
|
||||
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);
|
||||
|
||||
console.error(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) {
|
||||
result.result = AttestationResult.Failure;
|
||||
} else {
|
||||
if (result.response != null) {
|
||||
result.result = AttestationResult.Success;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getAssertionPublicKeyCredentialResult(
|
||||
requestOptions: PublicKeyCredentialRequestOptions,
|
||||
): Promise<AssertionPublicKeyCredentialResult> {
|
||||
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);
|
||||
|
||||
console.error(exception);
|
||||
|
||||
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;
|
||||
|
@ -312,82 +202,62 @@ export async function getAssertionPublicKeyCredentialResult(
|
|||
return result;
|
||||
}
|
||||
|
||||
async function postAttestationPublicKeyCredentialResult(
|
||||
credential: AttestationPublicKeyCredential,
|
||||
async function postRegistrationResponse(
|
||||
response: RegistrationResponseJSON,
|
||||
): Promise<AxiosResponse<OptionalDataServiceResponse<any>>> {
|
||||
const credentialJSON = encodeAttestationPublicKeyCredential(credential);
|
||||
|
||||
return axios.post<OptionalDataServiceResponse<any>>(WebAuthnAttestationPath, credentialJSON);
|
||||
return axios.post<OptionalDataServiceResponse<any>>(WebAuthnRegistrationPath, response);
|
||||
}
|
||||
|
||||
export async function postAssertionPublicKeyCredentialResult(
|
||||
credential: PublicKeyCredential,
|
||||
export async function postAuthenticationResponse(
|
||||
response: AuthenticationResponseJSON,
|
||||
targetURL: string | undefined,
|
||||
workflow?: string,
|
||||
workflowID?: string,
|
||||
): Promise<AxiosResponse<ServiceResponse<SignInResponse>>> {
|
||||
const credentialJSON = encodeAssertionPublicKeyCredential(credential, targetURL, workflow, workflowID);
|
||||
|
||||
return axios.post<ServiceResponse<SignInResponse>>(WebAuthnAssertionPath, credentialJSON);
|
||||
) {
|
||||
return axios.post<ServiceResponse<SignInResponse>>(WebAuthnAssertionPath, {
|
||||
response: response,
|
||||
targetURL: targetURL,
|
||||
workflow: workflow,
|
||||
workflowID: workflowID,
|
||||
});
|
||||
}
|
||||
|
||||
export async function performAttestationCeremony(token: string): Promise<AttestationResult> {
|
||||
const attestationCreationOpts = await getAttestationCreationOptions(token);
|
||||
export async function finishRegistration(response: RegistrationResponseJSON) {
|
||||
let result = {
|
||||
status: AttestationResult.Failure,
|
||||
message: "Device registration failed.",
|
||||
};
|
||||
|
||||
if (attestationCreationOpts.status !== 200 || attestationCreationOpts.options == null) {
|
||||
if (attestationCreationOpts.status === 403) {
|
||||
return AttestationResult.FailureToken;
|
||||
try {
|
||||
const resp = await postRegistrationResponse(response);
|
||||
if (resp.data.status === "OK" && (resp.status === 200 || resp.status === 201)) {
|
||||
return {
|
||||
status: AttestationResult.Success,
|
||||
message: "",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError && error.response !== undefined) {
|
||||
result.message = error.response.data.message;
|
||||
}
|
||||
}
|
||||
|
||||
return AttestationResult.Failure;
|
||||
return result;
|
||||
}
|
||||
|
||||
const attestationResult = await getAttestationPublicKeyCredentialResult(attestationCreationOpts.options);
|
||||
|
||||
if (attestationResult.result !== AttestationResult.Success) {
|
||||
return attestationResult.result;
|
||||
} else if (attestationResult.credential == null) {
|
||||
return AttestationResult.Failure;
|
||||
export async function deleteUserWebAuthnDevice(deviceID: string) {
|
||||
return await axios<AuthenticationOKResponse>({
|
||||
method: "DELETE",
|
||||
url: `${WebAuthnDevicePath}/${deviceID}`,
|
||||
validateStatus: validateStatusAuthentication,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await postAttestationPublicKeyCredentialResult(attestationResult.credential);
|
||||
|
||||
if (response.data.status === "OK" && (response.status === 200 || response.status === 201)) {
|
||||
return AttestationResult.Success;
|
||||
}
|
||||
|
||||
return AttestationResult.Failure;
|
||||
}
|
||||
|
||||
export async function performAssertionCeremony(
|
||||
targetURL?: string,
|
||||
workflow?: string,
|
||||
workflowID?: string,
|
||||
): Promise<AssertionResult> {
|
||||
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 updateUserWebAuthnDevice(deviceID: string, description: string) {
|
||||
return await axios<AuthenticationOKResponse>({
|
||||
method: "PUT",
|
||||
url: `${WebAuthnDevicePath}/${deviceID}`,
|
||||
data: { description: description },
|
||||
validateStatus: validateStatusAuthentication,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -20,15 +20,18 @@ import LoginLayout from "@layouts/LoginLayout";
|
|||
import { completeTOTPRegistrationProcess } from "@services/RegisterDevice";
|
||||
|
||||
const RegisterOneTimePassword = function () {
|
||||
const { t: translate } = useTranslation();
|
||||
|
||||
const styles = useStyles();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { createSuccessNotification, createErrorNotification } = useNotifications();
|
||||
|
||||
// The secret retrieved from the API is all is ok.
|
||||
const [secretURL, setSecretURL] = useState("empty");
|
||||
const [secretBase32, setSecretBase32] = useState(undefined as string | undefined);
|
||||
const { createSuccessNotification, createErrorNotification } = useNotifications();
|
||||
const [hasErrored, setHasErrored] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { t: translate } = useTranslation();
|
||||
|
||||
// Get the token from the query param to give it back to the API when requesting
|
||||
// the secret for OTP.
|
||||
|
|
|
@ -1,112 +0,0 @@
|
|||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { Button, Theme, Typography } from "@mui/material";
|
||||
import makeStyles from "@mui/styles/makeStyles";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import FingerTouchIcon from "@components/FingerTouchIcon";
|
||||
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 { FirstFactorPath } from "@services/Api";
|
||||
import { performAttestationCeremony } from "@services/WebAuthn";
|
||||
|
||||
const RegisterWebAuthn = function () {
|
||||
const styles = useStyles();
|
||||
const navigate = useNavigate();
|
||||
const { createErrorNotification } = useNotifications();
|
||||
const [, setRegistrationInProgress] = useState(false);
|
||||
|
||||
const processToken = useQueryParam(IdentityToken);
|
||||
|
||||
const handleBackClick = () => {
|
||||
navigate(FirstFactorPath);
|
||||
};
|
||||
|
||||
const attestation = useCallback(async () => {
|
||||
if (!processToken) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setRegistrationInProgress(true);
|
||||
|
||||
const result = await performAttestationCeremony(processToken);
|
||||
|
||||
setRegistrationInProgress(false);
|
||||
|
||||
switch (result) {
|
||||
case AttestationResult.Success:
|
||||
navigate(FirstFactorPath);
|
||||
break;
|
||||
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;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createErrorNotification(
|
||||
"Failed to register your device. The identity verification process might have timed out.",
|
||||
);
|
||||
}
|
||||
}, [processToken, createErrorNotification, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
attestation();
|
||||
}, [attestation]);
|
||||
|
||||
return (
|
||||
<LoginLayout title="Touch Security Key">
|
||||
<div className={styles.icon}>
|
||||
<FingerTouchIcon size={64} animated />
|
||||
</div>
|
||||
<Typography className={styles.instruction}>Touch the token on your security key</Typography>
|
||||
<Button color="primary" onClick={handleBackClick}>
|
||||
Retry
|
||||
</Button>
|
||||
<Button color="primary" onClick={handleBackClick}>
|
||||
Cancel
|
||||
</Button>
|
||||
</LoginLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterWebAuthn;
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => ({
|
||||
icon: {
|
||||
paddingTop: theme.spacing(4),
|
||||
paddingBottom: theme.spacing(4),
|
||||
},
|
||||
instruction: {
|
||||
paddingBottom: theme.spacing(4),
|
||||
},
|
||||
}));
|
|
@ -6,6 +6,7 @@ import BaseLoadingPage from "@views/LoadingPage/BaseLoadingPage";
|
|||
|
||||
const LoadingPage = function () {
|
||||
const { t: translate } = useTranslation();
|
||||
|
||||
return <BaseLoadingPage message={translate("Loading")} />;
|
||||
};
|
||||
|
||||
|
|
|
@ -7,8 +7,10 @@ import { useTranslation } from "react-i18next";
|
|||
import SuccessIcon from "@components/SuccessIcon";
|
||||
|
||||
const Authenticated = function () {
|
||||
const styles = useStyles();
|
||||
const { t: translate } = useTranslation();
|
||||
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<div id="authenticated-stage">
|
||||
<div className={styles.iconContainer}>
|
||||
|
|
|
@ -14,10 +14,12 @@ export interface Props {
|
|||
}
|
||||
|
||||
const AuthenticatedView = function (props: Props) {
|
||||
const styles = useStyles();
|
||||
const navigate = useNavigate();
|
||||
const { t: translate } = useTranslation();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const styles = useStyles();
|
||||
|
||||
const handleLogoutClick = () => {
|
||||
navigate(SignOutRoute);
|
||||
};
|
||||
|
|
|
@ -47,23 +47,26 @@ function scopeNameToAvatar(id: string) {
|
|||
}
|
||||
|
||||
const ConsentView = function (props: Props) {
|
||||
const styles = useStyles();
|
||||
const { t: translate } = useTranslation();
|
||||
|
||||
const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoGET();
|
||||
|
||||
const { createErrorNotification, resetNotification } = useNotifications();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const redirect = useRedirector();
|
||||
const consentID = searchParams.get(Identifier);
|
||||
const { createErrorNotification, resetNotification } = useNotifications();
|
||||
|
||||
const [response, setResponse] = useState<ConsentGetResponseBody | undefined>(undefined);
|
||||
const [error, setError] = useState<any>(undefined);
|
||||
const [preConfigure, setPreConfigure] = useState(false);
|
||||
|
||||
const styles = useStyles();
|
||||
|
||||
const handlePreConfigureChanged = () => {
|
||||
setPreConfigure((preConfigure) => !preConfigure);
|
||||
};
|
||||
|
||||
const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoGET();
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserInfo();
|
||||
}, [fetchUserInfo]);
|
||||
|
@ -167,7 +170,7 @@ const ConsentView = function (props: Props) {
|
|||
<div className={styles.scopesListContainer}>
|
||||
<List className={styles.scopesList}>
|
||||
{response?.scopes.map((scope: string) => (
|
||||
<Tooltip title={"Scope " + scope}>
|
||||
<Tooltip title={translate("Scope", { name: scope })}>
|
||||
<ListItem id={"scope-" + scope} dense>
|
||||
<ListItemIcon>{scopeNameToAvatar(scope)}</ListItemIcon>
|
||||
<ListItemText primary={translateScopeNameToDescription(scope)} />
|
||||
|
@ -180,10 +183,7 @@ const ConsentView = function (props: Props) {
|
|||
{response?.pre_configuration ? (
|
||||
<Grid item xs={12}>
|
||||
<Tooltip
|
||||
title={
|
||||
translate("This saves this consent as a pre-configured consent for future use") ||
|
||||
"This saves this consent as a pre-configured consent for future use"
|
||||
}
|
||||
title={translate("This saves this consent as a pre-configured consent for future use")}
|
||||
>
|
||||
<FormControlLabel
|
||||
control={
|
||||
|
|
|
@ -30,23 +30,27 @@ export interface Props {
|
|||
}
|
||||
|
||||
const FirstFactorForm = function (props: Props) {
|
||||
const styles = useStyles();
|
||||
const { t: translate } = useTranslation();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const redirectionURL = useQueryParam(RedirectionURL);
|
||||
const requestMethod = useQueryParam(RequestMethod);
|
||||
const [workflow] = useWorkflow();
|
||||
const { createErrorNotification } = useNotifications();
|
||||
|
||||
const loginChannel = useMemo(() => new BroadcastChannel<boolean>("login"), []);
|
||||
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [usernameError, setUsernameError] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordError, setPasswordError] = useState(false);
|
||||
const { createErrorNotification } = useNotifications();
|
||||
|
||||
// TODO (PR: #806, Issue: #511) potentially refactor
|
||||
const usernameRef = useRef() as MutableRefObject<HTMLInputElement>;
|
||||
const passwordRef = useRef() as MutableRefObject<HTMLInputElement>;
|
||||
const { t: translate } = useTranslation();
|
||||
|
||||
const styles = useStyles();
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => usernameRef.current.focus(), 10);
|
||||
|
@ -122,7 +126,7 @@ const FirstFactorForm = function (props: Props) {
|
|||
onFocus={() => setUsernameError(false)}
|
||||
autoCapitalize="none"
|
||||
autoComplete="username"
|
||||
onKeyPress={(ev) => {
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
if (!username.length) {
|
||||
setUsernameError(true);
|
||||
|
@ -152,7 +156,7 @@ const FirstFactorForm = function (props: Props) {
|
|||
onFocus={() => setPasswordError(false)}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
onKeyPress={(ev) => {
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
if (!username.length) {
|
||||
usernameRef.current.focus();
|
||||
|
@ -174,7 +178,7 @@ const FirstFactorForm = function (props: Props) {
|
|||
disabled={disabled}
|
||||
checked={rememberMe}
|
||||
onChange={handleRememberMeChange}
|
||||
onKeyPress={(ev) => {
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
if (!username.length) {
|
||||
usernameRef.current.focus();
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, { Fragment, ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import React, { Fragment, ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import { Route, Routes, useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Route, Routes, useLocation } from "react-router-dom";
|
||||
|
||||
import {
|
||||
AuthenticatedRoute,
|
||||
|
@ -15,6 +15,7 @@ import { useConfiguration } from "@hooks/Configuration";
|
|||
import { useNotifications } from "@hooks/NotificationsContext";
|
||||
import { useQueryParam } from "@hooks/QueryParam";
|
||||
import { useRedirector } from "@hooks/Redirector";
|
||||
import { useRouterNavigate } from "@hooks/RouterNavigate";
|
||||
import { useAutheliaState } from "@hooks/State";
|
||||
import { useUserInfoPOST } from "@hooks/UserInfo";
|
||||
import { SecondFactorMethod } from "@models/Methods";
|
||||
|
@ -37,7 +38,6 @@ const RedirectionErrorMessage =
|
|||
"Redirection was determined to be unsafe and aborted. Ensure the redirection URL is correct.";
|
||||
|
||||
const LoginPortal = function (props: Props) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const redirectionURL = useQueryParam(RedirectionURL);
|
||||
const { createErrorNotification } = useNotifications();
|
||||
|
@ -48,24 +48,8 @@ const LoginPortal = function (props: Props) {
|
|||
const [state, fetchState, , fetchStateError] = useAutheliaState();
|
||||
const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoPOST();
|
||||
const [configuration, fetchConfiguration, , fetchConfigurationError] = useConfiguration();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const redirect = useCallback(
|
||||
(
|
||||
pathname: string,
|
||||
preserveSearchParams: boolean = true,
|
||||
searchParamsOverride: URLSearchParams | undefined = undefined,
|
||||
) => {
|
||||
if (searchParamsOverride && URLSearchParamsHasValues(searchParamsOverride)) {
|
||||
navigate({ pathname: pathname, search: `?${searchParamsOverride.toString()}` });
|
||||
} else if (preserveSearchParams && URLSearchParamsHasValues(searchParams)) {
|
||||
navigate({ pathname: pathname, search: `?${searchParams.toString()}` });
|
||||
} else {
|
||||
navigate({ pathname: pathname });
|
||||
}
|
||||
},
|
||||
[navigate, searchParams],
|
||||
);
|
||||
const navigate = useRouterNavigate();
|
||||
|
||||
// Fetch the state when portal is mounted.
|
||||
useEffect(() => {
|
||||
|
@ -138,17 +122,17 @@ const LoginPortal = function (props: Props) {
|
|||
|
||||
if (state.authentication_level === AuthenticationLevel.Unauthenticated) {
|
||||
setFirstFactorDisabled(false);
|
||||
redirect(IndexRoute);
|
||||
navigate(IndexRoute);
|
||||
} else if (state.authentication_level >= AuthenticationLevel.OneFactor && userInfo && configuration) {
|
||||
if (configuration.available_methods.size === 0) {
|
||||
redirect(AuthenticatedRoute, false);
|
||||
navigate(AuthenticatedRoute, false);
|
||||
} else {
|
||||
if (userInfo.method === SecondFactorMethod.WebAuthn) {
|
||||
redirect(`${SecondFactorRoute}${SecondFactorWebAuthnSubRoute}`);
|
||||
navigate(`${SecondFactorRoute}${SecondFactorWebAuthnSubRoute}`);
|
||||
} else if (userInfo.method === SecondFactorMethod.MobilePush) {
|
||||
redirect(`${SecondFactorRoute}${SecondFactorPushSubRoute}`);
|
||||
navigate(`${SecondFactorRoute}${SecondFactorPushSubRoute}`);
|
||||
} else {
|
||||
redirect(`${SecondFactorRoute}${SecondFactorTOTPSubRoute}`);
|
||||
navigate(`${SecondFactorRoute}${SecondFactorTOTPSubRoute}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -156,7 +140,7 @@ const LoginPortal = function (props: Props) {
|
|||
}, [
|
||||
state,
|
||||
redirectionURL,
|
||||
redirect,
|
||||
navigate,
|
||||
userInfo,
|
||||
setFirstFactorDisabled,
|
||||
configuration,
|
||||
|
@ -205,7 +189,7 @@ const LoginPortal = function (props: Props) {
|
|||
}
|
||||
/>
|
||||
<Route
|
||||
path={`${SecondFactorRoute}*`}
|
||||
path={`${SecondFactorRoute}/*`}
|
||||
element={
|
||||
state && userInfo && configuration ? (
|
||||
<SecondFactorForm
|
||||
|
@ -245,7 +229,3 @@ function ComponentOrLoading(props: ComponentOrLoadingProps) {
|
|||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function URLSearchParamsHasValues(params?: URLSearchParams) {
|
||||
return params ? !params.entries().next().done : false;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, { ReactNode, useState } from "react";
|
||||
|
||||
import { Button, Container, Grid, Theme, Typography } from "@mui/material";
|
||||
import { Box, Button, Container, Grid, Theme, Typography } from "@mui/material";
|
||||
import makeStyles from "@mui/styles/makeStyles";
|
||||
|
||||
import PushNotificationIcon from "@components/PushNotificationIcon";
|
||||
|
@ -127,12 +127,12 @@ function DeviceItem(props: DeviceItemProps) {
|
|||
variant="contained"
|
||||
onClick={props.onSelect}
|
||||
>
|
||||
<div className={style.icon}>
|
||||
<Box className={style.icon}>
|
||||
<PushNotificationIcon width={32} height={32} />
|
||||
</div>
|
||||
<div>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography>{props.device.name}</Typography>
|
||||
</div>
|
||||
</Box>
|
||||
</Button>
|
||||
</Grid>
|
||||
);
|
||||
|
@ -172,12 +172,12 @@ function MethodItem(props: MethodItemProps) {
|
|||
variant="contained"
|
||||
onClick={props.onSelect}
|
||||
>
|
||||
<div className={style.icon}>
|
||||
<Box className={style.icon}>
|
||||
<PushNotificationIcon width={32} height={32} />
|
||||
</div>
|
||||
<div>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography>{props.method}</Typography>
|
||||
</div>
|
||||
</Box>
|
||||
</Button>
|
||||
</Grid>
|
||||
);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, { ReactNode } from "react";
|
||||
|
||||
import { Theme } from "@mui/material";
|
||||
import { Box, Theme } from "@mui/material";
|
||||
import makeStyles from "@mui/styles/makeStyles";
|
||||
import classnames from "classnames";
|
||||
|
||||
|
@ -30,12 +30,12 @@ const IconWithContext = function (props: IconWithContextProps) {
|
|||
}))();
|
||||
|
||||
return (
|
||||
<div className={classnames(props.className, styles.root)}>
|
||||
<div className={styles.iconContainer}>
|
||||
<div className={styles.icon}>{props.icon}</div>
|
||||
</div>
|
||||
<div className={styles.context}>{props.children}</div>
|
||||
</div>
|
||||
<Box className={classnames(props.className, styles.root)}>
|
||||
<Box className={styles.iconContainer}>
|
||||
<Box className={styles.icon}>{props.icon}</Box>
|
||||
</Box>
|
||||
<Box className={styles.context}>{props.children}</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -28,14 +28,15 @@ export interface Props {
|
|||
}
|
||||
|
||||
const DefaultMethodContainer = function (props: Props) {
|
||||
const styles = useStyles();
|
||||
const { t: translate } = useTranslation();
|
||||
|
||||
const styles = useStyles();
|
||||
|
||||
const registerMessage = props.registered
|
||||
? props.title === "Push Notification"
|
||||
? ""
|
||||
: translate("Lost your device?")
|
||||
: translate("Manage devices")
|
||||
: translate("Register device");
|
||||
const selectMessage = translate("Select a Device");
|
||||
|
||||
let container: ReactNode;
|
||||
let stateClass: string = "";
|
||||
|
@ -62,7 +63,7 @@ const DefaultMethodContainer = function (props: Props) {
|
|||
</div>
|
||||
{props.onSelectClick && props.registered ? (
|
||||
<Link component="button" id="selection-link" onClick={props.onSelectClick} underline="hover">
|
||||
{selectMessage}
|
||||
{translate("Select a Device")}
|
||||
</Link>
|
||||
) : null}
|
||||
{(props.onRegisterClick && props.title !== "Push Notification") ||
|
||||
|
|
|
@ -42,7 +42,7 @@ const MethodSelectionDialog = function (props: Props) {
|
|||
{props.methods.has(SecondFactorMethod.WebAuthn) && props.webauthnSupported ? (
|
||||
<MethodItem
|
||||
id="webauthn-option"
|
||||
method={translate("Security Key - WebAuthN")}
|
||||
method={translate("Security Key - WebAuthn")}
|
||||
icon={<FingerTouchIcon size={32} />}
|
||||
onClick={() => props.onClick(SecondFactorMethod.WebAuthn)}
|
||||
/>
|
||||
|
@ -59,7 +59,7 @@ const MethodSelectionDialog = function (props: Props) {
|
|||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button color="primary" onClick={props.onClose}>
|
||||
Close
|
||||
{translate("Close")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
|
|
@ -6,9 +6,12 @@ import { useTranslation } from "react-i18next";
|
|||
import { Route, Routes, useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
RegisterOneTimePasswordRoute,
|
||||
SecondFactorPushSubRoute,
|
||||
SecondFactorTOTPSubRoute,
|
||||
SecondFactorWebAuthnSubRoute,
|
||||
SettingsRoute,
|
||||
SettingsTwoFactorAuthenticationSubRoute,
|
||||
LogoutRoute as SignOutRoute,
|
||||
} from "@constants/Routes";
|
||||
import { useNotifications } from "@hooks/NotificationsContext";
|
||||
|
@ -16,7 +19,7 @@ import LoginLayout from "@layouts/LoginLayout";
|
|||
import { Configuration } from "@models/Configuration";
|
||||
import { SecondFactorMethod } from "@models/Methods";
|
||||
import { UserInfo } from "@models/UserInfo";
|
||||
import { initiateTOTPRegistrationProcess, initiateWebAuthnRegistrationProcess } from "@services/RegisterDevice";
|
||||
import { initiateTOTPRegistrationProcess } from "@services/RegisterDevice";
|
||||
import { AuthenticationLevel } from "@services/State";
|
||||
import { setPreferred2FAMethod } from "@services/UserInfo";
|
||||
import { isWebAuthnSupported } from "@services/WebAuthn";
|
||||
|
@ -48,8 +51,11 @@ const SecondFactorForm = function (props: Props) {
|
|||
setStateWebAuthnSupported(isWebAuthnSupported());
|
||||
}, [setStateWebAuthnSupported]);
|
||||
|
||||
const initiateRegistration = (initiateRegistrationFunc: () => Promise<void>) => {
|
||||
const initiateRegistration = (initiateRegistrationFunc: () => Promise<void>, redirectRoute: string) => {
|
||||
return async () => {
|
||||
if (props.authenticationLevel >= AuthenticationLevel.TwoFactor) {
|
||||
navigate(redirectRoute);
|
||||
} else {
|
||||
if (registrationInProgress) {
|
||||
return;
|
||||
}
|
||||
|
@ -62,6 +68,7 @@ const SecondFactorForm = function (props: Props) {
|
|||
createErrorNotification(translate("There was a problem initiating the registration process"));
|
||||
}
|
||||
setRegistrationInProgress(false);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -85,7 +92,12 @@ const SecondFactorForm = function (props: Props) {
|
|||
};
|
||||
|
||||
return (
|
||||
<LoginLayout id="second-factor-stage" title={`${translate("Hi")} ${props.userInfo.display_name}`} showBrand>
|
||||
<LoginLayout
|
||||
id="second-factor-stage"
|
||||
title={`${translate("Hi")} ${props.userInfo.display_name}`}
|
||||
showBrand
|
||||
showSettings
|
||||
>
|
||||
{props.configuration.available_methods.size > 1 ? (
|
||||
<MethodSelectionDialog
|
||||
open={methodSelectionOpen}
|
||||
|
@ -117,7 +129,10 @@ const SecondFactorForm = function (props: Props) {
|
|||
authenticationLevel={props.authenticationLevel}
|
||||
// Whether the user has a TOTP secret registered already
|
||||
registered={props.userInfo.has_totp}
|
||||
onRegisterClick={initiateRegistration(initiateTOTPRegistrationProcess)}
|
||||
onRegisterClick={initiateRegistration(
|
||||
initiateTOTPRegistrationProcess,
|
||||
RegisterOneTimePasswordRoute,
|
||||
)}
|
||||
onSignInError={(err) => createErrorNotification(err.message)}
|
||||
onSignInSuccess={props.onAuthenticationSuccess}
|
||||
/>
|
||||
|
@ -131,7 +146,9 @@ const SecondFactorForm = function (props: Props) {
|
|||
authenticationLevel={props.authenticationLevel}
|
||||
// Whether the user has a WebAuthn device registered already
|
||||
registered={props.userInfo.has_webauthn}
|
||||
onRegisterClick={initiateRegistration(initiateWebAuthnRegistrationProcess)}
|
||||
onRegisterClick={() => {
|
||||
navigate(`${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`);
|
||||
}}
|
||||
onSignInError={(err) => createErrorNotification(err.message)}
|
||||
onSignInSuccess={props.onAuthenticationSuccess}
|
||||
/>
|
||||
|
|
|
@ -1,32 +1,15 @@
|
|||
import React, { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Button, Theme, useTheme } from "@mui/material";
|
||||
import makeStyles from "@mui/styles/makeStyles";
|
||||
|
||||
import FailureIcon from "@components/FailureIcon";
|
||||
import FingerTouchIcon from "@components/FingerTouchIcon";
|
||||
import LinearProgressBar from "@components/LinearProgressBar";
|
||||
import WebAuthnTryIcon from "@components/WebAuthnTryIcon";
|
||||
import { RedirectionURL } from "@constants/SearchParams";
|
||||
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, AssertionResultFailureString, WebAuthnTouchState } from "@models/WebAuthn";
|
||||
import { AuthenticationLevel } from "@services/State";
|
||||
import {
|
||||
getAssertionPublicKeyCredentialResult,
|
||||
getAssertionRequestOptions,
|
||||
postAssertionPublicKeyCredentialResult,
|
||||
} from "@services/WebAuthn";
|
||||
import IconWithContext from "@views/LoginPortal/SecondFactor/IconWithContext";
|
||||
import { getAuthenticationOptions, getAuthenticationResult, postAuthenticationResponse } from "@services/WebAuthn";
|
||||
import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer";
|
||||
|
||||
export enum State {
|
||||
WaitTouch = 1,
|
||||
InProgress = 2,
|
||||
Failure = 3,
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
id: string;
|
||||
authenticationLevel: AuthenticationLevel;
|
||||
|
@ -38,13 +21,10 @@ export interface Props {
|
|||
}
|
||||
|
||||
const WebAuthnMethod = function (props: Props) {
|
||||
const signInTimeout = 30;
|
||||
const [state, setState] = useState(State.WaitTouch);
|
||||
const styles = useStyles();
|
||||
const [state, setState] = useState(WebAuthnTouchState.WaitTouch);
|
||||
const redirectionURL = useQueryParam(RedirectionURL);
|
||||
const [workflow, workflowID] = useWorkflow();
|
||||
const mounted = useIsMountedRef();
|
||||
const [timerPercent, triggerTimer] = useTimer(signInTimeout * 1000 - 500);
|
||||
|
||||
const { onSignInSuccess, onSignInError } = props;
|
||||
const onSignInErrorCallback = useRef(onSignInError).current;
|
||||
|
@ -57,70 +37,40 @@ const WebAuthnMethod = function (props: Props) {
|
|||
}
|
||||
|
||||
try {
|
||||
triggerTimer();
|
||||
setState(State.WaitTouch);
|
||||
const assertionRequestResponse = await getAssertionRequestOptions();
|
||||
setState(WebAuthnTouchState.WaitTouch);
|
||||
const optionsStatus = await getAuthenticationOptions();
|
||||
|
||||
if (assertionRequestResponse.status !== 200 || assertionRequestResponse.options == null) {
|
||||
setState(State.Failure);
|
||||
if (optionsStatus.status !== 200 || optionsStatus.options == null) {
|
||||
setState(WebAuthnTouchState.Failure);
|
||||
onSignInErrorCallback(new Error("Failed to initiate security key sign in process"));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await getAssertionPublicKeyCredentialResult(assertionRequestResponse.options);
|
||||
const result = await getAuthenticationResult(optionsStatus.options);
|
||||
|
||||
if (result.result !== AssertionResult.Success) {
|
||||
if (!mounted.current) return;
|
||||
switch (result.result) {
|
||||
case AssertionResult.FailureUserConsent:
|
||||
onSignInErrorCallback(new Error("You cancelled the assertion request."));
|
||||
break;
|
||||
case AssertionResult.FailureU2FFacetID:
|
||||
onSignInErrorCallback(new Error("The server responded with an invalid Facet ID for the URL."));
|
||||
break;
|
||||
case AssertionResult.FailureSyntax:
|
||||
onSignInErrorCallback(
|
||||
new Error(
|
||||
"The assertion challenge was rejected as malformed or incompatible by your browser.",
|
||||
),
|
||||
);
|
||||
break;
|
||||
case AssertionResult.FailureWebAuthnNotSupported:
|
||||
onSignInErrorCallback(new Error("Your browser does not support the WebAuthN protocol."));
|
||||
break;
|
||||
case AssertionResult.FailureUnknownSecurity:
|
||||
onSignInErrorCallback(new Error("An unknown security error occurred."));
|
||||
break;
|
||||
case AssertionResult.FailureUnknown:
|
||||
onSignInErrorCallback(new Error("An unknown error occurred."));
|
||||
break;
|
||||
default:
|
||||
onSignInErrorCallback(new Error("An unexpected error occurred."));
|
||||
break;
|
||||
}
|
||||
setState(State.Failure);
|
||||
|
||||
setState(WebAuthnTouchState.Failure);
|
||||
|
||||
onSignInErrorCallback(new Error(AssertionResultFailureString(result.result)));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.credential == null) {
|
||||
if (result.response == null) {
|
||||
onSignInErrorCallback(new Error("The browser did not respond with the expected attestation data."));
|
||||
setState(State.Failure);
|
||||
setState(WebAuthnTouchState.Failure);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted.current) return;
|
||||
|
||||
setState(State.InProgress);
|
||||
setState(WebAuthnTouchState.InProgress);
|
||||
|
||||
const response = await postAssertionPublicKeyCredentialResult(
|
||||
result.credential,
|
||||
redirectionURL,
|
||||
workflow,
|
||||
workflowID,
|
||||
);
|
||||
const response = await postAuthenticationResponse(result.response, redirectionURL, workflow, workflowID);
|
||||
|
||||
if (response.data.status === "OK" && response.status === 200) {
|
||||
onSignInSuccessCallback(response.data.data ? response.data.data.redirect : undefined);
|
||||
|
@ -130,14 +80,14 @@ const WebAuthnMethod = function (props: Props) {
|
|||
if (!mounted.current) return;
|
||||
|
||||
onSignInErrorCallback(new Error("The server rejected the security key."));
|
||||
setState(State.Failure);
|
||||
setState(WebAuthnTouchState.Failure);
|
||||
} catch (err) {
|
||||
// If the request was initiated and the user changed 2FA method in the meantime,
|
||||
// the process is interrupted to avoid updating state of unmounted component.
|
||||
if (!mounted.current) return;
|
||||
console.error(err);
|
||||
onSignInErrorCallback(new Error("Failed to initiate security key sign in process"));
|
||||
setState(State.Failure);
|
||||
setState(WebAuthnTouchState.Failure);
|
||||
}
|
||||
}, [
|
||||
onSignInErrorCallback,
|
||||
|
@ -146,7 +96,6 @@ const WebAuthnMethod = function (props: Props) {
|
|||
workflow,
|
||||
workflowID,
|
||||
mounted,
|
||||
triggerTimer,
|
||||
props.authenticationLevel,
|
||||
props.registered,
|
||||
]);
|
||||
|
@ -172,59 +121,9 @@ const WebAuthnMethod = function (props: Props) {
|
|||
state={methodState}
|
||||
onRegisterClick={props.onRegisterClick}
|
||||
>
|
||||
<div className={styles.icon}>
|
||||
<Icon state={state} timer={timerPercent} onRetryClick={doInitiateSignIn} />
|
||||
</div>
|
||||
<WebAuthnTryIcon onRetryClick={doInitiateSignIn} webauthnTouchState={state} />
|
||||
</MethodContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebAuthnMethod;
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => ({
|
||||
icon: {
|
||||
display: "inline-block",
|
||||
},
|
||||
}));
|
||||
|
||||
interface IconProps {
|
||||
state: State;
|
||||
|
||||
timer: number;
|
||||
onRetryClick: () => void;
|
||||
}
|
||||
|
||||
function Icon(props: IconProps) {
|
||||
const state = props.state as State;
|
||||
const theme = useTheme();
|
||||
|
||||
const styles = makeStyles((theme: Theme) => ({
|
||||
progressBar: {
|
||||
marginTop: theme.spacing(),
|
||||
},
|
||||
}))();
|
||||
|
||||
const touch = (
|
||||
<IconWithContext
|
||||
icon={<FingerTouchIcon size={64} animated strong />}
|
||||
className={state === State.WaitTouch ? undefined : "hidden"}
|
||||
>
|
||||
<LinearProgressBar value={props.timer} className={styles.progressBar} height={theme.spacing(2)} />
|
||||
</IconWithContext>
|
||||
);
|
||||
|
||||
const failure = (
|
||||
<IconWithContext icon={<FailureIcon />} className={state === State.Failure ? undefined : "hidden"}>
|
||||
<Button color="secondary" onClick={props.onRetryClick}>
|
||||
Retry
|
||||
</Button>
|
||||
</IconWithContext>
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{touch}
|
||||
{failure}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue