@webtypen/webframez-core 0.3.72 → 0.3.73
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/auth-client.d.ts +11 -0
- package/auth-client.js +50 -0
- package/dist/Auth/ModelAuth.d.ts +72 -0
- package/dist/Auth/ModelAuth.js +226 -0
- package/dist/Auth/SessionAuth.d.ts +6 -0
- package/dist/Auth/SessionAuth.js +20 -2
- package/dist/Auth/index.d.ts +1 -0
- package/dist/Auth/index.js +1 -0
- package/dist/Router/Request.d.ts +2 -0
- package/dist/Router/Route.d.ts +3 -0
- package/dist/Router/Route.js +12 -0
- package/docs/authentication.md +85 -0
- package/package.json +5 -2
package/auth-client.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Public browser-only entry point for ModelAuth / Route.auth. */
|
|
2
|
+
export type AuthClientOptions = {
|
|
3
|
+
basePath?: string;
|
|
4
|
+
loginPaths?: string[];
|
|
5
|
+
cookieName?: string;
|
|
6
|
+
requiresCsrf?: (url: URL) => boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export declare function createAuthFetch(options?: AuthClientOptions, originalFetch?: typeof fetch): typeof fetch;
|
|
10
|
+
|
|
11
|
+
export declare function installAuthForms(options?: AuthClientOptions & { fetch?: typeof fetch }): () => void;
|
package/auth-client.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Browser-only entry point. No server or database modules are loaded. */
|
|
2
|
+
function csrfCookie(name) {
|
|
3
|
+
const cookieName = name || (window.location.protocol === "https:" ? "__Host-wf_csrf" : "wf_dev_csrf");
|
|
4
|
+
const values = document.cookie.split(";").map(part => part.trim()).filter(part => part.startsWith(`${cookieName}=`));
|
|
5
|
+
return values.length === 1 ? decodeURIComponent(values[0].slice(cookieName.length + 1)) : "";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
exports.createAuthFetch = function createAuthFetch(options = {}, originalFetch = window.fetch.bind(window)) {
|
|
9
|
+
const base = (options.basePath || "/api/auth").replace(/\/+$/, "");
|
|
10
|
+
const loginPaths = options.loginPaths || [`${base}/login`];
|
|
11
|
+
return async (input, init) => {
|
|
12
|
+
const url = new URL(input instanceof Request ? input.url : String(input), window.location.href);
|
|
13
|
+
const method = String(init?.method || (input instanceof Request ? input.method : "GET")).toUpperCase();
|
|
14
|
+
if (url.origin !== window.location.origin || ["GET", "HEAD", "OPTIONS"].includes(method)
|
|
15
|
+
|| (options.requiresCsrf && !options.requiresCsrf(url))) return originalFetch(input, init);
|
|
16
|
+
let token;
|
|
17
|
+
if (loginPaths.includes(url.pathname)) {
|
|
18
|
+
const bootstrap = await originalFetch(`${base}/csrf`, { credentials: "same-origin", cache: "no-store" });
|
|
19
|
+
if (!bootstrap.ok) return bootstrap;
|
|
20
|
+
token = (await bootstrap.json()).csrf;
|
|
21
|
+
} else token = csrfCookie(options.cookieName);
|
|
22
|
+
const headers = new Headers(init?.headers || (input instanceof Request ? input.headers : undefined));
|
|
23
|
+
if (typeof token === "string" && token) headers.set("X-CSRF-Token", token);
|
|
24
|
+
return originalFetch(input, { ...init, headers });
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
exports.installAuthForms = function installAuthForms(options = {}) {
|
|
29
|
+
const loginPaths = options.loginPaths || ["/api/auth/login"];
|
|
30
|
+
const fetch = options.fetch || window.fetch.bind(window);
|
|
31
|
+
const listener = event => {
|
|
32
|
+
const form = event.target;
|
|
33
|
+
if (!(form instanceof HTMLFormElement) || form.method.toLowerCase() !== "post") return;
|
|
34
|
+
const url = new URL(form.action);
|
|
35
|
+
if (url.origin !== window.location.origin || !loginPaths.includes(url.pathname)) return;
|
|
36
|
+
const token = csrfCookie(options.cookieName);
|
|
37
|
+
if (!token) {
|
|
38
|
+
event.preventDefault();
|
|
39
|
+
const submitter = event.submitter;
|
|
40
|
+
void fetch(`${(options.basePath || "/api/auth").replace(/\/+$/, "")}/csrf`, { credentials: "same-origin", cache: "no-store" })
|
|
41
|
+
.then(response => { if (response.ok && csrfCookie(options.cookieName)) form.requestSubmit(submitter); });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
let field = form.querySelector('input[name="_csrf"]');
|
|
45
|
+
if (!field) { field = document.createElement("input"); field.type = "hidden"; field.name = "_csrf"; form.append(field); }
|
|
46
|
+
field.value = token;
|
|
47
|
+
};
|
|
48
|
+
document.addEventListener("submit", listener, true);
|
|
49
|
+
return () => document.removeEventListener("submit", listener, true);
|
|
50
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Model } from "../Database/Model";
|
|
2
|
+
import { Request } from "../Router/Request";
|
|
3
|
+
import type { Response } from "../Router/Response";
|
|
4
|
+
import { AuthSession, SessionAuthOptions } from "./SessionAuth";
|
|
5
|
+
import { WebAuth, WebAuthError, WebAuthOptions } from "./WebAuth";
|
|
6
|
+
export type AuthContext<TUser extends Model = Model> = {
|
|
7
|
+
user: TUser;
|
|
8
|
+
session: AuthSession;
|
|
9
|
+
};
|
|
10
|
+
export type AuthSnapshot<TUser = Record<string, unknown>> = {
|
|
11
|
+
user: TUser;
|
|
12
|
+
session: AuthSession;
|
|
13
|
+
};
|
|
14
|
+
export type AuthErrorCode = "missing_login" | "invalid_login" | "inactive_user" | "invalid_origin" | "invalid_csrf" | "unauthorized" | "server_error";
|
|
15
|
+
export declare class AuthLoginError extends WebAuthError {
|
|
16
|
+
readonly code: AuthErrorCode;
|
|
17
|
+
constructor(code: AuthErrorCode, status: 401 | 403, message: string);
|
|
18
|
+
}
|
|
19
|
+
export type ModelAuthOptions = Partial<WebAuthOptions> & {
|
|
20
|
+
model: typeof Model;
|
|
21
|
+
/** Takes precedence over session.audience; defaults to the configured origin. */
|
|
22
|
+
audience?: string;
|
|
23
|
+
locale?: "en" | "de";
|
|
24
|
+
/** Record browser metadata and update activity at most once per minute. */
|
|
25
|
+
trackActivity?: boolean;
|
|
26
|
+
fields?: {
|
|
27
|
+
identifier?: string;
|
|
28
|
+
password?: string;
|
|
29
|
+
active?: string | false;
|
|
30
|
+
primaryKey?: string;
|
|
31
|
+
};
|
|
32
|
+
session?: Partial<Omit<SessionAuthOptions, "issuer" | "isSessionAllowed">>;
|
|
33
|
+
csrfDisabled?: false;
|
|
34
|
+
verifyPassword?: (password: string, storedHash: string) => boolean | Promise<boolean>;
|
|
35
|
+
isUserAllowed?: (user: Model) => boolean | Promise<boolean>;
|
|
36
|
+
onLogin?: (session: AuthSession, req: Request) => void | Promise<void>;
|
|
37
|
+
publicUserFields?: readonly string[];
|
|
38
|
+
onAuthenticate?: (auth: AuthContext, req: Request) => void | Promise<void>;
|
|
39
|
+
messages?: Partial<Record<AuthErrorCode, string>>;
|
|
40
|
+
};
|
|
41
|
+
export type AuthOperation = "csrf" | "login" | "logout" | "refresh";
|
|
42
|
+
export type AuthRouteOptions = Partial<ModelAuthOptions> & {
|
|
43
|
+
auth?: ModelAuth | (() => ModelAuth);
|
|
44
|
+
loginPath?: string;
|
|
45
|
+
loginResponse?: (req: Request, session: AuthSession) => object | Promise<object>;
|
|
46
|
+
middleware?: string[];
|
|
47
|
+
domains?: string[];
|
|
48
|
+
};
|
|
49
|
+
/** Model-backed browser authentication with secure default routes and revocable sessions. */
|
|
50
|
+
export declare class ModelAuth extends WebAuth {
|
|
51
|
+
private readonly modelOptions;
|
|
52
|
+
private readonly model;
|
|
53
|
+
private readonly sessionUsers;
|
|
54
|
+
private readonly fields;
|
|
55
|
+
constructor(modelOptions: ModelAuthOptions);
|
|
56
|
+
private message;
|
|
57
|
+
/** Use this when creating users; existing bcrypt hashes are supported without migration. */
|
|
58
|
+
static hashPassword(password: string): Promise<string>;
|
|
59
|
+
verifyCredentials(req: Request): Promise<string>;
|
|
60
|
+
login(req: Request, res: Response, verifyCredentials?: () => Promise<string | null>): Promise<AuthSession>;
|
|
61
|
+
/** Resolve a fresh validated session and its model; never trusts a client-supplied req.auth. */
|
|
62
|
+
resolve<TUser extends Model = Model>(req: Request): Promise<AuthContext<TUser> | null>;
|
|
63
|
+
resolveCookies<TUser extends Model = Model>(cookies: Record<string, string | undefined>): Promise<AuthContext<TUser> | null>;
|
|
64
|
+
/** Only explicit public fields are sent to browsers; Model instances remain on the server. */
|
|
65
|
+
snapshot(auth: AuthContext | null | undefined): AuthSnapshot | null;
|
|
66
|
+
/** Optional auth for public routes, or required auth for protected controllers. */
|
|
67
|
+
middleware(options?: {
|
|
68
|
+
required?: boolean;
|
|
69
|
+
}): (next: Function, reject: Function, req: Request, res: Response) => Promise<any>;
|
|
70
|
+
handle(operation: AuthOperation, req: Request, res: Response, loginResponse?: AuthRouteOptions["loginResponse"]): Promise<Response>;
|
|
71
|
+
}
|
|
72
|
+
export declare function authRoute(operation: AuthOperation, options: AuthRouteOptions): (req: Request, res: Response) => Promise<Response>;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.authRoute = exports.ModelAuth = exports.AuthLoginError = void 0;
|
|
13
|
+
const bcryptjs_1 = require("bcryptjs");
|
|
14
|
+
const Config_1 = require("../Config");
|
|
15
|
+
const Request_1 = require("../Router/Request");
|
|
16
|
+
const AuthSecurity_1 = require("./AuthSecurity");
|
|
17
|
+
const SessionAuth_1 = require("./SessionAuth");
|
|
18
|
+
const WebAuth_1 = require("./WebAuth");
|
|
19
|
+
class AuthLoginError extends WebAuth_1.WebAuthError {
|
|
20
|
+
constructor(code, status, message) {
|
|
21
|
+
super(status, message);
|
|
22
|
+
this.code = code;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
exports.AuthLoginError = AuthLoginError;
|
|
26
|
+
const defaultMessages = {
|
|
27
|
+
en: {
|
|
28
|
+
missing_login: "Please provide your login and password.",
|
|
29
|
+
invalid_login: "Invalid login or password.",
|
|
30
|
+
inactive_user: "This account is inactive.",
|
|
31
|
+
invalid_origin: "The login address does not match the configured website URL.",
|
|
32
|
+
invalid_csrf: "Please reload the page and try logging in again.",
|
|
33
|
+
unauthorized: "Please log in.",
|
|
34
|
+
server_error: "Authentication could not be processed.",
|
|
35
|
+
},
|
|
36
|
+
de: {
|
|
37
|
+
missing_login: "Bitte gib E-Mail-Adresse und Passwort an.",
|
|
38
|
+
invalid_login: "Es wurde kein Benutzer mit dieser E-Mail-/Passwort-Kombination gefunden.",
|
|
39
|
+
inactive_user: "Bitte aktiviere deinen Account über die E-Mail, die wir dir geschickt haben.",
|
|
40
|
+
invalid_origin: "Die Login-Adresse stimmt nicht mit der konfigurierten Website-URL überein.",
|
|
41
|
+
invalid_csrf: "Der Login konnte gerade nicht verarbeitet werden. Bitte lade die Seite neu.",
|
|
42
|
+
unauthorized: "Bitte melde dich an.",
|
|
43
|
+
server_error: "Die Anmeldung konnte gerade nicht verarbeitet werden.",
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
// Verify a dummy password hash for unknown accounts as well.
|
|
47
|
+
let dummyHash;
|
|
48
|
+
/** Model-backed browser authentication with secure default routes and revocable sessions. */
|
|
49
|
+
class ModelAuth extends WebAuth_1.WebAuth {
|
|
50
|
+
constructor(modelOptions) {
|
|
51
|
+
var _a, _b, _c, _d, _e;
|
|
52
|
+
if (modelOptions.csrfDisabled)
|
|
53
|
+
throw new Error("Browser auth routes require CSRF protection.");
|
|
54
|
+
if (!modelOptions.model)
|
|
55
|
+
throw new Error("Route.auth requires a Model class.");
|
|
56
|
+
const origin = new URL(modelOptions.origin || Config_1.Config.get("auth.origin") || Config_1.Config.get("website.baseUrl") || Config_1.Config.get("application.website.baseUrl") || process.env.WEBSITE_URL || process.env.PUBLIC_BASE_URL || `http://localhost:${process.env.PORT || 3035}`).origin;
|
|
57
|
+
const model = modelOptions.model;
|
|
58
|
+
const fields = Object.assign({ identifier: "email", password: "password", active: "is_active", primaryKey: new model().__primaryKey || "_id" }, modelOptions.fields);
|
|
59
|
+
const allowed = (user) => __awaiter(this, void 0, void 0, function* () {
|
|
60
|
+
return Boolean(user && (fields.active === false || user[fields.active] !== false)
|
|
61
|
+
&& (!modelOptions.isUserAllowed || (yield modelOptions.isUserAllowed(user))));
|
|
62
|
+
});
|
|
63
|
+
const sessionUsers = new WeakMap();
|
|
64
|
+
const sessions = new SessionAuth_1.SessionAuth(Object.assign(Object.assign({ accessTokenSeconds: 30 * 86400, sessionSeconds: 30 * 86400 }, modelOptions.session), { issuer: origin, audience: (_c = (_a = modelOptions.audience) !== null && _a !== void 0 ? _a : (_b = modelOptions.session) === null || _b === void 0 ? void 0 : _b.audience) !== null && _c !== void 0 ? _c : origin, connection: ((_d = modelOptions.session) === null || _d === void 0 ? void 0 : _d.connection) || new model().__connection, isSessionAllowed: (session) => __awaiter(this, void 0, void 0, function* () {
|
|
65
|
+
const id = fields.primaryKey === "_id" ? yield model.objectId(session.subject, { noExceptions: true }) : session.subject;
|
|
66
|
+
const user = id !== null && id !== undefined ? yield model.where(fields.primaryKey, "=", id).first() : null;
|
|
67
|
+
if (!(yield allowed(user)))
|
|
68
|
+
return false;
|
|
69
|
+
sessionUsers.set(session, user);
|
|
70
|
+
return true;
|
|
71
|
+
}) }));
|
|
72
|
+
super(sessions, Object.assign(Object.assign({}, modelOptions), { origin, allowInsecureLocalhost: (_e = modelOptions.allowInsecureLocalhost) !== null && _e !== void 0 ? _e : true }));
|
|
73
|
+
this.modelOptions = modelOptions;
|
|
74
|
+
this.sessionUsers = sessionUsers;
|
|
75
|
+
this.model = model;
|
|
76
|
+
this.fields = fields;
|
|
77
|
+
}
|
|
78
|
+
message(code) {
|
|
79
|
+
var _a, _b;
|
|
80
|
+
return (_b = (_a = this.modelOptions.messages) === null || _a === void 0 ? void 0 : _a[code]) !== null && _b !== void 0 ? _b : defaultMessages[this.modelOptions.locale || "en"][code];
|
|
81
|
+
}
|
|
82
|
+
/** Use this when creating users; existing bcrypt hashes are supported without migration. */
|
|
83
|
+
static hashPassword(password) {
|
|
84
|
+
return (0, bcryptjs_1.hash)(password, 12);
|
|
85
|
+
}
|
|
86
|
+
verifyCredentials(req) {
|
|
87
|
+
var _a, _b;
|
|
88
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
89
|
+
const identifier = (_a = req.body) === null || _a === void 0 ? void 0 : _a[this.fields.identifier], password = (_b = req.body) === null || _b === void 0 ? void 0 : _b.password;
|
|
90
|
+
if (typeof identifier !== "string" || !identifier.trim() || identifier.length > 320
|
|
91
|
+
|| typeof password !== "string" || !password || password.length > 1024) {
|
|
92
|
+
throw new AuthLoginError("missing_login", 401, this.message("missing_login"));
|
|
93
|
+
}
|
|
94
|
+
const user = yield this.model.where(this.fields.identifier, "=", identifier.trim().toLowerCase()).first();
|
|
95
|
+
const stored = user === null || user === void 0 ? void 0 : user[this.fields.password];
|
|
96
|
+
const verify = this.modelOptions.verifyPassword || bcryptjs_1.compare;
|
|
97
|
+
const valid = yield verify(password, typeof stored === "string" ? stored : yield (dummyHash || (dummyHash = (0, bcryptjs_1.hash)("webframez-invalid-account", 12))));
|
|
98
|
+
if (!user || typeof stored !== "string" || !valid)
|
|
99
|
+
throw new AuthLoginError("invalid_login", 401, this.message("invalid_login"));
|
|
100
|
+
if ((this.fields.active !== false && user[this.fields.active] === false)
|
|
101
|
+
|| (this.modelOptions.isUserAllowed && !(yield this.modelOptions.isUserAllowed(user)))) {
|
|
102
|
+
throw new AuthLoginError("inactive_user", 403, this.message("inactive_user"));
|
|
103
|
+
}
|
|
104
|
+
const id = user[this.fields.primaryKey];
|
|
105
|
+
if (id === null || id === undefined || id === "")
|
|
106
|
+
throw new Error("Auth user has no primary key.");
|
|
107
|
+
return String(id);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
login(req, res, verifyCredentials = () => this.verifyCredentials(req)) {
|
|
111
|
+
const _super = Object.create(null, {
|
|
112
|
+
login: { get: () => super.login }
|
|
113
|
+
});
|
|
114
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
115
|
+
const session = yield _super.login.call(this, req, res, verifyCredentials);
|
|
116
|
+
if (this.modelOptions.trackActivity !== false)
|
|
117
|
+
yield this.sessions.recordLogin(session, (0, AuthSecurity_1.authHeader)(req, "user-agent"));
|
|
118
|
+
if (this.modelOptions.onLogin)
|
|
119
|
+
yield this.modelOptions.onLogin(session, req);
|
|
120
|
+
return session;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/** Resolve a fresh validated session and its model; never trusts a client-supplied req.auth. */
|
|
124
|
+
resolve(req) {
|
|
125
|
+
const _super = Object.create(null, {
|
|
126
|
+
authenticate: { get: () => super.authenticate }
|
|
127
|
+
});
|
|
128
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
129
|
+
Object.defineProperty(req, "auth", { value: null, writable: true, configurable: true, enumerable: false });
|
|
130
|
+
const session = yield _super.authenticate.call(this, req);
|
|
131
|
+
if (!session)
|
|
132
|
+
return null;
|
|
133
|
+
const user = this.sessionUsers.get(session);
|
|
134
|
+
if (!user)
|
|
135
|
+
return null;
|
|
136
|
+
if (this.modelOptions.trackActivity !== false)
|
|
137
|
+
yield this.sessions.touch(session);
|
|
138
|
+
const auth = { user, session };
|
|
139
|
+
if (this.modelOptions.onAuthenticate)
|
|
140
|
+
yield this.modelOptions.onAuthenticate(auth, req);
|
|
141
|
+
req.auth = auth;
|
|
142
|
+
return auth;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
resolveCookies(cookies) {
|
|
146
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
147
|
+
const req = Object.assign(new Request_1.Request(), { method: "GET", headers: { cookie: `${this.cookies.prefix}access=${encodeURIComponent(cookies[`${this.cookies.prefix}access`] || "")}` } });
|
|
148
|
+
return this.resolve(req);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/** Only explicit public fields are sent to browsers; Model instances remain on the server. */
|
|
152
|
+
snapshot(auth) {
|
|
153
|
+
var _a;
|
|
154
|
+
if (!auth)
|
|
155
|
+
return null;
|
|
156
|
+
const fields = this.modelOptions.publicUserFields || [this.fields.primaryKey, "email", "name", "firstname", "lastname", "roles"];
|
|
157
|
+
const user = {};
|
|
158
|
+
for (const field of fields) {
|
|
159
|
+
if (field.startsWith("__") || field === this.fields.password || ((_a = auth.user.__hidden) === null || _a === void 0 ? void 0 : _a.includes(field))
|
|
160
|
+
|| ["constructor", "prototype"].includes(field))
|
|
161
|
+
continue;
|
|
162
|
+
const value = auth.user[field];
|
|
163
|
+
if (value !== undefined)
|
|
164
|
+
user[field] = value;
|
|
165
|
+
}
|
|
166
|
+
return JSON.parse(JSON.stringify({ user, session: auth.session }));
|
|
167
|
+
}
|
|
168
|
+
/** Optional auth for public routes, or required auth for protected controllers. */
|
|
169
|
+
middleware(options = {}) {
|
|
170
|
+
return (next, reject, req, res) => __awaiter(this, void 0, void 0, function* () {
|
|
171
|
+
try {
|
|
172
|
+
const auth = yield this.resolve(req);
|
|
173
|
+
res.header("Cache-Control", "no-store");
|
|
174
|
+
if (!auth && options.required) {
|
|
175
|
+
res.status(401).send({ status: "error", code: "unauthorized" });
|
|
176
|
+
res.end();
|
|
177
|
+
return reject("Unauthorized");
|
|
178
|
+
}
|
|
179
|
+
return next(true);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (!(error instanceof WebAuth_1.WebAuthError))
|
|
183
|
+
throw error;
|
|
184
|
+
res.status(error.status).send({ status: "error", code: "invalid_csrf" });
|
|
185
|
+
res.end();
|
|
186
|
+
return reject(error.message);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
handle(operation, req, res, loginResponse) {
|
|
191
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
192
|
+
res.header("Cache-Control", "no-store");
|
|
193
|
+
try {
|
|
194
|
+
if (operation === "csrf")
|
|
195
|
+
return res.send({ csrf: this.bootstrap(req, res) });
|
|
196
|
+
if (operation === "logout") {
|
|
197
|
+
yield this.logout(req, res);
|
|
198
|
+
return res.send({ status: "success" });
|
|
199
|
+
}
|
|
200
|
+
if (operation === "refresh") {
|
|
201
|
+
yield this.refresh(req, res);
|
|
202
|
+
return res.send({ status: "success" });
|
|
203
|
+
}
|
|
204
|
+
const session = yield this.login(req, res);
|
|
205
|
+
return res.send(Object.assign(Object.assign({}, (loginResponse ? yield loginResponse(req, session) : {})), { status: "success" }));
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
const code = error instanceof AuthLoginError ? error.code : error instanceof WebAuth_1.WebAuthError
|
|
209
|
+
? error.message === "Invalid request origin." ? "invalid_origin" : error.status === 401 ? "unauthorized" : "invalid_csrf" : "server_error";
|
|
210
|
+
if (!(error instanceof WebAuth_1.WebAuthError))
|
|
211
|
+
console.error(error);
|
|
212
|
+
return res.status(error instanceof WebAuth_1.WebAuthError ? error.status : 500).send({ status: "error", code,
|
|
213
|
+
message: this.message(code) });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
exports.ModelAuth = ModelAuth;
|
|
219
|
+
function authRoute(operation, options) {
|
|
220
|
+
let auth;
|
|
221
|
+
return (req, res) => {
|
|
222
|
+
auth || (auth = typeof options.auth === "function" ? options.auth() : options.auth || new ModelAuth(options));
|
|
223
|
+
return auth.handle(operation, req, res, options.loginResponse);
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
exports.authRoute = authRoute;
|
|
@@ -5,6 +5,8 @@ export type AuthSession = {
|
|
|
5
5
|
issuer: string;
|
|
6
6
|
audience: string;
|
|
7
7
|
environment: string | null;
|
|
8
|
+
userAgent?: string;
|
|
9
|
+
lastActiveAt?: number;
|
|
8
10
|
createdAt: number;
|
|
9
11
|
expiresAt: number;
|
|
10
12
|
parent: {
|
|
@@ -53,6 +55,10 @@ export declare class SessionAuth {
|
|
|
53
55
|
verifyCsrf(token: string, csrf: string, kind?: "access" | "refresh"): Promise<boolean>;
|
|
54
56
|
/** One winner under concurrency. A previously consumed refresh token revokes its device session. */
|
|
55
57
|
refresh(token: string): Promise<SessionTokenPair | null>;
|
|
58
|
+
/** Browser login metadata; arbitrary request data and token hashes are never exposed. */
|
|
59
|
+
recordLogin(session: AuthSession, userAgent: string): Promise<void>;
|
|
60
|
+
/** Persist activity with a one-minute throttle; authorization does not depend on this timestamp. */
|
|
61
|
+
touch(session: AuthSession): Promise<void>;
|
|
56
62
|
revoke(sessionId: string): Promise<void>;
|
|
57
63
|
revokeAll(subject: string): Promise<void>;
|
|
58
64
|
list(subject: string): Promise<AuthSession[]>;
|
package/dist/Auth/SessionAuth.js
CHANGED
|
@@ -13,8 +13,7 @@ exports.SessionAuth = void 0;
|
|
|
13
13
|
const DBConnection_1 = require("../Database/DBConnection");
|
|
14
14
|
const AuthSecurity_1 = require("./AuthSecurity");
|
|
15
15
|
function publicSession(record) {
|
|
16
|
-
return { id: record.id, subject: record.subject, issuer: record.issuer, audience: record.audience,
|
|
17
|
-
environment: record.environment, createdAt: record.createdAt, expiresAt: record.expiresAt, parent: record.parent };
|
|
16
|
+
return Object.assign(Object.assign({ id: record.id, subject: record.subject, issuer: record.issuer, audience: record.audience, environment: record.environment, createdAt: record.createdAt, expiresAt: record.expiresAt, parent: record.parent }, (record.userAgent !== undefined ? { userAgent: record.userAgent } : {})), (record.lastActiveAt !== undefined ? { lastActiveAt: record.lastActiveAt } : {}));
|
|
18
17
|
}
|
|
19
18
|
function tokenSessionId(token) {
|
|
20
19
|
return typeof token === "string" && /^[A-Za-z0-9_-]{43}\.[A-Za-z0-9_-]{43}$/.test(token) ? token.split(".")[0] : null;
|
|
@@ -122,6 +121,25 @@ class SessionAuth {
|
|
|
122
121
|
return this.pair(next, access, refresh);
|
|
123
122
|
});
|
|
124
123
|
}
|
|
124
|
+
/** Browser login metadata; arbitrary request data and token hashes are never exposed. */
|
|
125
|
+
recordLogin(session, userAgent) {
|
|
126
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
127
|
+
const metadata = { userAgent: userAgent.slice(0, 512), lastActiveAt: session.createdAt };
|
|
128
|
+
yield (yield this.rows()).updateOne(Object.assign(Object.assign({ _id: session.id }, this.scope), { revokedAt: null }), { $set: metadata });
|
|
129
|
+
Object.assign(session, metadata);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/** Persist activity with a one-minute throttle; authorization does not depend on this timestamp. */
|
|
133
|
+
touch(session) {
|
|
134
|
+
var _a;
|
|
135
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
if (((_a = session.lastActiveAt) !== null && _a !== void 0 ? _a : session.createdAt) >= now - 60000)
|
|
138
|
+
return;
|
|
139
|
+
yield (yield this.rows()).updateOne(Object.assign(Object.assign({ _id: session.id }, this.scope), { revokedAt: null, expiresAt: { $gt: now } }), { $set: { lastActiveAt: now } });
|
|
140
|
+
session.lastActiveAt = now;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
125
143
|
revoke(sessionId) {
|
|
126
144
|
return __awaiter(this, void 0, void 0, function* () {
|
|
127
145
|
(0, AuthSecurity_1.authText)(sessionId, "session ID");
|
package/dist/Auth/index.d.ts
CHANGED
package/dist/Auth/index.js
CHANGED
package/dist/Router/Request.d.ts
CHANGED
package/dist/Router/Route.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DataBuilderRouteOptions } from "../DataBuilder/DataBuilderRoute";
|
|
2
|
+
import { AuthRouteOptions } from "../Auth/ModelAuth";
|
|
2
3
|
type RouteRegistrationOptions = {
|
|
3
4
|
[key: string]: any;
|
|
4
5
|
};
|
|
@@ -28,6 +29,8 @@ export declare class RouteFacade {
|
|
|
28
29
|
get(path: string, component: any, options?: RouteRegistrationOptions): void;
|
|
29
30
|
/** Register the existing DataBuilder POST protocol with request-local configuration. */
|
|
30
31
|
databuilder(path: string, options?: DataBuilderRouteOptions): void;
|
|
32
|
+
/** Register CSRF, model login, logout and refresh using the browser auth contract. */
|
|
33
|
+
auth(prefix: string, options: AuthRouteOptions): void;
|
|
31
34
|
/**
|
|
32
35
|
* Register a POST-Method
|
|
33
36
|
*
|
package/dist/Router/Route.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.Route = exports.RouteFacade = void 0;
|
|
|
4
4
|
const routing_1 = require("../routing");
|
|
5
5
|
const Router_1 = require("./Router");
|
|
6
6
|
const DataBuilderRoute_1 = require("../DataBuilder/DataBuilderRoute");
|
|
7
|
+
const ModelAuth_1 = require("../Auth/ModelAuth");
|
|
7
8
|
class RouteFacade {
|
|
8
9
|
constructor() {
|
|
9
10
|
this.tempGroupPrefix = null;
|
|
@@ -69,6 +70,17 @@ class RouteFacade {
|
|
|
69
70
|
databuilder(path, options = {}) {
|
|
70
71
|
this.registerWithGroupContext("POST", path, (0, DataBuilderRoute_1.dataBuilderRoute)(options), { middleware: options.middleware, domains: options.domains });
|
|
71
72
|
}
|
|
73
|
+
/** Register CSRF, model login, logout and refresh using the browser auth contract. */
|
|
74
|
+
auth(prefix, options) {
|
|
75
|
+
if (!(options === null || options === void 0 ? void 0 : options.auth) && !(options === null || options === void 0 ? void 0 : options.model))
|
|
76
|
+
throw new Error("Route.auth requires a Model class or auth instance.");
|
|
77
|
+
const base = prefix.replace(/\/+$/, "");
|
|
78
|
+
const registration = { middleware: options.middleware, domains: options.domains };
|
|
79
|
+
this.get(`${base}/csrf`, (0, ModelAuth_1.authRoute)("csrf", options), registration);
|
|
80
|
+
this.post(options.loginPath || `${base}/login`, (0, ModelAuth_1.authRoute)("login", options), registration);
|
|
81
|
+
this.post(`${base}/logout`, (0, ModelAuth_1.authRoute)("logout", options), registration);
|
|
82
|
+
this.post(`${base}/refresh`, (0, ModelAuth_1.authRoute)("refresh", options), registration);
|
|
83
|
+
}
|
|
72
84
|
/**
|
|
73
85
|
* Register a POST-Method
|
|
74
86
|
*
|
package/docs/authentication.md
CHANGED
|
@@ -279,3 +279,88 @@ configuration before rolling the new auth flow out to existing applications.
|
|
|
279
279
|
- [OWASP Session Management](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
|
|
280
280
|
- [OWASP CSRF Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
|
|
281
281
|
- [OAuth Security BCP (RFC 9700)](https://www.rfc-editor.org/rfc/rfc9700.html)
|
|
282
|
+
|
|
283
|
+
## Standard browser login with `Route.auth`
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
import { Model, ModelAuth, Route } from "@webtypen/webframez-core";
|
|
287
|
+
|
|
288
|
+
class User extends Model {
|
|
289
|
+
__table = "users";
|
|
290
|
+
__hidden = ["password"];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
Route.auth("/api/auth", { model: User });
|
|
294
|
+
// GET /api/auth/csrf
|
|
295
|
+
// POST /api/auth/login, /api/auth/logout, /api/auth/refresh
|
|
296
|
+
|
|
297
|
+
// During registration: user.password = await ModelAuth.hashPassword(password);
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Set `auth.origin` or `website.baseUrl` to the canonical public URL (including the
|
|
301
|
+
correct development port); alternatively pass `origin` explicitly. HTTPS is
|
|
302
|
+
required outside localhost/loopback. Origins are never inferred from untrusted
|
|
303
|
+
Host or forwarding headers. Origin mismatches return `invalid_origin`; token
|
|
304
|
+
mismatches return `invalid_csrf`. Browser CSRF protection cannot be disabled:
|
|
305
|
+
`csrfDisabled` accepts only `false`. Non-browser bearer authentication uses a
|
|
306
|
+
separate adapter.
|
|
307
|
+
|
|
308
|
+
The default model fields are `_id`, `email`, `password` and `is_active`. Identifiers
|
|
309
|
+
are trimmed and lowercased before lookup; store them normalized. Passwords are
|
|
310
|
+
not trimmed. Store a bcrypt hash; existing bcrypt hashes work without migration.
|
|
311
|
+
`fields` configures field names, `verifyPassword` supports other existing hashes,
|
|
312
|
+
and `isUserAllowed` adds account/tenant checks on login and every session access.
|
|
313
|
+
An explicit `false` in `is_active` rejects the account; `fields.active: false`
|
|
314
|
+
disables this field check. Optional `session` options configure storage, scope
|
|
315
|
+
and lifetimes. For `ModelAuth`, access and absolute session lifetimes default to
|
|
316
|
+
30 days; both remain configurable through `session.accessTokenSeconds` and
|
|
317
|
+
`session.sessionSeconds`. The lower-level `SessionAuth` retains its 15-minute
|
|
318
|
+
access-token default.
|
|
319
|
+
|
|
320
|
+
```ts
|
|
321
|
+
import { createAuthFetch, installAuthForms } from "@webtypen/webframez-core/auth-client";
|
|
322
|
+
|
|
323
|
+
window.fetch = createAuthFetch({ basePath: "/api/auth" });
|
|
324
|
+
installAuthForms({ basePath: "/api/auth" });
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
This public entry is browser-only and does not import server modules. It fetches
|
|
328
|
+
CSRF before login, sends `X-CSRF-Token` on same-origin mutations, and inserts
|
|
329
|
+
`_csrf` into native login forms. For a basename, supply the mounted `basePath`.
|
|
330
|
+
`loginPaths` supports aliases; `requiresCsrf` narrows mutation paths;
|
|
331
|
+
`cookieName` supports custom cookie prefixes. For a custom Fetch implementation,
|
|
332
|
+
pass it as the second argument to `createAuthFetch`.
|
|
333
|
+
|
|
334
|
+
Existing applications can pass `auth: instance` or `auth: () => instance` instead
|
|
335
|
+
of `model`; use a shared `ModelAuth` for middleware and session-management APIs.
|
|
336
|
+
`loginPath` keeps existing URLs, `loginResponse` adds application-specific response
|
|
337
|
+
fields (for example a validated redirect), and `onLogin` / `onAuthenticate` add application-specific callbacks. Browser
|
|
338
|
+
user-agent metadata and last activity are recorded automatically; `trackActivity: false`
|
|
339
|
+
disables tracking. Activity writes are throttled to once per minute. No user object or tokens are serialized by the default routes.
|
|
340
|
+
Route groups retain their middleware and domain restrictions. Login routes must
|
|
341
|
+
be accessible before authentication; apply authentication middleware to protected
|
|
342
|
+
application routes separately.
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
`audience` is a top-level option in both `Route.auth` and `ModelAuth`. It takes
|
|
346
|
+
precedence over the still-supported `session.audience`; the default is the origin.
|
|
347
|
+
Standard error messages are included in English and German (`locale: "de"`);
|
|
348
|
+
`messages` overrides individual entries while retaining the remaining defaults.
|
|
349
|
+
Origin resolution also supports `application.website.baseUrl`, `WEBSITE_URL` and
|
|
350
|
+
`PUBLIC_BASE_URL`; explicit `origin` retains precedence.
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
Route.auth("/api/auth", {
|
|
354
|
+
model: User,
|
|
355
|
+
audience: "simplebis-website",
|
|
356
|
+
locale: "de",
|
|
357
|
+
session: { collection: "website_auth_sessions" },
|
|
358
|
+
// Optional overrides:
|
|
359
|
+
messages: { invalid_login: "Bitte prüfe deine Zugangsdaten." },
|
|
360
|
+
});
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
Session metadata stays in the existing session collection. `sessions.list(subject)`
|
|
364
|
+
includes `userAgent` and `lastActiveAt` when available, so applications do not need
|
|
365
|
+
a second model/query or callbacks to maintain the session list. Existing records
|
|
366
|
+
without metadata remain valid; last activity falls back to the creation timestamp.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webtypen/webframez-core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.73",
|
|
4
4
|
"description": "The core package of the typescript based webframez (web-framework) by webtypen.de",
|
|
5
5
|
"homepage": "https://webtypen.de/",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
"docs/authentication.md",
|
|
29
29
|
"docs/databuilder.md",
|
|
30
30
|
"routing.js",
|
|
31
|
-
"routing.d.ts"
|
|
31
|
+
"routing.d.ts",
|
|
32
|
+
"auth-client.js",
|
|
33
|
+
"auth-client.d.ts"
|
|
32
34
|
],
|
|
33
35
|
"scripts": {
|
|
34
36
|
"build": "tsc",
|
|
@@ -47,6 +49,7 @@
|
|
|
47
49
|
"typescript": "^4.9.3"
|
|
48
50
|
},
|
|
49
51
|
"dependencies": {
|
|
52
|
+
"bcryptjs": "^3.0.3",
|
|
50
53
|
"busboy": "^1.6.0",
|
|
51
54
|
"dotenv": "^8.2.0",
|
|
52
55
|
"extract-zip": "^2.0.1",
|