feat: mandatory password change on login + UI fixes

This commit is contained in:
Nick
2019-08-24 22:19:35 -04:00
parent 38008f0460
commit d3e693ab46
40 changed files with 1468 additions and 1064 deletions

View File

@@ -42,6 +42,13 @@ defaults:
theme: 'default'
iconset: 'md'
darkMode: false
security:
securityIframe: true
securityReferrerPolicy: true
securityHSTS: false
securityHSTSDuration: 300
securityCSP: false
securityCSPDirectives: ''
flags:
ldapdebug: false
sqllog: false

View File

@@ -7,16 +7,16 @@ const graphHelper = require('../../helpers/graph')
module.exports = {
Query: {
async authentication() { return {} }
async authentication () { return {} }
},
Mutation: {
async authentication() { return {} }
async authentication () { return {} }
},
AuthenticationQuery: {
/**
* Fetch active authentication strategies
*/
async strategies(obj, args, context, info) {
async strategies (obj, args, context, info) {
let strategies = await WIKI.models.authentication.getStrategies(args.isEnabled)
strategies = strategies.map(stg => {
const strategyInfo = _.find(WIKI.data.authentication, ['key', stg.key]) || {}
@@ -44,7 +44,7 @@ module.exports = {
/**
* Perform Login
*/
async login(obj, args, context) {
async login (obj, args, context) {
try {
const authResult = await WIKI.models.users.login(args, context)
return {
@@ -63,7 +63,7 @@ module.exports = {
/**
* Perform 2FA Login
*/
async loginTFA(obj, args, context) {
async loginTFA (obj, args, context) {
try {
const authResult = await WIKI.models.users.loginTFA(args, context)
return {
@@ -74,10 +74,24 @@ module.exports = {
return graphHelper.generateError(err)
}
},
/**
* Perform Mandatory Password Change after Login
*/
async loginChangePassword (obj, args, context) {
try {
const authResult = await WIKI.models.users.loginChangePassword(args, context)
return {
...authResult,
responseResult: graphHelper.generateSuccess('Password changed successfully')
}
} catch (err) {
return graphHelper.generateError(err)
}
},
/**
* Register a new account
*/
async register(obj, args, context) {
async register (obj, args, context) {
try {
await WIKI.models.users.register({ ...args, verify: true }, context)
return {
@@ -90,7 +104,7 @@ module.exports = {
/**
* Update Authentication Strategies
*/
async updateStrategies(obj, args, context) {
async updateStrategies (obj, args, context) {
try {
WIKI.config.auth = {
audience: _.get(args, 'config.audience', WIKI.config.auth.audience),
@@ -122,7 +136,7 @@ module.exports = {
/**
* Generate New Authentication Public / Private Key Certificates
*/
async regenerateCertificates(obj, args, context) {
async regenerateCertificates (obj, args, context) {
try {
await WIKI.auth.regenerateCertificates()
return {
@@ -135,7 +149,7 @@ module.exports = {
/**
* Reset Guest User
*/
async resetGuestUser(obj, args, context) {
async resetGuestUser (obj, args, context) {
try {
await WIKI.auth.resetGuestUser()
return {

View File

@@ -17,7 +17,8 @@ module.exports = {
company: WIKI.config.company,
...WIKI.config.seo,
...WIKI.config.logo,
...WIKI.config.features
...WIKI.config.features,
...WIKI.config.security
}
}
},
@@ -42,7 +43,15 @@ module.exports = {
featurePageComments: args.featurePageComments,
featurePersonalWikis: args.featurePersonalWikis
}
await WIKI.configSvc.saveToDb(['host', 'title', 'company', 'seo', 'logo', 'features'])
WIKI.config.security = {
securityIframe: args.securityIframe,
securityReferrerPolicy: args.securityReferrerPolicy,
securityHSTS: args.securityHSTS,
securityHSTSDuration: args.securityHSTSDuration,
securityCSP: args.securityCSP,
securityCSPDirectives: args.securityCSPDirectives
}
await WIKI.configSvc.saveToDb(['host', 'title', 'company', 'seo', 'logo', 'features', 'security'])
return {
responseResult: graphHelper.generateSuccess('Site configuration updated successfully')

View File

@@ -32,9 +32,14 @@ type AuthenticationMutation {
): AuthenticationLoginResponse @rateLimit(limit: 5, duration: 60)
loginTFA(
loginToken: String!
continuationToken: String!
securityCode: String!
): DefaultResponse @rateLimit(limit: 5, duration: 60)
): AuthenticationLoginResponse @rateLimit(limit: 5, duration: 60)
loginChangePassword(
continuationToken: String!
newPassword: String!
): AuthenticationLoginResponse @rateLimit(limit: 5, duration: 60)
register(
email: String!
@@ -76,8 +81,9 @@ type AuthenticationStrategy {
type AuthenticationLoginResponse {
responseResult: ResponseStatus
jwt: String
tfaRequired: Boolean
tfaLoginToken: String
mustChangePwd: Boolean
mustProvideTFA: Boolean
continuationToken: String
}
type AuthenticationRegisterResponse {

View File

@@ -36,6 +36,12 @@ type SiteMutation {
featurePageRatings: Boolean!
featurePageComments: Boolean!
featurePersonalWikis: Boolean!
securityIframe: Boolean!
securityReferrerPolicy: Boolean!
securityHSTS: Boolean!
securityHSTSDuration: Int!
securityCSP: Boolean!
securityCSPDirectives: String!
): DefaultResponse @auth(requires: ["manage:system"])
}
@@ -56,4 +62,10 @@ type SiteConfig {
featurePageRatings: Boolean!
featurePageComments: Boolean!
featurePersonalWikis: Boolean!
securityIframe: Boolean!
securityReferrerPolicy: Boolean!
securityHSTS: Boolean!
securityHSTSDuration: Int!
securityCSP: Boolean!
securityCSPDirectives: String!
}

View File

@@ -89,6 +89,8 @@ type User {
providerKey: String!
providerId: String
isSystem: Boolean!
isActive: Boolean!
isVerified: Boolean!
location: String!
jobTitle: String!
timezone: String!

View File

@@ -1,4 +1,4 @@
'use strict'
/* global WIKI */
/**
* Security Middleware
@@ -13,7 +13,9 @@ module.exports = function (req, res, next) {
req.app.disable('x-powered-by')
// -> Disable Frame Embedding
res.set('X-Frame-Options', 'deny')
if (WIKI.config.securityIframe) {
res.set('X-Frame-Options', 'deny')
}
// -> Re-enable XSS Fitler if disabled
res.set('X-XSS-Protection', '1; mode=block')
@@ -25,7 +27,14 @@ module.exports = function (req, res, next) {
res.set('X-UA-Compatible', 'IE=edge')
// -> Disables referrer header when navigating to a different origin
res.set('Referrer-Policy', 'same-origin')
if (WIKI.config.securityReferrerPolicy) {
res.set('Referrer-Policy', 'same-origin')
}
// -> Enforce HSTS
if (WIKI.config.securityHSTS) {
res.set('Strict-Transport-Security', `max-age=${WIKI.config.securityHSTSDuration}; includeSubDomains`)
}
return next()
}

View File

@@ -45,7 +45,7 @@ module.exports = class UserKey extends Model {
}
static async generateToken ({ userId, kind }, context) {
const token = await nanoid()
const token = nanoid()
await WIKI.models.userKeys.query().insert({
kind,
token,

View File

@@ -3,7 +3,6 @@
const bcrypt = require('bcryptjs-then')
const _ = require('lodash')
const tfa = require('node-2fa')
const securityHelper = require('../helpers/security')
const jwt = require('jsonwebtoken')
const Model = require('objection').Model
const validate = require('validate.js')
@@ -280,30 +279,46 @@ module.exports = class User extends Model {
if (err) { return reject(err) }
if (!user) { return reject(new WIKI.Error.AuthLoginFailed()) }
// Is 2FA required?
if (user.tfaIsActive) {
// Must Change Password?
if (user.mustChangePwd) {
try {
let loginToken = await securityHelper.generateToken(32)
await WIKI.redis.set(`tfa:${loginToken}`, user.id, 'EX', 600)
const pwdChangeToken = await WIKI.models.userKeys.generateToken({
kind: 'changePwd',
userId: user.id
})
return resolve({
tfaRequired: true,
tfaLoginToken: loginToken
mustChangePwd: true,
continuationToken: pwdChangeToken
})
} catch (err) {
WIKI.logger.warn(err)
return reject(new WIKI.Error.AuthGenericError())
}
} else {
// No 2FA, log in user
return context.req.logIn(user, { session: !strInfo.useForm }, async err => {
if (err) { return reject(err) }
const jwtToken = await WIKI.models.users.refreshToken(user)
resolve({
jwt: jwtToken.token,
tfaRequired: false
})
})
}
// Is 2FA required?
if (user.tfaIsActive) {
try {
const tfaToken = await WIKI.models.userKeys.generateToken({
kind: 'tfa',
userId: user.id
})
return resolve({
tfaRequired: true,
continuationToken: tfaToken
})
} catch (err) {
WIKI.logger.warn(err)
return reject(new WIKI.Error.AuthGenericError())
}
}
context.req.logIn(user, { session: !strInfo.useForm }, async err => {
if (err) { return reject(err) }
const jwtToken = await WIKI.models.users.refreshToken(user)
resolve({ jwt: jwtToken.token })
})
})(context.req, context.res, () => {})
})
} else {
@@ -348,7 +363,7 @@ module.exports = class User extends Model {
}
}
static async loginTFA(opts, context) {
static async loginTFA (opts, context) {
if (opts.securityCode.length === 6 && opts.loginToken.length === 64) {
let result = await WIKI.redis.get(`tfa:${opts.loginToken}`)
if (result) {
@@ -374,6 +389,36 @@ module.exports = class User extends Model {
throw new WIKI.Error.AuthTFAInvalid()
}
/**
* Change Password from a Mandatory Password Change after Login
*/
static async loginChangePassword ({ continuationToken, newPassword }, context) {
if (!newPassword || newPassword.length < 6) {
throw new WIKI.Error.InputInvalid('Password must be at least 6 characters!')
}
const usr = await WIKI.models.userKeys.validateToken({
kind: 'changePwd',
token: continuationToken
})
if (usr) {
await WIKI.models.users.query().patch({
password: newPassword,
mustChangePwd: false
}).findById(usr.id)
return new Promise((resolve, reject) => {
context.req.logIn(usr, { session: false }, async err => {
if (err) { return reject(err) }
const jwtToken = await WIKI.models.users.refreshToken(usr)
resolve({ jwt: jwtToken.token })
})
})
} else {
throw new WIKI.Error.UserNotFound()
}
}
/**
* Create a new user
*
@@ -520,7 +565,7 @@ module.exports = class User extends Model {
}
usrData.password = newPassword
}
if (!_.isEmpty(groups)) {
if (_.isArray(groups)) {
const usrGroupsRaw = await usr.$relatedQuery('groups')
const usrGroups = _.map(usrGroupsRaw, 'id')
// Relate added groups

View File

@@ -3,7 +3,7 @@ title: Local
description: Built-in authentication for Wiki.js
author: requarks.io
logo: https://static.requarks.io/logo/wikijs.svg
color: yellow darken-3
color: primary
website: https://wiki.js.org
isAvailable: true
useForm: true

View File

@@ -2,25 +2,11 @@ extends master.pug
block body
#root.is-fullscreen
v-app(dark)
.app-error
v-container
.pt-5
v-layout(row)
v-flex(xs10)
a(href='/'): img(src='/svg/logo-wikijs.svg')
v-flex.text-right(xs2)
v-btn(href='/', depressed, color='red darken-3')
v-icon(left) home
span Home
v-alert(color='grey', outline, :value='true', icon='error')
strong.red--text.text--lighten-3 Oops, something went wrong...
.body-1.red--text.text--lighten-2= message
.app-error
a(href='/')
img(src='/svg/logo-wikijs.svg')
strong Oops, something went wrong...
span= message
if error.stack
v-expansion-panel.mt-5
v-expansion-panel-content.red.darken-3(:value='true')
div(slot='header') View Debug Trace
v-card(color='grey darken-4')
v-card-text
pre: code #{error.stack}
if error.stack
pre: code #{error.stack}

View File

@@ -32,7 +32,7 @@ html
link(
type='text/css'
rel='stylesheet'
href='https://use.fontawesome.com/releases/v5.9.0/css/all.css'
href='https://use.fontawesome.com/releases/v5.10.0/css/all.css'
)
else if config.theming.iconset === 'fa4'
link(

View File

@@ -36,7 +36,7 @@ html(lang=siteConfig.lang)
link(
type='text/css'
rel='stylesheet'
href='https://use.fontawesome.com/releases/v5.9.0/css/all.css'
href='https://use.fontawesome.com/releases/v5.10.0/css/all.css'
)
else if config.theming.iconset === 'fa4'
link(