Skip to content

Commit

Permalink
Merge branch 'Monyer-TOTP'
Browse files Browse the repository at this point in the history
  • Loading branch information
yeln4ts committed Nov 12, 2021
2 parents 0a0295b + c7f7676 commit c1aaf12
Show file tree
Hide file tree
Showing 11 changed files with 404 additions and 12 deletions.
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
"jszip": "^2.6.1",
"lodash": "^4.17.15",
"mongoose": "^5.7.7",
"otpauth": "^7.0.6",
"qrcode": "^1.4.4",
"socket.io": "^2.3.0",
"winston": "^2.3.1",
"xml": "^1.0.1"
Expand Down
122 changes: 120 additions & 2 deletions backend/src/models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ var auth = require('../lib/auth.js');
const { generateUUID } = require('../lib/utils.js');
var _ = require('lodash')

var QRCode = require('qrcode');
var OTPAuth = require('otpauth');

var UserSchema = new Schema({
username: {type: String, unique: true, required: true},
password: {type: String, required: true},
Expand All @@ -15,9 +18,45 @@ var UserSchema = new Schema({
email: {type: String, required: false},
phone: {type: String, required: false},
role: {type: String, default: 'user'},
totpEnabled: {type: Boolean, default: false},
totpSecret: {type: String, default: ''},
refreshTokens: [{_id: false, sessionId: String, userAgent: String, token: String}]
}, {timestamps: true});

var totpConfig = {
issuer: 'PwnDoc',
label: '',
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: ''
};

//check TOTP token
var checkTotpToken = function(token, secret) {
if (!token)
throw({fn: 'BadParameters', message: 'TOTP token required'});
if (token.length !== 6)
throw({fn: 'BadParameters', message: 'Invalid TOTP token length'});
if(!secret)
throw({fn: 'BadParameters', message: 'TOTP secret required'});

let newConfig = totpConfig;
newConfig.secret = secret;
let totp = new OTPAuth.TOTP(newConfig);
let delta = totp.validate({
token: token,
window: 5,
});
//The token is valid in 2 windows in the past and the future, current window is 0.
if ( delta === null) {
throw({fn: 'Unauthorized', message: 'Wrong TOTP token.'});
}else if (delta < -2 || delta > 2) {
throw({fn: 'Unauthorized', message: 'TOTP token out of window.'});
}
return true;
};

/*
*** Statics ***
*/
Expand All @@ -44,7 +83,7 @@ UserSchema.statics.create = function (user) {
UserSchema.statics.getAll = function () {
return new Promise((resolve, reject) => {
var query = this.find();
query.select('username firstname lastname email phone role');
query.select('username firstname lastname email phone role totpEnabled');
query.exec()
.then(function(rows) {
resolve(rows);
Expand All @@ -59,7 +98,7 @@ UserSchema.statics.getAll = function () {
UserSchema.statics.getByUsername = function (username) {
return new Promise((resolve, reject) => {
var query = this.findOne({username: username})
query.select('username firstname lastname email phone role');
query.select('username firstname lastname email phone role totpEnabled');
query.exec()
.then(function(row) {
if (row)
Expand Down Expand Up @@ -89,6 +128,7 @@ UserSchema.statics.updateProfile = function (username, user) {
if (!_.isNil(user.email)) row.email = user.email;
if (!_.isNil(user.phone)) row.phone = user.phone;
if (user.newPassword) row.password = bcrypt.hashSync(user.newPassword, 10);
if (typeof(user.totpEnabled)=='boolean') row.totpEnabled = user.totpEnabled;

payload.id = row._id;
payload.username = row.username;
Expand Down Expand Up @@ -243,6 +283,80 @@ UserSchema.statics.removeSession = function (userId, sessionId) {
})
}

// gen totp QRCode url
UserSchema.statics.getTotpQrcode = function (username) {
return new Promise((resolve, reject) => {
let newConfig = totpConfig;
newConfig.label = username;
const secret = new OTPAuth.Secret({
size: 10,
}).base32;
newConfig.secret = secret;

let totp = new OTPAuth.TOTP(newConfig);
let totpUrl = totp.toString();

QRCode.toDataURL(totpUrl, function (err, url) {
resolve({
totpQrCode: url,
totpSecret: secret,
});
});
})
}

// verify TOTP and Setup enabled status and secret code
UserSchema.statics.setupTotp = function (token, secret, username){
return new Promise((resolve, reject) => {
checkTotpToken(token, secret);

var query = this.findOne({username: username});
query.exec()
.then(function(row) {
if (!row)
throw({errmsg: 'User not found'});
else if (row.totpEnabled === true)
throw({errmsg: 'TOTP already enabled by this user'});
else {
row.totpEnabled = true;
row.totpSecret = secret;
return row.save();
}
})
.then(function() {
resolve({msg: true});
})
.catch(function(err) {
reject(err);
})
})
}

// verify TOTP and Cancel enabled status and secret code
UserSchema.statics.cancelTotp = function (token, username){
return new Promise((resolve, reject) => {
var query = this.findOne({username: username});
query.exec()
.then(function(row) {
if (!row)
throw({errmsg: 'User not found'});
else if (row.totpEnabled !== true)
throw({errmsg: 'TOTP is not enabled yet'});
else {
checkTotpToken(token, row.totpSecret);
row.totpEnabled = false;
row.totpSecret = '';
return row.save();
}
})
.then(function() {
resolve({msg: 'TOTP is canceled.'});
})
.catch(function(err) {
reject(err);
})
})
}

/*
*** Methods ***
Expand All @@ -256,6 +370,10 @@ UserSchema.methods.getToken = function (userAgent) {
query.exec()
.then(function(row) {
if (row && bcrypt.compareSync(user.password, row.password)) {
if (row.totpEnabled && user.totpToken)
checkTotpToken(user.totpToken, row.totpSecret)
else if (row.totpEnabled)
throw({fn: 'BadParameters', message: 'Missing TOTP token'})
var refreshToken = jwt.sign({sessionId: null, userId: row._id}, auth.jwtRefreshSecret)
return User.updateRefreshToken(refreshToken, userAgent)
}
Expand Down
35 changes: 35 additions & 0 deletions backend/src/routes/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ module.exports = function(app) {
user.username = req.body.username;
user.password = req.body.password;

//Optional params
if (req.body.totpToken) user.totpToken = req.body.totpToken;

user.getToken(req.headers['user-agent'])
.then(msg => {
res.cookie('token', `JWT ${msg.token}`, {secure: true, httpOnly: true})
Expand Down Expand Up @@ -112,6 +115,37 @@ module.exports = function(app) {
.catch(err => Response.Internal(res, err))
});

//get TOTP Qrcode URL
app.get("/api/users/totp", acl.hasPermission('validtoken'), function(req, res) {
User.getTotpQrcode(req.decodedToken.username)
.then(msg => Response.Ok(res, msg))
.catch(err => Response.Internal(res, err))
});

//setup TOTP
app.post("/api/users/totp", acl.hasPermission('validtoken'), function(req, res) {
if (!req.body.totpToken || !req.body.totpSecret) {
Response.BadParameters(res, 'Missing some required parameters');
return;
}

User.setupTotp(req.body.totpToken, req.body.totpSecret, req.decodedToken.username)
.then(msg => Response.Ok(res, msg))
.catch(err => Response.Internal(res, err))
});

//cancel TOTP
app.delete("/api/users/totp", acl.hasPermission('validtoken'), function(req, res) {
if (!req.body.totpToken) {
Response.BadParameters(res, 'Missing some required parameters');
return;
}

User.cancelTotp(req.body.totpToken, req.decodedToken.username)
.then(msg => Response.Ok(res, msg))
.catch(err => Response.Internal(res, err))
});

// Get user by username
app.get("/api/users/:username", acl.hasPermission('users:read'), function(req, res) {
User.getByUsername(req.params.username)
Expand Down Expand Up @@ -229,6 +263,7 @@ module.exports = function(app) {
if (!_.isNil(req.body.email)) user.email = req.body.email;
if (!_.isNil(req.body.phone)) user.phone = req.body.phone;
if (req.body.role) user.role = req.body.role;
if (typeof(req.body.totpEnabled)=='boolean') user.totpEnabled = req.body.totpEnabled;

User.updateUser(req.params.id, user)
.then(msg => Response.Ok(res, msg))
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/boot/axios.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ axiosInstance.interceptors.response.use(
error => {
const originalRequest = error.config

// **** 401 exceptions to avoid infinite loop

// 401 after User.refreshToken function call
if (error.response.status === 401 && originalRequest.url.endsWith('/users/refreshtoken')) {
User.clear()
Expand All @@ -33,6 +35,13 @@ axiosInstance.interceptors.response.use(
return Promise.reject(error)
}

// 401 after totp
if (error.response.status === 401 && originalRequest.url.endsWith('/users/totp')) {
return Promise.reject(error)
}

// **** End of exceptions

// All other 401 calls
if (error.response.status === 401) {
if (!refreshPending) {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/en-US/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -348,4 +348,6 @@ export default {
languageMoveToLeft: 'Language (Move to left)',
merge: 'Merge',
goBack: 'Go back',
twoStepVerification: '2-Step Verification',
twoStepVerificationMessage: 'Open your authentication app and enter the security code it provides.'
}
5 changes: 4 additions & 1 deletion frontend/src/pages/data/collaborators/collaborators.html
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,10 @@
bg-color="white"
/>
</q-card-section>

<q-card-section>
<p v-if="currentCollab.totpEnabled">2FA <b>enabled</b> for this user</p>
<p v-else>2FA <b>disabled</b> for this user</p>
</q-card-section>
<q-separator />

<q-card-actions align="right">
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/pages/data/collaborators/collaborators.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ export default {
username: '',
role: '',
email: '',
phone: ''
phone: '',
totpEnabled: false
},
// Username to identify collab to update
idUpdate: '',
Expand Down
54 changes: 49 additions & 5 deletions frontend/src/pages/login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,7 @@
</div>

<div v-else>

<q-card-section>
<q-card-section v-show="step === 0">
<q-input
:label="$t('username')"
:error="!!errors.username"
Expand All @@ -90,7 +89,7 @@
<template v-slot:prepend><q-icon name="fa fa-user" /></template>
</q-input>
</q-card-section>
<q-card-section>
<q-card-section v-show="step === 0">
<q-input
:label="$t('password')"
:error="!!errors.password"
Expand All @@ -106,6 +105,38 @@
<template v-slot:prepend><q-icon name="fa fa-key" /></template>
</q-input>
</q-card-section>
<q-card-section v-show="step === 1">
<q-item class="q-pl-none">
<q-item-section avatar style="min-width:0" class="q-pr-sm">
<q-btn dense flat size="sm" icon="mdi-arrow-left" style="top:-8px" @click="step=0;totpToken=''">
<q-tooltip>{{$t('goBack')}}</q-tooltip>
</q-btn>
</q-item-section>
<q-item-section>
<p class="text-left text-h6 text-center text-vertical">{{$t('twoStepVerification')}}</p>
</q-item-section>
</q-item>
<q-item class="q-pl-none">
<q-item-section avatar class="no-padding">
<q-icon name="mdi-cellphone-key" size="70px" />
</q-item-section>
<q-item-section>
<p>{{$t('twoStepVerificationMessage')}}</p>
</q-item-section>
</q-item>
<q-input
ref="totptoken"
v-model="totpToken"
placeholder="Enter 6-digit code"
outlined
bg-color="white"
for="totpToken"
maxlength=6
@keyup.enter="getToken()"
>
<template v-slot:prepend><q-icon name="fa fa-unlock-alt" /></template>
</q-input>
</q-card-section>

<q-card-section align="center">
<q-btn color="blue" class="full-width" unelevated no-caps @click="getToken()">{{$t('login')}}</q-btn>
Expand All @@ -131,6 +162,8 @@ export default {
firstname: "",
lastname: "",
password: "",
totpToken: "",
step: 0,
errors: {alert: "", username: "", password: "", firstname: "", lastname: ""}
}
},
Expand Down Expand Up @@ -205,13 +238,24 @@ export default {
if (this.errors.username || this.errors.password)
return;
UserService.getToken(this.username, this.password)
UserService.getToken(this.username, this.password, this.totpToken)
.then(async () => {
await this.$settings.refresh();
this.$router.push('/');
})
.catch(err => {
this.errors.alert = $t('err.invalidCredentials');
if (err.response.status === 422) {
this.step = 1
this.$nextTick(() => {
this.$refs.totptoken.focus()
})
}
else {
let errmsg = $t('err.invalidCredentials');
if (err.response.data.datas)
errmsg = err.response.data.datas;
this.errors.alert = errmsg;
}
})
}
}
Expand Down

0 comments on commit c1aaf12

Please sign in to comment.