Add persist session checkbox on login

Makes session not persistent by default
Closes #11
This commit is contained in:
Alice Gaudon 2021-01-24 16:29:23 +01:00
parent 5897b6bf36
commit 1b8ff1428f
11 changed files with 132 additions and 11 deletions

View File

@ -31,8 +31,8 @@
secret: 'default', secret: 'default',
cookie: { cookie: {
secure: false, secure: false,
maxAge: 2592000000, // 30 days maxAge: 31557600000, // 1 year
} },
}, },
mail: { mail: {
host: "127.0.0.1", host: "127.0.0.1",

View File

@ -6,4 +6,9 @@
database: "swaf_test", database: "swaf_test",
create_database_automatically: true, create_database_automatically: true,
}, },
session: {
cookie: {
maxAge: 1000, // 1s
},
},
} }

View File

@ -81,6 +81,7 @@ export default class AuthGuard {
if (proofs.length === 0) { if (proofs.length === 0) {
session.is_authenticated = false; session.is_authenticated = false;
session.persistent = false;
} }
return proofs; return proofs;
@ -107,6 +108,7 @@ export default class AuthGuard {
public async authenticateOrRegister( public async authenticateOrRegister(
session: Session & Partial<SessionData>, session: Session & Partial<SessionData>,
proof: AuthProof<User>, proof: AuthProof<User>,
persistSession: boolean,
onLogin?: (user: User) => Promise<void>, onLogin?: (user: User) => Promise<void>,
beforeRegister?: (connection: Connection, user: User) => Promise<RegisterCallback[]>, beforeRegister?: (connection: Connection, user: User) => Promise<RegisterCallback[]>,
afterRegister?: (connection: Connection, user: User) => Promise<RegisterCallback[]>, afterRegister?: (connection: Connection, user: User) => Promise<RegisterCallback[]>,
@ -151,6 +153,7 @@ export default class AuthGuard {
// Login // Login
session.is_authenticated = true; session.is_authenticated = true;
session.persistent = persistSession;
if (onLogin) await onLogin(user); if (onLogin) await onLogin(user);
return user; return user;

View File

@ -98,6 +98,8 @@ export default class MagicLinkAuthMethod implements AuthMethod<MagicLink> {
}); });
} }
req.getSession().wantsSessionPersistence = !!req.body.persist_session || isRegistration;
await MagicLinkController.sendMagicLink( await MagicLinkController.sendMagicLink(
this.app, this.app,
req.getSession().id, req.getSession().id,

View File

@ -62,7 +62,7 @@ export default class MagicLinkController<A extends Application> extends Controll
// Auth // Auth
try { try {
return await req.as(AuthMiddleware).getAuthGuard().authenticateOrRegister( return await req.as(AuthMiddleware).getAuthGuard().authenticateOrRegister(
session, magicLink, undefined, async (connection, user) => { session, magicLink, !!session.wantsSessionPersistence, undefined, async (connection, user) => {
const userNameComponent = user.asOptional(UserNameComponent); const userNameComponent = user.asOptional(UserNameComponent);
if (userNameComponent) userNameComponent.name = magicLink.as(MagicLinkUserNameComponent).username; if (userNameComponent) userNameComponent.name = magicLink.as(MagicLinkUserNameComponent).username;
return []; return [];

View File

@ -58,7 +58,11 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
await passwordAuthProof.authorize(req.body.password); await passwordAuthProof.authorize(req.body.password);
try { try {
await this.app.as(AuthComponent).getAuthGuard().authenticateOrRegister(req.getSession(), passwordAuthProof); await this.app.as(AuthComponent).getAuthGuard().authenticateOrRegister(
req.getSession(),
passwordAuthProof,
!!req.body.persist_session,
);
} catch (e) { } catch (e) {
if (e instanceof AuthError) { if (e instanceof AuthError) {
Throttler.throttle('login_failed_attempts_user', 3, 3 * 60 * 1000, // 3min Throttler.throttle('login_failed_attempts_user', 3, 3 * 60 * 1000, // 3min
@ -102,7 +106,7 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
const passwordAuthProof = PasswordAuthProof.createAuthorizedProofForRegistration(req.getSession()); const passwordAuthProof = PasswordAuthProof.createAuthorizedProofForRegistration(req.getSession());
try { try {
await this.app.as(AuthComponent).getAuthGuard().authenticateOrRegister(req.getSession(), passwordAuthProof, await this.app.as(AuthComponent).getAuthGuard().authenticateOrRegister(req.getSession(), passwordAuthProof,
undefined, async (connection, user) => { true, undefined, async (connection, user) => {
const callbacks: RegisterCallback[] = []; const callbacks: RegisterCallback[] = [];
// Password // Password

View File

@ -114,10 +114,10 @@ class RedisStore extends Store {
} }
public get(sid: string, callback: (err?: Error, session?: (session.SessionData | null)) => void): void { public get(sid: string, callback: (err?: Error, session?: (session.SessionData | null)) => void): void {
this.redisComponent.get('-session:' + sid) this.redisComponent.get(`-session:${sid}`)
.then(value => { .then(value => {
if (value) { if (value) {
this.redisComponent.persist('-session:' + sid, 2592000000) // 30 days this.redisComponent.persist(`-session:${sid}`, config.get<number>('session.cookie.maxAge'))
.then(() => { .then(() => {
callback(undefined, JSON.parse(value)); callback(undefined, JSON.parse(value));
}) })
@ -130,7 +130,7 @@ class RedisStore extends Store {
} }
public set(sid: string, session: session.SessionData, callback?: (err?: Error) => void): void { public set(sid: string, session: session.SessionData, callback?: (err?: Error) => void): void {
this.redisComponent.remember('-session:' + sid, JSON.stringify(session), 2592000000) // 30 days this.redisComponent.remember(`-session:${sid}`, JSON.stringify(session), config.get<number>('session.cookie.maxAge'))
.then(() => { .then(() => {
if (callback) callback(); if (callback) callback();
}) })
@ -138,7 +138,7 @@ class RedisStore extends Store {
} }
public destroy(sid: string, callback?: (err?: Error) => void): void { public destroy(sid: string, callback?: (err?: Error) => void): void {
this.redisComponent.forget('-session:' + sid) this.redisComponent.forget(`-session:${sid}`)
.then(() => { .then(() => {
if (callback) callback(); if (callback) callback();
}) })

View File

@ -30,7 +30,6 @@ export default class SessionComponent extends ApplicationComponent {
cookie: { cookie: {
httpOnly: true, httpOnly: true,
secure: config.get('session.cookie.secure'), secure: config.get('session.cookie.secure'),
maxAge: config.get('session.cookie.maxAge'),
}, },
rolling: true, rolling: true,
})); }));
@ -38,6 +37,7 @@ export default class SessionComponent extends ApplicationComponent {
router.use(flash()); router.use(flash());
router.use((req, res, next) => { router.use((req, res, next) => {
// Request session getters
req.getSessionOptional = () => { req.getSessionOptional = () => {
return req.session; return req.session;
}; };
@ -47,8 +47,18 @@ export default class SessionComponent extends ApplicationComponent {
return session; return session;
}; };
res.locals.session = req.getSession(); // Session persistence
const session = req.getSession();
if (session.persistent) {
session.cookie.maxAge = config.get('session.cookie.maxAge');
} else {
session.cookie.maxAge = session.cookie.expires = undefined;
}
// Views session local
res.locals.session = session;
// Views flash function
const _flash: FlashStorage = {}; const _flash: FlashStorage = {};
res.locals.flash = (key?: string): FlashMessages | unknown[] => { res.locals.flash = (key?: string): FlashMessages | unknown[] => {
if (key !== undefined) { if (key !== undefined) {

View File

@ -40,6 +40,10 @@ declare module 'express-session' {
previousUrl?: string; previousUrl?: string;
wantsSessionPersistence?: boolean;
persistent?: boolean;
is_authenticated?: boolean; is_authenticated?: boolean;
auth_password_proof?: PasswordAuthProofSessionData; auth_password_proof?: PasswordAuthProofSessionData;

View File

@ -1028,3 +1028,94 @@ describe('Manage email addresses', () => {
}); });
}); });
}); });
describe('Session persistence', () => {
let cookies: string[], csrf: string;
test('Not persistent at registration', async () => {
let res = await agent.get('/csrf').expect(200);
cookies = res.get('Set-Cookie');
csrf = res.text;
await agent.post('/auth/register')
.set('Cookie', cookies)
.send({
csrf: csrf,
auth_method: 'magic_link',
identifier: 'zuko@example.org',
name: 'zuko',
})
.expect(302)
.expect('Location', '/magic/lobby?redirect_uri=%2Fcsrf');
await followMagicLinkFromMail(agent, cookies);
expect(cookies[0]).toMatch(/^connect\.sid=.+; Path=\/; HttpOnly$/);
res = await agent.get('/csrf')
.set('Cookie', cookies)
.expect(200);
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; HttpOnly$/);
// Logout
await agent.post('/auth/logout')
.set('Cookie', cookies)
.send({csrf})
.expect(302)
.expect('Location', '/');
});
test('Login with persistence', async () => {
await agent.post('/auth/login')
.set('Cookie', cookies)
.send({
csrf: csrf,
auth_method: 'magic_link',
identifier: 'zuko@example.org',
persist_session: 'on',
})
.expect(302)
.expect('Location', '/magic/lobby?redirect_uri=%2Fcsrf');
await followMagicLinkFromMail(agent, cookies);
const res = await agent.get('/csrf')
.set('Cookie', cookies)
.expect(200);
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; HttpOnly$/);
// Logout
await agent.post('/auth/logout')
.set('Cookie', cookies)
.send({csrf})
.expect(302)
.expect('Location', '/');
});
test('Login without persistence', async () => {
await agent.post('/auth/login')
.set('Cookie', cookies)
.send({
csrf: csrf,
auth_method: 'magic_link',
identifier: 'zuko@example.org',
persist_session: undefined,
})
.expect(302)
.expect('Location', '/magic/lobby?redirect_uri=%2Fcsrf');
await followMagicLinkFromMail(agent, cookies);
const res = await agent.get('/csrf')
.set('Cookie', cookies)
.expect(200);
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; HttpOnly$/);
// Logout
await agent.post('/auth/logout')
.set('Cookie', cookies)
.send({csrf})
.expect(302)
.expect('Location', '/');
});
});

View File

@ -20,6 +20,8 @@
{{ macros.field(_locals, 'password', 'password', null, 'Your password', 'Do not fill to log in via magic link.') }} {{ macros.field(_locals, 'password', 'password', null, 'Your password', 'Do not fill to log in via magic link.') }}
{{ macros.field(_locals, 'checkbox', 'persist_session', null, 'Stay logged in on this computer.') }}
<button type="submit">Authenticate</button> <button type="submit">Authenticate</button>
{{ macros.csrf(getCsrfToken) }} {{ macros.csrf(getCsrfToken) }}