Skip to content

Commit

Permalink
Automatic redirect on session timeout (#1839)
Browse files Browse the repository at this point in the history
* feat(session-redirect): add js module for redirect on session timeout

* feat(sign-out): redirect to sign-in page on logout and display message

* feat(session-timeout): explicit end users session when they redirect for session timeout

* tests(sign-out): add tests for session timeout:
1. Redirect does not occur when not logged in
2. Redirect does occur (on app and public pages) when logged in
3. Explicit logout shows banner message

* chore: formatting

* test(sign out): app should redirect to /sign-in

* chore: fix tests that checked that signing out brought you to `/home`

* fix(sign-in): add note about session length to sign in page

* style(signin): move session timeout message, update content

* test(session-timeout): test for message on sign-in page; add `getByTestId` shortcut to cypress commands

* chore: fix typo in test

* chore: fix typo in test

* Update app/translations/csv/fr.csv
  • Loading branch information
andrewleith authored May 13, 2024
1 parent 25b8340 commit 5fde768
Show file tree
Hide file tree
Showing 12 changed files with 95 additions and 7 deletions.
20 changes: 20 additions & 0 deletions app/assets/javascripts/sessionRedirect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Redirects the user after a specified period of time.
*/
(function () {
const REDIRECT_LOCATION = "/sign-in?timeout=true";
const SESSION_TIMEOUT_MS = 7 * 60 * 60 * 1000 + 55 * 60 * 1000; // 7 hours 55 minutes

redirectCountdown(REDIRECT_LOCATION, SESSION_TIMEOUT_MS); // 7 hours 55 minutes

/**
* Redirects to the specified location after a given period of time.
* @param {string} redirectLocation - The URL to redirect to.
* @param {number} period - The period of time (in milliseconds) before redirecting.
*/
function redirectCountdown(redirectLocation, period) {
setTimeout(function () {
window.location.href = redirectLocation;
}, period);
}
})();
1 change: 1 addition & 0 deletions app/assets/javascripts/sessionRedirect.min.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
setTimeout((function(){window.location.href="/sign-in?timeout=true"}),285e5);
7 changes: 6 additions & 1 deletion app/main/views/sign_in.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from flask import abort, flash, redirect, render_template, request, session, url_for
from flask_babel import _
from flask_login import current_user
from flask_login import current_user, logout_user

from app import login_manager
from app.main import main
Expand All @@ -11,6 +11,10 @@

@main.route("/sign-in", methods=(["GET", "POST"]))
def sign_in():
if request.args.get("timeout"):
session.clear()
logout_user()

if current_user and current_user.is_authenticated:
return redirect(url_for("main.show_accounts_or_dashboard"))

Expand Down Expand Up @@ -62,6 +66,7 @@ def sign_in():
form=form,
again=bool(request.args.get("next")),
other_device=other_device,
timeout=bool(request.args.get("timeout")),
)


Expand Down
6 changes: 4 additions & 2 deletions app/main/views/sign_out.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from flask import current_app, redirect, session, url_for
from flask import current_app, flash, redirect, session, url_for
from flask_babel import _
from flask_login import logout_user

from app import get_current_locale
Expand All @@ -12,4 +13,5 @@ def sign_out():
logout_user()
session["userlang"] = currentlang

return redirect(url_for("main.index"))
flash(_("You have been signed out."), "default_with_tick")
return redirect(url_for("main.sign_in"))
5 changes: 5 additions & 0 deletions app/templates/main_template.html
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@
{% block page_script %}
<script nonce="{{ request_nonce }}" type="text/javascript" src="{{ asset_url('javascripts/main.min.js') }}"></script>
<script nonce="{{ request_nonce }}" type="text/javascript" src="{{ asset_url('javascripts/all.min.js') }}"></script>

{% if current_user.is_authenticated %}
<script nonce="{{ request_nonce }}" src="{{ asset_url('javascripts/sessionRedirect.min.js') }}"></script>
{% endif %}

{% endblock %}
</body>
</html>
5 changes: 3 additions & 2 deletions app/templates/views/signin.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@

<div class="grid-row contain-floats">
<div class="md:w-2/3 float-left py-0 px-0 px-gutterHalf box-border">

{% if again %}
{% if again or timeout %}
<h1 class="heading-large">{{ _('You need to sign in again') }}</h1>
{% if other_device %}
<p>
Expand All @@ -35,8 +34,10 @@ <h1 class="heading-large">{{ _("Sign in") }}</h1>
{% call form_wrapper(autocomplete=True) %}
{{ textbox(form.email_address, width='w-2/3', autocomplete='username') }}
{{ textbox(form.password, width='w-2/3', autocomplete='current-password') }}
<p data-testid="session_timeout_info">{{ _('Your session ends after 8 hours of inactivity') }}</p>
{{ page_footer(btn, secondary_link=url_for('.forgot_password'), secondary_link_text=forgot) }}
{% endcall %}

</div>
</div>

Expand Down
2 changes: 2 additions & 0 deletions app/translations/csv/fr.csv
Original file line number Diff line number Diff line change
Expand Up @@ -1868,3 +1868,5 @@
"Alternative text in French","Texte alternatif en français"
"Enter the alternative text in English","Entrez le texte alternatif en anglais"
"Enter the alternative text in French","Entrez le texte alternatif en français"
"You have been signed out.","Déconnexion réussie"
"Your session ends after 8 hours of inactivity","Votre session se termine au bout de 8 heures d’inactivité"
1 change: 1 addition & 0 deletions gulpfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ const javascripts = () => {
paths.src + "javascripts/scheduler.min.js",
paths.src + "javascripts/branding_request.min.js",
paths.src + "javascripts/formValidateRequired.min.js",
paths.src + "javascripts/sessionRedirect.min.js",
])
)
.pipe(dest(paths.dist + "javascripts/"));
Expand Down
4 changes: 2 additions & 2 deletions tests/app/main/views/test_sign_out.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
def test_render_sign_out_redirects_to_sign_in(client):
response = client.get(url_for("main.sign_out"))
assert response.status_code == 302
assert response.location == url_for("main.index")
assert response.location == url_for("main.sign_in")


def test_sign_out_user(
Expand Down Expand Up @@ -35,7 +35,7 @@ def test_sign_out_user(
"main.sign_out",
_expected_status=302,
_expected_redirect=url_for(
"main.index",
"main.sign_in",
),
)
with client_request.session_transaction() as session:
Expand Down
46 changes: 46 additions & 0 deletions tests_cypress/cypress/e2e/admin/sign_out/sign_out.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { LoginPage } from "../../../Notify/Admin/Pages/AllPages";

const REDIRECT_LOCATION = '/sign-in?timeout=true';
const SESSION_TIMEOUT_MS = 7 * 60 * 60 * 1000 + 55 * 60 * 1000; // 7 hours 55 minutes
const vistPageAndFastForwardTime = (page = '/') => {
cy.clock();
cy.visit(page);
cy.tick(SESSION_TIMEOUT_MS);
};

describe('Sign out', () => {

it('Does not redirect to session timeout page when logged out', () => {
cy.clearCookie('notify_admin_session');
vistPageAndFastForwardTime();

// asserts
cy.url().should('not.include', REDIRECT_LOCATION);
});

it('Redirects to session timeout page when logged in (multiple pages)', () => {
['/home', '/features'].forEach((page) => {
LoginPage.Login(Cypress.env('NOTIFY_USER'), Cypress.env('NOTIFY_PASSWORD'));
vistPageAndFastForwardTime(page);

// asserts
cy.url().should('include', REDIRECT_LOCATION);
cy.get('h1').should('contain', 'You need to sign in again');
});
});

it('Displays banner on explicit logout', () => {
cy.visit('/sign-out');

// asserts
cy.url().should('include', '/sign-in');
cy.get('.banner-default-with-tick').should('be.visible');
});

it('Displays session timeout info on login page', () => {
cy.visit('/sign-in');

// asserts
cy.getByTestId('session_timeout_info').should('be.visible');
});
});
4 changes: 4 additions & 0 deletions tests_cypress/cypress/support/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,7 @@ Cypress.Commands.add('a11yScan', (url, options={ a11y: true, htmlValidate: true,
});
}
})

Cypress.Commands.add('getByTestId', (selector, ...args) => {
return cy.get(`[data-testid=${selector}]`, ...args)
});
1 change: 1 addition & 0 deletions webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ module.exports = {
main: ["./app/assets/javascripts/index.js", "./app/assets/stylesheets/tailwind/style.css"],
branding_request: ["./app/assets/javascripts/branding_request.js"],
formValidateRequired: ["./app/assets/javascripts/formValidateRequired.js"],
sessionRedirect: ["./app/assets/javascripts/sessionRedirect.js"],
scheduler: {
import: './app/assets/javascripts/scheduler/scheduler.js',
library: {
Expand Down

0 comments on commit 5fde768

Please sign in to comment.