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('avatar_file')
|
||||
|
||||
table.text('password').notNullable()
|
||||
table.text('password')
|
||||
|
||||
table.boolean('activated').defaultTo(false)
|
||||
table.boolean('locked').defaultTo(false)
|
||||
|
@ -4,6 +4,8 @@ import config from '../../scripts/load-config'
|
||||
import database from '../../scripts/load-database'
|
||||
import models from './models'
|
||||
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,}))$/
|
||||
|
||||
@ -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 = {
|
||||
Hash: (len) => {
|
||||
return crypto.randomBytes(len).toString('hex')
|
||||
},
|
||||
User: {
|
||||
get: async function (identifier) {
|
||||
let scope = 'id'
|
||||
@ -57,8 +75,8 @@ const API = {
|
||||
|
||||
return user[0]
|
||||
},
|
||||
ensureObject: async function (user) {
|
||||
if (!typeof user === 'object') {
|
||||
ensureObject: async function (user, fieldsPresent = ['id']) {
|
||||
if (typeof user !== 'object' || !keysAvailable(user, fieldsPresent)) {
|
||||
return await API.User.get(user)
|
||||
}
|
||||
|
||||
@ -84,6 +102,78 @@ const API = {
|
||||
await models.User.query().patchAndFetchById(user.id, {activated: 1})
|
||||
await models.Token.query().delete().where('id', getToken[0].id)
|
||||
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: {
|
||||
@ -112,7 +202,7 @@ const API = {
|
||||
let user = await models.User.query().insert(data)
|
||||
|
||||
// Activation token
|
||||
let activationToken = crypto.randomBytes(16).toString('hex')
|
||||
let activationToken = API.Hash(16)
|
||||
await models.Token.query().insert({
|
||||
expires_at: new Date(Date.now() + 86400000), // 1 day
|
||||
token: activationToken,
|
||||
|
@ -56,6 +56,29 @@ router.get('/register', wrap(async (req, res) => {
|
||||
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
|
||||
@ -68,6 +91,80 @@ function formError (req, res, error, 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) => {
|
||||
if (!req.body.username || !req.body.password) {
|
||||
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.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
|
||||
|
||||
// Set session
|
||||
@ -173,17 +275,6 @@ router.post('/register', wrap(async (req, res) => {
|
||||
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
|
||||
@ -198,6 +289,17 @@ router.get('/logout', wrap(async (req, res) => {
|
||||
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) => {
|
||||
console.error(err)
|
||||
next()
|
||||
|
@ -129,6 +129,8 @@ input[type="submit"]
|
||||
width: 46%
|
||||
&#login
|
||||
max-width: 700px
|
||||
&#totpcheck
|
||||
max-width: 400px
|
||||
padding: 20px
|
||||
margin: auto
|
||||
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