@erwininteractive/mvc 0.3.8 → 0.4.2
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/README.md +41 -0
- package/dist/cli.js +16 -0
- package/dist/framework/WebAuthn.d.ts +10 -0
- package/dist/framework/WebAuthn.js +240 -0
- package/dist/framework/index.d.ts +1 -0
- package/dist/framework/index.js +8 -1
- package/dist/generators/generateWebAuthn.d.ts +7 -0
- package/dist/generators/generateWebAuthn.js +89 -0
- package/package.json +3 -1
- package/prisma/schema.prisma +20 -0
- package/templates/webauthnController.ts.ejs +53 -0
- package/templates/webauthnView.ejs.ejs +61 -0
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ A lightweight, full-featured MVC framework for Node.js 20+ built with TypeScript
|
|
|
9
9
|
- **Optional Database** - Add Prisma + PostgreSQL when you need it
|
|
10
10
|
- **Optional Redis Sessions** - Scalable session management
|
|
11
11
|
- **JWT Authentication** - Secure token-based auth with bcrypt password hashing
|
|
12
|
+
- **WebAuthn (Passkeys)** - Passwordless authentication with security keys (YubiKey, Touch ID, Face ID)
|
|
12
13
|
- **CLI Tools** - Scaffold apps and generate models/controllers
|
|
13
14
|
|
|
14
15
|
## Quick Start
|
|
@@ -280,6 +281,45 @@ app.get("/protected", authenticate, (req, res) => {
|
|
|
280
281
|
|
|
281
282
|
---
|
|
282
283
|
|
|
284
|
+
### WebAuthn (Passkeys)
|
|
285
|
+
|
|
286
|
+
Passwordless authentication with security keys:
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
npx erwinmvc webauthn
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
This generates:
|
|
293
|
+
- `src/controllers/WebAuthnController.ts` - Registration and login handlers
|
|
294
|
+
- `src/views/webauthn/` - EJS views for register/login pages
|
|
295
|
+
- Prisma `WebAuthnCredential` model for storing security key data
|
|
296
|
+
|
|
297
|
+
```typescript
|
|
298
|
+
import {
|
|
299
|
+
startRegistration,
|
|
300
|
+
completeRegistration,
|
|
301
|
+
startAuthentication,
|
|
302
|
+
completeAuthentication,
|
|
303
|
+
getRPConfig,
|
|
304
|
+
} from "@erwininteractive/mvc";
|
|
305
|
+
|
|
306
|
+
const { rpID, rpName } = getRPConfig();
|
|
307
|
+
|
|
308
|
+
const options = await startRegistration(req, res);
|
|
309
|
+
await completeRegistration(req, res);
|
|
310
|
+
|
|
311
|
+
const options = await startAuthentication(req, res);
|
|
312
|
+
await completeAuthentication(req, res);
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Environment variables:
|
|
316
|
+
- `WEBAUTHN_RP_ID` - Your domain (e.g., "localhost" or "example.com")
|
|
317
|
+
- `WEBAUTHN_RP_NAME` - Your app name (e.g., "ErwinMVC App")
|
|
318
|
+
|
|
319
|
+
Note: WebAuthn requires HTTPS or localhost.
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
283
323
|
## CLI Commands
|
|
284
324
|
|
|
285
325
|
| Command | Description |
|
|
@@ -288,6 +328,7 @@ app.get("/protected", authenticate, (req, res) => {
|
|
|
288
328
|
| `npx erwinmvc generate resource <name>` | Generate model + controller + views |
|
|
289
329
|
| `npx erwinmvc generate controller <name>` | Generate a CRUD controller |
|
|
290
330
|
| `npx erwinmvc generate model <name>` | Generate a database model |
|
|
331
|
+
| `npx erwinmvc webauthn` | Generate WebAuthn authentication (security keys) |
|
|
291
332
|
|
|
292
333
|
### Init Options
|
|
293
334
|
|
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ const initApp_1 = require("./generators/initApp");
|
|
|
6
6
|
const generateModel_1 = require("./generators/generateModel");
|
|
7
7
|
const generateController_1 = require("./generators/generateController");
|
|
8
8
|
const generateResource_1 = require("./generators/generateResource");
|
|
9
|
+
const generateWebAuthn_1 = require("./generators/generateWebAuthn");
|
|
9
10
|
const program = new commander_1.Command();
|
|
10
11
|
program
|
|
11
12
|
.name("erwinmvc")
|
|
@@ -78,4 +79,19 @@ generate
|
|
|
78
79
|
process.exit(1);
|
|
79
80
|
}
|
|
80
81
|
});
|
|
82
|
+
// WebAuthn command - generate WebAuthn authentication views and controller
|
|
83
|
+
program
|
|
84
|
+
.command("webauthn")
|
|
85
|
+
.alias("w")
|
|
86
|
+
.description("Generate WebAuthn authentication (security key login)")
|
|
87
|
+
.option("--skip-migrate", "Skip running Prisma migrate")
|
|
88
|
+
.action(async (options) => {
|
|
89
|
+
try {
|
|
90
|
+
await (0, generateWebAuthn_1.generateWebAuthn)(options);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
console.error("Error:", err instanceof Error ? err.message : err);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
81
97
|
program.parse();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type PublicKeyCredentialCreationOptionsJSON, type PublicKeyCredentialRequestOptionsJSON } from "@simplewebauthn/server";
|
|
2
|
+
import type { Request, Response } from "express";
|
|
3
|
+
export declare function startRegistration(req: Request, res: Response): Promise<PublicKeyCredentialCreationOptionsJSON>;
|
|
4
|
+
export declare function completeRegistration(req: Request, res: Response): Promise<void>;
|
|
5
|
+
export declare function startAuthentication(req: Request, res: Response): Promise<PublicKeyCredentialRequestOptionsJSON>;
|
|
6
|
+
export declare function completeAuthentication(req: Request, res: Response): Promise<void>;
|
|
7
|
+
export declare function getRPConfig(): {
|
|
8
|
+
rpID: string;
|
|
9
|
+
rpName: string;
|
|
10
|
+
};
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.startRegistration = startRegistration;
|
|
7
|
+
exports.completeRegistration = completeRegistration;
|
|
8
|
+
exports.startAuthentication = startAuthentication;
|
|
9
|
+
exports.completeAuthentication = completeAuthentication;
|
|
10
|
+
exports.getRPConfig = getRPConfig;
|
|
11
|
+
const server_1 = require("@simplewebauthn/server");
|
|
12
|
+
const base64url_1 = __importDefault(require("base64url"));
|
|
13
|
+
const RP_ID = process.env.WEBAUTHN_RP_ID || "localhost";
|
|
14
|
+
const RP_NAME = process.env.WEBAUTHN_RP_NAME || "ErwinMVC";
|
|
15
|
+
function getOrigin(req) {
|
|
16
|
+
const protocol = req.protocol;
|
|
17
|
+
const host = req.get("host") || "localhost";
|
|
18
|
+
return `${protocol}://${host}`;
|
|
19
|
+
}
|
|
20
|
+
function bufferToBase64URL(buffer) {
|
|
21
|
+
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
|
22
|
+
return base64url_1.default.encode(buf);
|
|
23
|
+
}
|
|
24
|
+
function base64URLToBuffer(str) {
|
|
25
|
+
return new Uint8Array(base64url_1.default.toBuffer(str));
|
|
26
|
+
}
|
|
27
|
+
async function startRegistration(req, res) {
|
|
28
|
+
const { username, displayName } = req.body;
|
|
29
|
+
if (!username || !displayName) {
|
|
30
|
+
res.status(400).json({ error: "Username and display name are required" });
|
|
31
|
+
throw new Error("Validation failed");
|
|
32
|
+
}
|
|
33
|
+
const options = await (0, server_1.generateRegistrationOptions)({
|
|
34
|
+
rpName: RP_NAME,
|
|
35
|
+
rpID: RP_ID,
|
|
36
|
+
userName: username,
|
|
37
|
+
userDisplayName: displayName,
|
|
38
|
+
timeout: 60000,
|
|
39
|
+
attestationType: "none",
|
|
40
|
+
authenticatorSelection: {
|
|
41
|
+
residentKey: "preferred",
|
|
42
|
+
userVerification: "preferred",
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
req.session.webauthnRegistration = {
|
|
46
|
+
username,
|
|
47
|
+
challenge: options.challenge,
|
|
48
|
+
};
|
|
49
|
+
return options;
|
|
50
|
+
}
|
|
51
|
+
async function completeRegistration(req, res) {
|
|
52
|
+
const prisma = getPrismaClient();
|
|
53
|
+
const { username, challenge } = req.session.webauthnRegistration || {};
|
|
54
|
+
if (!username || !challenge) {
|
|
55
|
+
res.status(400).json({ error: "Registration session expired" });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const credential = req.body;
|
|
59
|
+
if (!credential || !credential.id) {
|
|
60
|
+
res.status(400).json({ error: "Invalid credential data" });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
let verified;
|
|
64
|
+
try {
|
|
65
|
+
verified = await (0, server_1.verifyRegistrationResponse)({
|
|
66
|
+
response: credential,
|
|
67
|
+
expectedChallenge: challenge,
|
|
68
|
+
expectedOrigin: getOrigin(req),
|
|
69
|
+
expectedRPID: RP_ID,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
res.status(400).json({ error: err instanceof Error ? err.message : "Verification failed" });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const { verified: isVerified, registrationInfo } = verified;
|
|
77
|
+
if (!isVerified || !registrationInfo) {
|
|
78
|
+
res.status(400).json({ error: "Registration verification failed" });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const cred = registrationInfo.credential;
|
|
82
|
+
const { id: credentialID, publicKey: credentialPublicKey, counter } = cred;
|
|
83
|
+
try {
|
|
84
|
+
await prisma.user.create({
|
|
85
|
+
data: {
|
|
86
|
+
username,
|
|
87
|
+
email: `${username}@${RP_ID}`,
|
|
88
|
+
hashedPassword: "",
|
|
89
|
+
webauthnCredentials: {
|
|
90
|
+
create: {
|
|
91
|
+
credentialID: credentialID,
|
|
92
|
+
credentialPublicKey: bufferToBase64URL(credentialPublicKey),
|
|
93
|
+
counter: counter.toString(),
|
|
94
|
+
transports: JSON.stringify(credential.transports || []),
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
res.status(400).json({ error: err instanceof Error ? err.message : "Failed to save credential" });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
delete req.session.webauthnRegistration;
|
|
105
|
+
res.json({ success: true });
|
|
106
|
+
}
|
|
107
|
+
async function startAuthentication(req, res) {
|
|
108
|
+
const prisma = getPrismaClient();
|
|
109
|
+
const users = await prisma.user.findMany({
|
|
110
|
+
select: {
|
|
111
|
+
id: true,
|
|
112
|
+
username: true,
|
|
113
|
+
webauthnCredentials: {
|
|
114
|
+
select: {
|
|
115
|
+
id: true,
|
|
116
|
+
credentialID: true,
|
|
117
|
+
counter: true,
|
|
118
|
+
transports: true,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
const allowCredentials = users
|
|
124
|
+
.map((user) => {
|
|
125
|
+
return user.webauthnCredentials.map((cred) => ({
|
|
126
|
+
id: cred.credentialID,
|
|
127
|
+
transports: JSON.parse(cred.transports || "[]"),
|
|
128
|
+
}));
|
|
129
|
+
})
|
|
130
|
+
.flat();
|
|
131
|
+
const options = await (0, server_1.generateAuthenticationOptions)({
|
|
132
|
+
rpID: RP_ID,
|
|
133
|
+
timeout: 60000,
|
|
134
|
+
userVerification: "preferred",
|
|
135
|
+
allowCredentials: allowCredentials.length > 0 ? allowCredentials : undefined,
|
|
136
|
+
});
|
|
137
|
+
req.session.webauthnAuthentication = {
|
|
138
|
+
challenge: options.challenge,
|
|
139
|
+
};
|
|
140
|
+
return options;
|
|
141
|
+
}
|
|
142
|
+
async function completeAuthentication(req, res) {
|
|
143
|
+
const prisma = getPrismaClient();
|
|
144
|
+
const { challenge } = req.session.webauthnAuthentication || {};
|
|
145
|
+
if (!challenge) {
|
|
146
|
+
res.status(400).json({ error: "Authentication session expired" });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const credential = req.body;
|
|
150
|
+
if (!credential || !credential.id) {
|
|
151
|
+
res.status(400).json({ error: "Invalid credential data" });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const credentialID = credential.id;
|
|
155
|
+
const user = await prisma.user.findFirst({
|
|
156
|
+
where: {
|
|
157
|
+
webauthnCredentials: {
|
|
158
|
+
some: {
|
|
159
|
+
credentialID,
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
select: {
|
|
164
|
+
id: true,
|
|
165
|
+
username: true,
|
|
166
|
+
webauthnCredentials: {
|
|
167
|
+
where: {
|
|
168
|
+
credentialID,
|
|
169
|
+
},
|
|
170
|
+
select: {
|
|
171
|
+
id: true,
|
|
172
|
+
credentialID: true,
|
|
173
|
+
credentialPublicKey: true,
|
|
174
|
+
counter: true,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
if (!user || user.webauthnCredentials.length === 0) {
|
|
180
|
+
res.status(401).json({ error: "Credential not found" });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const webauthnCred = user.webauthnCredentials[0];
|
|
184
|
+
const credentialData = {
|
|
185
|
+
id: webauthnCred.credentialID,
|
|
186
|
+
publicKey: base64URLToBuffer(webauthnCred.credentialPublicKey),
|
|
187
|
+
counter: parseInt(webauthnCred.counter, 10),
|
|
188
|
+
};
|
|
189
|
+
let verified;
|
|
190
|
+
try {
|
|
191
|
+
verified = await (0, server_1.verifyAuthenticationResponse)({
|
|
192
|
+
response: credential,
|
|
193
|
+
expectedChallenge: challenge,
|
|
194
|
+
expectedOrigin: getOrigin(req),
|
|
195
|
+
expectedRPID: RP_ID,
|
|
196
|
+
credential: credentialData,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
res.status(400).json({ error: err instanceof Error ? err.message : "Verification failed" });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const { verified: isVerified, authenticationInfo } = verified;
|
|
204
|
+
if (!isVerified) {
|
|
205
|
+
res.status(401).json({ error: "Authentication verification failed" });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
await prisma.webAuthnCredential.update({
|
|
209
|
+
where: { id: webauthnCred.id },
|
|
210
|
+
data: {
|
|
211
|
+
counter: authenticationInfo.newCounter.toString(),
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
req.session.userId = user.id;
|
|
215
|
+
req.session.username = user.username;
|
|
216
|
+
delete req.session.webauthnAuthentication;
|
|
217
|
+
res.json({ success: true, username: user.username });
|
|
218
|
+
}
|
|
219
|
+
let _prisma = null;
|
|
220
|
+
let _PrismaClient = null;
|
|
221
|
+
function getPrismaClient() {
|
|
222
|
+
if (!_prisma) {
|
|
223
|
+
if (!_PrismaClient) {
|
|
224
|
+
try {
|
|
225
|
+
_PrismaClient = require("@prisma/client").PrismaClient;
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
throw new Error("Prisma is not installed. Run 'npm install @prisma/client prisma' to use database features.");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
_prisma = new _PrismaClient();
|
|
232
|
+
}
|
|
233
|
+
return _prisma;
|
|
234
|
+
}
|
|
235
|
+
function getRPConfig() {
|
|
236
|
+
return {
|
|
237
|
+
rpID: RP_ID,
|
|
238
|
+
rpName: RP_NAME,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
@@ -2,4 +2,5 @@ export { createMvcApp, startServer } from "./App";
|
|
|
2
2
|
export type { MvcAppOptions, MvcApp } from "./App";
|
|
3
3
|
export { getPrismaClient, disconnectPrisma } from "./db";
|
|
4
4
|
export { hashPassword, verifyPassword, signToken, verifyToken, authenticate, } from "./Auth";
|
|
5
|
+
export { startRegistration, completeRegistration, startAuthentication, completeAuthentication, getRPConfig, } from "./WebAuthn";
|
|
5
6
|
export { registerControllers, registerController } from "./Router";
|
package/dist/framework/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.registerController = exports.registerControllers = exports.authenticate = exports.verifyToken = exports.signToken = exports.verifyPassword = exports.hashPassword = exports.disconnectPrisma = exports.getPrismaClient = exports.startServer = exports.createMvcApp = void 0;
|
|
3
|
+
exports.registerController = exports.registerControllers = exports.getRPConfig = exports.completeAuthentication = exports.startAuthentication = exports.completeRegistration = exports.startRegistration = exports.authenticate = exports.verifyToken = exports.signToken = exports.verifyPassword = exports.hashPassword = exports.disconnectPrisma = exports.getPrismaClient = exports.startServer = exports.createMvcApp = void 0;
|
|
4
4
|
// App factory and server
|
|
5
5
|
var App_1 = require("./App");
|
|
6
6
|
Object.defineProperty(exports, "createMvcApp", { enumerable: true, get: function () { return App_1.createMvcApp; } });
|
|
@@ -16,6 +16,13 @@ Object.defineProperty(exports, "verifyPassword", { enumerable: true, get: functi
|
|
|
16
16
|
Object.defineProperty(exports, "signToken", { enumerable: true, get: function () { return Auth_1.signToken; } });
|
|
17
17
|
Object.defineProperty(exports, "verifyToken", { enumerable: true, get: function () { return Auth_1.verifyToken; } });
|
|
18
18
|
Object.defineProperty(exports, "authenticate", { enumerable: true, get: function () { return Auth_1.authenticate; } });
|
|
19
|
+
// WebAuthn
|
|
20
|
+
var WebAuthn_1 = require("./WebAuthn");
|
|
21
|
+
Object.defineProperty(exports, "startRegistration", { enumerable: true, get: function () { return WebAuthn_1.startRegistration; } });
|
|
22
|
+
Object.defineProperty(exports, "completeRegistration", { enumerable: true, get: function () { return WebAuthn_1.completeRegistration; } });
|
|
23
|
+
Object.defineProperty(exports, "startAuthentication", { enumerable: true, get: function () { return WebAuthn_1.startAuthentication; } });
|
|
24
|
+
Object.defineProperty(exports, "completeAuthentication", { enumerable: true, get: function () { return WebAuthn_1.completeAuthentication; } });
|
|
25
|
+
Object.defineProperty(exports, "getRPConfig", { enumerable: true, get: function () { return WebAuthn_1.getRPConfig; } });
|
|
19
26
|
// Routing
|
|
20
27
|
var Router_1 = require("./Router");
|
|
21
28
|
Object.defineProperty(exports, "registerControllers", { enumerable: true, get: function () { return Router_1.registerControllers; } });
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.generateWebAuthn = generateWebAuthn;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const ejs_1 = __importDefault(require("ejs"));
|
|
10
|
+
const child_process_1 = require("child_process");
|
|
11
|
+
const paths_1 = require("./paths");
|
|
12
|
+
/**
|
|
13
|
+
* Generate WebAuthn authentication views and controller.
|
|
14
|
+
*/
|
|
15
|
+
async function generateWebAuthn(options = {}) {
|
|
16
|
+
console.log("Generating WebAuthn authentication...");
|
|
17
|
+
const controllersDir = path_1.default.resolve("src/controllers");
|
|
18
|
+
if (!fs_1.default.existsSync(controllersDir)) {
|
|
19
|
+
fs_1.default.mkdirSync(controllersDir, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
const viewsDir = path_1.default.resolve("src/views/webauthn");
|
|
22
|
+
if (!fs_1.default.existsSync(viewsDir)) {
|
|
23
|
+
fs_1.default.mkdirSync(viewsDir, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
// Generate controller
|
|
26
|
+
const controllerTemplatePath = path_1.default.join((0, paths_1.getTemplatesDir)(), "webauthnController.ts.ejs");
|
|
27
|
+
const controllerTemplate = fs_1.default.readFileSync(controllerTemplatePath, "utf-8");
|
|
28
|
+
const controllerContent = ejs_1.default.render(controllerTemplate, {
|
|
29
|
+
controllerName: "WebAuthnController",
|
|
30
|
+
resourcePath: "webauthn",
|
|
31
|
+
});
|
|
32
|
+
fs_1.default.writeFileSync(path_1.default.join(controllersDir, "WebAuthnController.ts"), controllerContent);
|
|
33
|
+
console.log("Created src/controllers/WebAuthnController.ts");
|
|
34
|
+
// Generate views
|
|
35
|
+
const viewTemplatePath = path_1.default.join((0, paths_1.getTemplatesDir)(), "webauthnView.ejs.ejs");
|
|
36
|
+
const views = [
|
|
37
|
+
{ name: "register", title: "Register Security Key" },
|
|
38
|
+
{ name: "login", title: "Log in with Security Key" },
|
|
39
|
+
{ name: "authenticate", title: "Two-Factor Authentication" },
|
|
40
|
+
];
|
|
41
|
+
for (const view of views) {
|
|
42
|
+
const viewTemplate = fs_1.default.readFileSync(viewTemplatePath, "utf-8");
|
|
43
|
+
const viewContent = ejs_1.default.render(viewTemplate, {
|
|
44
|
+
title: view.title,
|
|
45
|
+
viewName: view.name,
|
|
46
|
+
});
|
|
47
|
+
fs_1.default.writeFileSync(path_1.default.join(viewsDir, `${view.name}.ejs`), viewContent);
|
|
48
|
+
console.log(`Created src/views/webauthn/${view.name}.ejs`);
|
|
49
|
+
}
|
|
50
|
+
// Run migrations if prisma exists
|
|
51
|
+
if (!options.skipMigrate) {
|
|
52
|
+
const schemaPath = path_1.default.resolve("prisma/schema.prisma");
|
|
53
|
+
if (fs_1.default.existsSync(schemaPath)) {
|
|
54
|
+
console.log("\nRunning Prisma migrate...");
|
|
55
|
+
try {
|
|
56
|
+
(0, child_process_1.execSync)("npx prisma migrate dev --name add_webauthn_credentials", {
|
|
57
|
+
stdio: "inherit",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
console.error("Migration failed. You may need to run it manually.");
|
|
62
|
+
}
|
|
63
|
+
console.log("\nGenerating Prisma client...");
|
|
64
|
+
try {
|
|
65
|
+
(0, child_process_1.execSync)("npx prisma generate", { stdio: "inherit" });
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
console.error("Failed to generate Prisma client.");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
console.log(`
|
|
73
|
+
WebAuthn authentication created successfully!
|
|
74
|
+
|
|
75
|
+
Routes:
|
|
76
|
+
GET /webauthn/register -> register (display registration form)
|
|
77
|
+
POST /webauthn/register -> completeRegistration (process registration)
|
|
78
|
+
GET /webauthn/login -> login (display login form)
|
|
79
|
+
POST /webauthn/login -> completeAuthentication (process login)
|
|
80
|
+
GET /webauthn/authenticate -> authenticate (2FA challenge)
|
|
81
|
+
POST /webauthn/authenticate -> completeAuthentication (2FA completion)
|
|
82
|
+
|
|
83
|
+
Required environment variables:
|
|
84
|
+
WEBAUTHN_RP_ID - Your domain (e.g., "localhost" or "example.com")
|
|
85
|
+
WEBAUTHN_RP_NAME - Your app name (e.g., "ErwinMVC App")
|
|
86
|
+
|
|
87
|
+
Note: WebAuthn requires a secure context (HTTPS or localhost).
|
|
88
|
+
`);
|
|
89
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erwininteractive/mvc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "A lightweight, full-featured MVC framework for Node.js with Express, Prisma, and EJS",
|
|
5
5
|
"main": "dist/framework/index.js",
|
|
6
6
|
"types": "dist/framework/index.d.ts",
|
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@prisma/client": "^6.0.0",
|
|
42
|
+
"@simplewebauthn/server": "^13.3.0",
|
|
43
|
+
"base64url": "^3.0.1",
|
|
42
44
|
"bcryptjs": "^2.4.3",
|
|
43
45
|
"commander": "^12.1.0",
|
|
44
46
|
"connect-redis": "^7.1.1",
|
package/prisma/schema.prisma
CHANGED
|
@@ -12,8 +12,28 @@ model User {
|
|
|
12
12
|
email String @unique
|
|
13
13
|
hashedPassword String
|
|
14
14
|
role String @default("user")
|
|
15
|
+
username String? @unique
|
|
15
16
|
createdAt DateTime @default(now())
|
|
16
17
|
updatedAt DateTime @updatedAt
|
|
17
18
|
|
|
19
|
+
webauthnCredentials WebAuthnCredential[]
|
|
20
|
+
|
|
18
21
|
@@map("users")
|
|
19
22
|
}
|
|
23
|
+
|
|
24
|
+
model WebAuthnCredential {
|
|
25
|
+
id Int @id @default(autoincrement())
|
|
26
|
+
credentialID String @unique
|
|
27
|
+
credentialPublicKey String
|
|
28
|
+
counter Int
|
|
29
|
+
attestationType String @default("none")
|
|
30
|
+
aaguid String?
|
|
31
|
+
transports String?
|
|
32
|
+
createdAt DateTime @default(now())
|
|
33
|
+
updatedAt DateTime @updatedAt
|
|
34
|
+
|
|
35
|
+
user User @relation(fields: [userID], references: [id], onDelete: Cascade)
|
|
36
|
+
userID Int
|
|
37
|
+
|
|
38
|
+
@@map("webauthn_credentials")
|
|
39
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Request, Response } from "express";
|
|
2
|
+
import {
|
|
3
|
+
startRegistration,
|
|
4
|
+
completeRegistration,
|
|
5
|
+
startAuthentication,
|
|
6
|
+
completeAuthentication,
|
|
7
|
+
} from "@erwininteractive/mvc";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Display the registration form
|
|
11
|
+
*/
|
|
12
|
+
export async function register(req: Request, res: Response): Promise<void> {
|
|
13
|
+
res.render("webauthn/register", {
|
|
14
|
+
title: "Register Security Key",
|
|
15
|
+
success: req.query.success === "true",
|
|
16
|
+
error: req.query.error || null,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Process registration response
|
|
22
|
+
*/
|
|
23
|
+
export async function completeRegister(req: Request, res: Response): Promise<void> {
|
|
24
|
+
try {
|
|
25
|
+
await completeRegistration(req, res);
|
|
26
|
+
} catch (err) {
|
|
27
|
+
const error = err instanceof Error ? err.message : err;
|
|
28
|
+
res.redirect("/webauthn/register?error=" + encodeURIComponent(error));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Display the login form
|
|
34
|
+
*/
|
|
35
|
+
export async function login(req: Request, res: Response): Promise<void> {
|
|
36
|
+
res.render("webauthn/login", {
|
|
37
|
+
title: "Log in with Security Key",
|
|
38
|
+
error: req.query.error || null,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Process authentication response
|
|
44
|
+
*/
|
|
45
|
+
export async function completeLogin(req: Request, res: Response): Promise<void> {
|
|
46
|
+
try {
|
|
47
|
+
await completeAuthentication(req, res);
|
|
48
|
+
res.redirect("/dashboard");
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const error = err instanceof Error ? err.message : err;
|
|
51
|
+
res.redirect("/webauthn/login?error=" + encodeURIComponent(error));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title><%= title %></title>
|
|
7
|
+
<script src="https://cdn.jsdelivr.net/npm/@simplewebauthn/browser@9"></script>
|
|
8
|
+
<style>
|
|
9
|
+
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
|
|
10
|
+
.form-group { margin-bottom: 15px; }
|
|
11
|
+
label { display: block; margin-bottom: 5px; }
|
|
12
|
+
input { width: 100%; padding: 8px; box-sizing: border-box; }
|
|
13
|
+
button { padding: 10px 20px; cursor: pointer; }
|
|
14
|
+
.error { color: red; margin-bottom: 15px; }
|
|
15
|
+
.success { color: green; margin-bottom: 15px; }
|
|
16
|
+
</style>
|
|
17
|
+
</head>
|
|
18
|
+
<body>
|
|
19
|
+
<h1><%= title %></h1>
|
|
20
|
+
|
|
21
|
+
<% if (error) { %>
|
|
22
|
+
<div class="error"><%= error %></div>
|
|
23
|
+
<% } %>
|
|
24
|
+
|
|
25
|
+
<form id="webauthnForm" method="POST">
|
|
26
|
+
<div class="form-group">
|
|
27
|
+
<label for="username">Username:</label>
|
|
28
|
+
<input type="text" id="username" name="username" required>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<button type="submit"><%= title %></button>
|
|
32
|
+
</form>
|
|
33
|
+
|
|
34
|
+
<script>
|
|
35
|
+
document.getElementById('webauthnForm').addEventListener('submit', async function(e) {
|
|
36
|
+
e.preventDefault();
|
|
37
|
+
|
|
38
|
+
const username = document.getElementById('username').value;
|
|
39
|
+
const response = await fetch('/webauthn/start-authentication', {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: { 'Content-Type': 'application/json' },
|
|
42
|
+
body: JSON.stringify({ username })
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const options = await response.json();
|
|
46
|
+
|
|
47
|
+
if (options.error) {
|
|
48
|
+
alert(options.error);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const credential = await startAuthentication(options);
|
|
54
|
+
window.location.href = '/webauthn/complete?token=' + encodeURIComponent(JSON.stringify(credential));
|
|
55
|
+
} catch (err) {
|
|
56
|
+
alert('Authentication failed: ' + err.message);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
</script>
|
|
60
|
+
</body>
|
|
61
|
+
</html>
|