From 74d63eac327f65ebf6e929f6df15e7f7c08a068f Mon Sep 17 00:00:00 2001 From: Pablo Ortega Date: Tue, 7 Jan 2025 18:59:24 -0500 Subject: [PATCH] Adding JWT Authentication Functionality stopping point added tests for action loginJwt Added some more tests for the actions class JWT Token Authentication - Fixes #1468 --- .gitignore | 3 + app/addons/auth/__tests__/actions.test.js | 238 +++++++++++++++++++ app/addons/auth/__tests__/api.test.js | 61 +++++ app/addons/auth/__tests__/components.test.js | 19 +- app/addons/auth/__tests__/fauxtonjwt.test.js | 129 ++++++++++ app/addons/auth/actions.js | 51 +++- app/addons/auth/api.js | 9 + app/addons/auth/components/loginform.js | 114 +++++++-- app/addons/auth/fauxtonjwt.js | 98 ++++++++ app/addons/documents/doc-editor/actions.js | 2 + app/addons/permissions/actions.js | 15 +- app/core/ajax.js | 4 +- app/core/api.js | 7 +- app/helpers.js | 18 ++ docker/docker-configure-jwt.sh | 98 ++++++++ i18n.json.default.json | 3 + jwt-auth.md | 63 +++++ 17 files changed, 892 insertions(+), 40 deletions(-) create mode 100644 app/addons/auth/__tests__/actions.test.js create mode 100644 app/addons/auth/__tests__/api.test.js create mode 100644 app/addons/auth/__tests__/fauxtonjwt.test.js create mode 100644 app/addons/auth/fauxtonjwt.js create mode 100644 docker/docker-configure-jwt.sh create mode 100644 jwt-auth.md diff --git a/.gitignore b/.gitignore index 96b40103b..71ea3ec97 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ coverage # IDEs .idea/ .vscode +/docker/formatted.key +/docker/test_private_key.pem +/docker/test_public_key.pem diff --git a/app/addons/auth/__tests__/actions.test.js b/app/addons/auth/__tests__/actions.test.js new file mode 100644 index 000000000..e2fc47ef1 --- /dev/null +++ b/app/addons/auth/__tests__/actions.test.js @@ -0,0 +1,238 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy of +// the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations under +// the License. + +import FauxtonAPI from "../../../core/api"; +import FauxtonJwt from "../fauxtonjwt"; +import Api from '../api'; +import {loginJwt, login} from "../actions"; +import utils from "../../../../test/mocha/testUtils"; +import sinon, {stub} from "sinon"; + +const {restore} = utils; + +describe('Auth -- Actions', () => { + + describe('loginJwt', () => { + + const authenticatedResponse = { + "ok": true, + "userCtx": { + "name": "tester", + "roles": [ + "manage-account", + "view-profile", + "_admin" + ] + }, + "info": { + "authentication_handlers": [ + "cookie", + "jwt", + "default" + ], + "authenticated": "jwt" + } + }; + + const jwtNotSetupResponse = { + "ok": true, + "userCtx": { + "name": null, + "roles": [] + }, + "info": { + "authentication_handlers": [ + "cookie", + "default", + ] + } + }; + + const unathenticatedResponse = { + "ok": true, + "userCtx": { + "name": null, + "roles": [] + }, + "info": { + "authentication_handlers": [ + "cookie", + "default", + "jwt" + ] + } + }; + + let loginStub; + let loginJwtStub; + let deleteJwtCookieStub; + let addNotificationStub; + let navigateStub; + + beforeEach(() => { + loginJwtStub = stub(Api, "loginJwt"); + loginStub = stub(Api, "login"); + deleteJwtCookieStub = stub(FauxtonJwt, "deleteJwtCookie"); + addNotificationStub = stub(FauxtonAPI, "addNotification"); + navigateStub = stub(FauxtonAPI, "navigate"); + }); + + afterEach(() => { + restore(loginJwtStub); + restore(loginStub); + restore(addNotificationStub); + restore(deleteJwtCookieStub); + restore(navigateStub); + }); + + it('loginJwt logs in if userCtx present', async () => { + loginJwtStub.returns(Promise.resolve(authenticatedResponse)); + + const mockToken = "mockToken"; + + await loginJwt(mockToken); + + expect(loginJwtStub.calledOnce).toBeTruthy(); + expect(loginStub.notCalled).toBeTruthy(); + expect(addNotificationStub.calledOnce).toBeTruthy(); + expect(navigateStub.calledOnce).toBeTruthy(); + sinon.assert.calledWithExactly( + navigateStub, + '/'); + }); + + it('loginJwt is not called if token is empty', async () => { + loginJwtStub.returns(Promise.resolve(authenticatedResponse)); + + const mockToken = ""; + + await loginJwt(mockToken); + + expect(loginJwtStub.notCalled).toBeTruthy(); + expect(loginStub.notCalled).toBeTruthy(); + expect(addNotificationStub.calledOnce).toBeTruthy(); + expect(navigateStub.notCalled).toBeTruthy(); + }); + + it('loginJwt does not navigate if jwt auth is not enabled', async () => { + loginJwtStub.returns(Promise.resolve(jwtNotSetupResponse)); + + const mockToken = "mockToken"; + + await loginJwt(mockToken); + + expect(loginJwtStub.calledOnce).toBeTruthy(); + expect(loginStub.notCalled).toBeTruthy(); + expect(deleteJwtCookieStub.calledOnce).toBeTruthy(); + expect(addNotificationStub.calledOnce).toBeTruthy(); + expect(navigateStub.notCalled).toBeTruthy(); + }); + + it('loginJwt does not navigate if jwt auth is not successful', async () => { + loginJwtStub.returns(Promise.resolve(unathenticatedResponse)); + + const mockToken = "mockToken"; + + await loginJwt(mockToken); + + expect(loginJwtStub.calledOnce).toBeTruthy(); + expect(loginStub.notCalled).toBeTruthy(); + expect(deleteJwtCookieStub.calledOnce).toBeTruthy(); + expect(addNotificationStub.calledOnce).toBeTruthy(); + expect(navigateStub.notCalled).toBeTruthy(); + }); + }); + + describe('login', () => { + + const authenticatedResponse = { + "ok": true, + "userCtx": { + "name": "tester", + "roles": [ + "_admin" + ] + }, + "info": { + "authentication_handlers": [ + "cookie", + "default" + ], + "authenticated": "cookie" + } + }; + + let loginStub; + let loginJwtStub; + let addNotificationStub; + let navigateStub; + + beforeEach(() => { + loginJwtStub = stub(Api, "loginJwt"); + loginStub = stub(Api, "login"); + addNotificationStub = stub(FauxtonAPI, "addNotification"); + navigateStub = stub(FauxtonAPI, "navigate"); + }); + + afterEach(() => { + restore(loginJwtStub); + restore(loginStub); + restore(addNotificationStub); + restore(navigateStub); + }); + + it('login logs in if there is no error', async () => { + loginStub.returns(Promise.resolve(authenticatedResponse)); + + const mockUser = "mockUser"; + const mockPassword = "mockPassword"; + + await login(mockUser, mockPassword); + + expect(loginJwtStub.notCalled).toBeTruthy(); + expect(loginStub.calledOnce).toBeTruthy(); + expect(addNotificationStub.calledOnce).toBeTruthy(); + expect(navigateStub.calledOnce).toBeTruthy(); + sinon.assert.calledWithExactly( + navigateStub, + '/'); + }); + + it('login does not log in if username is blank', async () => { + loginStub.returns(Promise.resolve(authenticatedResponse)); + + const mockUser = ""; + const mockPassword = "mockPassword"; + + await login(mockUser, mockPassword); + + expect(loginJwtStub.notCalled).toBeTruthy(); + expect(loginStub.notCalled).toBeTruthy(); + expect(addNotificationStub.calledOnce).toBeTruthy(); + expect(navigateStub.notCalled).toBeTruthy(); + }); + + it('login does not log in if password is blank', async () => { + loginStub.returns(Promise.resolve(authenticatedResponse)); + + const mockUser = "mockUser"; + const mockPassword = ""; + + await login(mockUser, mockPassword); + + expect(loginJwtStub.notCalled).toBeTruthy(); + expect(loginStub.notCalled).toBeTruthy(); + expect(addNotificationStub.calledOnce).toBeTruthy(); + expect(navigateStub.notCalled).toBeTruthy(); + }); + }); +}); diff --git a/app/addons/auth/__tests__/api.test.js b/app/addons/auth/__tests__/api.test.js new file mode 100644 index 000000000..5a241562e --- /dev/null +++ b/app/addons/auth/__tests__/api.test.js @@ -0,0 +1,61 @@ +import FauxtonJwt from "../fauxtonjwt"; +import Helpers from "../../../helpers"; +import utils from "../../../../test/mocha/testUtils"; +import sinon, {stub} from "sinon"; +import {loginJwt, logout} from "../api"; +import * as ajax from "../../../core/ajax"; + +const {restore} = utils; + +jest.mock("../../../core/ajax"); + +describe('Api -- Actions', () => { + + const sessionUrl = "http://testurl/_session"; + + describe('loginJwt', () => { + + let setJwtCookieStub; + let deleteJwtCookieStub; + let getServerUrlStub; + + beforeEach(() => { + setJwtCookieStub = stub(FauxtonJwt, "setJwtCookie"); + deleteJwtCookieStub = stub(FauxtonJwt, "deleteJwtCookie"); + getServerUrlStub = stub(Helpers, "getServerUrl"); + ajax.get.mockReturnValue(Promise.resolve({})); + ajax.deleteFormEncoded.mockReturnValue(Promise.resolve({})); + }); + + afterEach(() => { + restore(setJwtCookieStub); + restore(deleteJwtCookieStub); + restore(getServerUrlStub); + }); + + it('loginJwt sets jwt auth as token and calls _session endpoint using global get method', async () => { + const mockToken = "mockToken"; + getServerUrlStub.returns(sessionUrl); + await loginJwt(mockToken); + expect(setJwtCookieStub.calledOnce).toBeTruthy(); + expect(getServerUrlStub.calledOnce).toBeTruthy(); + sinon.assert.calledWithExactly( + setJwtCookieStub, + mockToken); + sinon.assert.calledWithExactly( + getServerUrlStub, + '/_session'); + }); + + + it('logout deletes cookie from browser and calls global delete _session endpoint', async () => { + getServerUrlStub.returns(sessionUrl); + await logout(); + expect(deleteJwtCookieStub.calledOnce).toBeTruthy(); + expect(getServerUrlStub.calledOnce).toBeTruthy(); + sinon.assert.calledWithExactly( + getServerUrlStub, + '/_session'); + }); + }); +}); diff --git a/app/addons/auth/__tests__/components.test.js b/app/addons/auth/__tests__/components.test.js index 222d284ce..70a557143 100644 --- a/app/addons/auth/__tests__/components.test.js +++ b/app/addons/auth/__tests__/components.test.js @@ -22,22 +22,38 @@ describe('Auth -- Components', () => { describe('LoginForm', () => { let stub; + let stubJwt; beforeEach(() => { stub = sinon.stub(Actions, 'login'); + stubJwt = sinon.stub(Actions, 'loginJwt'); }); afterEach(() => { Actions.login.restore(); + Actions.loginJwt.restore(); }); it('should trigger login event when form submitted', () => { const loginForm = mount(); + expect(loginForm.find('select#auth-method').prop('value')).toEqual('basic'); loginForm.find('#login').simulate('submit'); expect(stub.calledOnce).toBeTruthy(); + expect(stubJwt.notCalled).toBeTruthy(); }); - it('in case of nothing in state, should pass actual values to Actions.login()', () => { + it('should change login type when dropdown option is chosen', () => { + const loginForm = mount(); + const dropdown = loginForm.find('select#auth-method'); + expect(loginForm.find('select').prop('value')).toEqual('basic'); + dropdown.simulate('change', {target: {value: 'token'}}); + expect(loginForm.find('select').prop('value')).toEqual('token'); + loginForm.find('#login').simulate('submit'); + expect(stub.notCalled).toBeTruthy(); + expect(stubJwt.calledOnce).toBeTruthy(); + }); + + it('in case of nothing in state, should pass actual basic auth values to Actions.login()', () => { const username = 'bob'; const password = 'smith'; @@ -55,6 +71,7 @@ describe('Auth -- Components', () => { expect(stub.args[0][1]).toBe(password); }); + }); describe('ChangePasswordForm', () => { diff --git a/app/addons/auth/__tests__/fauxtonjwt.test.js b/app/addons/auth/__tests__/fauxtonjwt.test.js new file mode 100644 index 000000000..01de89fc8 --- /dev/null +++ b/app/addons/auth/__tests__/fauxtonjwt.test.js @@ -0,0 +1,129 @@ +import FauxtonJwt from "../fauxtonjwt"; +import Helpers from "../../../helpers"; +import sinon, {stub} from "sinon"; +import utils from "../../../../test/mocha/testUtils"; + +const {restore} = utils; + +describe('FauxtonJwt Module', () => { + let deleteCookieStub, getCookieStub; + + beforeEach(() => { + deleteCookieStub = stub(Helpers, "deleteCookie"); + getCookieStub = stub(Helpers, "getCookie"); + }); + + afterEach(() => { + restore(deleteCookieStub); + restore(getCookieStub); + }); + + describe('jwtStillValid', () => { + it('returns false if token is null', () => { + expect(FauxtonJwt.jwtStillValid(null)).toBe(false); + }); + + it('returns false if token cannot be decoded', () => { + const invalidToken = "invalid.token"; + expect(FauxtonJwt.jwtStillValid(invalidToken)).toBe(false); + }); + + it('returns true if token is not expired', () => { + const validToken = btoa(JSON.stringify({exp: Math.floor(Date.now() / 1000) + 3600})); + const token = `header.${validToken}.signature`; + expect(FauxtonJwt.jwtStillValid(token)).toBe(true); + }); + + it('returns false if token is expired', () => { + const expiredToken = btoa(JSON.stringify({exp: Math.floor(Date.now() / 1000) - 3600})); + const token = `header.${expiredToken}.signature`; + expect(FauxtonJwt.jwtStillValid(token)).toBe(false); + }); + }); + + describe('decodeToken', () => { + it('returns null for invalid token', () => { + const invalidToken = "invalid.token"; + expect(FauxtonJwt.decodeToken(invalidToken)).toBeNull(); + }); + + it('returns decoded payload for valid token', () => { + const payload = {exp: 12345}; + const validToken = `header.${btoa(JSON.stringify(payload))}.signature`; + expect(FauxtonJwt.decodeToken(validToken)).toEqual(payload); + }); + }); + + describe('getExpiry', () => { + it('returns 0 if token cannot be decoded', () => { + const invalidToken = "invalid.token"; + expect(FauxtonJwt.getExpiry(invalidToken)).toBe(0); + }); + + it('returns the exp value from a valid token', () => { + const payload = {exp: 12345}; + const validToken = `header.${btoa(JSON.stringify(payload))}.signature`; + expect(FauxtonJwt.getExpiry(validToken)).toBe(12345); + }); + }); + + + describe('setJwtCookie', () => { + + beforeEach(() => { + Object.defineProperty(document, "cookie", { + writable: true, + value: "" + }); + }); + + it('sets the JWT cookie', () => { + const token = "test-token"; + FauxtonJwt.setJwtCookie(token); + expect(document.cookie).toContain(`${FauxtonJwt.cookieName}=${token}`); + }); + }); + + describe('deleteJwtCookie', () => { + it('calls Helpers.deleteCookie with the correct cookie name', () => { + FauxtonJwt.deleteJwtCookie(); + expect(deleteCookieStub.calledWith(FauxtonJwt.cookieName)).toBe(true); + }); + }); + + describe('addAuthToken', () => { + it('adds Authorization header if token is valid', () => { + const token = `header.${btoa(JSON.stringify({exp: Math.floor(Date.now() / 1000) + 3600}))}.signature`; + getCookieStub.returns(token); + + const fetchOptions = {headers: {}}; + const result = FauxtonJwt.addAuthToken(fetchOptions); + + expect(result.headers.Authorization).toBe(`Bearer ${token}`); + }); + + it('deletes cookie if token is invalid', () => { + const token = "invalid.token"; + getCookieStub.returns(token); + + const fetchOptions = {headers: {}}; + FauxtonJwt.addAuthToken(fetchOptions); + + expect(deleteCookieStub.calledWith(FauxtonJwt.cookieName)).toBe(true); + }); + }); + + describe('addAuthHeader', () => { + it('sets Authorization header on the HTTP request if token is valid', () => { + const token = `header.${btoa(JSON.stringify({exp: Math.floor(Date.now() / 1000) + 3600}))}.signature`; + getCookieStub.returns(token); + + const httpRequest = { + setRequestHeader: sinon.stub() + }; + FauxtonJwt.addAuthHeader(httpRequest); + + expect(httpRequest.setRequestHeader.calledWith('Authorization', `Bearer ${token}`)).toBe(true); + }); + }); +}); diff --git a/app/addons/auth/actions.js b/app/addons/auth/actions.js index 1e094c9cf..62c2f1705 100644 --- a/app/addons/auth/actions.js +++ b/app/addons/auth/actions.js @@ -13,6 +13,7 @@ import FauxtonAPI from "../../core/api"; import app from "../../app"; import ActionTypes from './actiontypes'; import Api from './api'; +import FauxtonJwt from "./fauxtonjwt"; const { AUTH_HIDE_PASSWORD_MODAL, @@ -33,6 +34,10 @@ export const validateUser = (username, password) => { return validate(!_.isEmpty(username), !_.isEmpty(password)); }; +export const validateToken = (token) => { + return validate(!_.isEmpty(token)); +}; + export const validatePasswords = (password, passwordConfirm) => { return validate( !_.isEmpty(password), @@ -52,20 +57,52 @@ export const login = (username, password, urlBack) => { errorHandler({message: resp.reason}); return resp; } + navigateToDatabasePage(resp, urlBack); + }) + .catch(errorHandler); +}; - let msg = app.i18n.en_US['auth-logged-in']; - if (msg) { - FauxtonAPI.addNotification({msg}); - } +export const loginJwt = (token, urlBack) => { + if (!validateToken(token)) { + return errorHandler({message: app.i18n.en_US['auth-missing-token']}); + } - if (urlBack && !urlBack.includes("login")) { - return FauxtonAPI.navigate(urlBack); + return Api.loginJwt(token) + .then(resp => { + if (resp.error) { + FauxtonJwt.deleteJwtCookie(); + errorHandler({message: resp.reason}); + return resp; + } + if (!resp.info.authentication_handlers.includes("jwt")) { + FauxtonJwt.deleteJwtCookie(); + let msg = app.i18n.en_US['auth-jwt-not-available']; + errorHandler({message: msg}); + return resp; + } + if (!resp.userCtx.name) { + FauxtonJwt.deleteJwtCookie(); + let msg = app.i18n.en_US['auth-jwt-failure']; + errorHandler({message: msg}); + return resp; } - FauxtonAPI.navigate("/"); + navigateToDatabasePage(resp, urlBack); }) .catch(errorHandler); }; +const navigateToDatabasePage = (resp, urlBack) => { + let msg = app.i18n.en_US['auth-logged-in']; + if (msg) { + FauxtonAPI.addNotification({msg}); + } + + if (urlBack && !urlBack.includes("login")) { + return FauxtonAPI.navigate(urlBack); + } + FauxtonAPI.navigate("/"); +}; + export const changePassword = (username, password, passwordConfirm, nodes) => () => { if (!validatePasswords(password, passwordConfirm)) { return errorHandler({message: app.i18n.en_US['auth-passwords-not-matching']}); diff --git a/app/addons/auth/api.js b/app/addons/auth/api.js index a281675e6..342f63166 100644 --- a/app/addons/auth/api.js +++ b/app/addons/auth/api.js @@ -13,6 +13,7 @@ import app from './../../app'; import Helpers from "../../helpers"; import {deleteFormEncoded, get, postFormEncoded, put} from '../../core/ajax'; +import FauxtonJwt from "./fauxtonjwt"; export function createAdmin({name, password, node}) { @@ -43,15 +44,23 @@ export function login(body) { return postFormEncoded(url, app.utils.queryParams(body)); } +export function loginJwt(token) { + FauxtonJwt.setJwtCookie(token); + const url = Helpers.getServerUrl('/_session'); + return get(url); +} + export function logout() { loggedInSessionPromise = null; const url = Helpers.getServerUrl('/_session'); + FauxtonJwt.deleteJwtCookie(); return deleteFormEncoded(url, app.utils.queryParams({ username: "_", password: "_" })); } export default { createAdmin, login, + loginJwt, logout, getSession }; diff --git a/app/addons/auth/components/loginform.js b/app/addons/auth/components/loginform.js index 34a56ebcd..95e147e9b 100644 --- a/app/addons/auth/components/loginform.js +++ b/app/addons/auth/components/loginform.js @@ -13,7 +13,7 @@ import PropTypes from 'prop-types'; import React from "react"; -import { login } from "./../actions"; +import { login, loginJwt } from "./../actions"; import { Button, Form } from 'react-bootstrap'; class LoginForm extends React.Component { @@ -21,7 +21,9 @@ class LoginForm extends React.Component { super(); this.state = { username: "", - password: "" + password: "", + authMethod: "basic", + token : "" }; } onUsernameChange(e) { @@ -33,8 +35,14 @@ class LoginForm extends React.Component { submit(e) { e.preventDefault(); - if (!this.checkUnrecognizedAutoFill()) { - this.login(this.state.username, this.state.password); + if (this.state.authMethod === "basic") { + if (!this.checkUnrecognizedAutoFill()) { + // Handle basic authentication + this.login(this.state.username, this.state.password); + } + } else if (this.state.authMethod === "token") { + // Handle token authentication + this.loginJwt(this.state.token); } } // Safari has a bug where autofill doesn't trigger a change event. This checks for the condition where the state @@ -57,38 +65,94 @@ class LoginForm extends React.Component { login(username, password) { login(username, password, this.props.urlBack); } + + loginJwt(token) { + loginJwt(token, this.props.urlBack); + } + componentDidMount() { - this.usernameField.focus(); + if (this.usernameField) { + this.usernameField.focus(); + } + if (this.tokenField) { + this.tokenField.focus(); + } + } + + onAuthMethodChange(event) { + this.setState({ authMethod: event.target.value }); + } + + onTokenChange(event) { + this.setState({ token: event.target.value }); } + render() { return (
- - this.usernameField = node} - placeholder="Username" - onChange={this.onUsernameChange.bind(this)} - value={this.state.username} /> + +
+
-
-
- this.passwordField = node} - placeholder="Password" - onChange={this.onPasswordChange.bind(this)} - value={this.state.password} /> + + {/* Conditionally render Username/Password fields */} + {this.state.authMethod === "basic" && ( + <> +
+
+ + (this.usernameField = node)} + placeholder="Username" + onChange={this.onUsernameChange.bind(this)} + value={this.state.username} + /> +
+
+
+
+ (this.passwordField = node)} + placeholder="Password" + onChange={this.onPasswordChange.bind(this)} + value={this.state.password} + /> +
+
+ + )} + + {/* Conditionally render Token field */} + {this.state.authMethod === "token" && ( +
+
+ + (this.tokenField = node)} + placeholder="Token" + onChange={this.onTokenChange.bind(this)} + value={this.state.token} + /> +
-
+ )} +