diff --git a/lib/hardware-credentials.js b/lib/hardware-credentials.js new file mode 100644 index 00000000..36dd1f63 --- /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 getHardwareCredentialsOfUser (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, + getHardwareCredentialsOfUser, + 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..cd54e67c --- /dev/null +++ b/lib/new-admin/graphql/modules/authentication/FIDO2FAStrategy.js @@ -0,0 +1,165 @@ +const simpleWebauthn = require('@simplewebauthn/server') +const base64url = require('base64url') +const _ = require('lodash/fp') + +const userManagement = require('../userManagement') +const credentials = require('../../../../hardware-credentials') +const T = require('../../../../time') +const users = require('../../../../users') + +const REMEMBER_ME_AGE = 90 * T.day + +const rpID = `localhost` +const expectedOrigin = `https://${rpID}:3001` + +const generateAttestationOptions = (userID, session) => { + return users.getUserById(userID).then(user => { + return Promise.all([credentials.getHardwareCredentialsOfUser(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 = (username, password, context) => { + return userManagement.authenticateUser(username, password).then(user => { + return credentials.getHardwareCredentialsOfUser(user.id).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 + }) + + context.req.session.webauthn = { + assertion: { + challenge: options.challenge + } + } + + return options + }) + }) +} + +const validateAttestation = (userID, attestationResponse, context) => { + const webauthnData = context.req.session.webauthn.attestation + const expectedChallenge = webauthnData.challenge + + return users.getUserById(userID).then(user => { + return simpleWebauthn.verifyAttestationResponse({ + credential: attestationResponse, + expectedChallenge: `${expectedChallenge}`, + expectedOrigin, + expectedRPID: rpID + }).then(async verification => { + const { verified, attestationInfo } = verification + + if (verified && attestationInfo) { + const { + counter, + credentialPublicKey, + credentialID + } = attestationInfo + + const userDevices = await credentials.getHardwareCredentialsOfUser(user.id) + const existingDevice = userDevices.find(device => device.data.credentialID === credentialID) + + if (!existingDevice) { + const newDevice = { + counter, + credentialPublicKey, + credentialID + } + credentials.createHardwareCredential(user.id, newDevice) + } + } + + context.req.session.webauthn = null + return verified + }) + }) +} + +const validateAssertion = (username, password, rememberMe, assertionResponse, context) => { + return userManagement.authenticateUser(username, password).then(user => { + const expectedChallenge = context.req.session.webauthn.assertion.challenge + + return credentials.getHardwareCredentialsOfUser(user.id).then(async devices => { + const dbAuthenticator = _.find(dev => { + return Buffer.from(dev.data.credentialID).compare(base64url.toBuffer(assertionResponse.rawId)) === 0 + }, devices) + + if (!dbAuthenticator.data) { + throw new Error(`Could not find authenticator matching ${assertionResponse.id}`) + } + + const convertedAuthenticator = _.merge( + dbAuthenticator.data, + { credentialPublicKey: Buffer.from(dbAuthenticator.data.credentialPublicKey) } + ) + + let verification + try { + verification = simpleWebauthn.verifyAssertionResponse({ + credential: assertionResponse, + expectedChallenge: `${expectedChallenge}`, + expectedOrigin, + expectedRPID: rpID, + authenticator: convertedAuthenticator + }) + } catch (err) { + console.error(err) + return false + } + + const { verified, assertionInfo } = verification + + if (verified) { + dbAuthenticator.data.counter = assertionInfo.newCounter + await credentials.updateHardwareCredential(dbAuthenticator) + + const finalUser = { id: user.id, username: user.username, role: user.role } + context.req.session.user = finalUser + if (rememberMe) context.req.session.cookie.maxAge = REMEMBER_ME_AGE + } + + context.req.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..afe59d43 --- /dev/null +++ b/lib/new-admin/graphql/modules/authentication/FIDOPasswordlessStrategy.js @@ -0,0 +1,164 @@ +const simpleWebauthn = require('@simplewebauthn/server') +const base64url = require('base64url') +const _ = require('lodash/fp') + +const credentials = require('../../../../hardware-credentials') +const T = require('../../../../time') +const users = require('../../../../users') + +const REMEMBER_ME_AGE = 90 * T.day + +const rpID = `localhost` +const expectedOrigin = `https://${rpID}:3001` + +const generateAttestationOptions = (userID, session) => { + return users.getUserById(userID).then(user => { + return Promise.all([credentials.getHardwareCredentialsOfUser(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 = (username, context) => { + return users.getUserByUsername(username).then(user => { + return credentials.getHardwareCredentialsOfUser(user.id).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 + }) + + context.req.session.webauthn = { + assertion: { + challenge: options.challenge + } + } + + return options + }) + }) +} + +const validateAttestation = (userID, attestationResponse, context) => { + const webauthnData = context.req.session.webauthn.attestation + const expectedChallenge = webauthnData.challenge + + return users.getUserById(userID).then(user => { + return simpleWebauthn.verifyAttestationResponse({ + credential: attestationResponse, + expectedChallenge: `${expectedChallenge}`, + expectedOrigin, + expectedRPID: rpID + }).then(async verification => { + const { verified, attestationInfo } = verification + + if (verified && attestationInfo) { + const { + counter, + credentialPublicKey, + credentialID + } = attestationInfo + + const userDevices = await credentials.getHardwareCredentialsOfUser(user.id) + const existingDevice = userDevices.find(device => device.data.credentialID === credentialID) + + if (!existingDevice) { + const newDevice = { + counter, + credentialPublicKey, + credentialID + } + credentials.createHardwareCredential(user.id, newDevice) + } + } + + context.req.session.webauthn = null + return verified + }) + }) +} + +const validateAssertion = (username, rememberMe, assertionResponse, context) => { + return users.getUserByUsername(username).then(user => { + const expectedChallenge = context.req.session.webauthn.assertion.challenge + + return credentials.getHardwareCredentialsOfUser(user.id).then(async devices => { + const dbAuthenticator = _.find(dev => { + return Buffer.from(dev.data.credentialID).compare(base64url.toBuffer(assertionResponse.rawId)) === 0 + }, devices) + + if (!dbAuthenticator.data) { + throw new Error(`Could not find authenticator matching ${assertionResponse.id}`) + } + + const convertedAuthenticator = _.merge( + dbAuthenticator.data, + { credentialPublicKey: Buffer.from(dbAuthenticator.data.credentialPublicKey) } + ) + + let verification + try { + verification = simpleWebauthn.verifyAssertionResponse({ + credential: assertionResponse, + expectedChallenge: `${expectedChallenge}`, + expectedOrigin, + expectedRPID: rpID, + authenticator: convertedAuthenticator + }) + } catch (err) { + console.error(err) + return false + } + + const { verified, assertionInfo } = verification + + if (verified) { + dbAuthenticator.data.counter = assertionInfo.newCounter + await credentials.updateHardwareCredential(dbAuthenticator) + + const finalUser = { id: user.id, username: user.username, role: user.role } + context.req.session.user = finalUser + if (rememberMe) context.req.session.cookie.maxAge = REMEMBER_ME_AGE + } + + context.req.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..ca16b8ab --- /dev/null +++ b/lib/new-admin/graphql/modules/authentication/FIDOUsernamelessStrategy.js @@ -0,0 +1,169 @@ +const simpleWebauthn = require('@simplewebauthn/server') +const base64url = require('base64url') +const _ = require('lodash/fp') + +const credentials = require('../../../../hardware-credentials') +const T = require('../../../../time') +const users = require('../../../../users') + +const REMEMBER_ME_AGE = 90 * T.day + +const rpID = `localhost` +const expectedOrigin = `https://${rpID}:3001` + +const generateAttestationOptions = (userID, session) => { + return credentials.getHardwareCredentials().then(devices => { + const options = simpleWebauthn.generateAttestationOptions({ + rpName: 'Lamassu', + rpID, + userName: `Usernameless user created at ${new Date().toISOString()}`, + userID: 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: options.challenge + } + } + + return options + }) +} + +const generateAssertionOptions = context => { + 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 + }) + + context.req.session.webauthn = { + assertion: { + challenge: options.challenge + } + } + return options + }) +} + +const validateAttestation = (userID, attestationResponse, context) => { + const webauthnData = context.req.session.webauthn.attestation + const expectedChallenge = webauthnData.challenge + + return users.getUserById(userID).then(user => { + return simpleWebauthn.verifyAttestationResponse({ + credential: attestationResponse, + expectedChallenge: `${expectedChallenge}`, + expectedOrigin, + expectedRPID: rpID + }).then(async verification => { + const { verified, attestationInfo } = verification + + if (verified && attestationInfo) { + const { + fmt, + counter, + aaguid, + credentialPublicKey, + credentialID, + credentialType, + userVerified, + attestationObject + } = attestationInfo + + const userDevices = await credentials.getHardwareCredentialsOfUser(user.id) + 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) + } + } + + context.req.session.webauthn = null + return verified + }) + }) +} + +const validateAssertion = (assertionResponse, context) => { + const expectedChallenge = context.req.session.webauthn.assertion.challenge + + return credentials.getHardwareCredentials().then(async devices => { + const dbAuthenticator = _.find(dev => { + return Buffer.from(dev.data.credentialID).compare(base64url.toBuffer(assertionResponse.rawId)) === 0 + }, devices) + + if (!dbAuthenticator.data) { + throw new Error(`Could not find authenticator matching ${assertionResponse.id}`) + } + + const convertedAuthenticator = _.merge( + dbAuthenticator.data, + { credentialPublicKey: Buffer.from(dbAuthenticator.data.credentialPublicKey) } + ) + + let verification + try { + verification = simpleWebauthn.verifyAssertionResponse({ + credential: assertionResponse, + expectedChallenge: `${expectedChallenge}`, + expectedOrigin, + expectedRPID: rpID, + authenticator: convertedAuthenticator + }) + } catch (err) { + console.error(err) + return false + } + + const { verified, assertionInfo } = verification + + if (verified) { + dbAuthenticator.data.counter = assertionInfo.newCounter + await credentials.updateHardwareCredential(dbAuthenticator) + + const user = await users.getUserById(dbAuthenticator.user_id) + const finalUser = { id: user.id, username: user.username, role: user.role } + context.req.session.user = finalUser + context.req.session.cookie.maxAge = REMEMBER_ME_AGE + } + + context.req.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..d2836d06 --- /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 = 'FIDOPasswordless' + +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..3ad63b2f 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.getHardwareCredentialsOfUser(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..b01b91e4 100644 --- a/lib/new-admin/graphql/resolvers/users.resolver.js +++ b/lib/new-admin/graphql/resolvers/users.resolver.js @@ -1,34 +1,81 @@ const authentication = require('../modules/authentication') +const userManagement = require('../modules/userManagement') const users = require('../../../users') const sessionManager = require('../../../session-manager') +const getFIDOStrategyQueries = () => { + switch (authentication.CHOSEN_STRATEGY) { + case 'FIDO2FA': + return { + generateAttestationOptions: (...[, { userID }, context]) => authentication.strategy.generateAttestationOptions(userID, context.req.session), + generateAssertionOptions: (...[, { username, password }, context]) => authentication.strategy.generateAssertionOptions(username, password, context) + } + case 'FIDOPasswordless': + return { + generateAttestationOptions: (...[, { userID }, context]) => authentication.strategy.generateAttestationOptions(userID, context.req.session), + generateAssertionOptions: (...[, { username }, context]) => authentication.strategy.generateAssertionOptions(username, context) + } + case 'FIDOUsernameless': + return { + generateAttestationOptions: (...[, { userID }, context]) => authentication.strategy.generateAttestationOptions(userID, context.req.session), + generateAssertionOptions: (...[, { }, context]) => authentication.strategy.generateAssertionOptions(context) + } + default: + return {} + } +} + +const getFIDOStrategyMutations = () => { + switch (authentication.CHOSEN_STRATEGY) { + case 'FIDO2FA': + return { + validateAttestation: (...[, { userID, attestationResponse }, context]) => authentication.strategy.validateAttestation(userID, attestationResponse, context), + validateAssertion: (...[, { username, password, rememberMe, assertionResponse }, context]) => authentication.strategy.validateAssertion(username, password, rememberMe, assertionResponse, context) + } + case 'FIDOPasswordless': + return { + validateAttestation: (...[, { userID, attestationResponse }, context]) => authentication.strategy.validateAttestation(userID, attestationResponse, context), + validateAssertion: (...[, { username, rememberMe, assertionResponse }, context]) => authentication.strategy.validateAssertion(username, rememberMe, assertionResponse, context) + } + case 'FIDOUsernameless': + return { + validateAttestation: (...[, { userID, attestationResponse }, context]) => authentication.strategy.validateAttestation(userID, attestationResponse, context), + validateAssertion: (...[, { assertionResponse }, context]) => authentication.strategy.validateAssertion(assertionResponse, context) + } + 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), + ...getFIDOStrategyQueries() }, 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), + ...getFIDOStrategyMutations() } } 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..73764b16 --- /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, + user_id UUID REFERENCES users(id), + created TIMESTAMPTZ DEFAULT now(), + last_used TIMESTAMPTZ DEFAULT now(), + data JSONB + )` + ] + + db.multi(sql, next) +} + +exports.down = function (next) { + next() +} 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..cba61d7c --- /dev/null +++ b/new-lamassu-admin/src/pages/Authentication/InputFIDOState.js @@ -0,0 +1,214 @@ +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 }) => { + console.log(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 Yubikey and touch it

+ + + )} + + ) +} + +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..8229254b 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 = 'FIDOPasswordless' + 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..394c8ece 100644 --- a/new-lamassu-admin/src/pages/Authentication/LoginState.js +++ b/new-lamassu-admin/src/pages/Authentication/LoginState.js @@ -1,11 +1,14 @@ -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' @@ -21,6 +24,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 +69,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,8 +92,7 @@ const LoginState = ({ state, dispatch }) => { if (!loginResponse.login) return - const stateVar = - loginResponse.login === 'INPUT2FA' ? STATES.INPUT_2FA : STATES.SETUP_2FA + const stateVar = STATES[loginResponse.login] return dispatch({ type: stateVar, @@ -78,6 +104,43 @@ 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 }) => { + console.log(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' && ( + + )}