Skip to content

Commit

Permalink
expire sessions after a month. Auto-renew tokens when necessary
Browse files Browse the repository at this point in the history
  • Loading branch information
sdumetz committed Dec 12, 2024
1 parent 53190c1 commit 15bc804
Show file tree
Hide file tree
Showing 6 changed files with 76 additions and 13 deletions.
6 changes: 3 additions & 3 deletions source/server/auth/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ export default class User implements SafeUser {
/**
* Make a user safe for export and public use (remove password field)
*/
static safe(u :User) :SafeUser{
static safe(u :Partial<User> = {}) :SafeUser{
return {
uid: u.uid,
username: u.username,
uid: u.uid ?? 0,
username: u.username ?? "default",
email: u.email,
isAdministrator: !!u.isAdministrator,
isDefaultUser: u.isDefaultUser ?? true,
Expand Down
26 changes: 24 additions & 2 deletions source/server/routes/auth/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import request from "supertest";
import Vfs from "../../vfs/index.js";
import User from "../../auth/User.js";
import UserManager from "../../auth/UserManager.js";
import { AppLocals } from "../../utils/locals.js";



Expand Down Expand Up @@ -33,7 +34,7 @@ describe("/auth/login", function(){
await request(this.server).get("/auth/login")
.set("Accept", "application/json")
.expect(200)
.expect({isAdministrator:false, isDefaultUser: true});
.expect({uid: 0, username: "default", isAdministrator:false, isDefaultUser: true});
});

it("can get login status (connected)", async function(){
Expand Down Expand Up @@ -72,6 +73,24 @@ describe("/auth/login", function(){
});
});

it("expires sessions", async function(){
(this.server.locals as AppLocals).sessionMaxAge = -1;
this.agent = request.agent(this.server);
await this.agent.post("/auth/login")
.send({username: user.username, password: "12345678"})
.set("Content-Type", "application/json")
.set("Accept", "")
.expect(200);

await this.agent.get("/auth/login")
.set("Accept", "application/json")
.expect(401)
.expect({
code: 401,
message: "Error: [401] Session Token expired. Please reauthenticate"
});
});

it("send a proper error if username is missing", async function(){
this.agent = request.agent(this.server);
let res = await this.agent.post("/auth/login")
Expand Down Expand Up @@ -107,10 +126,13 @@ describe("/auth/login", function(){
await agent.get("/auth/login")
.expect(200)
.expect({
uid: 0,
username: "default",
isDefaultUser: true,
isAdministrator: false,
});
});

describe("Authorization header", function(){
it("can use header to authenticate a request", async function(){
let res = {
Expand Down Expand Up @@ -141,7 +163,7 @@ describe("/auth/login", function(){
.set("Authorization", `${Buffer.from(`${user.username}:12345678`).toString("base64")}`)
.expect(200); //Still answers 200, but no login data

expect(res.body).to.deep.equal({ isAdministrator: false, isDefaultUser: true });
expect(res.body).to.deep.equal({ uid: 0, username: "default", isAdministrator: false, isDefaultUser: true });
});
it("rejects bad user:password", async function(){
// Missing the "Basic " part
Expand Down
7 changes: 6 additions & 1 deletion source/server/routes/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@ import sendmail from "../../utils/mails/send.js";
* @type {RequestHandler}
*/
export const postLogin :RequestHandler = (req, res, next)=>{
const {sessionMaxAge} = getLocals(req);
let userManager = getUserManager(req);
let {username,password} = req.body;
if(!username) throw new BadRequestError("username not provided");
if(!password) throw new BadRequestError("password not provided");
userManager.getUserByNamePassword(username, password).then(user=>{
let safeUser = User.safe(user);
Object.assign((req as any).session as any, safeUser);
Object.assign(
(req as any).session as any,
{expires: Date.now() + sessionMaxAge},
safeUser
);
res.status(200).send({...safeUser, code: 200, message: "OK"});

}, (e)=>{
Expand Down
30 changes: 25 additions & 5 deletions source/server/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import express, { Request, Response } from "express";


import UserManager from "../auth/UserManager.js";
import { BadRequestError, HTTPError } from "../utils/errors.js";
import { BadRequestError, HTTPError, UnauthorizedError } from "../utils/errors.js";
import { mkdir } from "fs/promises";

import {AppLocals, canRead, canWrite, getHost, getUserManager, isUser} from "../utils/locals.js";
import {AppLocals, canRead, canWrite, getHost, getLocals, getUserManager, isUser} from "../utils/locals.js";

import openDatabase from "../vfs/helpers/db.js";
import Vfs from "../vfs/index.js";
Expand Down Expand Up @@ -58,25 +58,45 @@ export default async function createServer(config = defaultConfig) :Promise<expr
vfs,
templates,
config,
sessionMaxAge: 31 * 24 * 60 * 60 // 1 month, in seconds
}) as AppLocals;

app.use(cookieSession({
name: 'session',
keys: await userManager.getKeys(),
// Cookie Options
maxAge: 31 * 24 * 60 * 60 * 1000, // 1 month
maxAge: (app.locals as AppLocals).sessionMaxAge * 1000,
sameSite: "strict"
}));

/**
* Does authentication-related work like renewing and expiring session-cookies
*/
app.use((req, res, next)=>{
if((req.session as any).uid) return next();
const {sessionMaxAge} = getLocals(req);
const now = Date.now();
if(req.session && !req.session.isNew){
if(!req.session.expires || req.session.expires < now){
req.session = null;
return next(new UnauthorizedError(`Session Token expired. Please reauthenticate`));
}else if(now < req.session.expires + sessionMaxAge*0.66){
req.session.expires = now + sessionMaxAge;
}
}

if(req.session?.uid) return next();

let auth = req.get("Authorization");
if(!auth) return next()
else if(!auth.startsWith("Basic ") || auth.length <= "Basic ".length ) return next();
let [username, password] = Buffer.from(auth.slice("Basic ".length), "base64").toString("utf-8").split(":");
if(!username || !password) return next();
getUserManager(req).getUserByNamePassword(username, password).then((user)=>{
Object.assign(req.session as any, User.safe(user));
Object.assign(
req.session as any,
{expires: now + sessionMaxAge},
User.safe(user),
);
next();
}, (e)=>{
if((e as HTTPError).code === 404) next();
Expand Down
9 changes: 7 additions & 2 deletions source/server/routes/users/uid/patch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Request, Response } from "express";

import { getUser, getUserManager } from "../../../utils/locals.js";
import { getLocals, getUser, getUserManager } from "../../../utils/locals.js";
import User, { SafeUser } from "../../../auth/User.js";
import { UnauthorizedError } from "../../../utils/errors.js";

Expand All @@ -10,6 +10,7 @@ import { UnauthorizedError } from "../../../utils/errors.js";
export async function handlePatchUser(req:Request, res :Response){
const {uid}= req.params;
const update = req.body;
const {sessionMaxAge} = getLocals(req);
const requester = getUser(req);
const isAdmin = requester.isAdministrator;
const userManager = getUserManager(req);
Expand All @@ -27,6 +28,10 @@ export async function handlePatchUser(req:Request, res :Response){
}

let u = await userManager.patchUser(parseInt(uid, 10), update);
Object.assign(req.session as SafeUser, User.safe(u));
Object.assign(
req.session as SafeUser,
{expires: Date.now() + sessionMaxAge},
User.safe(u)
);
res.status(200).send(User.safe(u));
}
11 changes: 11 additions & 0 deletions source/server/utils/locals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,23 @@ export interface AppLocals extends Record<string, any>{
vfs :Vfs;
templates :Templates;
config: Config;
/** Length of a session, in milliseconds since epoch */
sessionMaxAge: number;
}

export function getLocals(req :Request){
return req.app.locals as AppLocals;
}

export interface SessionData extends SafeUser{
/** Expire date, in ms since epoch */
expires?: number;
}

export function getSession(req :Request){
return req.session as SessionData|null|undefined;
}

/**
* @throws {InternalError} if app.locals.userManager is not defined for this request
*/
Expand Down

0 comments on commit 15bc804

Please sign in to comment.