diff --git a/logic/auth.js b/logic/auth.js index a5758a3..c81a1f0 100644 --- a/logic/auth.js +++ b/logic/auth.js @@ -166,6 +166,8 @@ async function getInfo() { //remove sensitive info delete user.password; delete user.seed; + user.otpEnabled = Boolean(user.otpUri); + delete user.otpUri; return user; } catch (error) { diff --git a/logic/disk.js b/logic/disk.js index 94541eb..04f99c9 100644 --- a/logic/disk.js +++ b/logic/disk.js @@ -73,6 +73,14 @@ async function writeUserFile(json) { return diskService.writeJsonFile(constants.USER_FILE, json); } +// Accept a function to apply a transformation to the user file +async function updateUserFile(update) { + const userData = await readUserFile(); + const updatedUserData = update(userData); + + return writeUserFile(updatedUserData); +} + async function writeUmbrelSeedFile(umbrelSeed) { return diskService.ensureWriteFile(constants.UMBREL_SEED_FILE, umbrelSeed); } @@ -299,6 +307,7 @@ module.exports = { writeAppVersionFile, writeSettingsFile, writeUserFile, + updateUserFile, writeUmbrelSeedFile, umbrelSeedFileExists, settingsFileExists, diff --git a/middlewares/auth.js b/middlewares/auth.js index edf843c..f4e861d 100644 --- a/middlewares/auth.js +++ b/middlewares/auth.js @@ -7,6 +7,7 @@ const authLogic = require('logic/auth.js'); const NodeError = require('models/errors.js').NodeError; const UUID = require('utils/UUID.js'); const rsa = require('node-rsa'); +const otp = require('modules/otp'); const JwtStrategy = passportJWT.Strategy; const BasicStrategy = passportHTTP.BasicStrategy; @@ -85,10 +86,26 @@ function convertReqBodyToBasicAuth(req, res, next) { function basic(req, res, next) { passport.authenticate(BASIC_AUTH, { session: false }, function (error, user) { - function handleCompare(equal) { + async function handleCompare(equal) { if (!equal) { return next(new NodeError('Incorrect password', 401)); // eslint-disable-line no-magic-numbers } + + // Check if we have 2FA enabled + const userData = await diskLogic.readUserFile(); + if (userData.otpUri) { + + // Return an error if no OTP token is provided + if (!req.body.otpToken) { + return next(new NodeError('Missing OTP token', 401)); // eslint-disable-line no-magic-numbers + } + + // Return an error if provided OTP token is invalid + if(!otp.verify(userData.otpUri, req.body.otpToken)) { + return next(new NodeError('Invalid OTP token', 401)); // eslint-disable-line no-magic-numbers + } + } + req.logIn(user, function (err) { if (err) { return next(new NodeError('Unable to authenticate', 401)); // eslint-disable-line no-magic-numbers diff --git a/modules/otp.js b/modules/otp.js new file mode 100644 index 0000000..68b3b41 --- /dev/null +++ b/modules/otp.js @@ -0,0 +1,26 @@ +const crypto = require('crypto'); +const {URL} = require('url'); +const {totp} = require('notp'); +const base32 = require('thirty-two'); + +const generateUri = () => { + const secret = crypto.randomBytes(32); + const encodedSecret = base32.encode(secret).toString('utf8').replace(/=/g,''); + const uri = `otpauth://totp/Umbrel?secret=${encodedSecret}&period=30&digits=6&algorithm=SHA1&issuer=getumbrel.com`; + + return uri; +}; + +const verify = (uri, token) => { + const parsedUri = new URL(uri); + const secret = base32.decode(parsedUri.searchParams.get('secret')); + const period = parsedUri.searchParams.get('period'); + const isValid = totp.verify(token, secret, {window: 6, time: period}); + + return Boolean(isValid); +}; + +module.exports = { + generateUri, + verify, +}; diff --git a/package.json b/package.json index 0c1b05f..1345d8f 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "module-alias": "^2.1.0", "morgan": "^1.9.0", "node-rsa": "^1.0.8", + "notp": "^2.0.3", "npm": "^6.14.6", "passport": "^0.4.0", "passport-http": "^0.3.0", @@ -35,6 +36,7 @@ "request-promise": "^4.2.2", "semver": "^7.3.2", "socks-proxy-agent": "^5.0.0", + "thirty-two": "^1.0.2", "uuid": "^8.0.0", "validator": "^13.0.0", "winston": "^3.0.0-rc5", diff --git a/routes/v1/account.js b/routes/v1/account.js index d975b41..f5d0896 100644 --- a/routes/v1/account.js +++ b/routes/v1/account.js @@ -3,6 +3,7 @@ const router = express.Router(); // const applicationLogic = require('logic/application.js'); const authLogic = require('logic/auth.js'); +const diskLogic = require('logic/disk.js'); const auth = require('middlewares/auth.js'); const incorrectPasswordAuthHandler = require('middlewares/incorrectPasswordAuthHandler.js'); @@ -11,6 +12,8 @@ const constants = require('utils/const.js'); const safeHandler = require('utils/safeHandler'); const validator = require('utils/validator.js'); +const otp = require('modules/otp'); + const COMPLETE = 100; // Endpoint to change your password. @@ -118,4 +121,49 @@ router.post('/refresh', auth.jwt, safeHandler((req, res) => .then(jwt => res.json(jwt)) )); +// Gets a new random OTP uri +router.get('/otpUri', auth.jwt, safeHandler(async (req, res) => { + const otpUri = otp.generateUri(); + + return res.status(constants.STATUS_CODES.OK).json(otpUri); +})); + +// Enables OTP +router.post('/otp/enable', auth.jwt, safeHandler(async (req, res) => { + const {otpToken, otpUri} = req.body; + + // Verify provided OTP token matched provided OTP uri + if(!otp.verify(otpUri, otpToken)) { + throw new Error('Invalid OTP Token'); + } + + // Insert otpUri into the user file + diskLogic.updateUserFile(userData => { + userData.otpUri = otpUri; + return userData; + }); + + return res.status(constants.STATUS_CODES.OK).json(); +})); + +// Disables OTP +router.post('/otp/disable', auth.jwt, safeHandler(async (req, res) => { + const {otpToken} = req.body; + + // Read OTP uri on disk + const {otpUri} = await diskLogic.readUserFile(); + + // Verify provided OTP token + if(!otp.verify(otpUri, otpToken)) { + throw new Error('Invalid OTP Token'); + } + + // Remove OTP entry from user file + diskLogic.updateUserFile(userData => { + delete userData.otpUri; + return userData; + }); + + return res.status(constants.STATUS_CODES.OK).json(); +})); module.exports = router; diff --git a/yarn.lock b/yarn.lock index bd5514c..3a8df2d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3877,6 +3877,11 @@ normalize-url@^4.1.0: resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.0.tgz#453354087e6ca96957bd8f5baf753f5982142129" integrity sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ== +notp@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/notp/-/notp-2.0.3.tgz#a9fd11e25cfe1ccb39fc6689544ee4c10ef9a577" + integrity sha1-qf0R4lz+HMs5/GaJVE7kwQ75pXc= + npm-audit-report@^1.3.2: version "1.3.3" resolved "https://registry.yarnpkg.com/npm-audit-report/-/npm-audit-report-1.3.3.tgz#8226deeb253b55176ed147592a3995442f2179ed" @@ -5709,6 +5714,11 @@ text-table@^0.2.0, text-table@~0.2.0: resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= +thirty-two@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/thirty-two/-/thirty-two-1.0.2.tgz#4ca2fffc02a51290d2744b9e3f557693ca6b627a" + integrity sha1-TKL//AKlEpDSdEueP1V2k8prYno= + through2@^2.0.0: version "2.0.5" resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"