Skip to content
This repository was archived by the owner on Jul 28, 2026. It is now read-only.

Commit 04488b5

Browse files
committed
feat: add passkey (WebAuthn) support for passwordless login
- Add @simplewebauthn/server dependency for WebAuthn server-side logic - Add passkeys table to database migration - Create routes/passkeys.js with 6 endpoints: register/begin, register/complete, login/begin, login/complete, list, delete - Mount passkey routes under /api/auth - Update frontend: add passkey login button on sign-in page - Add passkey management UI in account settings (register/delete passkeys) - Add client-side WebAuthn credential serialization helper
1 parent efe832f commit 04488b5

5 files changed

Lines changed: 700 additions & 162 deletions

File tree

config/migrate.js

Lines changed: 176 additions & 161 deletions
Original file line numberDiff line numberDiff line change
@@ -1,161 +1,176 @@
1-
import { query } from './db.js';
2-
3-
const tables = {
4-
users: {
5-
columns: [
6-
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
7-
{ name: 'email', def: 'VARCHAR(255) NOT NULL' },
8-
{ name: 'username', def: 'VARCHAR(255) NOT NULL' },
9-
{ name: 'password_hash', def: 'VARCHAR(255) NOT NULL' },
10-
{ name: 'ptero_user_id', def: 'INT' },
11-
{ name: 'ptero_uuid', def: 'VARCHAR(255)' },
12-
{ name: 'first_name', def: 'VARCHAR(255)' },
13-
{ name: 'last_name', def: 'VARCHAR(255)' },
14-
{ name: 'password_set', def: 'TINYINT(1) NOT NULL DEFAULT 0' },
15-
{ name: 'is_admin', def: 'TINYINT(1) NOT NULL DEFAULT 0' },
16-
{ name: 'restricted', def: 'TINYINT(1) NOT NULL DEFAULT 0' },
17-
{ name: 'auth_restricted', def: 'TINYINT(1) NOT NULL DEFAULT 0' },
18-
{ name: 'token_version', def: 'INT NOT NULL DEFAULT 0' },
19-
{ name: 'avatar', def: 'VARCHAR(255) DEFAULT NULL' },
20-
{ name: 'ptero_client_api_key', def: 'VARCHAR(255) DEFAULT NULL' },
21-
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
22-
],
23-
},
24-
server_meta: {
25-
columns: [
26-
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
27-
{ name: 'ptero_server_id', def: 'INT NOT NULL' },
28-
{ name: 'user_id', def: 'INT NOT NULL' },
29-
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
30-
{ name: 'expires_at', def: 'TIMESTAMP NOT NULL' },
31-
{ name: 'status', def: "ENUM('active', 'suspended', 'expired') DEFAULT 'active'" },
32-
{ name: 'suspend_reason', def: 'TEXT DEFAULT NULL' },
33-
{ name: 'suspended_by', def: "VARCHAR(20) DEFAULT NULL" },
34-
],
35-
},
36-
user_ips: {
37-
columns: [
38-
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
39-
{ name: 'user_id', def: 'INT NOT NULL' },
40-
{ name: 'ip_address', def: 'VARCHAR(45) NOT NULL' },
41-
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
42-
],
43-
},
44-
activity_log: {
45-
columns: [
46-
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
47-
{ name: 'user_id', def: 'INT NOT NULL' },
48-
{ name: 'action', def: 'VARCHAR(50) NOT NULL' },
49-
{ name: 'details', def: 'VARCHAR(255) DEFAULT \'\'' },
50-
{ name: 'server_id', def: 'INT DEFAULT NULL' },
51-
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
52-
],
53-
},
54-
nests: {
55-
columns: [
56-
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
57-
{ name: 'ptero_nest_id', def: 'INT NOT NULL UNIQUE' },
58-
{ name: 'name', def: 'VARCHAR(255) NOT NULL' },
59-
{ name: 'logo', def: 'VARCHAR(255) DEFAULT NULL' },
60-
{ name: 'description', def: 'TEXT DEFAULT NULL' },
61-
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
62-
],
63-
},
64-
egg_resources: {
65-
columns: [
66-
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
67-
{ name: 'ptero_nest_id', def: 'INT NOT NULL' },
68-
{ name: 'ptero_egg_id', def: 'INT NOT NULL' },
69-
{ name: 'logo', def: 'VARCHAR(255) DEFAULT NULL' },
70-
{ name: 'cpu_limit', def: 'INT DEFAULT NULL' },
71-
{ name: 'memory_limit', def: 'INT DEFAULT NULL' },
72-
{ name: 'disk_limit', def: 'INT DEFAULT NULL' },
73-
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
74-
{ name: 'updated_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP' },
75-
],
76-
},
77-
notifications: {
78-
columns: [
79-
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
80-
{ name: 'user_id', def: 'INT NOT NULL' },
81-
{ name: 'title', def: 'VARCHAR(255) NOT NULL' },
82-
{ name: 'message', def: 'TEXT NOT NULL' },
83-
{ name: 'type', def: "VARCHAR(20) NOT NULL DEFAULT 'info'" },
84-
{ name: 'link', def: 'VARCHAR(255) DEFAULT NULL' },
85-
{ name: 'is_read', def: 'TINYINT(1) NOT NULL DEFAULT 0' },
86-
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
87-
],
88-
},
89-
90-
};
91-
92-
function escapeId(id) {
93-
return '`' + String(id).replace(/`/g, '``') + '`';
94-
}
95-
96-
function escapeLike(id) {
97-
return String(id).replace(/[%_\\]/g, '\\$&');
98-
}
99-
100-
const constraints = [
101-
{ table: 'server_meta', sql: 'ALTER TABLE server_meta ADD INDEX idx_expires (expires_at)', name: 'idx_expires' },
102-
{ table: 'server_meta', sql: 'ALTER TABLE server_meta ADD INDEX idx_user (user_id)', name: 'idx_user' },
103-
{ table: 'server_meta', sql: 'ALTER TABLE server_meta ADD INDEX idx_status (status)', name: 'idx_status' },
104-
{ table: 'user_ips', sql: 'ALTER TABLE user_ips ADD INDEX idx_ip (ip_address)', name: 'idx_ip' },
105-
{ table: 'user_ips', sql: 'ALTER TABLE user_ips ADD INDEX idx_user (user_id)', name: 'idx_user' },
106-
{ table: 'user_ips', sql: 'ALTER TABLE user_ips ADD CONSTRAINT fk_user_ips_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE', name: 'fk_user_ips_user' },
107-
{ table: 'activity_log', sql: 'ALTER TABLE activity_log ADD INDEX idx_activity_user (user_id)', name: 'idx_activity_user' },
108-
{ table: 'activity_log', sql: 'ALTER TABLE activity_log ADD INDEX idx_activity_created (created_at)', name: 'idx_activity_created' },
109-
{ table: 'egg_resources', sql: 'ALTER TABLE egg_resources ADD UNIQUE INDEX idx_egg_resources_nest_egg (ptero_nest_id, ptero_egg_id)', name: 'idx_egg_resources_nest_egg' },
110-
{ table: 'notifications', sql: 'ALTER TABLE notifications ADD INDEX idx_notif_user (user_id)', name: 'idx_notif_user' },
111-
{ table: 'notifications', sql: 'ALTER TABLE notifications ADD INDEX idx_notif_user_read (user_id, is_read)', name: 'idx_notif_user_read' },
112-
{ table: 'notifications', sql: 'ALTER TABLE notifications ADD INDEX idx_notif_created (created_at)', name: 'idx_notif_created' },
113-
{ table: 'notifications', sql: 'ALTER TABLE notifications ADD CONSTRAINT fk_notif_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE', name: 'fk_notif_user' },
114-
{ table: 'users', sql: 'ALTER TABLE users ADD INDEX idx_email (email)', name: 'idx_user_email' },
115-
{ table: 'users', sql: 'ALTER TABLE users ADD INDEX idx_username (username)', name: 'idx_user_username' },
116-
{ table: 'activity_log', sql: 'ALTER TABLE activity_log ADD INDEX idx_action (action)', name: 'idx_activity_action' },
117-
{ table: 'activity_log', sql: 'ALTER TABLE activity_log ADD CONSTRAINT fk_activity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE', name: 'fk_activity_user' },
118-
{ table: 'server_meta', sql: 'ALTER TABLE server_meta ADD INDEX idx_ptero_server (ptero_server_id)', name: 'idx_ptero_server' },
119-
{ table: 'server_meta', sql: 'ALTER TABLE server_meta ADD CONSTRAINT fk_server_meta_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE', name: 'fk_server_meta_user' },
120-
];
121-
122-
export async function migrate() {
123-
for (const [table, schema] of Object.entries(tables)) {
124-
try {
125-
const safeTable = escapeId(table);
126-
const exists = await query(`SHOW TABLES LIKE ?`, [escapeLike(table)]);
127-
if (exists.length === 0) {
128-
const colDefs = schema.columns.map(c => `${escapeId(c.name)} ${c.def}`).join(', ');
129-
await query(`CREATE TABLE ${safeTable} (${colDefs})`);
130-
console.log(`Created table: ${table}`);
131-
continue;
132-
}
133-
134-
for (const col of schema.columns) {
135-
try {
136-
const safeCol = escapeId(col.name);
137-
const cols = await query(`SHOW COLUMNS FROM ${safeTable} LIKE ?`, [escapeLike(col.name)]);
138-
if (cols.length === 0) {
139-
await query(`ALTER TABLE ${safeTable} ADD COLUMN ${safeCol} ${col.def}`);
140-
console.log(`Added column ${table}.${col.name}`);
141-
}
142-
} catch (err) {
143-
console.error(`Migration error ${table}.${col.name}:`, err.message);
144-
}
145-
}
146-
} catch (err) {
147-
console.error(`Migration error for table ${table}:`, err.message);
148-
}
149-
}
150-
151-
for (const c of constraints) {
152-
try {
153-
await query(c.sql);
154-
console.log(`Applied constraint: ${c.name}`);
155-
} catch (err) {
156-
if (process.env.NODE_ENV !== 'production') {
157-
console.log(`Skipped constraint ${c.name}: ${err.message}`);
158-
}
159-
}
160-
}
161-
}
1+
import { query } from './db.js';
2+
3+
const tables = {
4+
users: {
5+
columns: [
6+
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
7+
{ name: 'email', def: 'VARCHAR(255) NOT NULL' },
8+
{ name: 'username', def: 'VARCHAR(255) NOT NULL' },
9+
{ name: 'password_hash', def: 'VARCHAR(255) NOT NULL' },
10+
{ name: 'ptero_user_id', def: 'INT' },
11+
{ name: 'ptero_uuid', def: 'VARCHAR(255)' },
12+
{ name: 'first_name', def: 'VARCHAR(255)' },
13+
{ name: 'last_name', def: 'VARCHAR(255)' },
14+
{ name: 'password_set', def: 'TINYINT(1) NOT NULL DEFAULT 0' },
15+
{ name: 'is_admin', def: 'TINYINT(1) NOT NULL DEFAULT 0' },
16+
{ name: 'restricted', def: 'TINYINT(1) NOT NULL DEFAULT 0' },
17+
{ name: 'auth_restricted', def: 'TINYINT(1) NOT NULL DEFAULT 0' },
18+
{ name: 'token_version', def: 'INT NOT NULL DEFAULT 0' },
19+
{ name: 'avatar', def: 'VARCHAR(255) DEFAULT NULL' },
20+
{ name: 'ptero_client_api_key', def: 'VARCHAR(255) DEFAULT NULL' },
21+
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
22+
],
23+
},
24+
server_meta: {
25+
columns: [
26+
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
27+
{ name: 'ptero_server_id', def: 'INT NOT NULL' },
28+
{ name: 'user_id', def: 'INT NOT NULL' },
29+
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
30+
{ name: 'expires_at', def: 'TIMESTAMP NOT NULL' },
31+
{ name: 'status', def: "ENUM('active', 'suspended', 'expired') DEFAULT 'active'" },
32+
{ name: 'suspend_reason', def: 'TEXT DEFAULT NULL' },
33+
{ name: 'suspended_by', def: "VARCHAR(20) DEFAULT NULL" },
34+
],
35+
},
36+
user_ips: {
37+
columns: [
38+
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
39+
{ name: 'user_id', def: 'INT NOT NULL' },
40+
{ name: 'ip_address', def: 'VARCHAR(45) NOT NULL' },
41+
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
42+
],
43+
},
44+
activity_log: {
45+
columns: [
46+
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
47+
{ name: 'user_id', def: 'INT NOT NULL' },
48+
{ name: 'action', def: 'VARCHAR(50) NOT NULL' },
49+
{ name: 'details', def: 'VARCHAR(255) DEFAULT \'\'' },
50+
{ name: 'server_id', def: 'INT DEFAULT NULL' },
51+
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
52+
],
53+
},
54+
nests: {
55+
columns: [
56+
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
57+
{ name: 'ptero_nest_id', def: 'INT NOT NULL UNIQUE' },
58+
{ name: 'name', def: 'VARCHAR(255) NOT NULL' },
59+
{ name: 'logo', def: 'VARCHAR(255) DEFAULT NULL' },
60+
{ name: 'description', def: 'TEXT DEFAULT NULL' },
61+
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
62+
],
63+
},
64+
egg_resources: {
65+
columns: [
66+
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
67+
{ name: 'ptero_nest_id', def: 'INT NOT NULL' },
68+
{ name: 'ptero_egg_id', def: 'INT NOT NULL' },
69+
{ name: 'logo', def: 'VARCHAR(255) DEFAULT NULL' },
70+
{ name: 'cpu_limit', def: 'INT DEFAULT NULL' },
71+
{ name: 'memory_limit', def: 'INT DEFAULT NULL' },
72+
{ name: 'disk_limit', def: 'INT DEFAULT NULL' },
73+
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
74+
{ name: 'updated_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP' },
75+
],
76+
},
77+
notifications: {
78+
columns: [
79+
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
80+
{ name: 'user_id', def: 'INT NOT NULL' },
81+
{ name: 'title', def: 'VARCHAR(255) NOT NULL' },
82+
{ name: 'message', def: 'TEXT NOT NULL' },
83+
{ name: 'type', def: "VARCHAR(20) NOT NULL DEFAULT 'info'" },
84+
{ name: 'link', def: 'VARCHAR(255) DEFAULT NULL' },
85+
{ name: 'is_read', def: 'TINYINT(1) NOT NULL DEFAULT 0' },
86+
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
87+
],
88+
},
89+
passkeys: {
90+
columns: [
91+
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
92+
{ name: 'user_id', def: 'INT NOT NULL' },
93+
{ name: 'credential_id', def: 'VARCHAR(512) NOT NULL' },
94+
{ name: 'public_key', def: 'TEXT NOT NULL' },
95+
{ name: 'counter', def: 'INT NOT NULL DEFAULT 0' },
96+
{ name: 'transports', def: 'VARCHAR(255) DEFAULT NULL' },
97+
{ name: 'name', def: 'VARCHAR(255) DEFAULT NULL' },
98+
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
99+
],
100+
},
101+
102+
};
103+
104+
function escapeId(id) {
105+
return '`' + String(id).replace(/`/g, '``') + '`';
106+
}
107+
108+
function escapeLike(id) {
109+
return String(id).replace(/[%_\\]/g, '\\$&');
110+
}
111+
112+
const constraints = [
113+
{ table: 'server_meta', sql: 'ALTER TABLE server_meta ADD INDEX idx_expires (expires_at)', name: 'idx_expires' },
114+
{ table: 'server_meta', sql: 'ALTER TABLE server_meta ADD INDEX idx_user (user_id)', name: 'idx_user' },
115+
{ table: 'server_meta', sql: 'ALTER TABLE server_meta ADD INDEX idx_status (status)', name: 'idx_status' },
116+
{ table: 'user_ips', sql: 'ALTER TABLE user_ips ADD INDEX idx_ip (ip_address)', name: 'idx_ip' },
117+
{ table: 'user_ips', sql: 'ALTER TABLE user_ips ADD INDEX idx_user (user_id)', name: 'idx_user' },
118+
{ table: 'user_ips', sql: 'ALTER TABLE user_ips ADD CONSTRAINT fk_user_ips_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE', name: 'fk_user_ips_user' },
119+
{ table: 'activity_log', sql: 'ALTER TABLE activity_log ADD INDEX idx_activity_user (user_id)', name: 'idx_activity_user' },
120+
{ table: 'activity_log', sql: 'ALTER TABLE activity_log ADD INDEX idx_activity_created (created_at)', name: 'idx_activity_created' },
121+
{ table: 'egg_resources', sql: 'ALTER TABLE egg_resources ADD UNIQUE INDEX idx_egg_resources_nest_egg (ptero_nest_id, ptero_egg_id)', name: 'idx_egg_resources_nest_egg' },
122+
{ table: 'notifications', sql: 'ALTER TABLE notifications ADD INDEX idx_notif_user (user_id)', name: 'idx_notif_user' },
123+
{ table: 'notifications', sql: 'ALTER TABLE notifications ADD INDEX idx_notif_user_read (user_id, is_read)', name: 'idx_notif_user_read' },
124+
{ table: 'notifications', sql: 'ALTER TABLE notifications ADD INDEX idx_notif_created (created_at)', name: 'idx_notif_created' },
125+
{ table: 'notifications', sql: 'ALTER TABLE notifications ADD CONSTRAINT fk_notif_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE', name: 'fk_notif_user' },
126+
{ table: 'users', sql: 'ALTER TABLE users ADD INDEX idx_email (email)', name: 'idx_user_email' },
127+
{ table: 'users', sql: 'ALTER TABLE users ADD INDEX idx_username (username)', name: 'idx_user_username' },
128+
{ table: 'activity_log', sql: 'ALTER TABLE activity_log ADD INDEX idx_action (action)', name: 'idx_activity_action' },
129+
{ table: 'activity_log', sql: 'ALTER TABLE activity_log ADD CONSTRAINT fk_activity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE', name: 'fk_activity_user' },
130+
{ table: 'server_meta', sql: 'ALTER TABLE server_meta ADD INDEX idx_ptero_server (ptero_server_id)', name: 'idx_ptero_server' },
131+
{ table: 'server_meta', sql: 'ALTER TABLE server_meta ADD CONSTRAINT fk_server_meta_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE', name: 'fk_server_meta_user' },
132+
{ table: 'passkeys', sql: 'ALTER TABLE passkeys ADD INDEX idx_passkey_user (user_id)', name: 'idx_passkey_user' },
133+
{ table: 'passkeys', sql: 'ALTER TABLE passkeys ADD INDEX idx_passkey_credential (credential_id(255))', name: 'idx_passkey_credential' },
134+
{ table: 'passkeys', sql: 'ALTER TABLE passkeys ADD CONSTRAINT fk_passkey_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE', name: 'fk_passkey_user' },
135+
];
136+
137+
export async function migrate() {
138+
for (const [table, schema] of Object.entries(tables)) {
139+
try {
140+
const safeTable = escapeId(table);
141+
const exists = await query(`SHOW TABLES LIKE ?`, [escapeLike(table)]);
142+
if (exists.length === 0) {
143+
const colDefs = schema.columns.map(c => `${escapeId(c.name)} ${c.def}`).join(', ');
144+
await query(`CREATE TABLE ${safeTable} (${colDefs})`);
145+
console.log(`Created table: ${table}`);
146+
continue;
147+
}
148+
149+
for (const col of schema.columns) {
150+
try {
151+
const safeCol = escapeId(col.name);
152+
const cols = await query(`SHOW COLUMNS FROM ${safeTable} LIKE ?`, [escapeLike(col.name)]);
153+
if (cols.length === 0) {
154+
await query(`ALTER TABLE ${safeTable} ADD COLUMN ${safeCol} ${col.def}`);
155+
console.log(`Added column ${table}.${col.name}`);
156+
}
157+
} catch (err) {
158+
console.error(`Migration error ${table}.${col.name}:`, err.message);
159+
}
160+
}
161+
} catch (err) {
162+
console.error(`Migration error for table ${table}:`, err.message);
163+
}
164+
}
165+
166+
for (const c of constraints) {
167+
try {
168+
await query(c.sql);
169+
console.log(`Applied constraint: ${c.name}`);
170+
} catch (err) {
171+
if (process.env.NODE_ENV !== 'production') {
172+
console.log(`Skipped constraint ${c.name}: ${err.message}`);
173+
}
174+
}
175+
}
176+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"dev": "node --watch server.js"
99
},
1010
"dependencies": {
11+
"@simplewebauthn/server": "^13.3.2",
1112
"argon2": "^0.41.1",
1213
"cookie-parser": "^1.4.7",
1314
"cors": "^2.8.5",

0 commit comments

Comments
 (0)