@entity-access/server-pages 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/node.yml +24 -0
- package/.vscode/launch.json +24 -0
- package/.vscode/settings.json +66 -0
- package/LICENSE +201 -0
- package/README.md +2 -0
- package/package.json +39 -0
- package/src/Content.tsx +187 -0
- package/src/NotFoundPage.tsx +10 -0
- package/src/Page.tsx +217 -0
- package/src/ServerPages.ts +129 -0
- package/src/core/LocalFile.ts +92 -0
- package/src/core/RouteTree.ts +125 -0
- package/src/core/SessionUser.ts +95 -0
- package/src/core/TempFolder.ts +41 -0
- package/src/html/HtmlDocument.tsx +10 -0
- package/src/html/XNode.ts +52 -0
- package/src/index.d.ts +16 -0
- package/src/services/CookieService.ts +137 -0
- package/src/services/KeyProvider.ts +62 -0
- package/src/services/TokenService.ts +80 -0
- package/src/services/UserSessionProvider.ts +13 -0
- package/src/tests/logger/api/log/post.ts +6 -0
- package/src/tests/logger/index.tsx +20 -0
- package/test.js +12 -0
- package/tsconfig.json +31 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export default class XNode {
|
|
2
|
+
|
|
3
|
+
public static create(
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
5
|
+
name: string | Function,
|
|
6
|
+
attribs: Record<string, any>,
|
|
7
|
+
... nodes: (XNode | string)[]) {
|
|
8
|
+
if (typeof name === "function") {
|
|
9
|
+
return name(attribs ?? {}, ... nodes);
|
|
10
|
+
}
|
|
11
|
+
return new XNode(name, attribs, nodes);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
private constructor(
|
|
15
|
+
public readonly name: string,
|
|
16
|
+
public readonly attributes: Record<string, any>,
|
|
17
|
+
public readonly children: (XNode | string)[]
|
|
18
|
+
) {
|
|
19
|
+
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public render(nest = "") {
|
|
23
|
+
const { name, attributes } = this;
|
|
24
|
+
const children = [];
|
|
25
|
+
let a = "";
|
|
26
|
+
if (attributes) {
|
|
27
|
+
for (const key in attributes) {
|
|
28
|
+
if (Object.prototype.hasOwnProperty.call(attributes, key)) {
|
|
29
|
+
const element = attributes[key];
|
|
30
|
+
a += ` ${key}=${JSON.stringify(element)}`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (this.children) {
|
|
35
|
+
for (const child of this.children) {
|
|
36
|
+
if (typeof child === "string") {
|
|
37
|
+
children.push(child);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (!child) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
children.push(child.render(nest + "\t"));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!name) {
|
|
47
|
+
return `\n${nest}\t${children.join("\n\t")}`;
|
|
48
|
+
}
|
|
49
|
+
return `${nest}<${name}${a}>\n${nest}\t${children.join("\n\t")}\n${nest}</${name}>`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type SessionUser from "./core/SessionUser.ts";
|
|
2
|
+
|
|
3
|
+
export {};
|
|
4
|
+
|
|
5
|
+
declare global {
|
|
6
|
+
namespace Express {
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
8
|
+
export interface Request {
|
|
9
|
+
/**
|
|
10
|
+
* user will always be set, even in case if it is an anonymous request.
|
|
11
|
+
* This is to track actual views.
|
|
12
|
+
*/
|
|
13
|
+
user: SessionUser;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import Inject, { RegisterSingleton, ServiceProvider } from "@entity-access/entity-access/dist/di/di.js";
|
|
2
|
+
import TokenService, { IAuthCookie } from "./TokenService.js";
|
|
3
|
+
import TimedCache from "@entity-access/entity-access/dist/common/cache/TimedCache.js";
|
|
4
|
+
import { BaseDriver } from "@entity-access/entity-access/dist/drivers/base/BaseDriver.js";
|
|
5
|
+
import { Request, Response } from "express";
|
|
6
|
+
import cluster from "cluster";
|
|
7
|
+
import SessionUser from "../core/SessionUser.js";
|
|
8
|
+
import UserSessionProvider from "./UserSessionProvider.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* This will track userID,cookie pair so we can
|
|
12
|
+
* clear the logged in user's file permissions
|
|
13
|
+
*/
|
|
14
|
+
const userCookies = new Map<number,string>();
|
|
15
|
+
const sessionCache = new TimedCache<string, Partial<SessionUser>>();
|
|
16
|
+
|
|
17
|
+
let cookieName = null;
|
|
18
|
+
|
|
19
|
+
const tagForCache = Symbol("tagForCache");
|
|
20
|
+
|
|
21
|
+
const cacheFR = new FinalizationRegistry<number>((heldValue) => {
|
|
22
|
+
userCookies.delete(heldValue);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
@RegisterSingleton
|
|
26
|
+
export default class CookieService {
|
|
27
|
+
|
|
28
|
+
@Inject
|
|
29
|
+
private tokenService: TokenService;
|
|
30
|
+
|
|
31
|
+
@Inject
|
|
32
|
+
private userSessionProvider: UserSessionProvider;
|
|
33
|
+
|
|
34
|
+
constructor() {
|
|
35
|
+
process.on("message", (msg: any) => {
|
|
36
|
+
if (msg.type === "cookie-service-clear-cache") {
|
|
37
|
+
this.clearCache(msg.userID, false);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public clearCache(userID: number, broadcast = true) {
|
|
43
|
+
const cookie = userCookies.get(userID);
|
|
44
|
+
if (cookie) {
|
|
45
|
+
sessionCache.delete(cookie);
|
|
46
|
+
}
|
|
47
|
+
if (!broadcast) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const clearMessage = {
|
|
51
|
+
type: "cookie-service-clear-cache",
|
|
52
|
+
userID
|
|
53
|
+
};
|
|
54
|
+
if (cluster.isWorker) {
|
|
55
|
+
process.send(clearMessage);
|
|
56
|
+
} else {
|
|
57
|
+
if(cluster.workers) {
|
|
58
|
+
for (const key in cluster.workers) {
|
|
59
|
+
if (Object.prototype.hasOwnProperty.call(cluster.workers, key)) {
|
|
60
|
+
const element = cluster.workers[key];
|
|
61
|
+
element.send(clearMessage);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async createSessionUser(req: Request, resp: Response) {
|
|
69
|
+
cookieName ??= this.tokenService.authCookieName;
|
|
70
|
+
const sessionCookie = req.cookies[cookieName];
|
|
71
|
+
req.user = await this.createSessionUserFromCookie(sessionCookie, req.ip, resp);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async createSessionUserFromCookie(cookie: string, ip: string, resp?: Response) {
|
|
75
|
+
const user = new SessionUser(resp, cookieName, this.tokenService);
|
|
76
|
+
try {
|
|
77
|
+
user.ipAddress = ip;
|
|
78
|
+
if (cookie) {
|
|
79
|
+
const userInfo = await this.getVerifiedUser(cookie);
|
|
80
|
+
if (userInfo?.sessionID) {
|
|
81
|
+
user.sessionID = userInfo.sessionID;
|
|
82
|
+
}
|
|
83
|
+
if (userInfo?.userID) {
|
|
84
|
+
user[tagForCache] = userInfo;
|
|
85
|
+
for (const key in userInfo) {
|
|
86
|
+
if (Object.prototype.hasOwnProperty.call(userInfo, key)) {
|
|
87
|
+
const element = userInfo[key];
|
|
88
|
+
user[key] = element;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return user;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error(error);
|
|
96
|
+
return user;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private getVerifiedUser(cookie: string): Promise<Partial<SessionUser>> {
|
|
101
|
+
|
|
102
|
+
return sessionCache.getOrCreateAsync(cookie, async (k) => {
|
|
103
|
+
|
|
104
|
+
const parsedCookie = JSON.parse(cookie) as IAuthCookie;
|
|
105
|
+
if (!parsedCookie.id) {
|
|
106
|
+
return {};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if(!await this.tokenService.verifyContent(parsedCookie, false)) {
|
|
110
|
+
return {};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!parsedCookie.active) {
|
|
114
|
+
return {
|
|
115
|
+
sessionID: parsedCookie.id
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const r = await this.createUserInfo(parsedCookie.id, cookie);
|
|
120
|
+
return r;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async createUserInfo(id: number, cookie: string) {
|
|
125
|
+
const r = await this.userSessionProvider.getUserSession(id);
|
|
126
|
+
if (r === null) {
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
if (r.expiry.getTime() < Date.now() || r.invalid) {
|
|
130
|
+
return {};
|
|
131
|
+
}
|
|
132
|
+
cacheFR.register(r, r.userID);
|
|
133
|
+
userCookies.set(r.userID, cookie);
|
|
134
|
+
return r;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { RegisterSingleton } from "@entity-access/entity-access/dist/di/di.js";
|
|
2
|
+
import DateTime from "@entity-access/entity-access/dist/types/DateTime.js";
|
|
3
|
+
import { generateKeyPair } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
export interface IAuthKey {
|
|
8
|
+
publicKey: string,
|
|
9
|
+
privateKey: string,
|
|
10
|
+
expires: DateTime
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@RegisterSingleton
|
|
15
|
+
export default class KeyProvider {
|
|
16
|
+
|
|
17
|
+
public keyPath: string = "/data/keys";
|
|
18
|
+
|
|
19
|
+
private key: IAuthKey;
|
|
20
|
+
|
|
21
|
+
public async getKeys() {
|
|
22
|
+
if (this.key) {
|
|
23
|
+
return [this.key];
|
|
24
|
+
}
|
|
25
|
+
// check if we have key stored
|
|
26
|
+
const keyPath = this.keyPath;
|
|
27
|
+
if (!existsSync(keyPath)) {
|
|
28
|
+
mkdirSync(keyPath, { recursive: true});
|
|
29
|
+
}
|
|
30
|
+
const file = join(keyPath, "cookie-key.json");
|
|
31
|
+
if (existsSync(file)) {
|
|
32
|
+
this.key = JSON.parse(readFileSync(file, "utf-8"));
|
|
33
|
+
return [this.key];
|
|
34
|
+
}
|
|
35
|
+
this.key = await this.generateKey(null);
|
|
36
|
+
writeFileSync(file, JSON.stringify(this.key, undefined, 2));
|
|
37
|
+
return [this.key];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private generateKey(expires: DateTime) {
|
|
41
|
+
return new Promise<IAuthKey>((resolve, reject) => {
|
|
42
|
+
generateKeyPair('rsa', {
|
|
43
|
+
modulusLength: 2048,
|
|
44
|
+
publicKeyEncoding: {
|
|
45
|
+
type: 'spki',
|
|
46
|
+
format: 'pem'
|
|
47
|
+
},
|
|
48
|
+
privateKeyEncoding: {
|
|
49
|
+
type: 'pkcs8',
|
|
50
|
+
format: 'pem'
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
(error, publicKey, privateKey) => {
|
|
54
|
+
resolve({
|
|
55
|
+
publicKey,
|
|
56
|
+
privateKey,
|
|
57
|
+
expires
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import Inject, { RegisterSingleton } from "@entity-access/entity-access/dist/di/di.js";
|
|
2
|
+
import DateTime from "@entity-access/entity-access/dist/types/DateTime.js";
|
|
3
|
+
import { createSign, createVerify, generateKeyPair } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import KeyProvider from "./KeyProvider.js";
|
|
7
|
+
|
|
8
|
+
export interface IAuthCookie {
|
|
9
|
+
id: number;
|
|
10
|
+
expiry: Date;
|
|
11
|
+
sign: string;
|
|
12
|
+
version: string;
|
|
13
|
+
active?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface IAuthKey {
|
|
17
|
+
publicKey: string,
|
|
18
|
+
privateKey: string,
|
|
19
|
+
expires: DateTime
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ISignedContent<T> = T & {
|
|
23
|
+
sign: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
@RegisterSingleton
|
|
27
|
+
export default class TokenService {
|
|
28
|
+
|
|
29
|
+
public authCookieName = "ea-c1";
|
|
30
|
+
|
|
31
|
+
public shareCookieName = "ea-ca1";
|
|
32
|
+
|
|
33
|
+
@Inject
|
|
34
|
+
private keyProvider: KeyProvider;
|
|
35
|
+
|
|
36
|
+
public async getAuthToken(authCookie: Omit<IAuthCookie, "sign">): Promise<{ cookieName: string, cookie: string}> {
|
|
37
|
+
const cookie = await this.signContent(authCookie);
|
|
38
|
+
return { cookieName: this.authCookieName, cookie: JSON.stringify(cookie) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public async signContent<T>(content: T): Promise<ISignedContent<T>> {
|
|
42
|
+
const [key] = await this.keyProvider.getKeys();
|
|
43
|
+
const sign = this.sign(JSON.stringify(content), key);
|
|
44
|
+
return { ... content, sign};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public async verifyContent<T>(content: ISignedContent<T>, fail = true) {
|
|
48
|
+
const { sign , ... c } = content;
|
|
49
|
+
const keys = await this.keyProvider.getKeys();
|
|
50
|
+
for (const iterator of keys) {
|
|
51
|
+
if(this.verify(JSON.stringify(c), sign, iterator, false)) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (fail) {
|
|
56
|
+
throw new Error("Signature verification failed");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private sign(content: string, key: IAuthKey) {
|
|
61
|
+
const sign = createSign("SHA256");
|
|
62
|
+
sign.write(content);
|
|
63
|
+
sign.end();
|
|
64
|
+
return sign.sign(key.privateKey, "hex");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private verify(content: string | Buffer, signature: string, key: IAuthKey, fail = true) {
|
|
68
|
+
const verify = createVerify("SHA256");
|
|
69
|
+
verify.write(content);
|
|
70
|
+
verify.end();
|
|
71
|
+
if(verify.verify(key.publicKey, signature, "hex")) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
if (fail) {
|
|
75
|
+
throw new Error("Invalid signature");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { RegisterSingleton } from "@entity-access/entity-access/dist/di/di.js";
|
|
2
|
+
import SessionUser from "../core/SessionUser.js";
|
|
3
|
+
|
|
4
|
+
@RegisterSingleton
|
|
5
|
+
export default class UserSessionProvider {
|
|
6
|
+
|
|
7
|
+
async getUserSession(id: number): Promise<Partial<SessionUser>> {
|
|
8
|
+
return {
|
|
9
|
+
userID: 1,
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import Content from "../../Content.js";
|
|
2
|
+
import Page from "../../Page.js";
|
|
3
|
+
import HtmlDocument from "../../html/HtmlDocument.js";
|
|
4
|
+
import XNode from "../../html/XNode.js";
|
|
5
|
+
|
|
6
|
+
export default function(this: Page) {
|
|
7
|
+
|
|
8
|
+
const [child] = this.childPath;
|
|
9
|
+
if (child) {
|
|
10
|
+
if (!/^index\.htm/i.test(child)) {
|
|
11
|
+
return this.notFound();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return Content.html(<HtmlDocument>
|
|
16
|
+
<body>
|
|
17
|
+
Test 1
|
|
18
|
+
</body>
|
|
19
|
+
</HtmlDocument>);
|
|
20
|
+
}
|
package/test.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ServiceProvider } from "@entity-access/entity-access/dist/di/di.js";
|
|
2
|
+
import ServerPages from "./dist/ServerPages.js";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
const sp = ServerPages.create();
|
|
8
|
+
sp.registerRoutes(join(dirname( fileURLToPath(import.meta.url)), "./dist/tests/logger"));
|
|
9
|
+
|
|
10
|
+
const app = sp.build();
|
|
11
|
+
|
|
12
|
+
app.listen(8080);
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"jsx": "react",
|
|
4
|
+
"target": "ES2021",
|
|
5
|
+
"module":"NodeNext",
|
|
6
|
+
"incremental": true,
|
|
7
|
+
"sourceMap": true,
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"declarationMap": true,
|
|
10
|
+
"outDir": "dist",
|
|
11
|
+
"skipDefaultLibCheck": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"experimentalDecorators": true,
|
|
14
|
+
"emitDecoratorMetadata": true,
|
|
15
|
+
"jsxFactory": "XNode.create",
|
|
16
|
+
"lib": [
|
|
17
|
+
"ES2018",
|
|
18
|
+
"ES2021.WeakRef",
|
|
19
|
+
"esnext.disposable",
|
|
20
|
+
"ES2021.String",
|
|
21
|
+
"ES2022.Object"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"include": [
|
|
25
|
+
"src/**/*"
|
|
26
|
+
],
|
|
27
|
+
"exclude": [
|
|
28
|
+
"node_modules",
|
|
29
|
+
"tests"
|
|
30
|
+
]
|
|
31
|
+
}
|