Skip to content
This repository has been archived by the owner on Jun 23, 2023. It is now read-only.

Implement 2FA for Umbrel-Manager #111

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
124 changes: 86 additions & 38 deletions logic/auth.js
Expand Up @@ -10,6 +10,7 @@ const NodeError = require('models/errors.js').NodeError;
const JWTHelper = require('utils/jwt.js');
const constants = require('utils/const.js');
const UUID = require('utils/UUID.js');
const base32 = require('thirty-two');

const saltRounds = 10;
const SYSTEM_USER = UUID.fetchBootUUID() || 'admin';
Expand All @@ -19,6 +20,22 @@ let changePasswordStatus;

resetChangePasswordStatus();

function generateRandomKey() {
return crypto.randomBytes(10).toString('hex')
}

function encodeKey(key) {
return base32.encode(key);
}

async function enableTotp() {
return await setTotpEnabled(true);
}

async function disableTotp() {
return await setTotpEnabled(false);
}

function resetChangePasswordStatus() {
changePasswordStatus = { percent: 0 };
}
Expand Down Expand Up @@ -51,33 +68,58 @@ async function changePassword(currentPassword, newPassword, jwt) {
changePasswordStatus.percent = 1; // eslint-disable-line no-magic-numbers

try {
// update user file
const user = await diskLogic.readUserFile();
const credentials = hashCredentials(SYSTEM_USER, newPassword);
// update user file
const user = await diskLogic.readUserFile();
const credentials = hashCredentials(SYSTEM_USER, newPassword);

// re-encrypt seed with new password
const decryptedSeed = await iocane.createSession().decrypt(user.seed, currentPassword);
const encryptedSeed = await iocane.createSession().encrypt(decryptedSeed, newPassword);
// re-encrypt seed with new password
const decryptedSeed = await iocane.createSession().decrypt(user.seed, currentPassword);
const encryptedSeed = await iocane.createSession().encrypt(decryptedSeed, newPassword);

// update user file
await diskLogic.writeUserFile({ ...user, password: credentials.password, seed: encryptedSeed });
// update user file
await diskLogic.writeUserFile({ ...user, password: credentials.password, seed: encryptedSeed });

// update system password
await setSystemPassword(newPassword);
// update system password
await setSystemPassword(newPassword);
Comment on lines +82 to +83
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe reverting these changes would help getting this merged. I recommend to not lint any of your PRs, Umbrel hasn't used a configured linter in any project for over a year now, even though that could've prevented some bugs.


changePasswordStatus.percent = 100;
complete = true;
changePasswordStatus.percent = 100;
complete = true;

// cache the password for later use
cachePassword(newPassword);
// cache the password for later use
cachePassword(newPassword);
} catch (error) {
changePasswordStatus.percent = 100;
changePasswordStatus.error = true;
changePasswordStatus.percent = 100;
changePasswordStatus.error = true;

throw new Error('Unable to change password');
throw new Error('Unable to change password');
}
}

async function setTotpEnabled(setEnabled) {
try {
// update user file
const user = await diskLogic.readUserFile();
await diskLogic.writeUserFile({ ...user, totpEnabled: setEnabled });
} catch (error) {
throw new Error('Unable to set TOTP enabled status');
}
}

async function setTotpKey(key) {
try {
// update user file
const user = await diskLogic.readUserFile();
await diskLogic.writeUserFile({ ...user, totpKey: key });
} catch (error) {
throw new Error('Unable to set TOTP key');
}
}

async function getTotpStatus() {
let info = await getInfo();
return info.totpEnabled;
}

function getChangePasswordStatus() {
return changePasswordStatus;
}
Expand All @@ -103,32 +145,32 @@ async function isRegistered() {
// Derives the root umbrel seed and persists it to disk to be used for
// determinstically deriving further entropy for any other Umbrel service.
async function deriveUmbrelSeed(user) {
if (await diskLogic.umbrelSeedFileExists()) {
return;
}
const mnemonic = (await seed(user)).seed.join(' ');
const {entropy} = CipherSeed.fromMnemonic(mnemonic);
const umbrelSeed = crypto
.createHmac('sha256', entropy)
.update('umbrel-seed')
.digest('hex');
return diskLogic.writeUmbrelSeedFile(umbrelSeed);
if (await diskLogic.umbrelSeedFileExists()) {
return;
}
const mnemonic = (await seed(user)).seed.join(' ');
const { entropy } = CipherSeed.fromMnemonic(mnemonic);
const umbrelSeed = crypto
.createHmac('sha256', entropy)
.update('umbrel-seed')
.digest('hex');
return diskLogic.writeUmbrelSeedFile(umbrelSeed);
}

// Sets the LND password to a hardcoded password if it's locked so we can
// auto unlock it in future
async function removeLndPasswordIfLocked(currentPassword, jwt) {
const lndStatus = await lndApiService.getStatus();

if (!lndStatus.data.unlocked) {
console.log('LND is locked on login, attempting to change password...');
try {
await lndApiService.changePassword(currentPassword, constants.LND_WALLET_PASSWORD, jwt);
console.log('Sucessfully changed LND password!');
} catch (e) {
console.log('Failed to change LND password!');
const lndStatus = await lndApiService.getStatus();

if (!lndStatus.data.unlocked) {
console.log('LND is locked on login, attempting to change password...');
try {
await lndApiService.changePassword(currentPassword, constants.LND_WALLET_PASSWORD, jwt);
console.log('Sucessfully changed LND password!');
} catch (e) {
console.log('Failed to change LND password!');
}
}
}
}


Expand Down Expand Up @@ -255,8 +297,14 @@ async function refresh(user) {
}
}


module.exports = {
encodeKey,
setTotpKey,
enableTotp,
disableTotp,
setTotpEnabled,
generateRandomKey,
getTotpStatus,
changePassword,
getCachedPassword,
getChangePasswordStatus,
Expand Down
2 changes: 2 additions & 0 deletions logic/disk.js
Expand Up @@ -55,6 +55,8 @@ async function readUserFile() {
name: "",
password: "",
seed: "",
totpKey: "",
totpEnabled: false,
installedApps: [],
};
const userFile = await diskService.readJsonFile(constants.USER_FILE);
Expand Down
24 changes: 24 additions & 0 deletions middlewares/auth.js
@@ -1,20 +1,26 @@
const passport = require('passport');
const passportJWT = require('passport-jwt');
const passportHTTP = require('passport-http');
const passportTOTP = require('passport-totp');
const bcrypt = require('bcrypt');
const diskLogic = require('logic/disk.js');
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 notp = require('notp');

const JwtStrategy = passportJWT.Strategy;
const BasicStrategy = passportHTTP.BasicStrategy;
const TotpStrategy = passportTOTP.Strategy;


const ExtractJwt = passportJWT.ExtractJwt;

const JWT_AUTH = 'jwt';
const REGISTRATION_AUTH = 'register';
const BASIC_AUTH = 'basic';
const TOTP_AUTH = 'totp';

const SYSTEM_USER = UUID.fetchBootUUID() || 'admin';

Expand Down Expand Up @@ -56,6 +62,10 @@ passport.use(BASIC_AUTH, new BasicStrategy(function (username, password, next) {
return next(null, user);
}));

passport.use(new TotpStrategy(function(user, done) {
done(null, user.totpKey, 30)
}));

createJwtOptions().then(function (data) {
const jwtOptions = data;

Expand Down Expand Up @@ -106,6 +116,17 @@ function basic(req, res, next) {
.then(userData => {
const storedPassword = userData.password;

// check 2FA token when enabled
if(userData.totpEnabled) {
let vres = notp.totp.verify(req.body.totpToken, userData.totpKey)

if(vres && vres.delta == 0) {

} else {
return next(new NodeError('Unable to authenticate', 401));
}
dsbaars marked this conversation as resolved.
Show resolved Hide resolved
}

bcrypt.compare(user.password, storedPassword)
.then(handleCompare)
.catch(next);
Expand Down Expand Up @@ -136,7 +157,10 @@ async function accountJWTProtected(req, res, next) {
if (error || user === false) {
return next(new NodeError('Invalid JWT', 401)); // eslint-disable-line no-magic-numbers
}

req.logIn(user, function (err) {


Comment on lines +158 to +161
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please revert this too to keep changes minimal.

if (err) {
return next(new NodeError('Unable to authenticate', 401)); // eslint-disable-line no-magic-numbers
}
Expand Down
2 changes: 2 additions & 0 deletions package.json
Expand Up @@ -32,9 +32,11 @@
"passport": "^0.4.0",
"passport-http": "^0.3.0",
"passport-jwt": "^4.0.0",
"passport-totp": "^0.0.2",
"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",
Expand Down
78 changes: 78 additions & 0 deletions routes/v1/account.js
@@ -1,4 +1,6 @@
const express = require('express');
const notp = require('notp');

const router = express.Router();

// const applicationLogic = require('logic/application.js');
Expand Down Expand Up @@ -56,6 +58,82 @@ router.get('/change-password/status', auth.jwt, safeHandler(async (req, res) =>
return res.status(constants.STATUS_CODES.OK).json(status);
}));

router.get('/totp/setup', auth.jwt, safeHandler(async (req, res, next) => {
const info = await authLogic.getInfo();
let key;
if (info.totpKey && info.totpKey != "") {
// TOTP is already set up
key = info.totpKey;
} else {
// New TOTP setup
key = authLogic.generateRandomKey();
authLogic.setTotpKey(key);
}

const encodedKey = authLogic.encodeKey(key);
return res.json(encodedKey.toString());
}));

router.post('/totp/enable', auth.jwt, safeHandler(async (req, res, next) => {
const info = await authLogic.getInfo();

if (info.totpKey && req.body.authenticatorToken) {
// TOTP should be already set up
const key = info.totpKey;

let vres = notp.totp.verify(req.body.authenticatorToken, key)

if(vres && vres.delta == 0) {
authLogic.enableTotp();
return res.json("success");
} else {
throw new Error('TOTP token invalid');
}
} else {
throw new Error('TOTP enable failed');
}
}));

router.post('/totp/disable', auth.jwt, safeHandler(async (req, res, next) => {
const info = await authLogic.getInfo();

if (info.totpKey && req.body.authenticatorToken) {
// TOTP should be already set up
const key = info.totpKey;

let vres = notp.totp.verify(req.body.authenticatorToken, key)

if(vres && vres.delta == 0) {
await authLogic.disableTotp();
await authLogic.setTotpKey("");
return res.json("success");
} else {
throw new Error('TOTP token invalid');
}
} else {
throw new Error('TOTP disable failed');
}
}));

// Returns the current status of TOTP.
router.get('/totp/status', safeHandler(async (req, res) => {
const status = await authLogic.getTotpStatus();
return res.json({ "totpEnabled": status });
}));

router.post('/totp/auth', auth.jwt, safeHandler(async (req, res) => {
const info = await authLogic.getInfo();
if (info.totpKey && req.body.totpToken) {
let vres = notp.totp.verify(req.body.totpToken, info.totpKey)
if (vres && vres.delta == 0) {
req.session.totpAuthenticated = true;
return res.json("success");
} else {
throw new Error('TOTP token invalid');
}
}
}));

// Registered does not need auth. This is because the user may not be registered at the time and thus won't always have
// an auth token.
router.get('/registered', safeHandler((req, res) =>
Expand Down