CVE-2021-31590: Other (NVD-CWE-Other)
value: PwnDoc all versions until 0.4.0 (2021-08-23) has incorrect JSON Webtoken handling, leading to incorrect access control. With a valid JSON Webtoken that is used for authentication and authorization, a user can keep his admin privileges even if he is downgraded to the "user" privilege. Even after a user's account is deleted, the user can still access the administration panel (and add or delete users) and has complete access to the system.
CWE description: NVD is only using a subset of CWE for mapping instead of the entire CWE, and the weakness type is not covered by that subset.
CVSS v3 base score: 8.8
Published: 2021-07-19T20:15Z
Repository: https://github.com/pwndoc/pwndoc
Language: JavaScript
Files changed: backend/src/models/user.js, backend/src/routes/user.js, frontend/src/boot/auth.js, frontend/src/boot/axios.js, frontend/src/pages/profile/profile.js, frontend/src/services/user.js
Commit date: 2021-07-13 11:21:24 -0400
Commit message: Update Session management using refresh token

Added refresh token with lifetime of 7 days
Token has now a lifetime of 15min
Refresh token is updated on the frontend each time application is loaded or every 14min
Each refresh token is associated with a sessionId allowing to have multiple sessions on different devices

Diff: diff --git a/backend/src/models/user.js b/backend/src/models/user.js
index 84c0f57..3a2e802 100644
--- a/backend/src/models/user.js
+++ b/backend/src/models/user.js
@@ -96,11 +96,11 @@ UserSchema.statics.updateProfile = function (username, user) {
             }
             else
                 throw({fn: 'Unauthorized', message: 'Current password is invalid'});
         })
         .then(function() {
-            var token = jwt.sign(payload, auth.jwtSecret, {expiresIn: '24h'});
+            var token = jwt.sign(payload, auth.jwtSecret, {expiresIn: '15 minutes'});
             resolve({token: `JWT ${token}`});
         })
         .catch(function(err) {
             if (err.code === 11000)
                 reject({fn: 'BadParameters', message: 'Username already exists'});
@@ -137,21 +137,21 @@ UserSchema.statics.updateRefreshToken = function (refreshToken, userAgent) {
     return new Promise((resolve, reject) => {
         var token = ""
         var newRefreshToken = ""
         try {
             var decoded = jwt.verify(refreshToken, auth.jwtRefreshSecret)
-            var username = decoded.username
+            var userId = decoded.userId
             var sessionId = decoded.sessionId
             var expiration = decoded.exp
         }
         catch (err) {
             if (err.name === 'TokenExpiredError')
                 throw({fn: 'Unauthorized', message: 'Expired refreshToken'})
             else
                 throw({fn: 'Unauthorized', message: 'Invalid refreshToken'})
         }
-        var query = this.findOne({username: username})
+        var query = this.findById(userId)
         query.exec()
         .then(row => {
             if (row) {
                 // Check session exist and sessionId not null (if null then it is a login)
                 if (sessionId !== null) {
@@ -183,21 +183,21 @@ UserSchema.statics.updateRefreshToken = function (refreshToken, userAgent) {
                 })
                 // Update or add new refresh token
                 var foundIndex = row.refreshTokens.findIndex(e => e.sessionId === sessionId)
                 if (foundIndex === -1) { // Not found
                     sessionId = generateUUID()
-                    newRefreshToken = jwt.sign({sessionId: sessionId, username: username}, auth.jwtRefreshSecret, {expiresIn: '7 days'})
+                    newRefreshToken = jwt.sign({sessionId: sessionId, userId: userId}, auth.jwtRefreshSecret, {expiresIn: '7 days'})
                     row.refreshTokens.push({sessionId: sessionId, userAgent: userAgent, token:newRefreshToken})
                  }
                 else {
-                    newRefreshToken = jwt.sign({sessionId: sessionId, username: username, exp: expiration}, auth.jwtRefreshSecret)
+                    newRefreshToken = jwt.sign({sessionId: sessionId, userId: userId, exp: expiration}, auth.jwtRefreshSecret)
                     row.refreshTokens[foundIndex].token = newRefreshToken
                 }
                 return row.save()
             }
             else
-                reject({fn: 'NotFound', message: 'User not found'})
+                reject({fn: 'NotFound', message: 'Session not found'})
         })
         .then(() => {
             resolve({token: token, refreshToken: newRefreshToken})
         })
         .catch(err => {
@@ -208,13 +208,13 @@ UserSchema.statics.updateRefreshToken = function (refreshToken, userAgent) {
         })
     })
 }
 
 // Remove session
-UserSchema.statics.removeSession = function (username, sessionId) {
+UserSchema.statics.removeSession = function (userId, sessionId) {
     return new Promise((resolve, reject) => {
-        var query = this.findOne({username: username})
+        var query = this.findById(userId)
         query.exec()
         .then(row => {
             if (row) {
                 row.refreshTokens = row.refreshTokens.filter(e => e.sessionId !== sessionId)
                 return row.save()
@@ -241,16 +241,15 @@ UserSchema.statics.removeSession = function (username, sessionId) {
 
 // Authenticate user with username and password, return JWT token
 UserSchema.methods.getToken = function (userAgent) {
     return new Promise((resolve, reject) => {
         var user = this;
-        var token = ""
         var query = User.findOne({username: user.username});
         query.exec()
         .then(function(row) {
             if (row && bcrypt.compareSync(user.password, row.password)) {
-                var refreshToken = jwt.sign({sessionId: null, username: row.username}, auth.jwtRefreshSecret)
+                var refreshToken = jwt.sign({sessionId: null, userId: row._id}, auth.jwtRefreshSecret)
                 return User.updateRefreshToken(refreshToken, userAgent)
             }
             else
                 throw({fn: 'Unauthorized', message: 'Invalid credentials'});
         })
diff --git a/backend/src/routes/user.js b/backend/src/routes/user.js
index e6d057a..1d9572f 100644
--- a/backend/src/routes/user.js
+++ b/backend/src/routes/user.js
@@ -30,11 +30,11 @@ module.exports = function(app) {
             Response.Internal(res, err)
         })
     });
 
     // Remove token cookie
-    app.get("/api/users/destroytoken", function(req, res) {
+    app.delete("/api/users/refreshtoken", function(req, res) {
         var token = req.cookies['refreshToken']
         try {
             var decoded = jwt.verify(token, jwtRefreshSecret)
         }
         catch (err) {
@@ -44,11 +44,11 @@ module.exports = function(app) {
                 Response.Unauthorized(res, 'Expired refreshToken')
             else
                 Response.Unauthorized(res, 'Invalid refreshToken')
             return
         }
-        User.removeSession(decoded.username, decoded.sessionId)
+        User.removeSession(decoded.userId, decoded.sessionId)
         .then(msg => {
             res.clearCookie('token')
             res.clearCookie('refreshToken')
             Response.Ok(res, msg)
         })
@@ -148,13 +148,14 @@ module.exports = function(app) {
                     var newUser = new User();
                     //Required params
                     newUser.username = req.body.username;
                     newUser.password = req.body.password;
 
-                    newUser.getToken()
+                    newUser.getToken(req.headers['user-agent'])
                     .then(msg => {
-                        res.cookie('token', msg.token, {secure: true, httpOnly: true})
+                        res.cookie('token', `JWT ${msg.token}`, {secure: true, httpOnly: true})
+                        res.cookie('refreshToken', msg.refreshToken, {secure: true, httpOnly: true, path: '/api/users/refreshtoken'})
                         Response.Created(res, msg)
                     })
                     .catch(err => Response.Internal(res, err))
                 })
                 .catch((err) => Response.Internal(res, err))
diff --git a/frontend/src/boot/auth.js b/frontend/src/boot/auth.js
index 37c625a..9410b2d 100644
--- a/frontend/src/boot/auth.js
+++ b/frontend/src/boot/auth.js
@@ -1,17 +1,39 @@
 import User from '@/services/user';
 
-export default ({ app, router, Vue }) => {
+export default async ({ urlPath, router, redirect }) => {
+  // Launch refresh token countdown 840000=14min if not on login page
+  setInterval(() => {
+    User.refreshToken()
+    .then()
+    .catch(err => {
+      if (!router.currentRoute.path.startsWith('/login'))
+        if (err === 'Expired refreshToken')
+          redirect('/login?tokenError=2')
+        else
+          redirect('/login')
+    })
+  }, 840000)
+
+  // Call refreshToken when loading app and redirect to login if error
+  try {
+    await User.refreshToken()
+  }
+  catch(err) {
+    if (!urlPath.startsWith('/login'))
+      if (err === 'Expired refreshToken')
+        redirect('/login?tokenError=2')
+      else
+        redirect('/login')
+  }
+
   router.beforeEach((to, from, next) => {
     if (to.path === '/login') {
-      User.checkToken()
-      .then(() => {
+      if (User.isAuth())
         next('/')
-      })
-      .catch((error) => {
+      else
         next()
-      })
     }
     else {
       next()
     }
   })
diff --git a/frontend/src/boot/axios.js b/frontend/src/boot/axios.js
index 7a5a314..c7f8a60 100755
--- a/frontend/src/boot/axios.js
+++ b/frontend/src/boot/axios.js
@@ -1,23 +1,59 @@
 import axios from 'axios'
 import User from '@/services/user'
+import Router from '../router'
 
 const axiosInstance = axios.create({
   baseURL: `${window.location.origin}/api`
 })
 
-export default ({ Vue }) => {
+var refreshPending = false
+var requestsQueue = []
 
-  // Redirect to login if response is 401 (Unauthenticated)
-  axiosInstance.interceptors.response.use(response => {
+// Redirect to login if response is 401 (Unauthenticated)
+axiosInstance.interceptors.response.use(
+  response => {
     return response
-  }, error => {
-   if (error.response.status === 401) {
-     if (window.location.pathname !== '/login')
-      window.location = '/login'
-   }
-  return error
-  })
+  }, 
+  error => {
+    const originalRequest = error.config
+
+    // 401 after User.refreshToken function call
+    if (error.response.status === 401 && originalRequest.url.endsWith('/users/refreshtoken')) {
+      User.clear()
+      return Promise.reject(error)
+    }
+
+    // 401 after login request
+    if (error.response.status === 401 && originalRequest.url.endsWith('/users/token')) {
+      return Promise.reject(error)
+    }
 
+    // All other 401 calls
+    if (error.response.status === 401) {
+      if (!refreshPending) {
+        refreshPending = true
+        axiosInstance.get('/users/refreshtoken')
+        .then(() => {
+          requestsQueue.forEach(e => e())
+          requestsQueue = []
+        })
+        .catch(err => {
+          Router.push('/login')
+          return Promise.reject(error)
+          
+        })
+        .finally(() => {
+          refreshPending = false
+        })
+      }
+      return new Promise((resolve) => {
+        requestsQueue.push(() => resolve(axiosInstance.request(originalRequest)))
+      })
+    }
+    return Promise.reject(error)
+  }
+)
+
+export default ({ Vue }) => {
   Vue.prototype.$axios = axiosInstance
-  User.refreshToken()
 }
diff --git a/frontend/src/pages/profile/profile.js b/frontend/src/pages/profile/profile.js
index 71e497a..d1c2cf8 100755
--- a/frontend/src/pages/profile/profile.js
+++ b/frontend/src/pages/profile/profile.js
@@ -42,11 +42,11 @@ export default {
             if (this.errors.username || this.errors.firstname || this.errors.lastname || this.errors.currentPassword || this.errors.newPassword)
                 return;
 
             UserService.updateProfile(this.user)
             .then((data) => {
-                UserService.checkToken()
+                UserService.refreshToken()
                 Notify.create({
                     message: 'Profile updated successfully',
                     color: 'positive',
                     textColor:'white',
                     position: 'top-right'
diff --git a/frontend/src/services/user.js b/frontend/src/services/user.js
index f20a82c..8c9a8cf 100644
--- a/frontend/src/services/user.js
+++ b/frontend/src/services/user.js
@@ -1,77 +1,60 @@
 var jwtDecode = require('jwt-decode');
 import Vue from 'vue';
+import User from '@/services/user';
 
-import Router from '../router'
+import Router from '@/router'
 
 export default {
-    user: null,
+    user: {
+        username: "",
+        role: "",
+        firstname: "",
+        lastname: "",
+        roles: ""
+    },
 
     getToken(username, password) {
         return new Promise((resolve, reject) => {
             var params = {username: username, password: password};
             Vue.prototype.$axios.post(`users/token`, params)
             .then((response) => {
                 var token = response.data.datas.token;
                 this.user = jwtDecode(token);
-                var countdown = this.user.exp*1000 - Date.now() - 60000 // Countdown to expiration less 1 minute
-                setTimeout(() => {
-                    this.refreshToken()
-                }, countdown)
                 resolve();
             })
             .catch((error) => {
-                console.log(error)
                 reject(error);
             })
         })
     },
 
     refreshToken() {
-        Vue.prototype.$axios.get('users/refreshtoken')
-        .then((response) => {
-            var token = response.data.datas.token;
-            this.user = jwtDecode(token);
-            var countdown = this.user.exp*1000 - Date.now() - 60000 // Countdown to expiration less 1 minute
-            setTimeout(() => {
-                this.refreshToken()
-            }, countdown)
-        })
-        .catch(err => {
-            console.log(err)
-            if (err.response && err.response.data.datas.includes('Expired'))
-                Router.push('/login?tokenError=2')
-            else
-                Router.push('/login')
+        return new Promise((resolve, reject) => {
+            Vue.prototype.$axios.get('users/refreshtoken')
+            .then((response) => {
+                var token = response.data.datas.token;
+                this.user = jwtDecode(token);
+                resolve()
+            })
+            .catch(err => {
+                if (err.response && err.response.data)
+                    reject(err.response.data.datas)
+                else 
+                    reject('Invalid Token')
+            })
         })
     },
 
     destroyToken() {
-        Vue.prototype.$axios.get('users/destroytoken')
+        Vue.prototype.$axios.delete('users/refreshtoken')
         .then(() => {
+            User.clear()
             Router.push('/login');
         })
-        .catch(err => Router.push('/login'))
-    },
-
-    checkToken() {
-        return new Promise((resolve, reject) => {
-            Vue.prototype.$axios.get(`users/checktoken`)
-            .then(data => {
-                var token = data.data.datas
-                var decoded = jwtDecode(token);
-                if (decoded) {
-                    this.user = decoded;
-                    resolve();
-                }
-                else
-                    reject('InvalidToken');
-                resolve()
-            })
-            .catch((error) => {
-                reject(error);
-            })
+        .catch(err => {
+            console.log(err)
         })
     },
 
     initUser(username, firstname, lastname, password) {
         return new Promise((resolve, reject) => {
@@ -92,11 +75,24 @@ export default {
     isInit() {
         return (Vue.prototype.$axios.get(`users/init`, {timeout: 10000}));
     },
 
     isAuth() {
-        return (this.user !== null);
+        if (this.user && this.user.username)
+            return true
+        return false
+    },
+
+    // Reset user variable to default empty
+    clear() {
+        this.user = {
+            username: "",
+            role: "",
+            firstname: "",
+            lastname: "",
+            roles: ""
+        }
     },
 
     isAllowed(role) {
         return (this.user.roles && (this.user.roles.includes(role) || this.user.roles === '*'))
     },

Vulnerable code:             var token = jwt.sign(payload, auth.jwtSecret, {expiresIn: '24h'});
            var username = decoded.username
        var query = this.findOne({username: username})
                    newRefreshToken = jwt.sign({sessionId: sessionId, username: username}, auth.jwtRefreshSecret, {expiresIn: '7 days'})
                    newRefreshToken = jwt.sign({sessionId: sessionId, username: username, exp: expiration}, auth.jwtRefreshSecret)
                reject({fn: 'NotFound', message: 'User not found'})
UserSchema.statics.removeSession = function (username, sessionId) {
        var query = this.findOne({username: username})
        var token = ""
                var refreshToken = jwt.sign({sessionId: null, username: row.username}, auth.jwtRefreshSecret)
    app.get("/api/users/destroytoken", function(req, res) {
        User.removeSession(decoded.username, decoded.sessionId)
                    newUser.getToken()
                        res.cookie('token', msg.token, {secure: true, httpOnly: true})
export default ({ app, router, Vue }) => {
      User.checkToken()
      .then(() => {
      })
      .catch((error) => {
      })
export default ({ Vue }) => {
  // Redirect to login if response is 401 (Unauthenticated)
  axiosInstance.interceptors.response.use(response => {
  }, error => {
   if (error.response.status === 401) {
     if (window.location.pathname !== '/login')
      window.location = '/login'
   }
  return error
  })
  User.refreshToken()
                UserService.checkToken()
import Router from '../router'
    user: null,
                var countdown = this.user.exp*1000 - Date.now() - 60000 // Countdown to expiration less 1 minute
                setTimeout(() => {
                    this.refreshToken()
                }, countdown)
                console.log(error)
        Vue.prototype.$axios.get('users/refreshtoken')
        .then((response) => {
            var token = response.data.datas.token;
            this.user = jwtDecode(token);
            var countdown = this.user.exp*1000 - Date.now() - 60000 // Countdown to expiration less 1 minute
            setTimeout(() => {
                this.refreshToken()
            }, countdown)
        })
        .catch(err => {
            console.log(err)
            if (err.response && err.response.data.datas.includes('Expired'))
                Router.push('/login?tokenError=2')
            else
                Router.push('/login')
        Vue.prototype.$axios.get('users/destroytoken')
        .catch(err => Router.push('/login'))
    },

    checkToken() {
        return new Promise((resolve, reject) => {
            Vue.prototype.$axios.get(`users/checktoken`)
            .then(data => {
                var token = data.data.datas
                var decoded = jwtDecode(token);
                if (decoded) {
                    this.user = decoded;
                    resolve();
                }
                else
                    reject('InvalidToken');
                resolve()
            })
            .catch((error) => {
                reject(error);
            })
        return (this.user !== null);
Fixed code:             var token = jwt.sign(payload, auth.jwtSecret, {expiresIn: '15 minutes'});
            var userId = decoded.userId
        var query = this.findById(userId)
                    newRefreshToken = jwt.sign({sessionId: sessionId, userId: userId}, auth.jwtRefreshSecret, {expiresIn: '7 days'})
                    newRefreshToken = jwt.sign({sessionId: sessionId, userId: userId, exp: expiration}, auth.jwtRefreshSecret)
                reject({fn: 'NotFound', message: 'Session not found'})
UserSchema.statics.removeSession = function (userId, sessionId) {
        var query = this.findById(userId)
                var refreshToken = jwt.sign({sessionId: null, userId: row._id}, auth.jwtRefreshSecret)
    app.delete("/api/users/refreshtoken", function(req, res) {
        User.removeSession(decoded.userId, decoded.sessionId)
                    newUser.getToken(req.headers['user-agent'])
                        res.cookie('token', `JWT ${msg.token}`, {secure: true, httpOnly: true})
                        res.cookie('refreshToken', msg.refreshToken, {secure: true, httpOnly: true, path: '/api/users/refreshtoken'})
export default async ({ urlPath, router, redirect }) => {
  // Launch refresh token countdown 840000=14min if not on login page
  setInterval(() => {
    User.refreshToken()
    .then()
    .catch(err => {
      if (!router.currentRoute.path.startsWith('/login'))
        if (err === 'Expired refreshToken')
          redirect('/login?tokenError=2')
        else
          redirect('/login')
    })
  }, 840000)

  // Call refreshToken when loading app and redirect to login if error
  try {
    await User.refreshToken()
  }
  catch(err) {
    if (!urlPath.startsWith('/login'))
      if (err === 'Expired refreshToken')
        redirect('/login?tokenError=2')
      else
        redirect('/login')
  }

      if (User.isAuth())
      else
import Router from '../router'
var refreshPending = false
var requestsQueue = []
// Redirect to login if response is 401 (Unauthenticated)
axiosInstance.interceptors.response.use(
  response => {
  }, 
  error => {
    const originalRequest = error.config

    // 401 after User.refreshToken function call
    if (error.response.status === 401 && originalRequest.url.endsWith('/users/refreshtoken')) {
      User.clear()
      return Promise.reject(error)
    }

    // 401 after login request
    if (error.response.status === 401 && originalRequest.url.endsWith('/users/token')) {
      return Promise.reject(error)
    }
    // All other 401 calls
    if (error.response.status === 401) {
      if (!refreshPending) {
        refreshPending = true
        axiosInstance.get('/users/refreshtoken')
        .then(() => {
          requestsQueue.forEach(e => e())
          requestsQueue = []
        })
        .catch(err => {
          Router.push('/login')
          return Promise.reject(error)
          
        })
        .finally(() => {
          refreshPending = false
        })
      }
      return new Promise((resolve) => {
        requestsQueue.push(() => resolve(axiosInstance.request(originalRequest)))
      })
    }
    return Promise.reject(error)
  }
)

export default ({ Vue }) => {
                UserService.refreshToken()
import User from '@/services/user';
import Router from '@/router'
    user: {
        username: "",
        role: "",
        firstname: "",
        lastname: "",
        roles: ""
    },
        return new Promise((resolve, reject) => {
            Vue.prototype.$axios.get('users/refreshtoken')
            .then((response) => {
                var token = response.data.datas.token;
                this.user = jwtDecode(token);
                resolve()
            })
            .catch(err => {
                if (err.response && err.response.data)
                    reject(err.response.data.datas)
                else 
                    reject('Invalid Token')
            })
        Vue.prototype.$axios.delete('users/refreshtoken')
            User.clear()
        .catch(err => {
            console.log(err)
        if (this.user && this.user.username)
            return true
        return false
    },

    // Reset user variable to default empty
    clear() {
        this.user = {
            username: "",
            role: "",
            firstname: "",
            lastname: "",
            roles: ""
        }