-
Notifications
You must be signed in to change notification settings - Fork 514
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
TOTP / MFA login discussions #554
Comments
SQL Table schemaCREATE TABLE IF NOT EXISTS totp_user_devices (
user_id VARCHAR(128) NOT NULL,
device_name VARCHAR(256) NOT NULL,
secret_key VARCHAR(256) NOT NULL,
period INTEGER NOT NULL,
skew INTEGER NOT NULL,
verified BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (user_id, device_name)
);
-- This table needs to be cleared regularly (once an hour).
-- This table also contains codes that the user entered which did not succeed so that we can implement brute force protection
CREATE TABLE IF NOT EXISTS totp_used_codes (
user_id VARCHAR(128) NOT NULL,
code VARCHAR(6) NOT NULL,
is_valid_code BOOLEAN NOT NULL,
expiry_time BIGINT NOT NULL, -- 90 seconds in the future
PRIMARY KEY (user_id, code)
FOREIGN KEY (user_id) REFERENCES totp_user_devices(user_id) ON DELETE CASCADE
);
|
Core APIs specPOST /totp/deviceInput body{
"deviceName": "My Authy Account",
"userId": "UUIID of first factor (but it can be any string)",
"skew": "integer",
"period": "integer"
} Output body[{
"status": "OK",
"secret": "<32char-base32-random-secret>",
},
{
"status": "DEVICE_ALREADY_EXISTS_ERROR"
}] POST /totp/verifyInput body{
"userId": "UUIID of first factor (but it can be any string)",
"totp": "123456",
"allowUnverifiedDevice": "bool"
} Output body{
"status": "OK | INVALID_TOTP_ERROR | TOTP_NOT_ENABLED_ERROR | LIMIT_REACHED_ERROR"
}
POST /totp/device/verifyInput body{
"userId": "UUIID of first factor (but it can be any string)",
"deviceName": "My Authy Account",
"totp": "123456"
} Output body[{
"status": "OK",
"deviceWasAlreadyVerified": "bool",
},
{
"status": "INVALID_TOTP_ERROR | TOTP_NOT_ENABLED_ERROR | UNKNOWN_DEVICE_ERROR"
}] POST /totp/device/removeInput body{
"userId": "UUIID of first factor (but it can be any string)",
"deviceName": "str"
} Output body{
"status": "OK",
"didDeviceExist": "bool",
} | {
"status": "TOTP_NOT_ENABLED_ERROR"
} PUT /totp/deviceInput body{
"userId": "UUIID of first factor (but it can be any string)",
"existingDeviceName": "str",
"newDeviceName": "str",
} Output body{
"status": "OK | TOTP_NOT_ENABLED_ERROR | DEVICE_ALREADY_EXISTS_ERROR | UNKNOWN_DEVICE_ERROR"
} GET /totp/device/listInput body{
"userId": "UUIID of first factor (but it can be any string)"
} Output body[{
"status": "OK",
"devices": [
{"name": "<device-1-name>", "verified": "bool"},
{"name": "<device-2-name>", "verified": "bool"},
]
}, {
"status": "TOTP_NOT_ENABLED_ERROR"
}] |
Core API logic flowPOST /totp/device
INSERT INTO totp_user_devices (user_id, device_name, secret_key period, skew, verified)
VALUES (${userId}, ${device_name}, ${secretKey}, ${period}, ${skew}, False); Notes:
POST /totp/verify
SELECT device_name, secret_key, period, skew, verified FROM totp_user_devices
WHERE user_id = ${userId}; Notes:
POST /totp/device/verify
-- Fetch secret of given (user, device)
SELECT device_name, secret_key, period, skew, verified FROM totp_user_devices
WHERE user_id = ${userId} AND device_name = ${deviceName};
-- Mark the device as verified
UPDATE totp_user_devices
SET verified = True
WHERE user_id = ${userId} AND device_name = ${deviceName} POST /totp/device/remove
few points:
-- Fetch device_names for given user.
SELECT device_name FROM totp_user_devices
WHERE user_id = ${userId};
-- Delete the device
DELETE totp_user_devices
WHERE user_id = ${userId} AND device_name = ${deviceName}; PUT /totp/device
-- Fetch device_names for given user.
SELECT device_name FROM totp_user_devices
WHERE user_id = ${userId};
-- Delete the device
UPDATE totp_user_devices
SET device_name = ${newDeviceName}
WHERE user_id = ${userId} AND device_name = ${oldDeviceName}; Notes:
GET /totp/device/list
-- Fetch all devices for given user.
SELECT device_name, verified FROM totp_user_devices
WHERE user_id = ${userId}; |
Backend SDK API interface (node js)export type TOTPAPIInterface = {
// Skew and Period values are taken from the config not the user.
createNewDevicePOST?: ((input: { session: SessionContainer; deviceName: string}) => Promise<
{ status: "OK", secret: string } | { status: "DEVICE_ALREADY_EXISTS_ERROR" }
>);
// allowUnverifiedDevice is taken from config not the user
verifyTotpPOST?: ((input: { session: SessionContainer; totp: string }) => Promise<
{ status: "OK" | "INVALID_TOTP_ERROR" | "TOTP_NOT_ENABLED_ERROR" | "LIMIT_REACHED_ERROR" }
>);
verifyDevicePOST?: ((input: { session: SessionContainer; deviceName: string; totp: string }) => Promise<
{ status: "OK"; "deviceWasAlreadyVerified": boolean } | { status: "UNKNOWN_DEVICE_ERROR" | "INVALID_TOTP_ERROR" | "TOTP_NOT_ENABLED_ERROR" }
>);
removeTOTPDevicePOST?: ((input: { session: SessionContainer; deviceName: string }) => Promise<
{ status: "OK", "didDeviceExist": boolean } | { status: "TOTP_NOT_ENABLED_ERROR" }
>);
updateTOTPDevicePUT?: ((input: { session: SessionContainer; existingDeviceName: string; newDeviceName: string; userContext: any }) => Promise<
{ status: "OK" } | { status: "TOTP_NOT_ENABLED_ERROR" | "UNKNOWN_DEVICE_ERROR" }
>);
listTOTPDevicesGET?: ((input: { session: SessionContainer; }) => Promise<
{ status: "OK", devices: { name: string, verified: boolean }[] } | { status: "TOTP_NOT_ENABLED_ERROR" }
>);
} |
Backend SDK recipe interface (node js)export type TOTPRecipeInterface = {
createNewDevice(input: { userId: string; deviceName: string; userContext: any }): Promise<
{ status: "OK", secret: string } | { status: "DEVICE_ALREADY_EXISTS_ERROR" }
>;
verifyTotp(input: { userId: string; totp: string; userContext: any }): Promise<
{ status: "OK" } | { status: "INVALID_TOTP_ERROR" | "TOTP_NOT_ENABLED_ERROR" }
>;
verifyDevice(input: { userId: string; deviceName: string; totp: string; userContext: any }): Promise<
{ status: "OK"; "deviceWasAlreadyVerified": boolean } | { status: "UNKNOWN_DEVICE_ERROR" | "INVALID_TOTP_ERROR" | "TOTP_NOT_ENABLED_ERROR" }
>;
removeDevice(input: { userId: string; deviceName: string; userContext: any }): Promise<
{ status: "OK", "didDeviceExist": boolean } | { status: "TOTP_NOT_ENABLED_ERROR" }
>;
updateDevice(input: { userId: string; existingDeviceName: string; newDeviceName: string; userContext: any }): Promise<
{ status: "OK" } | { status: "TOTP_NOT_ENABLED_ERROR" | "DEVICE_ALREADY_EXISTS_ERROR" | "UNKNOWN_DEVICE_ERROR" }
>;
listDevices(input: { userId: string; userContext: any }): Promise<
{ status: "OK", devices: { name: string, verified: boolean }[] } | { status: "TOTP_NOT_ENABLED_ERROR" }
>;
} |
TODO
|
TODO for 2fa in general
How is a factor defined?
recipeList: [
MultiFactorAuth.init({
factors: [
{id: "thirdparty", order: 1}, {id: "emailpassword", order: 1},
{id: "totp", order: 2}, {id: "passwordless", order: 2},
{id: "totp", order: 3},
]
})
]
Defining a custom factorrecipeList: [
Passwordless.init({
flowType: "email_or_phone"
}),
MultiFactorAuth.init({
factors: [
{id: "passwordless", order: 1},
{id: "biometric", name: "Biometric", order: 2},
]
})
]
Reusing a current recipe multiple times (decided not to do this now)recipeList: [
Passwordless.init({
flowType: "email_or_phone"
}),
Passwordless.init({
flowType: "phone",
rid: "custom"
}),
MultiFactorAuth.init({
factors: [
{id: "passwordless", order: 1},
{id: "passwordless", name: "SMS OTP", rid: "custom", order: 2},
]
})
]
Recipe function to get factorsThere needs to be a recipe function which determines what the next factor to apply is. This is overridable by the user: getFactors: (session?: SessionContainer, userContext) => {
if (session !== undefined) {
// first query core to get factors based on user id (SELECT * FROM mfa_factor WHERE user_id = <user-id>;)
// if found, update the result to have the first factor from calling config.factors and return that.
}
return config.factors;
} 2fa session claim structureNeed to capture:
fetch_value implementation for st-mfa claim:
2fa session claim validators
Step up auth
How this ties in with email verification checkBy default, we can make it so that the emailverification.isVerified validator is checked after the hasCompletedAllFactors validator - by having the isVerified validator AFTER the hasCompletedAllFactors in the global validator array. This would mean that email verification happens after all factors are completed. If the user wants to change this order, then can shuffle the global claim validator array themselves. But is this a good way of doing it? TODO Alt: We can use the factors config of MFA recipe to allow the user to decide the order of email verification. |
Different user flows (TODO):Register the user:
{
"st-mfa": {
"v": [{
id: "emailpassword",
c: True,
t: 123,
o: 1
},
{
id: "thirdparty",
c: False,
t: 0,
o: 1
},
{
id: "totp",
c: False,
t: 0,
o: 2,
},
]
}
}
User adds a MFA (say TOTP) after registration:First factor:
Second factor:
Logging in a user:First factor:
2nd factor:
Validating if a certain factor has been completed:Use Accessing any protected API:
Disabling a factor (say TOTP):
Re-enabling a factor (say TOTP):
|
SQL Table Schema:CREATE TABLE IF NOT EXISTS mfa_factor (
user_id VARCHAR(128) NOT NULL, -- Assume this is final user ID after account linking feature is completed.
order INTEGER NOT NULL, -- Factor order. This is the position of the factor in the user's list of factors. There can be multiple factors at the same position.
id VARCHAR(128) NOT NULL, -- Type of factor. Can be "totp", "email-otp", "sms-otp", etc. Alternatively can be called as factor_flow or factor_name
PRIMARY KEY (user_id, order, id)
);
|
Core API spec
POST /mfa/factor{
"user_id": "user_id",
"factors": [
[
// [{"thirdpartyemailpassword": "tpep-user-id"}], // What will happen when account linking is enabled? // No benefit of providing first factor
[{"totp": "enabled", "email-otp": "disabled"}], // Just store that factor has been enabled/disabled/configured.
],
]
} {
"status": "OK"
} GET /mfa/factor/listRequest body:{
"user_id": "<user-id-after-account-linking>"
} Response body:{
"status": "OK",
"factors": [
[{"thirdpartyemailpassword": "enabled"}]
[{"totp": "enabled"}, {"email-otp": "enabled"}, ]
],
} PUT /mfa/factorRequest body:{
"status": "OK",
"factors": [
{"totp": False}
]
} Response body:{
"status": "OK | UNKNOWN_FACTOR_ERROR",
"unknown_factor": "<factor>"
}
|
Changes to passwordless recipe
Reuse Changes to emailpassword
Reuse Changes to thirdparty
Changes to totp
TODO:
|
TODO:
During sign in: when emailpassword factor is completed, we want to show the email verification screen in case the second factor is going to be TOTP. If the second factor is going to be passwordless, then we want to mark the email verification claim as completed automatically.
Cases:1:During sign in: when emailpassword factor is completed, we want to show the email verification screen if the second factor is going to be TOTP. If the second factor is going to be passwordless, then we want to mark the email verification claim as completed automatically. mfa.init(
factors=[
Factor("emailpassword", 1),
Factor("totp", 2), Factor("pless", 2),
]
)
def completed_factor(session, factor, context):
if factor == Factor("totp", 2):
redirect("/email-verification-screen")
if factor == Factor("pless", 2):
EVClaim.complete()
def get_factors(session, completed_factors: List[Factor], context) -> List[Factor]:
# all_factors = self.get_all_factors()
if Factor("totp", 2) in completed_factors:
redirect("/email-verification-screen")
if Factor("passwordless", 2) in completed_factors:
EVClaim.complete()
return oi_get_factors(session, completed_factors, context)
# Only the factors that remain in get_factors() will be allowed in the access token payload. Alt: mfa.init(factors=["emailpassword", "totp"])
def next_factors(session, completed_factors: List[Factor], context) -> List[Factor]:
if Factor("emailpassword", 1) in completed_factors:
return [Factor("totp", 2), Factor("pless", 2)] # When any of them is completed, we will remove the other one
if Factor("totp", 2) in completed_factors:
redirect("/email-verification-screen") # TODO: How will it redirect?
if Factor("pless", 2) in completed_factors:
EVClaim.complete()
return []
return oi_next_factors() 2:User logs in -> email password -> totp -> checked on remember this device -> access mfa.init(factors=["emailpassword", "totp"])
def next_factors(session, completed_factors: List[Factor], context) -> List[Factor]:
if completed_factors == [Factor("emailpassword", 1)] and context["flow"] == "login":
device_exist = core.query()
if device_exist:
return []
return oi_next_factors(session, completed_factors, context) 3:Sign up: email password -> present a choice of totp or passwordless -> if totp is selected, then do that and email verification. If passwordless is selected, then do passwordless only. mfa.init(factors=["emailpassword", "passwordless"])
def next_factors(session, completed_factors: List[Factor], context) -> List[Factor]:
if completed_factors == [Factor("emailpassword", 1)] and context["flow"] == "login":
return [Factor("totp", 2), Factor("passwordless", 2)]
if Factor("totp", 2) in completed_factors:
redirect("/email-verification-redirect")
return oi_next_factors(session, completed_factors, context) 4:Sign up: email password -> passwordless -> step up auth (if totp is enabled, then use totp, else ask the user for their password) // TODO: INCOMPLETE
mfa.init(factors=["emailpassword", "passwordless"])
def next_factors(session, completed_factors: List[Factor], context) -> List[Factor]:
if Factor("passwordless", 2) in completed_factors and context["flow"] == "login":
is_totp_enabled = core.query()
if is_totp_enabled:
return [Factor("totp", 3)]
else:
return [Factor("emailpassword", 3)]
return oi_next_factors(session, completed_factors, context) 5:Sign up: email password -> passwordless -> step up auth (if totp is enabled, then use totp, else ask the user for their password) 6:Sign in: email password -> passwordless -> if passwordless was done using a new IP address then ask for TOTP else give access. mfa.init(factors=["emailpassword", "passwordless"])
def next_factors(session, completed_factors: List[Factor], context) -> List[Factor]:
if Factor("passwordless", 2) in completed_factors and context["flow"] == "login":
is_different_ip = check_request_ip()
if is_different_ip:
return [Factor("totp", 3)]
return oi_next_factors(session, completed_factors, context) 7:sign in: Email password -> if logged in after 30 days -> do passwordless -> totp -> access. If sign in before 30 days -> totp -> passwordless mfa.init(factors=["emailpassword"])
def next_factors(session, completed_factors: List[Factor], context) -> List[Factor]:
if completed_factors == [Factor("emailpassword", 1)] and context["flow"] == "login":
days_elasped_since_login = db.query()
if days_elasped_since_login > 30:
return [Factor("passwordless", 3), [Factor("totp", 3)]]
else:
return [Factor("passwordless", 3), [Factor("totp", 3)]]
return oi_next_factors(session, completed_factors, context) |
Including step up auth factors in config.factors and mark them as completed ? |
factor[]:
[{id: string, order: number}] This can come from:
db: -- userId -> factor[]
CREATE TABLE mfa_factors(
user_id VARCHAR(32),
factor jsonb,
PRIMARY KEY (user_id)
); function nextFactor(session?, completedFactors?, factors, userContext) => string[]
Structure of claim in access token: "st-mfa": {
"v": {
"id": string,
"t": number,
"completed": boolean,
"order": number,
}[]
} mfaclaim:
validators:
————————
|
Alternatives considered:Alt 1What if we dump all the factors from the DB/config in the first factor login itself. FE can just follow that order. Edge cases:
Pros:
Cons:Soln to Problems:
|
About first factor login:Tenant creation:
Tenant info fetching
If frontend uses dynamic login method: true
If frontend does not use dynamic login methods:
How will the backend sign in / up APis know about the first factor?
|
About passwordless factor IDInstead of having factor id for passwordless where we have "otp-mobile" etc.. We do NOT have factor ID of |
TODOPlanned return values for the default implementations of
|
Factor ids associated with each recipe:
|
Meeting notes:
|
EDIT: 23rd November, 2023For the default implementation of
|
Implementing recovery codes (will be added as part of an example app):
|
All TODOS have been moved from here to the main list |
BE TODOs:
|
Flow for factor login: https://supertokens.slack.com/archives/C051BN9QJUX/p1702645397935389
Logic for the above discussion:
|
Notes from 24.01.02 discussion:
In this case:
What I propose instead:
The way we can communicate implementation for custom UIs:
If someone wants to get smart and use the next array in the claim (if we choose to leave it there) they can do so, but that would not be the standard recommendation. Our implementation could still use it. Changes:
|
Jan 3, 2024 Invalid first factor error is not a support code but instead a 401 error. Support codes are only meant to be used when the implementations are all correct but the user is unable to complete something because of an application state. Invalid first factor is either user trying to call unintended API or a dev mistake or a config error. |
This has been released in core version >= 9.0, node SDK version >= 17.0 |
Google doc link: https://docs.google.com/document/d/1qyd4XmepLJXNC4uURcak-7JbWP0UuzOPSsjBInVAuNk/edit
TODO:
Fetch user identifier based on first factor?Give Rishabh all the touch points between account linking and mfa recipeWe decided to prevent this by checking if all factors are completed in thecreateDevicePOST
implementation (claim assertion), if there already exists one device. But we should discuss this with the team (TODO)We need to get rid of TOTP_NOT_ENABLED_ERROR from the core entirely:Need to have a separate API for MFA account linking which is enabled if the MFA feature is enabled and account linking is not. This API will only allow account linking for the purpose of MFA. So you need to enforce that, somehow.One idea is that we disallow linking of accounts across the same recipe ID.Devs should be able to set MFA settings for tenants via an API call (similar to how they configure third party providers for tenants via an API call). This should be as flexible as possible.Maybe we can have enableFactorForTenant(tenantId, factorId…) just like we have enableFactorForUser. This is not necessary.. it's just an ideacreate-supertokens-app
(maybe?)createOrUpdate tenant, app, cud if firstFactors or defaultMFARequirements are set & non emptyif totpEnabled is true in tenant/app/cud creationNew structure for firstFactors in tenant creationtotpEnabled
AddWe decided not to do this cause of: https://supertokens.slack.com/archives/C051BN9QJUX/p1703693273706539?thread_ts=1703668285.242759&cid=C051BN9QJUXaccess-denied
special factor id + change the wayn
array is computed to take into account the claims that cannot be currently setup.factorIds
parameter tocreateCodePOST
isFactorFactor
in the SUCCESS event for all auth recipes on the frontend.in sign up API in core for emailpassword, we need to mark email as verified if it's a fake email. We also need to prevent sign in for first factor in emailpassword if it's a fake email.verifyCredentials
in emailpassword based recipes and also expose that. That will only call the core to verify credentials and not do account linking. Also, this function will be called by the signIn recipe function internally (as opposed to it calling the core)shouldAttemptAccountLinkingIfAllowed
input from everywhere.The text was updated successfully, but these errors were encountered: