diff --git a/bin/lamassu-register b/bin/lamassu-register index 1fbb98a4..c24a59b6 100755 --- a/bin/lamassu-register +++ b/bin/lamassu-register @@ -1,7 +1,7 @@ #!/usr/bin/env node const { asyncLocalStorage, defaultStore } = require('../lib/async-storage') -const authentication = require('../lib/new-admin/graphql/modules/authentication') +const userManagement = require('../lib/new-admin/graphql/modules/userManagement') const options = require('../lib/options') const name = process.argv[2] @@ -31,7 +31,7 @@ if (role !== 'user' && role !== 'superuser') { } asyncLocalStorage.run(defaultStore(), () => { - authentication.createRegisterToken(name, role).then(token => { + userManagement.createRegisterToken(name, role).then(token => { if (!token) { console.log(`A user named ${name} already exists!`) process.exit(2) diff --git a/lib/hardware-credentials.js b/lib/hardware-credentials.js new file mode 100644 index 00000000..761fd045 --- /dev/null +++ b/lib/hardware-credentials.js @@ -0,0 +1,36 @@ +const uuid = require('uuid') + +const db = require('./db') + +function createHardwareCredential (userID, credentialData) { + const sql = `INSERT INTO hardware_credentials (id, user_id, data) VALUES ($1, $2, $3)` + return db.none(sql, [uuid.v4(), userID, credentialData]) +} + +function getHardwareCredentials () { + const sql = `SELECT * FROM hardware_credentials` + return db.any(sql) +} + +function getHardwareCredentialsByUserId (userID) { + const sql = `SELECT * FROM hardware_credentials WHERE user_id=$1` + return db.any(sql, [userID]) +} + +function getUserByUserHandle (userHandle) { + const sql = `SELECT users.id, users.username, users.role FROM users INNER JOIN hardware_credentials hc ON users.id=hc.user_id WHERE data->>'userHandle'=$1::jsonb::text` + return db.oneOrNone(sql, [userHandle]) +} + +function updateHardwareCredential (credential) { + const sql = `UPDATE hardware_credentials SET last_used=now(), data=$1 WHERE id=$2` + return db.none(sql, [credential.data, credential.id]) +} + +module.exports = { + createHardwareCredential, + getHardwareCredentials, + getHardwareCredentialsByUserId, + getUserByUserHandle, + updateHardwareCredential +} diff --git a/lib/new-admin/graphql/modules/authentication/FIDO2FAStrategy.js b/lib/new-admin/graphql/modules/authentication/FIDO2FAStrategy.js new file mode 100644 index 00000000..82cd4ee1 --- /dev/null +++ b/lib/new-admin/graphql/modules/authentication/FIDO2FAStrategy.js @@ -0,0 +1,180 @@ +const simpleWebauthn = require('@simplewebauthn/server') +const base64url = require('base64url') +const _ = require('lodash/fp') + +const userManagement = require('../userManagement') +const credentials = require('../../../../hardware-credentials') +const options = require('../../../../options') +const T = require('../../../../time') +const users = require('../../../../users') + +const domain = options.hostname +const devMode = require('minimist')(process.argv.slice(2)).dev + +const REMEMBER_ME_AGE = 90 * T.day + +const rpID = devMode ? `localhost:3001` : domain +const expectedOrigin = `https://${rpID}` + +const generateAttestationOptions = (session, options) => { + return users.getUserById(options.userId).then(user => { + return Promise.all([credentials.getHardwareCredentialsByUserId(user.id), user]) + }).then(([userDevices, user]) => { + const options = simpleWebauthn.generateAttestationOptions({ + rpName: 'Lamassu', + rpID, + userName: user.username, + userID: user.id, + timeout: 60000, + attestationType: 'indirect', + excludeCredentials: userDevices.map(dev => ({ + id: dev.data.credentialID, + type: 'public-key', + transports: ['usb', 'ble', 'nfc', 'internal'] + })), + authenticatorSelection: { + userVerification: 'discouraged', + requireResidentKey: false + } + }) + + session.webauthn = { + attestation: { + challenge: options.challenge + } + } + + return options + }) +} + +const generateAssertionOptions = (session, options) => { + return userManagement.authenticateUser(options.username, options.password).then(user => { + return credentials.getHardwareCredentialsByUserId(user.id).then(devices => { + const opts = simpleWebauthn.generateAssertionOptions({ + timeout: 60000, + allowCredentials: devices.map(dev => ({ + id: dev.data.credentialID, + type: 'public-key', + transports: ['usb', 'ble', 'nfc', 'internal'] + })), + userVerification: 'discouraged', + rpID + }) + + session.webauthn = { + assertion: { + challenge: opts.challenge + } + } + + return opts + }) + }) +} + +const validateAttestation = (session, options) => { + const webauthnData = session.webauthn.attestation + const expectedChallenge = webauthnData.challenge + + return Promise.all([ + users.getUserById(options.userId), + simpleWebauthn.verifyAttestationResponse({ + credential: options.attestationResponse, + expectedChallenge: `${expectedChallenge}`, + expectedOrigin, + expectedRPID: rpID + }) + ]) + .then(([user, verification]) => { + const { verified, attestationInfo } = verification + + if (!(verified || attestationInfo)) { + session.webauthn = null + return false + } + + const { + counter, + credentialPublicKey, + credentialID + } = attestationInfo + + return credentials.getHardwareCredentialsByUserId(user.id) + .then(userDevices => { + const existingDevice = userDevices.find(device => device.data.credentialID === credentialID) + + if (!existingDevice) { + const newDevice = { + counter, + credentialPublicKey, + credentialID + } + credentials.createHardwareCredential(user.id, newDevice) + } + + session.webauthn = null + return verified + }) + }) +} + +const validateAssertion = (session, options) => { + return userManagement.authenticateUser(options.username, options.password).then(user => { + const expectedChallenge = session.webauthn.assertion.challenge + + return credentials.getHardwareCredentialsByUserId(user.id).then(devices => { + const dbAuthenticator = _.find(dev => { + return Buffer.from(dev.data.credentialID).compare(base64url.toBuffer(options.assertionResponse.rawId)) === 0 + }, devices) + + if (!dbAuthenticator.data) { + throw new Error(`Could not find authenticator matching ${options.assertionResponse.id}`) + } + + const convertedAuthenticator = _.merge( + dbAuthenticator.data, + { credentialPublicKey: Buffer.from(dbAuthenticator.data.credentialPublicKey) } + ) + + let verification + try { + verification = simpleWebauthn.verifyAssertionResponse({ + credential: options.assertionResponse, + expectedChallenge: `${expectedChallenge}`, + expectedOrigin, + expectedRPID: rpID, + authenticator: convertedAuthenticator + }) + } catch (err) { + console.error(err) + return false + } + + const { verified, assertionInfo } = verification + + if (!verified) { + session.webauthn = null + return false + } + + dbAuthenticator.data.counter = assertionInfo.newCounter + return credentials.updateHardwareCredential(dbAuthenticator) + .then(() => { + const finalUser = { id: user.id, username: user.username, role: user.role } + session.user = finalUser + if (options.rememberMe) session.cookie.maxAge = REMEMBER_ME_AGE + + session.webauthn = null + return verified + }) + }) + }) +} + +module.exports = { + generateAttestationOptions, + generateAssertionOptions, + validateAttestation, + validateAssertion +} diff --git a/lib/new-admin/graphql/modules/authentication/FIDOPasswordlessStrategy.js b/lib/new-admin/graphql/modules/authentication/FIDOPasswordlessStrategy.js new file mode 100644 index 00000000..8c39e624 --- /dev/null +++ b/lib/new-admin/graphql/modules/authentication/FIDOPasswordlessStrategy.js @@ -0,0 +1,179 @@ +const simpleWebauthn = require('@simplewebauthn/server') +const base64url = require('base64url') +const _ = require('lodash/fp') + +const credentials = require('../../../../hardware-credentials') +const options = require('../../../../options') +const T = require('../../../../time') +const users = require('../../../../users') + +const domain = options.hostname +const devMode = require('minimist')(process.argv.slice(2)).dev + +const REMEMBER_ME_AGE = 90 * T.day + +const rpID = devMode ? `localhost:3001` : domain +const expectedOrigin = `https://${rpID}` + +const generateAttestationOptions = (session, options) => { + return users.getUserById(options.userId).then(user => { + return Promise.all([credentials.getHardwareCredentialsByUserId(user.id), user]) + }).then(([userDevices, user]) => { + const opts = simpleWebauthn.generateAttestationOptions({ + rpName: 'Lamassu', + rpID, + userName: user.username, + userID: user.id, + timeout: 60000, + attestationType: 'indirect', + excludeCredentials: userDevices.map(dev => ({ + id: dev.data.credentialID, + type: 'public-key', + transports: ['usb', 'ble', 'nfc', 'internal'] + })), + authenticatorSelection: { + userVerification: 'discouraged', + requireResidentKey: false + } + }) + + session.webauthn = { + attestation: { + challenge: opts.challenge + } + } + + return opts + }) +} + +const generateAssertionOptions = (session, options) => { + return users.getUserByUsername(options.username).then(user => { + return credentials.getHardwareCredentialsByUserId(user.id).then(devices => { + const opts = simpleWebauthn.generateAssertionOptions({ + timeout: 60000, + allowCredentials: devices.map(dev => ({ + id: dev.data.credentialID, + type: 'public-key', + transports: ['usb', 'ble', 'nfc', 'internal'] + })), + userVerification: 'discouraged', + rpID + }) + + session.webauthn = { + assertion: { + challenge: opts.challenge + } + } + + return opts + }) + }) +} + +const validateAttestation = (session, options) => { + const webauthnData = session.webauthn.attestation + const expectedChallenge = webauthnData.challenge + + return Promise.all([ + users.getUserById(options.userId), + simpleWebauthn.verifyAttestationResponse({ + credential: options.attestationResponse, + expectedChallenge: `${expectedChallenge}`, + expectedOrigin, + expectedRPID: rpID + }) + ]) + .then(([user, verification]) => { + const { verified, attestationInfo } = verification + + if (!(verified || attestationInfo)) { + session.webauthn = null + return false + } + + const { + counter, + credentialPublicKey, + credentialID + } = attestationInfo + + return credentials.getHardwareCredentialsByUserId(user.id) + .then(userDevices => { + const existingDevice = userDevices.find(device => device.data.credentialID === credentialID) + + if (!existingDevice) { + const newDevice = { + counter, + credentialPublicKey, + credentialID + } + credentials.createHardwareCredential(user.id, newDevice) + } + + session.webauthn = null + return verified + }) + }) +} + +const validateAssertion = (session, options) => { + return users.getUserByUsername(options.username).then(user => { + const expectedChallenge = session.webauthn.assertion.challenge + + return credentials.getHardwareCredentialsByUserId(user.id).then(devices => { + const dbAuthenticator = _.find(dev => { + return Buffer.from(dev.data.credentialID).compare(base64url.toBuffer(options.assertionResponse.rawId)) === 0 + }, devices) + + if (!dbAuthenticator.data) { + throw new Error(`Could not find authenticator matching ${options.assertionResponse.id}`) + } + + const convertedAuthenticator = _.merge( + dbAuthenticator.data, + { credentialPublicKey: Buffer.from(dbAuthenticator.data.credentialPublicKey) } + ) + + let verification + try { + verification = simpleWebauthn.verifyAssertionResponse({ + credential: options.assertionResponse, + expectedChallenge: `${expectedChallenge}`, + expectedOrigin, + expectedRPID: rpID, + authenticator: convertedAuthenticator + }) + } catch (err) { + console.error(err) + return false + } + + const { verified, assertionInfo } = verification + + if (!verified) { + context.req.session.webauthn = null + return false + } + + dbAuthenticator.data.counter = assertionInfo.newCounter + return credentials.updateHardwareCredential(dbAuthenticator) + .then(() => { + const finalUser = { id: user.id, username: user.username, role: user.role } + session.user = finalUser + if (options.rememberMe) session.cookie.maxAge = REMEMBER_ME_AGE + + session.webauthn = null + return verified + }) + }) + }) +} + +module.exports = { + generateAttestationOptions, + generateAssertionOptions, + validateAttestation, + validateAssertion +} diff --git a/lib/new-admin/graphql/modules/authentication/FIDOUsernamelessStrategy.js b/lib/new-admin/graphql/modules/authentication/FIDOUsernamelessStrategy.js new file mode 100644 index 00000000..2a1ee59a --- /dev/null +++ b/lib/new-admin/graphql/modules/authentication/FIDOUsernamelessStrategy.js @@ -0,0 +1,186 @@ +const simpleWebauthn = require('@simplewebauthn/server') +const base64url = require('base64url') +const _ = require('lodash/fp') + +const credentials = require('../../../../hardware-credentials') +const options = require('../../../../options') +const T = require('../../../../time') +const users = require('../../../../users') + +const domain = options.hostname +const devMode = require('minimist')(process.argv.slice(2)).dev + +const REMEMBER_ME_AGE = 90 * T.day + +const rpID = devMode ? `localhost:3001` : domain +const expectedOrigin = `https://${rpID}` + +const generateAttestationOptions = (session, options) => { + return credentials.getHardwareCredentials().then(devices => { + const opts = simpleWebauthn.generateAttestationOptions({ + rpName: 'Lamassu', + rpID, + userName: `Usernameless user created at ${new Date().toISOString()}`, + userID: options.userId, + timeout: 60000, + attestationType: 'direct', + excludeCredentials: devices.map(dev => ({ + id: dev.data.credentialID, + type: 'public-key', + transports: ['usb', 'ble', 'nfc', 'internal'] + })), + authenticatorSelection: { + authenticatorAttachment: 'cross-platform', + userVerification: 'discouraged', + requireResidentKey: false + } + }) + + session.webauthn = { + attestation: { + challenge: opts.challenge + } + } + + return opts + }) +} + +const generateAssertionOptions = session => { + return credentials.getHardwareCredentials().then(devices => { + const options = simpleWebauthn.generateAssertionOptions({ + timeout: 60000, + allowCredentials: devices.map(dev => ({ + id: dev.data.credentialID, + type: 'public-key', + transports: ['usb', 'ble', 'nfc', 'internal'] + })), + userVerification: 'discouraged', + rpID + }) + + session.webauthn = { + assertion: { + challenge: options.challenge + } + } + return options + }) +} + +const validateAttestation = (session, options) => { + const webauthnData = session.webauthn.attestation + const expectedChallenge = webauthnData.challenge + + return Promise.all([ + users.getUserById(options.userId), + simpleWebauthn.verifyAttestationResponse({ + credential: options.attestationResponse, + expectedChallenge: `${expectedChallenge}`, + expectedOrigin, + expectedRPID: rpID + }) + ]) + .then(([user, verification]) => { + const { verified, attestationInfo } = verification + + if (!(verified || attestationInfo)) { + session.webauthn = null + return verified + } + + const { + fmt, + counter, + aaguid, + credentialPublicKey, + credentialID, + credentialType, + userVerified, + attestationObject + } = attestationInfo + + return credentials.getHardwareCredentialsByUserId(user.id) + .then(userDevices => { + const existingDevice = userDevices.find(device => device.data.credentialID === credentialID) + + if (!existingDevice) { + const newDevice = { + fmt, + counter, + aaguid, + credentialPublicKey, + credentialID, + credentialType, + userVerified, + attestationObject + } + credentials.createHardwareCredential(user.id, newDevice) + } + + session.webauthn = null + return verified + }) + }) +} + +const validateAssertion = (session, options) => { + const expectedChallenge = session.webauthn.assertion.challenge + + return credentials.getHardwareCredentials().then(devices => { + const dbAuthenticator = _.find(dev => { + return Buffer.from(dev.data.credentialID).compare(base64url.toBuffer(options.assertionResponse.rawId)) === 0 + }, devices) + + if (!dbAuthenticator.data) { + throw new Error(`Could not find authenticator matching ${options.assertionResponse.id}`) + } + + const convertedAuthenticator = _.merge( + dbAuthenticator.data, + { credentialPublicKey: Buffer.from(dbAuthenticator.data.credentialPublicKey) } + ) + + let verification + try { + verification = simpleWebauthn.verifyAssertionResponse({ + credential: options.assertionResponse, + expectedChallenge: `${expectedChallenge}`, + expectedOrigin, + expectedRPID: rpID, + authenticator: convertedAuthenticator + }) + } catch (err) { + console.error(err) + return false + } + + const { verified, assertionInfo } = verification + + if (!verified) { + session.webauthn = null + return false + } + + dbAuthenticator.data.counter = assertionInfo.newCounter + return Promise.all([ + credentials.updateHardwareCredential(dbAuthenticator), + users.getUserById(dbAuthenticator.user_id) + ]) + .then(([_, user]) => { + const finalUser = { id: user.id, username: user.username, role: user.role } + session.user = finalUser + session.cookie.maxAge = REMEMBER_ME_AGE + + session.webauthn = null + return verified + }) + }) +} + +module.exports = { + generateAttestationOptions, + generateAssertionOptions, + validateAttestation, + validateAssertion +} diff --git a/lib/new-admin/graphql/modules/authentication/index.js b/lib/new-admin/graphql/modules/authentication/index.js new file mode 100644 index 00000000..1b4591cc --- /dev/null +++ b/lib/new-admin/graphql/modules/authentication/index.js @@ -0,0 +1,17 @@ +const FIDO2FA = require('./FIDO2FAStrategy') +const FIDOPasswordless = require('./FIDOPasswordlessStrategy') +const FIDOUsernameless = require('./FIDOUsernamelessStrategy') + +const STRATEGIES = { + FIDO2FA, + FIDOPasswordless, + FIDOUsernameless +} + +// FIDO2FA, FIDOPasswordless or FIDOUsernameless +const CHOSEN_STRATEGY = 'FIDO2FA' + +module.exports = { + CHOSEN_STRATEGY, + strategy: STRATEGIES[CHOSEN_STRATEGY] +} diff --git a/lib/new-admin/graphql/modules/authentication.js b/lib/new-admin/graphql/modules/userManagement.js similarity index 94% rename from lib/new-admin/graphql/modules/authentication.js rename to lib/new-admin/graphql/modules/userManagement.js index c5be75b6..eeee2a26 100644 --- a/lib/new-admin/graphql/modules/authentication.js +++ b/lib/new-admin/graphql/modules/userManagement.js @@ -1,5 +1,6 @@ const otplib = require('otplib') const argon2 = require('argon2') +const _ = require('lodash/fp') const constants = require('../../../constants') const authTokens = require('../../../auth-tokens') @@ -8,6 +9,7 @@ const T = require('../../../time') const users = require('../../../users') const sessionManager = require('../../../session-manager') const authErrors = require('../errors/authentication') +const credentials = require('../../../hardware-credentials') const REMEMBER_ME_AGE = 90 * T.day @@ -140,10 +142,14 @@ const deleteSession = (sessionID, context) => { } const login = (username, password) => { - return authenticateUser(username, password).then(user => { - const twoFASecret = user.twofa_code - return twoFASecret ? 'INPUT2FA' : 'SETUP2FA' - }) + return authenticateUser(username, password) + .then(user => { + return Promise.all([credentials.getHardwareCredentialsByUserId(user.id), user.twofa_code]) + }) + .then(([devices, twoFASecret]) => { + if (!_.isEmpty(devices)) return 'FIDO' + return twoFASecret ? 'INPUT2FA' : 'SETUP2FA' + }) } const input2FA = (username, password, rememberMe, code, context) => { @@ -234,6 +240,7 @@ const reset2FA = (token, userID, code, context) => { } module.exports = { + authenticateUser, getUserData, get2FASecret, confirm2FA, diff --git a/lib/new-admin/graphql/resolvers/users.resolver.js b/lib/new-admin/graphql/resolvers/users.resolver.js index 149a8252..6f5f8b3d 100644 --- a/lib/new-admin/graphql/resolvers/users.resolver.js +++ b/lib/new-admin/graphql/resolvers/users.resolver.js @@ -1,34 +1,91 @@ const authentication = require('../modules/authentication') +const userManagement = require('../modules/userManagement') const users = require('../../../users') const sessionManager = require('../../../session-manager') +const getAttestationQueryOptions = variables => { + switch (authentication.CHOSEN_STRATEGY) { + case 'FIDO2FA': + return { userId: variables.userID } + case 'FIDOPasswordless': + return { userId: variables.userID } + case 'FIDOUsernameless': + return { userId: variables.userID } + default: + return {} + } +} + +const getAssertionQueryOptions = variables => { + switch (authentication.CHOSEN_STRATEGY) { + case 'FIDO2FA': + return { username: variables.username, password: variables.password } + case 'FIDOPasswordless': + return { username: variables.username } + case 'FIDOUsernameless': + return {} + default: + return {} + } +} + +const getAttestationMutationOptions = variables => { + switch (authentication.CHOSEN_STRATEGY) { + case 'FIDO2FA': + return { userId: variables.userID, attestationResponse: variables.attestationResponse } + case 'FIDOPasswordless': + return { userId: variables.userID, attestationResponse: variables.attestationResponse } + case 'FIDOUsernameless': + return { userId: variables.userID, attestationResponse: variables.attestationResponse } + default: + return {} + } +} + +const getAssertionMutationOptions = variables => { + switch (authentication.CHOSEN_STRATEGY) { + case 'FIDO2FA': + return { username: variables.username, password: variables.password, rememberMe: variables.rememberMe, assertionResponse: variables.assertionResponse } + case 'FIDOPasswordless': + return { username: variables.username, rememberMe: variables.rememberMe, assertionResponse: variables.assertionResponse } + case 'FIDOUsernameless': + return { assertionResponse: variables.assertionResponse } + default: + return {} + } +} + const resolver = { Query: { users: () => users.getUsers(), sessions: () => sessionManager.getSessions(), userSessions: (...[, { username }]) => sessionManager.getSessionsByUsername(username), - userData: (...[, {}, context]) => authentication.getUserData(context), - get2FASecret: (...[, { username, password }]) => authentication.get2FASecret(username, password), - confirm2FA: (...[, { code }, context]) => authentication.confirm2FA(code, context), - validateRegisterLink: (...[, { token }]) => authentication.validateRegisterLink(token), - validateResetPasswordLink: (...[, { token }]) => authentication.validateResetPasswordLink(token), - validateReset2FALink: (...[, { token }]) => authentication.validateReset2FALink(token) + userData: (...[, {}, context]) => userManagement.getUserData(context), + get2FASecret: (...[, { username, password }]) => userManagement.get2FASecret(username, password), + confirm2FA: (...[, { code }, context]) => userManagement.confirm2FA(code, context), + validateRegisterLink: (...[, { token }]) => userManagement.validateRegisterLink(token), + validateResetPasswordLink: (...[, { token }]) => userManagement.validateResetPasswordLink(token), + validateReset2FALink: (...[, { token }]) => userManagement.validateReset2FALink(token), + generateAttestationOptions: (...[, variables, context]) => authentication.strategy.generateAttestationOptions(context.req.session, getAttestationQueryOptions(variables)), + generateAssertionOptions: (...[, variables, context]) => authentication.strategy.generateAssertionOptions(context.req.session, getAssertionQueryOptions(variables)) }, Mutation: { - enableUser: (...[, { confirmationCode, id }, context]) => authentication.enableUser(confirmationCode, id, context), - disableUser: (...[, { confirmationCode, id }, context]) => authentication.disableUser(confirmationCode, id, context), - deleteSession: (...[, { sid }, context]) => authentication.deleteSession(sid, context), + enableUser: (...[, { confirmationCode, id }, context]) => userManagement.enableUser(confirmationCode, id, context), + disableUser: (...[, { confirmationCode, id }, context]) => userManagement.disableUser(confirmationCode, id, context), + deleteSession: (...[, { sid }, context]) => userManagement.deleteSession(sid, context), deleteUserSessions: (...[, { username }]) => sessionManager.deleteSessionsByUsername(username), - changeUserRole: (...[, { confirmationCode, id, newRole }, context]) => authentication.changeUserRole(confirmationCode, id, newRole, context), - login: (...[, { username, password }]) => authentication.login(username, password), - input2FA: (...[, { username, password, rememberMe, code }, context]) => authentication.input2FA(username, password, rememberMe, code, context), - setup2FA: (...[, { username, password, rememberMe, codeConfirmation }, context]) => authentication.setup2FA(username, password, rememberMe, codeConfirmation, context), - createResetPasswordToken: (...[, { confirmationCode, userID }, context]) => authentication.createResetPasswordToken(confirmationCode, userID, context), - createReset2FAToken: (...[, { confirmationCode, userID }, context]) => authentication.createReset2FAToken(confirmationCode, userID, context), - createRegisterToken: (...[, { username, role }]) => authentication.createRegisterToken(username, role), - register: (...[, { token, username, password, role }]) => authentication.register(token, username, password, role), - resetPassword: (...[, { token, userID, newPassword }, context]) => authentication.resetPassword(token, userID, newPassword, context), - reset2FA: (...[, { token, userID, code }, context]) => authentication.reset2FA(token, userID, code, context) + changeUserRole: (...[, { confirmationCode, id, newRole }, context]) => userManagement.changeUserRole(confirmationCode, id, newRole, context), + login: (...[, { username, password }]) => userManagement.login(username, password), + input2FA: (...[, { username, password, rememberMe, code }, context]) => userManagement.input2FA(username, password, rememberMe, code, context), + setup2FA: (...[, { username, password, rememberMe, codeConfirmation }, context]) => userManagement.setup2FA(username, password, rememberMe, codeConfirmation, context), + createResetPasswordToken: (...[, { confirmationCode, userID }, context]) => userManagement.createResetPasswordToken(confirmationCode, userID, context), + createReset2FAToken: (...[, { confirmationCode, userID }, context]) => userManagement.createReset2FAToken(confirmationCode, userID, context), + createRegisterToken: (...[, { username, role }]) => userManagement.createRegisterToken(username, role), + register: (...[, { token, username, password, role }]) => userManagement.register(token, username, password, role), + resetPassword: (...[, { token, userID, newPassword }, context]) => userManagement.resetPassword(token, userID, newPassword, context), + reset2FA: (...[, { token, userID, code }, context]) => userManagement.reset2FA(token, userID, code, context), + validateAttestation: (...[, variables, context]) => authentication.strategy.validateAttestation(context.req.session, getAttestationMutationOptions(variables)), + validateAssertion: (...[, variables, context]) => authentication.strategy.validateAssertion(context.req.session, getAssertionMutationOptions(variables)) } } diff --git a/lib/new-admin/graphql/types/users.type.js b/lib/new-admin/graphql/types/users.type.js index 9362d747..a1a63394 100644 --- a/lib/new-admin/graphql/types/users.type.js +++ b/lib/new-admin/graphql/types/users.type.js @@ -1,3 +1,37 @@ +const authentication = require('../modules/authentication') + +const getFIDOStrategyQueryTypes = () => { + switch (authentication.CHOSEN_STRATEGY) { + case 'FIDO2FA': + return `generateAttestationOptions(userID: ID!): JSONObject + generateAssertionOptions(username: String!, password: String!): JSONObject` + case 'FIDOPasswordless': + return `generateAttestationOptions(userID: ID!): JSONObject + generateAssertionOptions(username: String!): JSONObject` + case 'FIDOUsernameless': + return `generateAttestationOptions(userID: ID!): JSONObject + generateAssertionOptions: JSONObject` + default: + return `` + } +} + +const getFIDOStrategyMutationsTypes = () => { + switch (authentication.CHOSEN_STRATEGY) { + case 'FIDO2FA': + return `validateAttestation(userID: ID!, attestationResponse: JSONObject!): Boolean + validateAssertion(username: String!, password: String!, rememberMe: Boolean!, assertionResponse: JSONObject!): Boolean` + case 'FIDOPasswordless': + return `validateAttestation(userID: ID!, attestationResponse: JSONObject!): Boolean + validateAssertion(username: String!, rememberMe: Boolean!, assertionResponse: JSONObject!): Boolean` + case 'FIDOUsernameless': + return `validateAttestation(userID: ID!, attestationResponse: JSONObject!): Boolean + validateAssertion(assertionResponse: JSONObject!): Boolean` + default: + return `` + } +} + const typeDef = ` directive @auth( requires: [Role] = [USER, SUPERUSER] @@ -54,6 +88,7 @@ const typeDef = ` validateRegisterLink(token: String!): User validateResetPasswordLink(token: String!): User validateReset2FALink(token: String!): TwoFactorSecret + ${getFIDOStrategyQueryTypes()} } type Mutation { @@ -72,6 +107,7 @@ const typeDef = ` register(token: String!, username: String!, password: String!, role: String!): Boolean resetPassword(token: String!, userID: ID!, newPassword: String!): Boolean reset2FA(token: String!, userID: ID!, code: String!): Boolean + ${getFIDOStrategyMutationsTypes()} } ` diff --git a/migrations/1620335170327-hardware-credentials.js b/migrations/1620335170327-hardware-credentials.js new file mode 100644 index 00000000..a3a2b88f --- /dev/null +++ b/migrations/1620335170327-hardware-credentials.js @@ -0,0 +1,20 @@ +var db = require('./db') + +exports.up = function (next) { + var sql = [ + `ALTER TABLE user_register_tokens ADD COLUMN use_fido BOOLEAN DEFAULT false`, + `CREATE TABLE hardware_credentials ( + id UUID PRIMARY KEY NOT NULL, + user_id UUID REFERENCES users(id) NOT NULL, + created TIMESTAMPTZ DEFAULT now(), + last_used TIMESTAMPTZ DEFAULT now(), + data JSONB NOT NULL + )` + ] + + db.multi(sql, next) +} + +exports.down = function (next) { + next() +} diff --git a/new-lamassu-admin/package-lock.json b/new-lamassu-admin/package-lock.json index 9f9c3f31..cad5fe31 100644 --- a/new-lamassu-admin/package-lock.json +++ b/new-lamassu-admin/package-lock.json @@ -17172,25 +17172,6 @@ } } }, - "keccak": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/keccak/-/keccak-3.0.2.tgz", - "integrity": "sha512-PyKKjkH53wDMLGrvmRGSNWgmSxZOUqbnXwKL9tmgbFYA1iAYqW21kfR7mZXV0MlESiefxQQE9X9fTa3X+2MPDQ==", - "requires": { - "node-addon-api": "^2.0.0", - "node-gyp-build": "^4.2.0", - "readable-stream": "^3.6.0" - } - }, - "keccak256": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/keccak256/-/keccak256-1.0.3.tgz", - "integrity": "sha512-EkF/4twuPm1V/gn75nejOUrKfDUJn87RMLzDWosXF3pXuOvesiSgX35GcmbqzdImCASEkE/WaklWGWSa+Ha5bQ==", - "requires": { - "bn.js": "^4.11.8", - "keccak": "^3.0.1" - } - }, "killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", @@ -17232,14 +17213,12 @@ "from": "git+https://github.com/lamassu/lamassu-coins.git", "requires": { "bech32": "2.0.0", - "big-integer": "^1.6.48", "bignumber.js": "^9.0.0", "bitcoinjs-lib": "4.0.3", "bs58check": "^2.0.2", "cashaddrjs": "~0.2.8", "crypto-js": "^3.1.9-1", "ethereumjs-icap": "^0.3.1", - "keccak256": "^1.0.2", "lodash": "^4.17.10" }, "dependencies": { @@ -18486,11 +18465,6 @@ } } }, - "node-addon-api": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz", - "integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==" - }, "node-dir": { "version": "0.1.17", "resolved": "https://registry.npmjs.org/node-dir/-/node-dir-0.1.17.tgz", @@ -18512,11 +18486,6 @@ "integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==", "dev": true }, - "node-gyp-build": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.3.0.tgz", - "integrity": "sha512-iWjXZvmboq0ja1pUGULQBexmxq8CV4xBhX7VDOTbL7ZR4FOowwY/VOtRxBN/yKxmdGoIp4j5ysNT4u3S2pDQ3Q==" - }, "node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", diff --git a/new-lamassu-admin/src/pages/Authentication/InputFIDOState.js b/new-lamassu-admin/src/pages/Authentication/InputFIDOState.js new file mode 100644 index 00000000..69ac1588 --- /dev/null +++ b/new-lamassu-admin/src/pages/Authentication/InputFIDOState.js @@ -0,0 +1,215 @@ +import { useMutation, useLazyQuery } from '@apollo/react-hooks' +import { makeStyles } from '@material-ui/core' +import { startAssertion } from '@simplewebauthn/browser' +import { Field, Form, Formik } from 'formik' +import gql from 'graphql-tag' +import React, { useState, useContext } from 'react' +import { useHistory } from 'react-router-dom' +import * as Yup from 'yup' + +import AppContext from 'src/AppContext' +import { Button } from 'src/components/buttons' +import { Checkbox, TextInput } from 'src/components/inputs/formik' +import { H2, Label2, P } from 'src/components/typography' + +import styles from './shared.styles' + +const useStyles = makeStyles(styles) + +const GET_USER_DATA = gql` + { + userData { + id + username + role + } + } +` + +const validationSchema = Yup.object().shape({ + localClient: Yup.string() + .required('Client field is required!') + .email('Username field should be in an email format!'), + localRememberMe: Yup.boolean() +}) + +const initialValues = { + localClient: '', + localRememberMe: false +} + +const InputFIDOState = ({ state, strategy }) => { + const GENERATE_ASSERTION = gql` + query generateAssertionOptions($username: String!${ + strategy === 'FIDO2FA' ? `, $password: String!` : `` + }) { + generateAssertionOptions(username: $username${ + strategy === 'FIDO2FA' ? `, password: $password` : `` + }) + } + ` + + const VALIDATE_ASSERTION = gql` + mutation validateAssertion( + $username: String! + ${strategy === 'FIDO2FA' ? `, $password: String!` : ``} + $rememberMe: Boolean! + $assertionResponse: JSONObject! + ) { + validateAssertion( + username: $username + ${strategy === 'FIDO2FA' ? `password: $password` : ``} + rememberMe: $rememberMe + assertionResponse: $assertionResponse + ) + } + ` + + const classes = useStyles() + const history = useHistory() + const { setUserData } = useContext(AppContext) + + const [localClientField, setLocalClientField] = useState('') + const [localRememberMeField, setLocalRememberMeField] = useState(false) + const [invalidUsername, setInvalidUsername] = useState(false) + const [invalidToken, setInvalidToken] = useState(false) + + const [validateAssertion, { error: mutationError }] = useMutation( + VALIDATE_ASSERTION, + { + onCompleted: ({ validateAssertion: success }) => { + success ? getUserData() : setInvalidToken(true) + } + } + ) + + const [assertionOptions, { error: assertionQueryError }] = useLazyQuery( + GENERATE_ASSERTION, + { + variables: + strategy === 'FIDO2FA' + ? { + username: state.clientField, + password: state.passwordField + } + : { + username: localClientField + }, + onCompleted: ({ generateAssertionOptions: options }) => { + startAssertion(options) + .then(res => { + const variables = + strategy === 'FIDO2FA' + ? { + username: state.clientField, + password: state.passwordField, + rememberMe: state.rememberMeField, + assertionResponse: res + } + : { + username: localClientField, + rememberMe: localRememberMeField, + assertionResponse: res + } + validateAssertion({ + variables + }) + }) + .catch(err => { + console.error(err) + setInvalidToken(true) + }) + } + } + ) + + const [getUserData, { error: queryError }] = useLazyQuery(GET_USER_DATA, { + onCompleted: ({ userData }) => { + setUserData(userData) + history.push('/') + } + }) + + const getErrorMsg = (formikErrors, formikTouched) => { + if (!formikErrors || !formikTouched) return null + if (assertionQueryError || queryError || mutationError) + return 'Internal server error' + if (formikErrors.client && formikTouched.client) return formikErrors.client + if (invalidUsername) return 'Invalid login.' + if (invalidToken) return 'Code is invalid. Please try again.' + return null + } + + return ( + <> + {strategy === 'FIDOPasswordless' && ( + { + setInvalidUsername(false) + setLocalClientField(values.localClient) + setLocalRememberMeField(values.localRememberMe) + assertionOptions() + }}> + {({ errors, touched }) => ( +
+ { + if (invalidUsername) setInvalidUsername(false) + }} + /> +
+ + + Keep me logged in + +
+
+ {getErrorMsg(errors, touched) && ( +

+ {getErrorMsg(errors, touched)} +

+ )} + +
+ + )} +
+ )} + {strategy === 'FIDO2FA' && ( + <> +

+ Insert your hardware key and follow the instructions +

+ + + )} + + ) +} + +export default InputFIDOState diff --git a/new-lamassu-admin/src/pages/Authentication/LoginCard.js b/new-lamassu-admin/src/pages/Authentication/LoginCard.js index 6dc90c06..e1dc5aef 100644 --- a/new-lamassu-admin/src/pages/Authentication/LoginCard.js +++ b/new-lamassu-admin/src/pages/Authentication/LoginCard.js @@ -6,11 +6,15 @@ import { H5 } from 'src/components/typography' import { ReactComponent as Logo } from 'src/styling/icons/menu/logo.svg' import Input2FAState from './Input2FAState' +import InputFIDOState from './InputFIDOState' import LoginState from './LoginState' import Setup2FAState from './Setup2FAState' import styles from './shared.styles' import { STATES } from './states' +// FIDO2FA, FIDOPasswordless or FIDOUsernameless +const AUTHENTICATION_STRATEGY = 'FIDO2FA' + const useStyles = makeStyles(styles) const initialState = { @@ -34,11 +38,21 @@ const LoginCard = () => { const renderState = () => { switch (state.loginState) { case STATES.LOGIN: - return + return ( + + ) case STATES.INPUT_2FA: return case STATES.SETUP_2FA: return + case STATES.FIDO: + return ( + + ) default: break } diff --git a/new-lamassu-admin/src/pages/Authentication/LoginState.js b/new-lamassu-admin/src/pages/Authentication/LoginState.js index 6f83fc26..aaa91fe9 100644 --- a/new-lamassu-admin/src/pages/Authentication/LoginState.js +++ b/new-lamassu-admin/src/pages/Authentication/LoginState.js @@ -1,17 +1,19 @@ -import { useMutation } from '@apollo/react-hooks' +import { useMutation, useLazyQuery } from '@apollo/react-hooks' import { makeStyles } from '@material-ui/core/styles' +import { startAssertion } from '@simplewebauthn/browser' import base64 from 'base-64' import { Field, Form, Formik } from 'formik' import gql from 'graphql-tag' -import React from 'react' +import React, { useContext } from 'react' +import { useHistory } from 'react-router-dom' import * as Yup from 'yup' +import AppContext from 'src/AppContext' import { Button } from 'src/components/buttons' import { Checkbox, SecretInput, TextInput } from 'src/components/inputs/formik' import { Label3, P } from 'src/components/typography' import styles from './shared.styles' -import { STATES } from './states' const useStyles = makeStyles(styles) @@ -21,6 +23,28 @@ const LOGIN = gql` } ` +const GENERATE_ASSERTION = gql` + query generateAssertionOptions { + generateAssertionOptions + } +` + +const VALIDATE_ASSERTION = gql` + mutation validateAssertion($assertionResponse: JSONObject!) { + validateAssertion(assertionResponse: $assertionResponse) + } +` + +const GET_USER_DATA = gql` + { + userData { + id + username + role + } + } +` + const validationSchema = Yup.object().shape({ client: Yup.string() .required('Client field is required!') @@ -44,10 +68,12 @@ const getErrorMsg = (formikErrors, formikTouched, mutationError) => { return null } -const LoginState = ({ state, dispatch }) => { +const LoginState = ({ state, dispatch, strategy }) => { const classes = useStyles() + const history = useHistory() + const { setUserData } = useContext(AppContext) - const [login, { error: mutationError }] = useMutation(LOGIN) + const [login, { error: loginMutationError }] = useMutation(LOGIN) const submitLogin = async (username, password, rememberMe) => { const options = { @@ -65,11 +91,8 @@ const LoginState = ({ state, dispatch }) => { if (!loginResponse.login) return - const stateVar = - loginResponse.login === 'INPUT2FA' ? STATES.INPUT_2FA : STATES.SETUP_2FA - return dispatch({ - type: stateVar, + type: loginResponse.login, payload: { clientField: username, passwordField: password, @@ -78,6 +101,42 @@ const LoginState = ({ state, dispatch }) => { }) } + const [validateAssertion, { error: FIDOMutationError }] = useMutation( + VALIDATE_ASSERTION, + { + onCompleted: ({ validateAssertion: success }) => success && getUserData() + } + ) + + const [assertionOptions, { error: assertionQueryError }] = useLazyQuery( + GENERATE_ASSERTION, + { + onCompleted: ({ generateAssertionOptions: options }) => { + startAssertion(options) + .then(res => { + validateAssertion({ + variables: { + assertionResponse: res + } + }) + }) + .catch(err => { + console.error(err) + }) + } + } + ) + + const [getUserData, { error: userDataQueryError }] = useLazyQuery( + GET_USER_DATA, + { + onCompleted: ({ userData }) => { + setUserData(userData) + history.push('/') + } + } + ) + return ( { fullWidth autoFocus className={classes.input} - error={getErrorMsg(errors, touched, mutationError)} + error={getErrorMsg( + errors, + touched, + loginMutationError || + FIDOMutationError || + assertionQueryError || + userDataQueryError + )} /> { component={SecretInput} label="Password" fullWidth - error={getErrorMsg(errors, touched, mutationError)} + error={getErrorMsg( + errors, + touched, + loginMutationError || + FIDOMutationError || + assertionQueryError || + userDataQueryError + )} />
{ Keep me logged in
- {getErrorMsg(errors, touched, mutationError) && ( + {getErrorMsg( + errors, + touched, + loginMutationError || + FIDOMutationError || + assertionQueryError || + userDataQueryError + ) && (

- {getErrorMsg(errors, touched, mutationError)} + {getErrorMsg( + errors, + touched, + loginMutationError || + FIDOMutationError || + assertionQueryError || + userDataQueryError + )}

)} + {strategy !== 'FIDO2FA' && ( + + )}