diff --git a/source/server/auth/User.ts b/source/server/auth/User.ts index afa99f00..a783e444 100644 --- a/source/server/auth/User.ts +++ b/source/server/auth/User.ts @@ -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 = {}) :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, diff --git a/source/server/routes/auth/login.test.ts b/source/server/routes/auth/login.test.ts index 829b5433..6acf2c78 100644 --- a/source/server/routes/auth/login.test.ts +++ b/source/server/routes/auth/login.test.ts @@ -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"; @@ -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(){ @@ -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") @@ -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 = { @@ -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 diff --git a/source/server/routes/auth/login.ts b/source/server/routes/auth/login.ts index 2dbc74dd..9e8f9074 100644 --- a/source/server/routes/auth/login.ts +++ b/source/server/routes/auth/login.ts @@ -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)=>{ diff --git a/source/server/routes/index.ts b/source/server/routes/index.ts index 60b1478f..a697145d 100644 --- a/source/server/routes/index.ts +++ b/source/server/routes/index.ts @@ -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"; @@ -58,25 +58,45 @@ export default async function createServer(config = defaultConfig) :Promise{ - 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(); diff --git a/source/server/routes/users/uid/patch.ts b/source/server/routes/users/uid/patch.ts index a2a47cb0..89746c36 100644 --- a/source/server/routes/users/uid/patch.ts +++ b/source/server/routes/users/uid/patch.ts @@ -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"; @@ -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); @@ -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)); } \ No newline at end of file diff --git a/source/server/utils/locals.ts b/source/server/utils/locals.ts index 6f785bd4..41061ce9 100644 --- a/source/server/utils/locals.ts +++ b/source/server/utils/locals.ts @@ -14,12 +14,23 @@ export interface AppLocals extends Record{ 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 */