feat: login + TFA authentication

This commit is contained in:
NGPixel
2018-01-09 20:41:53 -05:00
parent 85717bd369
commit cb0d86906f
14 changed files with 402 additions and 44 deletions

View File

@@ -53,6 +53,9 @@ configNamespaces:
- site
- theme
- uploads
localeNamespaces:
- auth
- common
queues:
- gitSync
- uplClearTemp

View File

@@ -17,7 +17,12 @@ module.exports = {
usernameField: 'email',
passwordField: 'password'
}, (uEmail, uPassword, done) => {
wiki.db.User.findOne({ email: uEmail, provider: 'local' }).then((user) => {
wiki.db.User.findOne({
where: {
email: uEmail,
provider: 'local'
}
}).then((user) => {
if (user) {
return user.validatePassword(uPassword).then(() => {
return done(null, user) || true
@@ -25,7 +30,7 @@ module.exports = {
return done(err, null)
})
} else {
return done(new Error('INVALID_LOGIN'), null)
return done(new wiki.Error.AuthLoginFailed(), null)
}
}).catch((err) => {
done(err, null)

30
server/helpers/error.js Normal file
View File

@@ -0,0 +1,30 @@
class BaseError extends Error {
constructor (message) {
super(message)
this.name = this.constructor.name
Error.captureStackTrace(this, this.constructor)
}
}
class AuthGenericError extends BaseError { constructor (message = 'An unexpected error occured during login.') { super(message) } }
class AuthLoginFailed extends BaseError { constructor (message = 'Invalid email / username or password.') { super(message) } }
class AuthProviderInvalid extends BaseError { constructor (message = 'Invalid authentication provider.') { super(message) } }
class AuthTFAFailed extends BaseError { constructor (message = 'Incorrect TFA Security Code.') { super(message) } }
class AuthTFAInvalid extends BaseError { constructor (message = 'Invalid TFA Security Code or Login Token.') { super(message) } }
class BruteInstanceIsInvalid extends BaseError { constructor (message = 'Invalid Brute Force Instance.') { super(message) } }
class BruteTooManyAttempts extends BaseError { constructor (message = 'Too many attempts! Try again later.') { super(message) } }
class LocaleInvalidNamespace extends BaseError { constructor (message = 'Invalid locale or namespace.') { super(message) } }
class UserCreationFailed extends BaseError { constructor (message = 'An unexpected error occured during user creation.') { super(message) } }
module.exports = {
BaseError,
AuthGenericError,
AuthLoginFailed,
AuthProviderInvalid,
AuthTFAFailed,
AuthTFAInvalid,
BruteInstanceIsInvalid,
BruteTooManyAttempts,
LocaleInvalidNamespace,
UserCreationFailed
}

View File

@@ -1,15 +1,25 @@
'use strict'
/* global appdata, appconfig */
const _ = require('lodash')
const Promise = require('bluebird')
const crypto = require('crypto')
module.exports = {
sanitizeCommitUser (user) {
let wlist = new RegExp('[^a-zA-Z0-9-_.\',& ' + appdata.regex.cjk + appdata.regex.arabic + ']', 'g')
return {
name: _.chain(user.name).replace(wlist, '').trim().value(),
email: appconfig.git.showUserEmail ? user.email : appconfig.git.serverEmail
}
// let wlist = new RegExp('[^a-zA-Z0-9-_.\',& ' + appdata.regex.cjk + appdata.regex.arabic + ']', 'g')
// return {
// name: _.chain(user.name).replace(wlist, '').trim().value(),
// email: appconfig.git.showUserEmail ? user.email : appconfig.git.serverEmail
// }
},
/**
* Generate a random token
*
* @param {any} length
* @returns
*/
async generateToken (length) {
return Promise.fromCallback(clb => {
crypto.randomBytes(length, clb)
}).then(buf => {
return buf.toString('hex')
})
}
}

View File

@@ -11,6 +11,7 @@ let wiki = {
IS_MASTER: cluster.isMaster,
ROOTPATH: process.cwd(),
SERVERPATH: path.join(process.cwd(), 'server'),
Error: require('./helpers/error'),
configSvc: require('./modules/config'),
kernel: require('./modules/kernel')
}

View File

@@ -114,7 +114,17 @@ module.exports = async () => {
app.use('/', ctrl.auth)
app.use('/graphql', graphqlApollo.graphqlExpress({ schema: graphqlSchema }))
app.use('/graphql', (req, res, next) => {
graphqlApollo.graphqlExpress({
schema: graphqlSchema,
context: { req, res },
formatError: (err) => {
return {
message: err.message
}
}
})(req, res, next)
})
app.use('/graphiql', graphqlApollo.graphiqlExpress({ endpointURL: '/graphql' }))
// app.use('/uploads', mw.auth, ctrl.uploads)
app.use('/admin', mw.auth, ctrl.admin)

View File

@@ -1,8 +1,10 @@
/* global wiki, appconfig */
/* global wiki */
const Promise = require('bluebird')
const bcrypt = require('bcryptjs-then')
const _ = require('lodash')
const tfa = require('node-2fa')
const securityHelper = require('../helpers/security')
/**
* Users schema
@@ -56,10 +58,108 @@ module.exports = (sequelize, DataTypes) => {
]
})
userSchema.prototype.validatePassword = function (rawPwd) {
return bcrypt.compare(rawPwd, this.password).then((isValid) => {
return (isValid) ? true : Promise.reject(new Error(wiki.lang.t('auth:errors:invalidlogin')))
userSchema.prototype.validatePassword = async function (rawPwd) {
if (await bcrypt.compare(rawPwd, this.password) === true) {
return true
} else {
throw new wiki.Error.AuthLoginFailed()
}
}
userSchema.prototype.enableTFA = async function () {
let tfaInfo = tfa.generateSecret({
name: wiki.config.site.title
})
this.tfaIsActive = true
this.tfaSecret = tfaInfo.secret
return this.save()
}
userSchema.prototype.disableTFA = async function () {
this.tfaIsActive = false
this.tfaSecret = ''
return this.save()
}
userSchema.prototype.verifyTFA = function (code) {
let result = tfa.verifyToken(this.tfaSecret, code)
console.info(result)
return (result && _.has(result, 'delta') && result.delta === 0)
}
userSchema.login = async (opts, context) => {
if (_.has(wiki.config.auth.strategies, opts.provider)) {
_.set(context.req, 'body.email', opts.username)
_.set(context.req, 'body.password', opts.password)
// Authenticate
return new Promise((resolve, reject) => {
wiki.auth.passport.authenticate(opts.provider, async (err, user, info) => {
if (err) { return reject(err) }
if (!user) { return reject(new wiki.Error.AuthLoginFailed()) }
// Is 2FA required?
if (user.tfaIsActive) {
try {
let loginToken = await securityHelper.generateToken(32)
await wiki.redis.set(`tfa:${loginToken}`, user.id, 'EX', 600)
return resolve({
succeeded: true,
message: 'Login Successful. Awaiting 2FA security code.',
tfaRequired: true,
tfaLoginToken: loginToken
})
} catch (err) {
wiki.logger.warn(err)
return reject(new wiki.Error.AuthGenericError())
}
} else {
// No 2FA, log in user
return context.req.logIn(user, err => {
if (err) { return reject(err) }
resolve({
succeeded: true,
message: 'Login Successful',
tfaRequired: false
})
})
}
})(context.req, context.res, () => {})
})
} else {
throw new wiki.Error.AuthProviderInvalid()
}
}
userSchema.loginTFA = async (opts, context) => {
if (opts.securityCode.length === 6 && opts.loginToken.length === 64) {
console.info(opts.loginToken)
let result = await wiki.redis.get(`tfa:${opts.loginToken}`)
console.info(result)
if (result) {
console.info('DUDE2')
let userId = _.toSafeInteger(result)
if (userId && userId > 0) {
console.info('DUDE3')
let user = await wiki.db.User.findById(userId)
if (user && user.verifyTFA(opts.securityCode)) {
console.info('DUDE4')
return Promise.fromCallback(clb => {
context.req.logIn(user, clb)
}).return({
succeeded: true,
message: 'Login Successful'
}).catch(err => {
wiki.logger.warn(err)
throw new wiki.Error.AuthGenericError()
})
} else {
throw new wiki.Error.AuthTFAFailed()
}
}
}
}
throw new wiki.Error.AuthTFAInvalid()
}
userSchema.processProfile = (profile) => {
@@ -92,7 +192,7 @@ module.exports = (sequelize, DataTypes) => {
new: true
}).then((user) => {
// Handle unregistered accounts
if (!user && profile.provider !== 'local' && (appconfig.auth.defaultReadAccess || profile.provider === 'ldap' || profile.provider === 'azure')) {
if (!user && profile.provider !== 'local' && (wiki.config.auth.defaultReadAccess || profile.provider === 'ldap' || profile.provider === 'azure')) {
let nUsr = {
email: primaryEmail,
provider: profile.provider,

View File

@@ -13,7 +13,7 @@ module.exports = {
// Serialization user methods
passport.serializeUser(function (user, done) {
done(null, user._id)
done(null, user.id)
})
passport.deserializeUser(function (id, done) {

View File

@@ -9,8 +9,9 @@ const Promise = require('bluebird')
module.exports = {
engine: null,
namespaces: ['common', 'admin', 'auth', 'errors', 'git'],
namespaces: [],
init() {
this.namespaces = wiki.data.localeNamespaces
this.engine = i18next
this.engine.use(i18nBackend).init({
load: 'languageOnly',
@@ -21,12 +22,12 @@ module.exports = {
lng: wiki.config.site.lang,
fallbackLng: 'en',
backend: {
loadPath: path.join(wiki.SERVERPATH, 'locales/{{lng}}/{{ns}}.json')
loadPath: path.join(wiki.SERVERPATH, 'locales/{{lng}}/{{ns}}.yml')
}
})
return this
},
getByNamespace(locale, namespace) {
async getByNamespace(locale, namespace) {
if (this.engine.hasResourceBundle(locale, namespace)) {
let data = this.engine.getResourceBundle(locale, namespace)
return _.map(dotize.convert(data), (value, key) => {
@@ -39,12 +40,12 @@ module.exports = {
throw new Error('Invalid locale or namespace')
}
},
loadLocale(locale) {
async loadLocale(locale) {
return Promise.fromCallback(cb => {
return this.engine.loadLanguages(locale, cb)
})
},
setCurrentLocale(locale) {
async setCurrentLocale(locale) {
return Promise.fromCallback(cb => {
return this.engine.changeLanguage(locale, cb)
})

View File

@@ -19,6 +19,22 @@ module.exports = {
limit: 1
})
},
login(obj, args, context) {
return wiki.db.User.login(args, context).catch(err => {
return {
succeeded: false,
message: err.message
}
})
},
loginTFA(obj, args, context) {
return wiki.db.User.loginTFA(args, context).catch(err => {
return {
succeeded: false,
message: err.message
}
})
},
modifyUser(obj, args) {
return wiki.db.User.update({
email: args.email,

View File

@@ -148,8 +148,16 @@ type User implements Base {
}
type OperationResult {
succeded: Boolean!
succeeded: Boolean!
message: String
data: String
}
type LoginResult {
succeeded: Boolean!
message: String
tfaRequired: Boolean
tfaLoginToken: String
}
# Query (Read)
@@ -249,6 +257,17 @@ type Mutation {
id: Int!
): OperationResult
login(
username: String!
password: String!
provider: String!
): LoginResult
loginTFA(
loginToken: String!
securityCode: String!
): OperationResult
modifyComment(
id: Int!
content: String!