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

@ -1,20 +1,29 @@
<template lang="pug">
.login(:class='{ "is-error": error }')
.login-container(:class='{ "is-expanded": strategies.length > 1 }')
.login-container(:class='{ "is-expanded": strategies.length > 1, "is-loading": isLoading }')
.login-providers(v-show='strategies.length > 1')
button(v-for='strategy in strategies', :class='{ "is-active": strategy.key === selectedStrategy }', @click='selectStrategy(strategy.key, strategy.useForm)', :title='strategy.title')
em(v-html='strategy.icon')
span {{ strategy.title }}
.login-providers-fill
.login-frame
.login-frame(v-show='screen === "login"')
h1 {{ siteTitle }}
h2 {{ $t('auth:loginrequired') }}
input(type='text', ref='iptEmail', :placeholder='$t("auth:fields.emailuser")')
input(type='password', ref='iptPassword', :placeholder='$t("auth:fields.password")')
h2 {{ $t('auth:loginRequired') }}
input(type='text', ref='iptEmail', v-model='username', :placeholder='$t("auth:fields.emailUser")')
input(type='password', ref='iptPassword', v-model='password', :placeholder='$t("auth:fields.password")', @keyup.enter='login')
button.button.is-blue.is-fullwidth(@click='login')
span {{ $t('auth:actions.login') }}
.login-frame(v-show='screen === "tfa"')
.login-frame-icon
svg.icons.is-48(role='img')
title {{ $t('auth:tfa.title') }}
use(xlink:href='#nc-key')
h2 {{ $t('auth:tfa.subtitle') }}
input(type='text', ref='iptTFA', v-model='securityCode', :placeholder='$t("auth:tfa.placeholder")', @keyup.enter='verifySecurityCode')
button.button.is-blue.is-fullwidth(@click='verifySecurityCode')
span {{ $t('auth:tfa.verifyToken') }}
.login-copyright
span {{ $t('footer.poweredby') }}
span {{ $t('footer.poweredBy') }}
a(href='https://wiki.js.org', rel='external', title='Wiki.js') Wiki.js
</template>
@ -23,26 +32,36 @@
export default {
name: 'login',
data() {
data () {
return {
error: false,
strategies: [],
selectedStrategy: 'local'
selectedStrategy: 'local',
screen: 'login',
username: '',
password: '',
securityCode: '',
loginToken: '',
isLoading: false
}
},
computed: {
siteTitle() {
siteTitle () {
return siteConfig.title
}
},
methods: {
selectStrategy(key, useForm) {
selectStrategy (key, useForm) {
this.selectedStrategy = key
this.screen = 'login'
if (!useForm) {
window.location.assign(siteConfig.path + '/login/' + key)
window.location.assign(siteConfig.path + 'login/' + key)
} else {
this.$refs.iptEmail.focus()
}
},
refreshStrategies() {
refreshStrategies () {
this.isLoading = true
graphQL.query({
query: CONSTANTS.GRAPHQL.GQL_QUERY_AUTHENTICATION,
variables: {
@ -54,19 +73,122 @@ export default {
} else {
throw new Error('No authentication providers available!')
}
this.isLoading = false
}).catch(err => {
console.error(err)
this.$store.dispatch('alert', {
style: 'error',
icon: 'gg-warning',
msg: err.message
})
this.isLoading = false
})
},
login() {
this.$store.dispatch('alert', {
style: 'error',
icon: 'gg-warning',
msg: 'Email or password is invalid'
})
login () {
if (this.username.length < 2) {
this.$store.dispatch('alert', {
style: 'error',
icon: 'gg-warning',
msg: 'Enter a valid email / username.'
})
this.$refs.iptEmail.focus()
} else if (this.password.length < 2) {
this.$store.dispatch('alert', {
style: 'error',
icon: 'gg-warning',
msg: 'Enter a valid password.'
})
this.$refs.iptPassword.focus()
} else {
this.isLoading = true
graphQL.mutate({
mutation: CONSTANTS.GRAPHQL.GQL_MUTATION_LOGIN,
variables: {
username: this.username,
password: this.password,
provider: this.selectedStrategy
}
}).then(resp => {
if (resp.data.login) {
let respObj = resp.data.login
if (respObj.succeeded === true) {
if (respObj.tfaRequired === true) {
this.screen = 'tfa'
this.securityCode = ''
this.loginToken = respObj.tfaLoginToken
this.$nextTick(() => {
this.$refs.iptTFA.focus()
})
} else {
this.$store.dispatch('alert', {
style: 'success',
icon: 'gg-check',
msg: 'Login successful!'
})
}
this.isLoading = false
} else {
throw new Error(respObj.message)
}
} else {
throw new Error('Authentication is unavailable.')
}
}).catch(err => {
console.error(err)
this.$store.dispatch('alert', {
style: 'error',
icon: 'gg-warning',
msg: err.message
})
this.isLoading = false
})
}
},
verifySecurityCode () {
if (this.securityCode.length !== 6) {
this.$store.dispatch('alert', {
style: 'error',
icon: 'gg-warning',
msg: 'Enter a valid security code.'
})
this.$refs.iptTFA.focus()
} else {
this.isLoading = true
graphQL.mutate({
mutation: CONSTANTS.GRAPHQL.GQL_MUTATION_LOGINTFA,
variables: {
loginToken: this.loginToken,
securityCode: this.securityCode
}
}).then(resp => {
if (resp.data.loginTFA) {
let respObj = resp.data.loginTFA
if (respObj.succeeded === true) {
this.$store.dispatch('alert', {
style: 'success',
icon: 'gg-check',
msg: 'Login successful!'
})
this.isLoading = false
} else {
throw new Error(respObj.message)
}
} else {
throw new Error('Authentication is unavailable.')
}
}).catch(err => {
console.error(err)
this.$store.dispatch('alert', {
style: 'error',
icon: 'gg-warning',
msg: err.message
})
this.isLoading = false
})
}
}
},
mounted() {
mounted () {
this.$store.commit('navigator/subtitleStatic', 'Login')
this.refreshStrategies()
this.$refs.iptEmail.focus()

View File

@ -18,5 +18,23 @@ export default {
value
}
}
`,
GQL_MUTATION_LOGIN: gql`
mutation($username: String!, $password: String!, $provider: String!) {
login(username: $username, password: $password, provider: $provider) {
succeeded
message
tfaRequired
tfaLoginToken
}
}
`,
GQL_MUTATION_LOGINTFA: gql`
mutation($loginToken: String!, $securityCode: String!) {
loginTFA(loginToken: $loginToken, securityCode: $securityCode) {
succeeded
message
}
}
`
}

View File

@ -54,6 +54,15 @@
border-radius: 6px;
animation: zoomIn .5s ease;
&::after {
position: absolute;
top: 1rem;
right: 1rem;
content: " ";
@include spinner(mc('blue', '500'),0.5s,16px);
display: none;
}
&.is-expanded {
width: 650px;
@ -67,6 +76,10 @@
}
}
&.is-loading::after {
display: block;
}
@include until($tablet) {
width: 100%;
border-radius: 0;
@ -264,6 +277,16 @@
}
&-tfa {
position: relative;
display: flex;
width: 400px;
align-items: stretch;
box-shadow: 0 14px 28px rgba(0,0,0,0.2);
border-radius: 6px;
animation: zoomIn .5s ease;
}
&-copyright {
display: flex;
align-items: center;

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!