diff --git a/migrations/20170801231334_initial.js b/migrations/20170801231334_initial.js index c7c6b8d..9006f65 100644 --- a/migrations/20170801231334_initial.js +++ b/migrations/20170801231334_initial.js @@ -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) diff --git a/server/api/index.js b/server/api/index.js index 52bae5e..7e96272 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -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, diff --git a/server/routes/index.js b/server/routes/index.js index 7ab7db2..99b21ca 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -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() diff --git a/src/style/main.styl b/src/style/main.styl index 5a23b65..7510d33 100644 --- a/src/style/main.styl +++ b/src/style/main.styl @@ -129,6 +129,8 @@ input[type="submit"] width: 46% &#login max-width: 700px + &#totpcheck + max-width: 400px padding: 20px margin: auto margin-top: 5% diff --git a/views/password.pug b/views/password.pug new file mode 100644 index 0000000..70c03ff --- /dev/null +++ b/views/password.pug @@ -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") diff --git a/views/totp-check.pug b/views/totp-check.pug new file mode 100644 index 0000000..8524ac5 --- /dev/null +++ b/views/totp-check.pug @@ -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") diff --git a/views/totp.pug b/views/totp.pug new file mode 100644 index 0000000..0da3277 --- /dev/null +++ b/views/totp.pug @@ -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