diff --git a/src/client/app/redux/api/authApi.ts b/src/client/app/redux/api/authApi.ts index 9bff1db073..b45edf477d 100644 --- a/src/client/app/redux/api/authApi.ts +++ b/src/client/app/redux/api/authApi.ts @@ -15,7 +15,7 @@ export const authApi = baseApi.injectEndpoints({ endpoints: builder => ({ login: builder.mutation({ query: loginArgs => ({ - url: 'api/login', + url: 'api/loginLogout/login', method: 'POST', body: loginArgs }), @@ -42,12 +42,19 @@ export const authApi = baseApi.injectEndpoints({ return { data: null }; } }), - logout: builder.mutation({ - queryFn: (_, { dispatch }) => { - // Opt to use a RTK mutation instead of manually writing a thunk to take advantage mutation invalidations - deleteToken(); - dispatch(currentUserSlice.actions.clearCurrentUser()); - return { data: null }; + logout: builder.mutation<{ success: boolean, message: string }, void>({ + query: () => ({ + url: 'api/loginLogout/logout', + method: 'POST', + body: { token: getToken() } + }), + async onQueryStarted(_arg, { dispatch, queryFulfilled }) { + try { + await queryFulfilled; + } finally { + deleteToken(); + dispatch(currentUserSlice.actions.clearCurrentUser()); + } }, invalidatesTags: ['MeterData', 'GroupData'] }) @@ -55,4 +62,4 @@ export const authApi = baseApi.injectEndpoints({ }); // Poll interval in milliseconds (1 minute) -export const authPollInterval = 60000; \ No newline at end of file +export const authPollInterval = 60000; diff --git a/src/server/app.js b/src/server/app.js index 0dc5fdbc26..816af2ef19 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -17,7 +17,7 @@ const users = require('./routes/users'); const readings = require('./routes/readings'); const meters = require('./routes/meters'); const preferences = require('./routes/preferences'); -const login = require('./routes/login'); +const loginLogout = require('./routes/loginLogout'); const verification = require('./routes/verification'); const groups = require('./routes/groups'); const version = require('./routes/version'); @@ -132,7 +132,7 @@ app.use('/api/users', users); app.use('/api/meters', meters); app.use('/api/readings', readings); app.use('/api/preferences', preferences); -app.use('/api/login', login); +app.use('/api/loginLogout', loginLogout); app.use('/api/groups', groups); app.use('/api/verification', verification); app.use('/api/version', version); diff --git a/src/server/migrations/1.0.0-2.0.0/index.js b/src/server/migrations/1.0.0-2.0.0/index.js index bd7ba6e781..9040b893b1 100644 --- a/src/server/migrations/1.0.0-2.0.0/index.js +++ b/src/server/migrations/1.0.0-2.0.0/index.js @@ -16,6 +16,7 @@ module.exports = { await db.none(sqlFile('../migrations/1.0.0-2.0.0/sql/preferences/add_graph_type.sql')); await db.none(sqlFile('../migrations/1.0.0-2.0.0/sql/preferences/add_preferences_help_url.sql')); await db.none(sqlFile('../migrations/1.0.0-2.0.0/sql/users/add_users_note.sql')); + await db.none(sqlFile('../migrations/1.0.0-2.0.0/sql/users/add_token_invalid_before.sql')); await db.none(sqlFile('../migrations/1.0.0-2.0.0/sql/users/alter_users_table.sql')); // It should not matter but first rename cik and then do units. await db.none(sqlFile('../migrations/1.0.0-2.0.0/sql/cik/alter_cik_table.sql')); diff --git a/src/server/migrations/1.0.0-2.0.0/sql/users/add_token_invalid_before.sql b/src/server/migrations/1.0.0-2.0.0/sql/users/add_token_invalid_before.sql new file mode 100644 index 0000000000..0099c44d3e --- /dev/null +++ b/src/server/migrations/1.0.0-2.0.0/sql/users/add_token_invalid_before.sql @@ -0,0 +1,6 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +ALTER TABLE users +ADD COLUMN IF NOT EXISTS token_invalid_before TIMESTAMP NOT NULL DEFAULT TIMESTAMP 'epoch'; diff --git a/src/server/models/User.js b/src/server/models/User.js index ab41f242ac..0f2cdd5d46 100644 --- a/src/server/models/User.js +++ b/src/server/models/User.js @@ -13,13 +13,35 @@ class User { * @param passwordHash The user's passwordHash * @param role The user's role * @param note The user note + * @param tokenInvalidBefore Timestamp before which issued tokens are invalid */ - constructor(id, username, passwordHash, role, note = '') { + constructor(id, username, passwordHash, role, note = '', tokenInvalidBefore = null) { this.id = id; this.username = username; this.passwordHash = passwordHash; this.role = role; this.note = note; + this.tokenInvalidBefore = tokenInvalidBefore; + } + + /** + * Maps a database row to a User model. + * @param row + * @returns {User} + */ + static mapRow(row) { + if (row === null) { + return null; + } + + return new User( + row.id, + row.username, + row.password_hash, + row.role, + row.note, + row.token_invalid_before + ); } /** @@ -41,7 +63,7 @@ class User { */ static async getByID(id, conn) { const row = await conn.one(sqlFile('user/get_user_by_id.sql'), { id: id }); - return new User(row.id, row.username, row.password_hash, row.role, row.note); + return User.mapRow(row); } /** @@ -54,7 +76,7 @@ class User { */ static async getByUsername(username, conn) { const row = await conn.oneOrNone(sqlFile('user/get_user_by_username.sql'), { username: username }); - return row === null ? null : new User(row.id, row.username, row.password_hash, row.role, row.note); + return User.mapRow(row); } /** @@ -73,7 +95,7 @@ class User { */ static async getAll(conn) { const rows = await conn.any(sqlFile('user/get_all_users.sql')); - return rows.map(row => new User(row.id, row.username, undefined, row.role, row.note)); + return rows.map(User.mapRow); } /** @@ -81,7 +103,7 @@ class User { * @param id the id of the user whose password is to be updated * @param passwordHash the new password's hash * @param conn is the connection to use. - * @returns {Promise.>} + * @returns {Promise.} */ static async updateUserPassword(id, passwordHash, conn) { return conn.none(sqlFile('user/update_user_password.sql'), { id: id, password_hash: passwordHash }); @@ -132,6 +154,16 @@ class User { return conn.none(sqlFile('user/update_user.sql'), { id: id, username: username, role: role, note: note }); } + /** + * Returns a promise to invalidate all tokens issued before now for a user. + * @param id the id of the user whose tokens are to be invalidated + * @param conn is the connection to use. + * @returns {Promise} + */ + static async invalidateTokensBeforeNow(id, conn) { + return conn.none(sqlFile('user/update_token_invalid_before.sql'), { id: id }); + } + /** * Returns a promise to delete a user * @param username the username of the user @@ -168,7 +200,7 @@ class User { /** * Enum of roles. - * This enum needs to be kept in sync with the src/server/sql/create_user_types_enum.sql and the UserRoles enum in src/client/types/items.ts + * This enum needs to be kept in sync with the src/server/sql/create_user_types_enum.sql and the UserRoles enum in src/client/types/items.ts * @enum {string} */ User.role = Object.freeze({ diff --git a/src/server/routes/authenticator.js b/src/server/routes/authenticator.js index 1aa6367760..2d17555ee1 100644 --- a/src/server/routes/authenticator.js +++ b/src/server/routes/authenticator.js @@ -16,41 +16,36 @@ const { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, TOKEN_MAX_LENGTH, USERNAME_MIN = require('../util/validationConstants'); /** - * Middleware function to force a route to require authentication - * Verifies the request's token against the server's secret token - * It is used within this file but not by other parts of OED. + * Middleware function to require authentication on protected routes. + * Verifies the request's token, ensures the user exists, and checks that + * the token has not been invalidated. + * This middleware was created to only be used within this file. */ -authMiddleware = (req, res, next) => { +const authMiddleware = (req, res, next) => { const token = req.headers.token || req.body.token || req.query.token; const validParams = { type: 'string', maxLength: TOKEN_MAX_LENGTH }; + if (!validate(token, validParams).valid) { res.status(HTTP_CODES.FORBIDDEN).json({ success: false, message: 'No token provided or JSON was invalid.' }); } else if (token) { - jwt.verify(token, secretToken, async (err, decoded) => { - if (err) { + verifyActiveTokenAndGetUser(token) + .then(({ decoded }) => { + req.decoded = decoded; + next(); + }) + .catch(() => { res.status(HTTP_CODES.UNAUTHORIZED).json({ success: false, message: 'Failed to authenticate token.' }); - } else { - try { - const conn = getConnection(); - // checks if user exists in the database in case it was deleted - await User.getByID(decoded.data, conn); - req.decoded = decoded; - next(); - } catch (error) { - res.status(HTTP_CODES.UNAUTHORIZED).json({ success: false, message: 'User does not exist in database.' }); - } - } - }); + }); } else { res.status(HTTP_CODES.FORBIDDEN).send({ success: false, message: 'No token provided.' }); } }; /** - * Middleware that checks the request body for the username and password parameters. If the body contains the username and password parameters, then next + * Middleware that checks the request body for the username and password parameters. If the body contains the username and password parameters, then next * is executed. Otherwise, the server responds with a 400 error. */ function credentialsRequestValidationMiddleware(req, res, next) { @@ -82,9 +77,9 @@ function credentialsRequestValidationMiddleware(req, res, next) { /** * Verifies the username and password of a user. - * @param {string} username - * @param {string} password - * @param {boolean} returnUser + * @param {string} username + * @param {string} password + * @param {boolean} returnUser * @returns true if the user exists in the database. False otherwise. Returns the user itself if returnUser is set to true and user is verified. */ async function verifyCredentials(username, password, returnUser = false) { @@ -104,14 +99,60 @@ async function verifyCredentials(username, password, returnUser = false) { } } +/** + * Verifies a JWT, ensures the user exists, and rejects tokens that were invalidated. + * Returns the decoded token and matching user when successful. + * @param {string} token + * @returns {Promise<{decoded: object, user: User}>} + */ +async function verifyActiveTokenAndGetUser(token) { + const decoded = await new Promise((resolve, reject) => { + jwt.verify(token, secretToken, (err, payload) => { + if (err) { + reject(err); + } else { + resolve(payload); + } + }); + }); + + // jwt.verify confirms the token signature is valid, but it does not guarantee + // the referenced user still exists in the database. The user may have been + // deleted after the token was issued, so OED must still verify the user record. + const conn = getConnection(); + const user = await User.getByID(decoded.data, conn); + + // Compare timestamps at millisecond precision to avoid edge cases caused by + // JWT iat being stored in seconds while the database timestamp is more precise. + const tokenIssuedAtMs = decoded.iat * 1000; + let invalidBeforeMs = 0; + + if (user.tokenInvalidBefore) { + const parsedDate = new Date(user.tokenInvalidBefore); + if (!isNaN(parsedDate.getTime())) { + invalidBeforeMs = parsedDate.getTime(); + } else { + log.error(`Invalid tokenInvalidBefore value for user ${user.id}`); + } + } + + if (tokenIssuedAtMs <= invalidBeforeMs) { + const error = new Error('Token invalidated'); + error.code = 'TOKEN_INVALIDATED'; + throw error; + } + + return { decoded, user }; +} + /** * Returns middleware that verifies the requested token and only proceeds if the requestor is a particular user role or is Admin. - * @param {string} role - * @param action + * @param {string} role + * @param action */ function roleTokenAuthMiddleware(role, action) { return function (req, res, next) { - this.authMiddleware(req, res, async () => { + authMiddleware(req, res, async () => { const token = req.headers.token || req.body.token || req.query.token; if (await isTokenAuthorized(token, role)) { next(); @@ -120,8 +161,8 @@ function roleTokenAuthMiddleware(role, action) { res.status(HTTP_CODES.FORBIDDEN) .json({ message: `Invalid credentials supplied. Only ${role.toUpperCase()} can ${action}.` }); } - }) - } + }); + }; } /** @@ -179,7 +220,7 @@ function obviusUsernameAndPasswordAuthMiddleware(action) { } } }); - } + }; } /** @@ -187,7 +228,7 @@ function obviusUsernameAndPasswordAuthMiddleware(action) { * Verifies the request's token against the server's secret token * Sets the req field hasValidAuthToken to true or false */ -optionalAuthMiddleware = (req, res, next) => { +const optionalAuthMiddleware = (req, res, next) => { // Set auth token to false initially. req.hasValidAuthToken = false; @@ -201,26 +242,28 @@ optionalAuthMiddleware = (req, res, next) => { if (!validate(token, validParams).valid) { next(); } else if (token) { - jwt.verify(token, secretToken, (err, decoded) => { - if (err) { - // do nothing. Could log here if need be - } else { + verifyActiveTokenAndGetUser(token) + .then(({ decoded }) => { req.decoded = decoded; req.hasValidAuthToken = true; - } - next(); - }); + next(); + }) + .catch(() => { + next(); + }); } else { next(); } }; module.exports = { + authMiddleware, adminAuthMiddleware, csvAuthMiddleware, exportAuthMiddleware, obviusUsernameAndPasswordAuthMiddleware, optionalAuthMiddleware, verifyCredentials, + verifyActiveTokenAndGetUser, credentialsRequestValidationMiddleware }; diff --git a/src/server/routes/login.js b/src/server/routes/loginLogout.js similarity index 54% rename from src/server/routes/login.js rename to src/server/routes/loginLogout.js index e9af660ab1..fd6d68745b 100644 --- a/src/server/routes/login.js +++ b/src/server/routes/loginLogout.js @@ -10,8 +10,8 @@ const secretToken = require('../config').secretToken; const validate = require('jsonschema').validate; const { log } = require('../log'); const { getConnection } = require('../db'); -const { credentialsRequestValidationMiddleware } = require('./authenticator'); -const { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH } = require('../util/validationConstants'); +const { credentialsRequestValidationMiddleware, verifyActiveTokenAndGetUser } = require('./authenticator'); +const { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, TOKEN_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH } = require('../util/validationConstants'); const { HTTP_CODES } = require('../util/httpCodes'); const router = express.Router(); @@ -20,7 +20,7 @@ const router = express.Router(); * @param {String} username * @param {String} Password */ -router.post('/', credentialsRequestValidationMiddleware, async (req, res) => { +router.post('/login', credentialsRequestValidationMiddleware, async (req, res) => { const validParams = { type: 'object', additionalProperties: false, @@ -70,4 +70,53 @@ router.post('/', credentialsRequestValidationMiddleware, async (req, res) => { } }); +/** + * Logs out the authenticated user by invalidating previously issued tokens. + * + * Note: This route intentionally does not use auth middleware. + * Authentication is handled by verifyActiveTokenAndGetUser, which verifies + * the JWT, ensures the user exists, and checks token validity. + * + * The user ID is derived from the verified token (not request input), + * preventing a user from logging out another user. + */ +router.post('/logout', async (req, res) => { + const validParams = { + type: 'object', + maxProperties: 1, + required: ['token'], + properties: { + token: { + type: 'string', + maxLength: TOKEN_MAX_LENGTH + } + } + }; + + if (!validate(req.body, validParams).valid) { + res.sendStatus(HTTP_CODES.BAD_REQUEST); + return; + } + + try { + // This route does not trust a user id from the request body. + // It authenticates the provided token, ensures the referenced user + // still exists, and then uses that verified user record to determine + // which user's tokens should be invalidated. + const { user } = await verifyActiveTokenAndGetUser(req.body.token); + const conn = getConnection(); + await User.invalidateTokensBeforeNow(user.id, conn); + res.status(HTTP_CODES.OK).json({ success: true, message: 'Logout successful.' }); + } catch (error) { + if (error.code === 'TOKEN_INVALIDATED') { + res.status(HTTP_CODES.OK).json({ success: true, message: 'Logout successful.' }); + } else if (error.message === 'No data returned from the query.') { + res.status(HTTP_CODES.UNAUTHORIZED).json({ success: false, message: 'Logout failed.' }); + } else { + log.error('Logout failed while invalidating user tokens.', error); + res.status(HTTP_CODES.INTERNAL_SERVER_ERROR).json({ success: false, message: 'Logout failed.' }); + } + } +}); + module.exports = router; diff --git a/src/server/routes/verification.js b/src/server/routes/verification.js index c0bc792c04..572fa51f31 100644 --- a/src/server/routes/verification.js +++ b/src/server/routes/verification.js @@ -3,16 +3,19 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const express = require('express'); -const jwt = require('jsonwebtoken'); -const secretToken = require('../config').secretToken; const validate = require('jsonschema').validate; const { TOKEN_MAX_LENGTH } = require('../util/validationConstants'); +const { log } = require('../log'); +const { verifyActiveTokenAndGetUser } = require('./authenticator'); const { HTTP_CODES } = require('../util/httpCodes'); const router = express.Router(); /** * Route for verifying a JWT. + * Verifies that the token is cryptographically valid, belongs to an + * existing user, and has not been invalidated by server-side session + * invalidation logic. * @param token */ router.post('/', (req, res) => { @@ -27,17 +30,20 @@ router.post('/', (req, res) => { } } }; + if (!validate(req.body, validParams).valid) { res.sendStatus(HTTP_CODES.BAD_REQUEST); } else { const token = req.body.token; - jwt.verify(token, secretToken, err => { - if (err) { - res.status(HTTP_CODES.UNAUTHORIZED).json({ success: false, message: 'Failed to authenticate token.' }); - } else { + + verifyActiveTokenAndGetUser(token) + .then(() => { res.status(HTTP_CODES.OK).json({ success: true }); - } - }); + }) + .catch(error => { + log.error('Token verification failed.', error); + res.status(HTTP_CODES.UNAUTHORIZED).json({ success: false, message: 'Failed to authenticate token.' }); + }); } }); diff --git a/src/server/sql/user/create_users_table.sql b/src/server/sql/user/create_users_table.sql index c6ebd14ceb..5e39739318 100644 --- a/src/server/sql/user/create_users_table.sql +++ b/src/server/sql/user/create_users_table.sql @@ -7,5 +7,6 @@ CREATE TABLE IF NOT EXISTS users( username VARCHAR(254) UNIQUE, password_hash CHAR(60) NOT NULL, role user_type NOT NULL, - note TEXT DEFAULT '' + note TEXT DEFAULT '', + token_invalid_before TIMESTAMP NOT NULL DEFAULT TIMESTAMP 'epoch' ) diff --git a/src/server/sql/user/get_all_users.sql b/src/server/sql/user/get_all_users.sql index d5ec878b35..10661278df 100644 --- a/src/server/sql/user/get_all_users.sql +++ b/src/server/sql/user/get_all_users.sql @@ -3,4 +3,4 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -- This does not expose password_hash to the client -SELECT id, username, role, note FROM users; +SELECT id, username, role, note, token_invalid_before FROM users; diff --git a/src/server/sql/user/update_token_invalid_before.sql b/src/server/sql/user/update_token_invalid_before.sql new file mode 100644 index 0000000000..b5f858ddf7 --- /dev/null +++ b/src/server/sql/user/update_token_invalid_before.sql @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +UPDATE users +SET token_invalid_before = NOW() +WHERE id = ${id}; diff --git a/src/server/test/common.js b/src/server/test/common.js index ba4bca6afc..0ebfcb730d 100644 --- a/src/server/test/common.js +++ b/src/server/test/common.js @@ -71,7 +71,7 @@ const testUser = new User(undefined, 'test@example.invalid', bcrypt.hashSync('pa testUser.password = 'password'; async function recreateDB() { - conn = testDB.getConnection(); + const conn = testDB.getConnection(); // This should drop all database objects, as long as they were all created by the current database user // They should be, since they were all created during a previous test. await conn.none('DROP OWNED BY current_user;'); diff --git a/src/server/test/routes/authenticatorParamsTest.js b/src/server/test/routes/authenticatorParamsTest.js index 28111ff0fa..820dda6566 100644 --- a/src/server/test/routes/authenticatorParamsTest.js +++ b/src/server/test/routes/authenticatorParamsTest.js @@ -21,11 +21,12 @@ mocha.describe('Authenticator Parameter Validation', () => { // malicious inputs, and field length limits). Consider consolidating shared test data (malicious username // patterns, SQL injection strings, etc.) into a shared fixture file in src/server/test/util/ in the future. + const LOGIN_ENDPOINT = '/api/loginLogout/login'; + // Test the credentials validation used by login and obvius endpoints mocha.describe('Credentials Validation (username/password)', () => { // Since authenticator.js doesn't export direct endpoints, we test through routes that use it // The login route uses credentialsRequestValidationMiddleware - const LOGIN_ENDPOINT = '/api/login'; const baseCredentials = { username: 'validuser', @@ -213,7 +214,6 @@ mocha.describe('Authenticator Parameter Validation', () => { mocha.describe('Security Edge Cases', () => { mocha.it('should handle concurrent authentication attempts', async () => { - const LOGIN_ENDPOINT = '/api/login'; const invalidCredentials = { username: 'nonexistent', password: 'wrongpass' @@ -235,8 +235,6 @@ mocha.describe('Authenticator Parameter Validation', () => { }); mocha.it('should prevent username enumeration attacks', async () => { - const LOGIN_ENDPOINT = '/api/login'; - // Test with non-existent user vs invalid password for existing user // Both should return similar error responses (timing-safe) const nonExistentUser = { diff --git a/src/server/test/routes/loginParamsTest.js b/src/server/test/routes/loginParamsTest.js index 8b24f9b280..8d9e14efba 100644 --- a/src/server/test/routes/loginParamsTest.js +++ b/src/server/test/routes/loginParamsTest.js @@ -17,7 +17,7 @@ const { mocha.describe('Login Parameter Validation', () => { - const LOGIN_ENDPOINT = '/api/login'; + const LOGIN_ENDPOINT = '/api/loginLogout/login'; const baseCredentials = { username: 'validuser@example.com', diff --git a/src/server/test/routes/logsRouteTests.js b/src/server/test/routes/logsRouteTests.js index 047b030862..ee972038ed 100644 --- a/src/server/test/routes/logsRouteTests.js +++ b/src/server/test/routes/logsRouteTests.js @@ -14,7 +14,7 @@ mocha.describe('Log Routes', () => { mocha.before(async () => { // Login to get authentication token - const res = await chai.request(app).post('/api/login') + const res = await chai.request(app).post('/api/loginLogout/login') .send({ username: testUser.username, password: testUser.password }); token = res.body.token; diff --git a/src/server/test/routes/sessionInvalidationTest.js b/src/server/test/routes/sessionInvalidationTest.js new file mode 100644 index 0000000000..ac67dad66c --- /dev/null +++ b/src/server/test/routes/sessionInvalidationTest.js @@ -0,0 +1,200 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +const { expect } = require('chai'); +const common = require('../common'); +const bcrypt = require('bcryptjs'); +const User = require('../../models/User'); +const { HTTP_CODES } = require('../../util/httpCodes'); +const jwt = require('jsonwebtoken'); +const secretToken = require('../../config').secretToken; + +const { chai, mocha, app, testUser } = common; + +mocha.describe('Session Invalidation Security', () => { + const LOGIN_ENDPOINT = '/api/loginLogout/login'; + const LOGOUT_ENDPOINT = '/api/loginLogout/logout'; + const VERIFY_ENDPOINT = '/api/verification'; + const PROTECTED_ENDPOINT = '/api/users'; + const CSV_USER_PASSWORD = 'csv-password-2'; + + /* + * Logs in with the provided username and password, then returns the issued token. + */ + async function loginAndGetToken(username, password) { + const res = await chai.request(app) + .post(LOGIN_ENDPOINT) + .send({ + username, + password + }); + + expect(res).to.have.status(HTTP_CODES.OK); + expect(res.body).to.have.property('token'); + return res.body.token; + } + + mocha.describe('Admin user session invalidation', () => { + let token; + + mocha.beforeEach(async () => { + token = await loginAndGetToken(testUser.username, testUser.password); + }); + + mocha.it('should verify a valid token before logout', async () => { + const verifyRes = await chai.request(app) + .post(VERIFY_ENDPOINT) + .send({ token }); + + expect(verifyRes).to.have.status(HTTP_CODES.OK); + expect(verifyRes.body).to.have.property('success', true); + expect(verifyRes.body).to.not.have.property('message'); + }); + + mocha.it('should invalidate a token after logout', async () => { + const beforeVerify = await chai.request(app) + .post(VERIFY_ENDPOINT) + .send({ token }); + + expect(beforeVerify).to.have.status(HTTP_CODES.OK); + expect(beforeVerify.body).to.have.property('success', true); + expect(beforeVerify.body).to.not.have.property('message'); + + const logoutRes = await chai.request(app) + .post(LOGOUT_ENDPOINT) + .send({ token }); + + expect(logoutRes).to.have.status(HTTP_CODES.OK); + expect(logoutRes.body).to.have.property('success', true); + + const verifyRes = await chai.request(app) + .post(VERIFY_ENDPOINT) + .send({ token }); + + expect(verifyRes).to.have.status(HTTP_CODES.UNAUTHORIZED); + expect(verifyRes.body).to.have.property('success', false); + expect(verifyRes.body).to.have.property('message', 'Failed to authenticate token.'); + }); + + mocha.it('should allow repeated logout with an already invalidated token', async () => { + const firstLogoutRes = await chai.request(app) + .post(LOGOUT_ENDPOINT) + .send({ token }); + + expect(firstLogoutRes).to.have.status(HTTP_CODES.OK); + expect(firstLogoutRes.body).to.have.property('success', true); + expect(firstLogoutRes.body).to.have.property('message', 'Logout successful.'); + + const secondLogoutRes = await chai.request(app) + .post(LOGOUT_ENDPOINT) + .send({ token }); + + expect(secondLogoutRes).to.have.status(HTTP_CODES.OK); + expect(secondLogoutRes.body).to.have.property('success', true); + expect(secondLogoutRes.body).to.have.property('message', 'Logout successful.'); + }); + + mocha.it('should reject an invalidated token on a protected route', async () => { + const beforeLogoutRes = await chai.request(app) + .get(PROTECTED_ENDPOINT) + .set('token', token); + + expect(beforeLogoutRes).to.have.status(HTTP_CODES.OK); + + await chai.request(app) + .post(LOGOUT_ENDPOINT) + .send({ token }); + + const afterLogoutRes = await chai.request(app) + .get(PROTECTED_ENDPOINT) + .set('token', token); + + expect(afterLogoutRes).to.have.status(HTTP_CODES.UNAUTHORIZED); + expect(afterLogoutRes.body).to.have.property('success', false); + expect(afterLogoutRes.body).to.have.property('message', 'Failed to authenticate token.'); + }); + + mocha.it('should require a token for logout', async () => { + const res = await chai.request(app) + .post(LOGOUT_ENDPOINT) + .send({}); + + expect(res).to.have.status(HTTP_CODES.BAD_REQUEST); + }); + + mocha.it('should reject extra fields on logout', async () => { + const res = await chai.request(app) + .post(LOGOUT_ENDPOINT) + .send({ + token, + extraField: 'should be rejected' + }); + + expect(res).to.have.status(HTTP_CODES.BAD_REQUEST); + }); + + mocha.it('should reject an expired token through normal JWT expiration handling', async () => { + const expiredToken = jwt.sign( + { data: testUser.id }, + secretToken, + { expiresIn: 1 } + ); + + await new Promise(resolve => setTimeout(resolve, 1500)); + + const res = await chai.request(app) + .post(VERIFY_ENDPOINT) + .send({ token: expiredToken }); + + expect(res).to.have.status(HTTP_CODES.UNAUTHORIZED); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('message', 'Failed to authenticate token.'); + }); + }); + + mocha.describe('CSV user session invalidation', () => { + let csvUser; + + mocha.beforeEach(async () => { + const conn = common.testDB.getConnection(); + + csvUser = new User( + undefined, + 'test-csv@example.invalid', + bcrypt.hashSync(CSV_USER_PASSWORD, 10), + User.role.CSV + ); + + await csvUser.insert(conn); + }); + + mocha.it('should invalidate token for a CSV user', async () => { + const csvToken = await loginAndGetToken(csvUser.username, CSV_USER_PASSWORD); + + const beforeVerify = await chai.request(app) + .post(VERIFY_ENDPOINT) + .send({ token: csvToken }); + + expect(beforeVerify).to.have.status(HTTP_CODES.OK); + expect(beforeVerify.body).to.have.property('success', true); + + const logoutRes = await chai.request(app) + .post(LOGOUT_ENDPOINT) + .send({ token: csvToken }); + + expect(logoutRes).to.have.status(HTTP_CODES.OK); + expect(logoutRes.body).to.have.property('success', true); + + const verifyRes = await chai.request(app) + .post(VERIFY_ENDPOINT) + .send({ token: csvToken }); + + expect(verifyRes).to.have.status(HTTP_CODES.UNAUTHORIZED); + expect(verifyRes.body).to.have.property('success', false); + expect(verifyRes.body).to.have.property('message', 'Failed to authenticate token.'); + }); + }); +}); diff --git a/src/server/test/routes/unitsRouteTests.js b/src/server/test/routes/unitsRouteTests.js index 9ddaeccc1d..358f37e9fc 100644 --- a/src/server/test/routes/unitsRouteTests.js +++ b/src/server/test/routes/unitsRouteTests.js @@ -10,7 +10,7 @@ mocha.describe('Units Route', () => { let token; mocha.before(async () => { - const res = await chai.request(app).post('/api/login') + const res = await chai.request(app).post('/api/loginLogout/login') .send({ username: testUser.username, password: testUser.password }); token = res.body.token; }); diff --git a/src/server/test/web/groups.js b/src/server/test/web/groups.js index 5bba078c00..548d388506 100644 --- a/src/server/test/web/groups.js +++ b/src/server/test/web/groups.js @@ -99,7 +99,7 @@ mocha.describe('groups API', () => { // Since this .before is in the middle of tests, it should not have issues as // documented in usersTest.js. mocha.before(async () => { - let res = await chai.request(app).post('/api/login') + let res = await chai.request(app).post('/api/loginLogout/login') .send({ username: testUser.username, password: testUser.password }); token = res.body.token; }); @@ -127,7 +127,7 @@ mocha.describe('groups API', () => { unauthorizedUser.password = password; // login - res = await chai.request(app).post('/api/login') + res = await chai.request(app).post('/api/loginLogout/login') .send({ username: unauthorizedUser.username, password: unauthorizedUser.password }); currentToken = res.body.token; // create @@ -184,7 +184,7 @@ mocha.describe('groups API', () => { unauthorizedUser.password = password; // login - res = await chai.request(app).post('/api/login') + res = await chai.request(app).post('/api/loginLogout/login') .send({ username: unauthorizedUser.username, password: unauthorizedUser.password }); currentToken = res.body.token; // edit diff --git a/src/server/test/web/login.js b/src/server/test/web/login.js index 25f2bb17ee..88018a995c 100644 --- a/src/server/test/web/login.js +++ b/src/server/test/web/login.js @@ -11,20 +11,20 @@ const VERSION = require('../../version'); mocha.describe('login API', () => { mocha.it('returns JWT for a successful login attempt', async () => { - const res = await chai.request(app).post('/api/login') + const res = await chai.request(app).post('/api/loginLogout/login') .send({ username: testUser.username, password: testUser.password }); expect(res).to.have.status(HTTP_CODES.OK); expect(res).to.be.json; expect(res.body).to.have.property('token'); }); mocha.it('returns 401 for a wrong password', async () => { - const res = await chai.request(app).post('/api/login') + const res = await chai.request(app).post('/api/loginLogout/login') .send({ username: testUser.username, password: testUser.password + 'wrong' }); expect(res).to.have.status(HTTP_CODES.UNAUTHORIZED); expect(res.body).not.to.have.property('token'); }); mocha.it('returns 401 for a wrong user', async () => { - const res = await chai.request(app).post('/api/login') + const res = await chai.request(app).post('/api/loginLogout/login') .send({ username: testUser.username + 'nope', password: testUser.password }); expect(res).to.have.status(HTTP_CODES.UNAUTHORIZED); expect(res.body).not.to.have.property('token'); @@ -33,7 +33,7 @@ mocha.describe('login API', () => { mocha.describe('verification API', () => { mocha.it('returns 200 when passed a valid token', async () => { - const res = await chai.request(app).post('/api/login') + const res = await chai.request(app).post('/api/loginLogout/login') .send({ username: testUser.username, password: testUser.password }); expect(res).to.have.status(HTTP_CODES.OK); expect(res).to.be.json; diff --git a/src/server/test/web/maps.js b/src/server/test/web/maps.js index df6cb8e67b..7aa75ea595 100644 --- a/src/server/test/web/maps.js +++ b/src/server/test/web/maps.js @@ -80,7 +80,7 @@ mocha.describe('maps API', () => { // Since this .before is in the middle of tests, it should not have issues as // documented in usersTest.js. mocha.before(async () => { - let res = await chai.request(app).post('/api/login') + let res = await chai.request(app).post('/api/loginLogout/login') .send({ username: testUser.username, password: testUser.password }); token = res.body.token; }); @@ -115,7 +115,7 @@ mocha.describe('maps API', () => { unauthorizedUser.password = password; // login - let res = await chai.request(app).post('/api/login') + let res = await chai.request(app).post('/api/loginLogout/login') .send({ username: unauthorizedUser.username, password: unauthorizedUser.password }); token = res.body.token; }); diff --git a/src/server/test/web/meters.js b/src/server/test/web/meters.js index a6fcb32a76..814b92b517 100644 --- a/src/server/test/web/meters.js +++ b/src/server/test/web/meters.js @@ -162,7 +162,7 @@ mocha.describe('meters API', () => { // Since this .before is in the middle of tests, it should not have issues as // documented in usersTest.js. mocha.before(async () => { - let res = await chai.request(app).post('/api/login') + let res = await chai.request(app).post('/api/loginLogout/login') .send({ username: testUser.username, password: testUser.password }); token = res.body.token; }); @@ -213,7 +213,7 @@ mocha.describe('meters API', () => { unauthorizedUser.password = password; // login - let res = await chai.request(app).post('/api/login') + let res = await chai.request(app).post('/api/loginLogout/login') .send({ username: unauthorizedUser.username, password: unauthorizedUser.password }); token = res.body.token; }); diff --git a/src/server/test/web/preferencesTest.js b/src/server/test/web/preferencesTest.js index dce0d4d3b5..524dd07d53 100644 --- a/src/server/test/web/preferencesTest.js +++ b/src/server/test/web/preferencesTest.js @@ -14,7 +14,7 @@ mocha.describe('preferences API', () => { mocha.describe('modification api', () => { mocha.describe('edit endpoint', () => { mocha.it('should accept requests from Admin role', async () => { - let res = await chai.request(app).post('/api/login') + let res = await chai.request(app).post('/api/loginLogout/login') .send({ username: testUser.username, password: testUser.password }); expect(res).to.have.status(HTTP_CODES.OK); const token = res.body.token; @@ -47,7 +47,7 @@ mocha.describe('preferences API', () => { unauthorizedUser.password = password; // login - let res = await chai.request(app).post('/api/login') + let res = await chai.request(app).post('/api/loginLogout/login') .send({ username: unauthorizedUser.username, password: unauthorizedUser.password }); token = res.body.token; }); diff --git a/src/server/test/web/usersTest.js b/src/server/test/web/usersTest.js index 22dd53a078..328c46167c 100644 --- a/src/server/test/web/usersTest.js +++ b/src/server/test/web/usersTest.js @@ -8,7 +8,6 @@ const { chai, mocha, expect, app, testDB, testUser, recreateDB } = require('../common'); const User = require('../../models/User'); const bcrypt = require('bcryptjs'); -const { log } = require('console'); const { HTTP_CODES } = require('../../util/httpCodes'); mocha.describe('Users API', () => { @@ -21,7 +20,7 @@ mocha.describe('Users API', () => { // To fix this, manually call DB creation. This will also happen right after this // .before finishes. await recreateDB(); - let res = await chai.request(app).post('/api/login') + let res = await chai.request(app).post('/api/loginLogout/login') .send({ username: testUser.username, password: testUser.password }); token = res.body.token; }); @@ -115,7 +114,7 @@ mocha.describe('Users API', () => { unauthorizedUser.password = password; // login - let res = await chai.request(app).post('/api/login') + let res = await chai.request(app).post('/api/loginLogout/login') .send({ username: unauthorizedUser.username, password: unauthorizedUser.password }); token = res.body.token; });