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' && (
+
- {getErrorMsg(errors, touched, mutationError)} + {getErrorMsg( + errors, + touched, + loginMutationError || + FIDOMutationError || + assertionQueryError || + userDataQueryError + )}
)} + {strategy !== 'FIDO2FA' && ( + + )}