Support multiple authentication strategies
Proposed API:
In order to login, the frontend sends a POST request to /api/auth/2fa/query
with the following body every time the username field updates:
{
username: "mary"
}
If a user exists and has one or more 2fa methods enabled, the frontend receives the following response:
{
methods: [
{
type: "webauthn",
challenge: "DATA",
challengeId: "unique id, generated serverside",
keys: [
{
id: "webauthn credential id, generated by the authenticator"
}
],
passwordless: true
},
{
type: "totp",
passwordless: false
}
]
}
This is a POST request rather than a GET because it is effectful (for example, a webauthn challenge needs to be generated and put in Redis)
Note that the frontend can sort through the supported method types to find a method it supports. Methods should be put in order of their priority / likelihood of being used. If for example we supported something over SMS, that would be least prioritised and be the last method listed. A method will only appear in the array if the user has it enabled/configured. Otherwise, the server will return a 404.
Once the data is received from the query endpoint, the frontend should update its UI in some way to indicate the new information. For example, if a passwordless option is available, the UI shouldn't show password entry, or if it does, it should be less-prominent. If the result is a 404, the frontend should assume a normal username/password login flow.
Now that the frontend has information on the supported login flows, when the user completes the requested details, it should send a request to the new /api/auth/login
:
{
auth: AuthData
}
Note: all of our authenticated routes (everything that needs a password now) should work like this now.
...Where AuthData
is one of:
webauthn:
{
type: "webauthn", // one of "password", "totp", "webauthn"
username: "mary",
challengeId: "same unique id from before",
credentialId: "id of the credential used",
clientDataJSON: "clientDataJSON from the webauthn spec (encoded)",
authenticatorData: "authenticatorData from the webauthn spec (encoded)",
signature: "signature from the webauthn spec (encoded)",
password: "hunter2" // (not present if passwordless is true)
}
password
:
{
type: "password",
username: "mary",
password: "hunter2"
}
totp
:
{
type: "totp",
username: "mary",
password: "hunter2",
totpCode: "123456"
}
This should yield the same response from before, and ends our auth flow!
As far as the database:
- A
webauthn_credentials
table for information on multiple authenticators (multiple possible per-user) - A
webauthn_challenges
table for info on our challenges - A
totpSecret
column on a user - A
webauthnPasswordless
column on a user, false by default. Just means that we don't check/require a password on logins using that token. The password still exists serverside, for use with things like TOTP or similar.
Just some things to keep in mind:
- webauthn: abstracts away different authenticator types
- FIDO2: just a type of key, this is a device that will hold your webauthn credentials.
- Old Yubico things? These are legacy U2F keys. Supported under webauthn spec, we can ignore the differences for our purposes
Testing:
- For webauthn: We can do testing with some captured webauthn traffic from a real authenticator. This is the simplest and most elegant way to do it. Tests can make sure the routes add data to the DB as intended, and then put a pre-captured challenge into the DB and test the auth flow with pre-captured attestations.
- For totp: I know less about this, but I think the way it works means an authenticator is effectively running serverside anyways, so we should be able to just make sure we can generate a code?