totp
This commit is contained in:
parent
5536ecbf58
commit
298f9bf818
@ -9,7 +9,7 @@ exports.up = function(knex, Promise) {
|
|||||||
table.string('email').notNullable()
|
table.string('email').notNullable()
|
||||||
table.string('avatar_file')
|
table.string('avatar_file')
|
||||||
|
|
||||||
table.text('password').notNullable()
|
table.text('password')
|
||||||
|
|
||||||
table.boolean('activated').defaultTo(false)
|
table.boolean('activated').defaultTo(false)
|
||||||
table.boolean('locked').defaultTo(false)
|
table.boolean('locked').defaultTo(false)
|
||||||
|
@ -4,6 +4,8 @@ import config from '../../scripts/load-config'
|
|||||||
import database from '../../scripts/load-database'
|
import database from '../../scripts/load-database'
|
||||||
import models from './models'
|
import models from './models'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
|
import notp from 'notp'
|
||||||
|
import base32 from 'thirty-two'
|
||||||
|
|
||||||
const emailRe = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
const emailRe = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
||||||
|
|
||||||
@ -32,7 +34,23 @@ function bcryptTask (data) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function keysAvailable (object, required) {
|
||||||
|
let found = true
|
||||||
|
|
||||||
|
for (let i in required) {
|
||||||
|
let key = required[i]
|
||||||
|
if (object[key] == null) {
|
||||||
|
found = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
const API = {
|
const API = {
|
||||||
|
Hash: (len) => {
|
||||||
|
return crypto.randomBytes(len).toString('hex')
|
||||||
|
},
|
||||||
User: {
|
User: {
|
||||||
get: async function (identifier) {
|
get: async function (identifier) {
|
||||||
let scope = 'id'
|
let scope = 'id'
|
||||||
@ -57,8 +75,8 @@ const API = {
|
|||||||
|
|
||||||
return user[0]
|
return user[0]
|
||||||
},
|
},
|
||||||
ensureObject: async function (user) {
|
ensureObject: async function (user, fieldsPresent = ['id']) {
|
||||||
if (!typeof user === 'object') {
|
if (typeof user !== 'object' || !keysAvailable(user, fieldsPresent)) {
|
||||||
return await API.User.get(user)
|
return await API.User.get(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,6 +102,78 @@ const API = {
|
|||||||
await models.User.query().patchAndFetchById(user.id, {activated: 1})
|
await models.User.query().patchAndFetchById(user.id, {activated: 1})
|
||||||
await models.Token.query().delete().where('id', getToken[0].id)
|
await models.Token.query().delete().where('id', getToken[0].id)
|
||||||
return true
|
return true
|
||||||
|
},
|
||||||
|
totpTokenRequired: async function (user) {
|
||||||
|
let getToken = await models.TotpToken.query().where('user_id', user.id)
|
||||||
|
|
||||||
|
if (!getToken || !getToken.length) return false
|
||||||
|
if (getToken[0].activated !== 1) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
totpCheck: async function (user, code, emerg) {
|
||||||
|
user = await API.User.ensureObject(user)
|
||||||
|
let getToken = await models.TotpToken.query().where('user_id', user.id)
|
||||||
|
if (!getToken || !getToken.length) return false
|
||||||
|
getToken = getToken[0]
|
||||||
|
|
||||||
|
if (emerg) {
|
||||||
|
if (emerg === getToken.recovery_code) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let login = notp.totp.verify(code, getToken.token, {})
|
||||||
|
|
||||||
|
if (login) {
|
||||||
|
if (login.delta !== 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getToken.activated !== 1) {
|
||||||
|
// TODO: Send an email including the recovery code to the user
|
||||||
|
await models.TotpToken.query().patchAndFetchById(getToken.id, {activated: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
purgeTotp: async function (user, password) {
|
||||||
|
user = await API.User.ensureObject(user, ['password'])
|
||||||
|
let pwmatch = await API.User.Login.password(user, password)
|
||||||
|
if (!pwmatch) return false
|
||||||
|
|
||||||
|
// TODO: Inform user via email
|
||||||
|
await models.TotpToken.query().delete().where('user_id', user.id)
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
totpAquire: async function (user) {
|
||||||
|
user = await API.User.ensureObject(user)
|
||||||
|
let getToken = await models.TotpToken.query().where('user_id', user.id)
|
||||||
|
if (getToken && getToken.length) {
|
||||||
|
await models.TotpToken.query().delete().where('user_id', user.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
let newToken = {
|
||||||
|
user_id: user.id,
|
||||||
|
token: API.Hash(16),
|
||||||
|
recovery_code: API.Hash(8),
|
||||||
|
created_at: new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
let hashed = base32.encode(newToken.token)
|
||||||
|
let domain = 'icynet.eu'
|
||||||
|
|
||||||
|
await models.TotpToken.query().insert(newToken)
|
||||||
|
|
||||||
|
let uri = encodeURIComponent('otpauth://totp/' + user.username + '@' + domain + '?secret=' + hashed)
|
||||||
|
|
||||||
|
return uri
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Register: {
|
Register: {
|
||||||
@ -112,7 +202,7 @@ const API = {
|
|||||||
let user = await models.User.query().insert(data)
|
let user = await models.User.query().insert(data)
|
||||||
|
|
||||||
// Activation token
|
// Activation token
|
||||||
let activationToken = crypto.randomBytes(16).toString('hex')
|
let activationToken = API.Hash(16)
|
||||||
await models.Token.query().insert({
|
await models.Token.query().insert({
|
||||||
expires_at: new Date(Date.now() + 86400000), // 1 day
|
expires_at: new Date(Date.now() + 86400000), // 1 day
|
||||||
token: activationToken,
|
token: activationToken,
|
||||||
|
@ -56,6 +56,29 @@ router.get('/register', wrap(async (req, res) => {
|
|||||||
res.render('register')
|
res.render('register')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
router.get('/user/two-factor', wrap(async (req, res) => {
|
||||||
|
if (!req.session.user) return res.redirect('/login')
|
||||||
|
let twoFaEnabled = await API.User.Login.totpTokenRequired(req.session.user)
|
||||||
|
|
||||||
|
if (twoFaEnabled) return res.redirect('/')
|
||||||
|
let newToken = await API.User.Login.totpAquire(req.session.user)
|
||||||
|
|
||||||
|
res.locals.uri = newToken
|
||||||
|
res.render('totp')
|
||||||
|
}))
|
||||||
|
|
||||||
|
router.get('/user/two-factor/disable', wrap(async (req, res) => {
|
||||||
|
if (!req.session.user) return res.redirect('/login')
|
||||||
|
let twoFaEnabled = await API.User.Login.totpTokenRequired(req.session.user)
|
||||||
|
|
||||||
|
if (!twoFaEnabled) return res.redirect('/')
|
||||||
|
res.render('password')
|
||||||
|
}))
|
||||||
|
|
||||||
|
router.get('/login/verify', wrap(async (req, res) => {
|
||||||
|
res.render('totp-check')
|
||||||
|
}))
|
||||||
|
|
||||||
/*
|
/*
|
||||||
=================
|
=================
|
||||||
POST HANDLING
|
POST HANDLING
|
||||||
@ -68,6 +91,80 @@ function formError (req, res, error, path) {
|
|||||||
res.redirect(path || parseurl(req).path)
|
res.redirect(path || parseurl(req).path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
router.post('/user/two-factor', wrap(async (req, res) => {
|
||||||
|
if (!req.body.code) {
|
||||||
|
return formError(req, res, 'You need to enter the code.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body.csrf !== req.session.csrf) {
|
||||||
|
return formError(req, res, 'Invalid session! Try reloading the page.')
|
||||||
|
}
|
||||||
|
|
||||||
|
let verified = await API.User.Login.totpCheck(req.session.user, req.body.code)
|
||||||
|
if (!verified) {
|
||||||
|
return formError(req, res, 'Try again!')
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect('/')
|
||||||
|
}))
|
||||||
|
|
||||||
|
router.post('/user/two-factor/disable', wrap(async (req, res) => {
|
||||||
|
if (req.body.csrf !== req.session.csrf) {
|
||||||
|
return formError(req, res, 'Invalid session! Try reloading the page.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.body.password) {
|
||||||
|
return formError(req, res, 'Please enter your password.')
|
||||||
|
}
|
||||||
|
|
||||||
|
let purge = await API.User.Login.purgeTotp(req.session.user, req.body.password)
|
||||||
|
if (!purge) {
|
||||||
|
return formError(req, res, 'Invalid password.')
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect('/')
|
||||||
|
}))
|
||||||
|
|
||||||
|
router.post('/login/verify', wrap(async (req, res) => {
|
||||||
|
if (req.session.totp_check === null) return res.redirect('/login')
|
||||||
|
if (!req.body.code && !req.body.recovery) {
|
||||||
|
return formError(req, res, 'You need to enter the code.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body.csrf !== req.session.csrf) {
|
||||||
|
return formError(req, res, 'Invalid session! Try reloading the page.')
|
||||||
|
}
|
||||||
|
|
||||||
|
let totpCheck = await API.User.Login.totpCheck(req.session.totp_check, req.body.code, req.body.recovery || false)
|
||||||
|
if (!totpCheck) {
|
||||||
|
return formError(req, res, 'Invalid code!')
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = await API.User.get(req.session.totp_check)
|
||||||
|
delete req.session.totp_check
|
||||||
|
|
||||||
|
// Set session
|
||||||
|
req.session.user = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
display_name: user.display_name,
|
||||||
|
email: user.email,
|
||||||
|
avatar_file: user.avatar_file
|
||||||
|
}
|
||||||
|
|
||||||
|
let uri = '/'
|
||||||
|
if (req.session.redirectUri) {
|
||||||
|
uri = req.session.redirectUri
|
||||||
|
delete req.session.redirectUri
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.query.redirect) {
|
||||||
|
uri = req.query.redirect
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect(uri)
|
||||||
|
}))
|
||||||
|
|
||||||
router.post('/login', wrap(async (req, res) => {
|
router.post('/login', wrap(async (req, res) => {
|
||||||
if (!req.body.username || !req.body.password) {
|
if (!req.body.username || !req.body.password) {
|
||||||
return res.redirect('/login')
|
return res.redirect('/login')
|
||||||
@ -86,7 +183,12 @@ router.post('/login', wrap(async (req, res) => {
|
|||||||
if (user.activated === 0) return formError(req, res, 'Please activate your account first.')
|
if (user.activated === 0) return formError(req, res, 'Please activate your account first.')
|
||||||
if (user.locked === 1) return formError(req, res, 'This account has been locked.')
|
if (user.locked === 1) return formError(req, res, 'This account has been locked.')
|
||||||
|
|
||||||
// TODO: TOTP checks
|
let totpRequired = await API.User.Login.totpTokenRequired(user)
|
||||||
|
if (totpRequired) {
|
||||||
|
req.session.totp_check = user.id
|
||||||
|
return res.redirect('/login/verify')
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Ban checks
|
// TODO: Ban checks
|
||||||
|
|
||||||
// Set session
|
// Set session
|
||||||
@ -173,17 +275,6 @@ router.post('/register', wrap(async (req, res) => {
|
|||||||
res.redirect('/login')
|
res.redirect('/login')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
router.get('/activate/:token', wrap(async (req, res) => {
|
|
||||||
if (req.session.user) return res.redirect('/login')
|
|
||||||
let token = req.params.token
|
|
||||||
|
|
||||||
let success = await API.User.Login.activationToken(token)
|
|
||||||
if (!success) return formError(req, res, 'Unknown or invalid activation token')
|
|
||||||
|
|
||||||
req.flash('message', {error: false, text: 'Your account has been activated! You may now log in.'})
|
|
||||||
res.redirect('/login')
|
|
||||||
}))
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
=========
|
=========
|
||||||
OTHER
|
OTHER
|
||||||
@ -198,6 +289,17 @@ router.get('/logout', wrap(async (req, res) => {
|
|||||||
res.redirect('/')
|
res.redirect('/')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
router.get('/activate/:token', wrap(async (req, res) => {
|
||||||
|
if (req.session.user) return res.redirect('/login')
|
||||||
|
let token = req.params.token
|
||||||
|
|
||||||
|
let success = await API.User.Login.activationToken(token)
|
||||||
|
if (!success) return formError(req, res, 'Unknown or invalid activation token')
|
||||||
|
|
||||||
|
req.flash('message', {error: false, text: 'Your account has been activated! You may now log in.'})
|
||||||
|
res.redirect('/login')
|
||||||
|
}))
|
||||||
|
|
||||||
router.use((err, req, res, next) => {
|
router.use((err, req, res, next) => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
next()
|
next()
|
||||||
|
@ -129,6 +129,8 @@ input[type="submit"]
|
|||||||
width: 46%
|
width: 46%
|
||||||
&#login
|
&#login
|
||||||
max-width: 700px
|
max-width: 700px
|
||||||
|
&#totpcheck
|
||||||
|
max-width: 400px
|
||||||
padding: 20px
|
padding: 20px
|
||||||
margin: auto
|
margin: auto
|
||||||
margin-top: 5%
|
margin-top: 5%
|
||||||
|
21
views/password.pug
Normal file
21
views/password.pug
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
extends layout.pug
|
||||||
|
block title
|
||||||
|
|Icy Network - Password Required
|
||||||
|
|
||||||
|
block body
|
||||||
|
.wrapper
|
||||||
|
.boxcont
|
||||||
|
.box#totpcheck
|
||||||
|
h1 Enter your password
|
||||||
|
small.descr This action requires your password to continue
|
||||||
|
if message
|
||||||
|
if message.error
|
||||||
|
.message.error
|
||||||
|
else
|
||||||
|
.message
|
||||||
|
span #{message.text}
|
||||||
|
form#loginForm(method="POST", action="")
|
||||||
|
input(type="hidden", name="csrf", value=csrf)
|
||||||
|
label(for="password") Password
|
||||||
|
input(type="password", name="password", id="password")
|
||||||
|
input(type="submit", value="Continue")
|
21
views/totp-check.pug
Normal file
21
views/totp-check.pug
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
extends layout.pug
|
||||||
|
block title
|
||||||
|
|Icy Network - Log In - Verification Required
|
||||||
|
|
||||||
|
block body
|
||||||
|
.wrapper
|
||||||
|
.boxcont
|
||||||
|
.box#totpcheck
|
||||||
|
h1 Enter Code
|
||||||
|
small.descr This user has Two Factor Authentication enabled. Enter the code to log in.
|
||||||
|
if message
|
||||||
|
if message.error
|
||||||
|
.message.error
|
||||||
|
else
|
||||||
|
.message
|
||||||
|
span #{message.text}
|
||||||
|
form#loginForm(method="POST", action="")
|
||||||
|
input(type="hidden", name="csrf", value=csrf)
|
||||||
|
label(for="code") Code
|
||||||
|
input(type="text", name="code", id="code")
|
||||||
|
input(type="submit", value="Log in")
|
30
views/totp.pug
Normal file
30
views/totp.pug
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
extends layout.pug
|
||||||
|
block title
|
||||||
|
|Icy Network - Activate Authenticator
|
||||||
|
|
||||||
|
block body
|
||||||
|
.wrapper
|
||||||
|
.boxcont
|
||||||
|
.box#login
|
||||||
|
.left
|
||||||
|
h1 Two Factor Authentication
|
||||||
|
if message
|
||||||
|
if message.error
|
||||||
|
.message.error
|
||||||
|
else
|
||||||
|
.message
|
||||||
|
span #{message.text}
|
||||||
|
img.qr(src="//api.qrserver.com/v1/create-qr-code/?data=" + uri)
|
||||||
|
form#totpForm(method="POST", action="")
|
||||||
|
input(type="hidden", name="csrf", value=csrf)
|
||||||
|
label(for="code") Enter the Code
|
||||||
|
input(type="text", name="code", id="code")
|
||||||
|
input(type="submit", value="Enable Now")
|
||||||
|
.right
|
||||||
|
h3 How to use
|
||||||
|
ol
|
||||||
|
li Scan the QR Code with your authenticator app
|
||||||
|
li Enter the one-time code given to you
|
||||||
|
li You will now be asked for a code every time you log in
|
||||||
|
h3 Authenticator app
|
||||||
|
p We recommend using Google Authenticator
|
Reference in New Issue
Block a user