- Switch frontend sign-in to /api/v1/auth/sign-in endpoint (includes persona) - Add updateUser() to App context for proper state management - Fix Settings.tsx save flow to use updateUser() instead of broken localStorage merge - Remove unused web AuthController (gadget-code/src/controllers/auth.ts) - Fix UserApiControllerV1 to return flat user object instead of double-wrapped - Remove SessionType enum and references (dead code) - Add proper server sign-out call before clearing local state Resolves issue where User Settings view didn't display persona text even though it existed in the database.
150 lines
4.0 KiB
TypeScript
150 lines
4.0 KiB
TypeScript
// src/services/session.ts
|
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
|
// All Rights Reserved
|
|
|
|
import env from "../config/env.js";
|
|
|
|
import { Request } from "express";
|
|
|
|
import jwt from "jsonwebtoken";
|
|
import dayjs from "dayjs";
|
|
import geoip from "geoip-lite";
|
|
|
|
import { GadgetId, IUser } from "@gadget/api";
|
|
import { IWebToken, WebToken } from "../models/web-token.js";
|
|
import { WebVisit } from "../models/web-visit.js";
|
|
|
|
import UserService from "../services/user.js";
|
|
import { DtpService } from "../lib/service.js";
|
|
import { PopulateOptions } from "mongoose";
|
|
|
|
interface UserWebToken {
|
|
_id: string;
|
|
email: string;
|
|
displayName: string;
|
|
webToken: IWebToken | GadgetId;
|
|
}
|
|
|
|
class SessionService extends DtpService {
|
|
private populateWebToken: PopulateOptions[] = [
|
|
{
|
|
path: "user",
|
|
select: "-passwordSalt -password",
|
|
},
|
|
];
|
|
|
|
get name(): string {
|
|
return "SessionService";
|
|
}
|
|
get slug(): string {
|
|
return "session";
|
|
}
|
|
|
|
async start(): Promise<void> {}
|
|
async stop(): Promise<void> {}
|
|
|
|
async createJsonWebToken(user: IUser): Promise<string> {
|
|
const NOW = new Date();
|
|
const webToken = new WebToken();
|
|
webToken.created = NOW;
|
|
webToken.expires = dayjs(NOW).add(7, "day").toDate();
|
|
webToken.user = user._id;
|
|
|
|
const payload: UserWebToken = {
|
|
_id: user._id,
|
|
email: user.email,
|
|
displayName: user.displayName,
|
|
webToken: webToken._id,
|
|
};
|
|
const token = jwt.sign(payload, env.auth.jwtSecret, {
|
|
expiresIn: "24h",
|
|
});
|
|
|
|
webToken.token = token;
|
|
await webToken.save();
|
|
|
|
return token;
|
|
}
|
|
|
|
async verifyJsonWebToken(token: string): Promise<IUser> {
|
|
try {
|
|
const NOW = new Date();
|
|
const payload = jwt.verify(token, env.auth.jwtSecret) as UserWebToken;
|
|
const userId = payload._id;
|
|
|
|
const webToken = await WebToken.findOne({
|
|
_id: payload.webToken,
|
|
}).populate(this.populateWebToken);
|
|
if (!webToken) {
|
|
const error = new Error("Invalid JSON Web Token");
|
|
error.name = "InvalidToken";
|
|
error.statusCode = 401;
|
|
throw error;
|
|
}
|
|
|
|
if (webToken.expires < NOW) {
|
|
await WebToken.deleteOne({ _id: webToken._id });
|
|
const error = new Error("JSON Web Token has expired");
|
|
error.name = "ExpiredToken";
|
|
error.statusCode = 401;
|
|
throw error;
|
|
}
|
|
if (userId !== (webToken.user as IUser)._id) {
|
|
const error = new Error("JSON Web Token ownership mismatch");
|
|
error.name = "TokenOwnershipMismatch";
|
|
error.statusCode = 401;
|
|
throw error;
|
|
}
|
|
|
|
const user = await UserService.getById(userId);
|
|
return user;
|
|
} catch (cause) {
|
|
// this.log.error("failed to verify JSON Web Token", {
|
|
// token,
|
|
// error: cause,
|
|
// });
|
|
const error = new Error("Invalid JSON Web Token", { cause });
|
|
error.name = "TokenVerifyError";
|
|
error.statusCode = 401;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async revokeJsonWebToken(token: string): Promise<void> {
|
|
const payload = jwt.verify(token, env.auth.jwtSecret) as UserWebToken;
|
|
this.log.info("revoking JSON Web Token", { tokenId: payload.webToken });
|
|
await WebToken.deleteOne({ _id: payload.webToken });
|
|
}
|
|
|
|
async recordWebVisit(req: Request): Promise<void> {
|
|
const NOW = new Date();
|
|
const visit = new WebVisit();
|
|
visit.created = NOW;
|
|
visit.url = req.url;
|
|
if (req.user) {
|
|
visit.user = req.user._id;
|
|
}
|
|
visit.userAgent = req.headers["user-agent"];
|
|
visit.referrer = req.headers["referer"];
|
|
visit.ipAddress = req.ip;
|
|
|
|
if (req.ip) {
|
|
const ipLookup = geoip.lookup(req.ip);
|
|
if (ipLookup) {
|
|
visit.country = ipLookup.country;
|
|
visit.region = ipLookup.region;
|
|
visit.eu = ipLookup.eu === "1";
|
|
visit.timezone = ipLookup.timezone;
|
|
visit.city = ipLookup.city;
|
|
visit.latitude = ipLookup.ll[0];
|
|
visit.longitude = ipLookup.ll[1];
|
|
visit.metroCode = ipLookup.metro;
|
|
}
|
|
}
|
|
|
|
await visit.save();
|
|
}
|
|
}
|
|
|
|
export default new SessionService();
|