@yeaft/webchat-agent 1.0.188 → 1.0.189

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.
Files changed (121) hide show
  1. package/cli.js +12 -0
  2. package/index.js +10 -2
  3. package/local-run.js +218 -0
  4. package/local-runtime/server/.env.example +54 -0
  5. package/local-runtime/server/api.js +111 -0
  6. package/local-runtime/server/auth/aad.js +235 -0
  7. package/local-runtime/server/auth/login.js +156 -0
  8. package/local-runtime/server/auth/oauth-flow.js +277 -0
  9. package/local-runtime/server/auth/password-reset.js +134 -0
  10. package/local-runtime/server/auth/providers/alipay.js +125 -0
  11. package/local-runtime/server/auth/providers/github.js +82 -0
  12. package/local-runtime/server/auth/providers/google.js +60 -0
  13. package/local-runtime/server/auth/providers/microsoft.js +71 -0
  14. package/local-runtime/server/auth/providers/types.js +57 -0
  15. package/local-runtime/server/auth/providers/wechat.js +68 -0
  16. package/local-runtime/server/auth/register.js +91 -0
  17. package/local-runtime/server/auth/session-store.js +57 -0
  18. package/local-runtime/server/auth/token.js +85 -0
  19. package/local-runtime/server/auth/totp-auth.js +133 -0
  20. package/local-runtime/server/auth/utils.js +42 -0
  21. package/local-runtime/server/auth.js +8 -0
  22. package/local-runtime/server/check-node-version.js +74 -0
  23. package/local-runtime/server/config.js +298 -0
  24. package/local-runtime/server/context.js +140 -0
  25. package/local-runtime/server/create-user.js +59 -0
  26. package/local-runtime/server/database.js +12 -0
  27. package/local-runtime/server/db/connection.js +963 -0
  28. package/local-runtime/server/db/expert-db.js +171 -0
  29. package/local-runtime/server/db/identity-db.js +92 -0
  30. package/local-runtime/server/db/invitation-db.js +38 -0
  31. package/local-runtime/server/db/message-db.js +257 -0
  32. package/local-runtime/server/db/session-db.js +118 -0
  33. package/local-runtime/server/db/user-db.js +165 -0
  34. package/local-runtime/server/db/user-stats-db.js +185 -0
  35. package/local-runtime/server/db/yeaft-session-db.js +258 -0
  36. package/local-runtime/server/email.js +96 -0
  37. package/local-runtime/server/encryption.js +105 -0
  38. package/local-runtime/server/handlers/agent-conversation.js +347 -0
  39. package/local-runtime/server/handlers/agent-file-terminal.js +99 -0
  40. package/local-runtime/server/handlers/agent-output.js +854 -0
  41. package/local-runtime/server/handlers/agent-sync.js +399 -0
  42. package/local-runtime/server/handlers/agent-work-center.js +27 -0
  43. package/local-runtime/server/handlers/client-conversation.js +1182 -0
  44. package/local-runtime/server/handlers/client-misc.js +254 -0
  45. package/local-runtime/server/handlers/client-work-center.js +269 -0
  46. package/local-runtime/server/handlers/client-workbench.js +146 -0
  47. package/local-runtime/server/handlers/session-pin-router.js +61 -0
  48. package/local-runtime/server/heartbeat-policy.js +46 -0
  49. package/local-runtime/server/index.js +275 -0
  50. package/local-runtime/server/package.json +55 -0
  51. package/local-runtime/server/perf-trace.js +154 -0
  52. package/local-runtime/server/proxy.js +273 -0
  53. package/local-runtime/server/routes/admin-routes.js +207 -0
  54. package/local-runtime/server/routes/auth-routes.js +322 -0
  55. package/local-runtime/server/routes/expert-routes.js +117 -0
  56. package/local-runtime/server/routes/invitation-routes.js +60 -0
  57. package/local-runtime/server/routes/session-routes.js +112 -0
  58. package/local-runtime/server/routes/upload-routes.js +109 -0
  59. package/local-runtime/server/routes/user-routes.js +241 -0
  60. package/local-runtime/server/totp.js +74 -0
  61. package/local-runtime/server/work-item-attachment-policy.js +56 -0
  62. package/local-runtime/server/ws-agent.js +319 -0
  63. package/local-runtime/server/ws-client.js +214 -0
  64. package/local-runtime/server/ws-utils.js +394 -0
  65. package/local-runtime/server/yeaft-asset-store.js +339 -0
  66. package/local-runtime/version.json +1 -0
  67. package/local-runtime/web/app.bundle.js +7673 -0
  68. package/local-runtime/web/app.bundle.js.gz +0 -0
  69. package/local-runtime/web/assets/avatars/README.md +34 -0
  70. package/local-runtime/web/assets/avatars/ada.svg +1 -0
  71. package/local-runtime/web/assets/avatars/alan.svg +1 -0
  72. package/local-runtime/web/assets/avatars/alice.svg +1 -0
  73. package/local-runtime/web/assets/avatars/anders.svg +1 -0
  74. package/local-runtime/web/assets/avatars/bezos.svg +1 -0
  75. package/local-runtime/web/assets/avatars/borges.svg +1 -0
  76. package/local-runtime/web/assets/avatars/buffett.svg +1 -0
  77. package/local-runtime/web/assets/avatars/clausewitz.svg +1 -0
  78. package/local-runtime/web/assets/avatars/dalio.svg +1 -0
  79. package/local-runtime/web/assets/avatars/dieter.svg +1 -0
  80. package/local-runtime/web/assets/avatars/drucker.svg +1 -0
  81. package/local-runtime/web/assets/avatars/einstein.svg +1 -0
  82. package/local-runtime/web/assets/avatars/grace.svg +1 -0
  83. package/local-runtime/web/assets/avatars/harari.svg +1 -0
  84. package/local-runtime/web/assets/avatars/jung.svg +1 -0
  85. package/local-runtime/web/assets/avatars/kahneman.svg +1 -0
  86. package/local-runtime/web/assets/avatars/ken.svg +1 -0
  87. package/local-runtime/web/assets/avatars/kongzi.svg +1 -0
  88. package/local-runtime/web/assets/avatars/kubrick.svg +1 -0
  89. package/local-runtime/web/assets/avatars/linus.svg +1 -0
  90. package/local-runtime/web/assets/avatars/luxun.svg +1 -0
  91. package/local-runtime/web/assets/avatars/margaret.svg +1 -0
  92. package/local-runtime/web/assets/avatars/martin.svg +1 -0
  93. package/local-runtime/web/assets/avatars/miyazaki.svg +1 -0
  94. package/local-runtime/web/assets/avatars/munger.svg +1 -0
  95. package/local-runtime/web/assets/avatars/nietzsche.svg +1 -0
  96. package/local-runtime/web/assets/avatars/norman.svg +1 -0
  97. package/local-runtime/web/assets/avatars/shannon.svg +1 -0
  98. package/local-runtime/web/assets/avatars/simaqian.svg +1 -0
  99. package/local-runtime/web/assets/avatars/socrates.svg +1 -0
  100. package/local-runtime/web/assets/avatars/steve.svg +1 -0
  101. package/local-runtime/web/assets/avatars/sudongpo.svg +1 -0
  102. package/local-runtime/web/assets/avatars/sunzi.svg +1 -0
  103. package/local-runtime/web/docx-preview.min.js +2 -0
  104. package/local-runtime/web/docx-preview.min.js.gz +0 -0
  105. package/local-runtime/web/html-to-image.min.js +2 -0
  106. package/local-runtime/web/html-to-image.min.js.gz +0 -0
  107. package/local-runtime/web/index.html +21 -0
  108. package/local-runtime/web/jszip.min.js +13 -0
  109. package/local-runtime/web/jszip.min.js.gz +0 -0
  110. package/local-runtime/web/mermaid.min.js +2843 -0
  111. package/local-runtime/web/mermaid.min.js.gz +0 -0
  112. package/local-runtime/web/msal-browser.min.js +69 -0
  113. package/local-runtime/web/msal-browser.min.js.gz +0 -0
  114. package/local-runtime/web/style.bundle.css +10 -0
  115. package/local-runtime/web/style.bundle.css.gz +0 -0
  116. package/local-runtime/web/vendor.bundle.js +1522 -0
  117. package/local-runtime/web/vendor.bundle.js.gz +0 -0
  118. package/local-runtime/web/xlsx.min.js +24 -0
  119. package/local-runtime/web/xlsx.min.js.gz +0 -0
  120. package/package.json +13 -3
  121. package/scripts/prepare-local-runtime.js +25 -0
@@ -0,0 +1,156 @@
1
+ import bcrypt from 'bcrypt';
2
+ import jwt from 'jsonwebtoken';
3
+ import { CONFIG, getUserByUsername, isEmailConfigured, isTotpEnabled } from '../config.js';
4
+ import { issueSessionToken } from './token.js';
5
+ import { sendVerificationCode } from '../email.js';
6
+ import { generateSessionKey, encodeKey } from '../encryption.js';
7
+ import { generateTotpSecret, generateTotpQRCode } from '../totp.js';
8
+ import { pendingVerifications, pendingTotpVerifications, pendingTotpSetup, activeSessions } from './session-store.js';
9
+ import { generateVerificationCode, maskEmail } from './utils.js';
10
+
11
+ /**
12
+ * Helper: complete login and return token + sessionKey + role
13
+ */
14
+ export function completeLogin(username, sessionKey, role) {
15
+ const token = issueSessionToken(username);
16
+ activeSessions.set(token, { username, sessionKey });
17
+ return {
18
+ success: true,
19
+ token,
20
+ sessionKey: encodeKey(sessionKey),
21
+ role: role === 'admin' ? 'admin' : 'pro',
22
+ needTotpCode: false,
23
+ needTotpSetup: false,
24
+ needEmailCode: false
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Authenticate user with username and password (Step 1)
30
+ */
31
+ export async function loginStep1(username, password) {
32
+ const user = getUserByUsername(username);
33
+
34
+ if (!user) {
35
+ await bcrypt.compare(password, '$2b$10$invalidhashfortiminginvalidhash');
36
+ return { success: false, error: 'Invalid username or password' };
37
+ }
38
+
39
+ if (!user.passwordHash) {
40
+ await bcrypt.compare(password, '$2b$10$invalidhashfortiminginvalidhash');
41
+ return { success: false, error: 'Invalid username or password' };
42
+ }
43
+
44
+ const passwordValid = await bcrypt.compare(password, user.passwordHash);
45
+ if (!passwordValid) {
46
+ return { success: false, error: 'Invalid username or password' };
47
+ }
48
+
49
+ const sessionKey = generateSessionKey();
50
+ const role = user.role === 'admin' ? 'admin' : 'pro';
51
+
52
+ // Check if TOTP is enabled for this user
53
+ if (user.totpEnabled && user.totpSecret) {
54
+ const tempToken = jwt.sign({ username, type: 'totp' }, CONFIG.jwtSecret, { expiresIn: CONFIG.tempTokenExpiresIn });
55
+
56
+ pendingTotpVerifications.set(tempToken, {
57
+ username,
58
+ sessionKey,
59
+ role,
60
+ expiresAt: Date.now() + CONFIG.emailCodeExpiresIn
61
+ });
62
+
63
+ return {
64
+ success: true,
65
+ tempToken,
66
+ needTotpCode: true,
67
+ needTotpSetup: false,
68
+ needEmailCode: false
69
+ };
70
+ }
71
+
72
+ // Check if TOTP setup is required
73
+ if (isTotpEnabled() && !user.totpSecret) {
74
+ const tempSecret = generateTotpSecret();
75
+ const setupToken = jwt.sign({ username, type: 'totp-setup' }, CONFIG.jwtSecret, { expiresIn: '15m' });
76
+
77
+ pendingTotpSetup.set(setupToken, {
78
+ username,
79
+ secret: tempSecret,
80
+ sessionKey,
81
+ role,
82
+ expiresAt: Date.now() + 15 * 60 * 1000
83
+ });
84
+
85
+ const qrCode = await generateTotpQRCode(username, tempSecret);
86
+
87
+ return {
88
+ success: true,
89
+ setupToken,
90
+ needTotpSetup: true,
91
+ totpSecret: tempSecret,
92
+ qrCode,
93
+ needTotpCode: false,
94
+ needEmailCode: false
95
+ };
96
+ }
97
+
98
+ // No TOTP - proceed to email verification or complete login
99
+ if (!isEmailConfigured()) {
100
+ return completeLogin(username, sessionKey, role);
101
+ }
102
+
103
+ // Need email verification
104
+ const code = generateVerificationCode();
105
+ const tempToken = jwt.sign({ username, type: 'temp' }, CONFIG.jwtSecret, { expiresIn: CONFIG.tempTokenExpiresIn });
106
+
107
+ pendingVerifications.set(tempToken, {
108
+ username,
109
+ code,
110
+ email: user.email,
111
+ sessionKey,
112
+ role,
113
+ expiresAt: Date.now() + CONFIG.emailCodeExpiresIn
114
+ });
115
+
116
+ try {
117
+ await sendVerificationCode(user.email, code, username);
118
+ } catch (err) {
119
+ console.error('Failed to send verification email:', err.message);
120
+ pendingVerifications.delete(tempToken);
121
+ return { success: false, error: 'Failed to send verification email' };
122
+ }
123
+
124
+ return {
125
+ success: true,
126
+ tempToken,
127
+ needTotpCode: false,
128
+ needTotpSetup: false,
129
+ needEmailCode: true,
130
+ emailHint: maskEmail(user.email)
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Verify email code (Step 2)
136
+ */
137
+ export function loginStep2(tempToken, code) {
138
+ const pending = pendingVerifications.get(tempToken);
139
+
140
+ if (!pending) {
141
+ return { success: false, error: 'Invalid or expired verification token' };
142
+ }
143
+
144
+ if (Date.now() > pending.expiresAt) {
145
+ pendingVerifications.delete(tempToken);
146
+ return { success: false, error: 'Verification code has expired' };
147
+ }
148
+
149
+ if (pending.code !== code) {
150
+ return { success: false, error: 'Invalid verification code' };
151
+ }
152
+
153
+ pendingVerifications.delete(tempToken);
154
+
155
+ return completeLogin(pending.username, pending.sessionKey, pending.role);
156
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Shared OAuth start/callback flow used by all providers.
3
+ *
4
+ * Responsibilities:
5
+ * - Generate a CSRF-safe `state` token, remember `intent` (login | bind) and
6
+ * the binding user (if intent='bind') for the callback to consume.
7
+ * - On callback, exchange code → identity, then either log the user in or
8
+ * attach a new user_identities row, enforcing the conflict policy.
9
+ *
10
+ * The state store is in-memory and short-lived (10 minute TTL). It does not
11
+ * survive a server restart — that's fine: a stale OAuth attempt would just
12
+ * fail validation and the user would retry.
13
+ */
14
+ import { randomBytes } from 'crypto';
15
+ import { CONFIG } from '../config.js';
16
+ import { userDb, identityDb } from '../database.js';
17
+ import { generateSessionKey } from '../encryption.js';
18
+ import { completeLogin } from './login.js';
19
+ import { getProvider } from './providers/types.js';
20
+
21
+ const STATE_TTL_MS = 10 * 60 * 1000;
22
+ const _stateStore = new Map(); // state → { provider, intent, userId, createdAt, mode }
23
+
24
+ // QR-mode pending-result store. When a QR-flow callback completes, we don't
25
+ // redirect the (mobile) browser into the SPA — instead we park the result
26
+ // here keyed by `state`, and the PC frontend polls /api/auth/sso/poll/:state
27
+ // to retrieve it. Entries are auto-GC'd after the same TTL.
28
+ const _pendingResults = new Map(); // state → { kind, ...result, createdAt }
29
+
30
+ function _gcStates() {
31
+ const now = Date.now();
32
+ for (const [k, v] of _stateStore.entries()) {
33
+ if (now - v.createdAt > STATE_TTL_MS) _stateStore.delete(k);
34
+ }
35
+ for (const [k, v] of _pendingResults.entries()) {
36
+ if (now - v.createdAt > STATE_TTL_MS) _pendingResults.delete(k);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Allocate a state token. `intent` is either 'login' (logged-out user) or
42
+ * 'bind' (logged-in user attaching another identity). For 'bind', `userId`
43
+ * MUST be supplied so the callback knows which internal account to attach to.
44
+ *
45
+ * `mode` defaults to 'redirect' (the user's browser is the one going to the
46
+ * provider). Set it to 'qr' for the QR-scan flow where the callback fires on
47
+ * a different device (the user's phone) and the original PC frontend polls
48
+ * for completion.
49
+ */
50
+ export function createState({ provider, intent = 'login', userId = null, mode = 'redirect' }) {
51
+ _gcStates();
52
+ const state = randomBytes(24).toString('hex');
53
+ _stateStore.set(state, { provider, intent, userId, mode, createdAt: Date.now() });
54
+ return state;
55
+ }
56
+
57
+ /**
58
+ * Consume a state token, returning the stored metadata or null if invalid.
59
+ * One-shot: a valid state is removed once consumed.
60
+ */
61
+ export function consumeState(state, expectedProvider) {
62
+ if (!state) return null;
63
+ const entry = _stateStore.get(state);
64
+ if (!entry) return null;
65
+ _stateStore.delete(state);
66
+ if (Date.now() - entry.createdAt > STATE_TTL_MS) return null;
67
+ if (entry.provider !== expectedProvider) return null;
68
+ return entry;
69
+ }
70
+
71
+ /**
72
+ * Build the provider's authorize redirect URL.
73
+ * Throws if the provider is unknown or disabled.
74
+ */
75
+ export function buildAuthorizeUrl({ provider, intent = 'login', userId = null, mode = 'redirect' }) {
76
+ const impl = getProvider(provider);
77
+ if (!impl) throw new Error(`Unknown provider: ${provider}`);
78
+ if (!impl.isEnabled()) throw new Error(`Provider not enabled: ${provider}`);
79
+ const state = createState({ provider, intent, userId, mode });
80
+ const url = impl.getAuthorizeUrl(state, intent);
81
+ return { url, state };
82
+ }
83
+
84
+ /**
85
+ * Park a completed QR-flow result so the PC frontend can poll for it.
86
+ */
87
+ export function storePendingResult(state, result) {
88
+ _pendingResults.set(state, { ...result, createdAt: Date.now() });
89
+ }
90
+
91
+ /**
92
+ * Read (and delete on success) a parked QR-flow result.
93
+ * Returns null if not found / expired.
94
+ */
95
+ export function consumePendingResult(state) {
96
+ _gcStates();
97
+ const r = _pendingResults.get(state);
98
+ if (!r) return null;
99
+ // Only one-shot for terminal kinds; pending shouldn't reach here.
100
+ _pendingResults.delete(state);
101
+ return r;
102
+ }
103
+
104
+ /**
105
+ * Inspect whether a state was issued in QR mode without consuming it.
106
+ * Used by the callback handler to decide whether to redirect or park-and-stop.
107
+ */
108
+ export function peekStateMode(state) {
109
+ const e = _stateStore.get(state);
110
+ return e ? e.mode : null;
111
+ }
112
+
113
+ /**
114
+ * Sanitize an arbitrary string into a username candidate.
115
+ *
116
+ * We allow Unicode letters/numbers (so a Chinese nickname like "张三" comes
117
+ * through intact instead of being collapsed to "__"), plus underscore and
118
+ * hyphen. Anything else (whitespace, emoji, punctuation) is replaced with
119
+ * underscore. Falls back to 'sso_user' if nothing usable remains.
120
+ */
121
+ function sanitizeUsername(raw) {
122
+ const cleaned = String(raw || '').replace(/[^\p{L}\p{N}_-]/gu, '_').slice(0, 32);
123
+ // Strip leading/trailing underscores so we don't end up with "_张三_".
124
+ const trimmed = cleaned.replace(/^_+|_+$/g, '');
125
+ return trimmed || 'sso_user';
126
+ }
127
+
128
+ /**
129
+ * Pick a unique username close to `base`, appending _1, _2, ... if needed.
130
+ */
131
+ function uniqueUsername(base) {
132
+ let candidate = base;
133
+ let suffix = 1;
134
+ while (userDb.getByUsername(candidate)) {
135
+ candidate = `${base}_${suffix++}`;
136
+ if (suffix > 1000) throw new Error('Could not allocate username');
137
+ }
138
+ return candidate;
139
+ }
140
+
141
+ /**
142
+ * Pick the per-provider config for autoCreateUser/defaultRole. Microsoft
143
+ * reuses CONFIG.aad; the rest live under CONFIG.sso[provider].
144
+ */
145
+ function providerPolicy(provider) {
146
+ if (provider === 'microsoft') {
147
+ return {
148
+ autoCreateUser: CONFIG.aad?.autoCreateUser !== false,
149
+ defaultRole: CONFIG.aad?.defaultRole || 'pro'
150
+ };
151
+ }
152
+ const c = CONFIG.sso?.[provider] || {};
153
+ return {
154
+ autoCreateUser: c.autoCreateUser !== false,
155
+ defaultRole: c.defaultRole || 'pro'
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Process a callback: validate state, exchange code, then either log in or
161
+ * bind. Returns one of:
162
+ *
163
+ * { kind: 'login', token, sessionKey, role } — issue session JWT
164
+ * { kind: 'bind', provider } — binding succeeded for current user
165
+ * { kind: 'error', status, error } — surface to the user
166
+ */
167
+ export async function handleCallback({ provider, code, state }) {
168
+ const impl = getProvider(provider);
169
+ if (!impl) return { kind: 'error', status: 400, error: 'Unknown provider' };
170
+ if (!impl.isEnabled()) return { kind: 'error', status: 400, error: 'Provider not enabled' };
171
+
172
+ const stateEntry = consumeState(state, provider);
173
+ if (!stateEntry) return { kind: 'error', status: 400, error: 'Invalid or expired state' };
174
+
175
+ let identity;
176
+ try {
177
+ identity = await impl.exchangeCode(code, state);
178
+ } catch (err) {
179
+ console.error(`[SSO ${provider}] exchangeCode failed:`, err.message);
180
+ return { kind: 'error', status: 400, error: 'Failed to verify provider response' };
181
+ }
182
+ if (!identity || !identity.subject) {
183
+ return { kind: 'error', status: 400, error: 'Provider response missing subject' };
184
+ }
185
+
186
+ const existing = identityDb.findBySubject(provider, identity.subject);
187
+
188
+ if (stateEntry.intent === 'bind') {
189
+ // Logged-in user binding a new identity.
190
+ if (!stateEntry.userId) {
191
+ return { kind: 'error', status: 401, error: 'Bind requires authenticated user' };
192
+ }
193
+ if (existing && existing.user_id !== stateEntry.userId) {
194
+ // Already linked to someone else → reject (per agreed conflict policy).
195
+ return { kind: 'error', status: 409, error: 'This account is already linked to another user' };
196
+ }
197
+ if (!existing) {
198
+ const created = identityDb.create({
199
+ userId: stateEntry.userId,
200
+ provider,
201
+ subject: identity.subject,
202
+ email: identity.email,
203
+ displayName: identity.displayName
204
+ });
205
+ if (!created) {
206
+ return { kind: 'error', status: 409, error: 'This account is already linked to another user' };
207
+ }
208
+ } else {
209
+ identityDb.touchLogin(existing.id);
210
+ }
211
+ return { kind: 'bind', provider };
212
+ }
213
+
214
+ // intent='login' (or anything else — default to login).
215
+ if (existing) {
216
+ const user = userDb.get(existing.user_id);
217
+ if (!user) {
218
+ return { kind: 'error', status: 500, error: 'Bound user no longer exists' };
219
+ }
220
+ // Backfill display_name on subsequent logins if it was never set away
221
+ // from the auto-generated username (e.g. early logins before this code
222
+ // existed). Don't clobber a name the user has manually changed.
223
+ if (identity.displayName && user.display_name === user.username) {
224
+ userDb.updateDisplayName(user.id, identity.displayName);
225
+ }
226
+ identityDb.touchLogin(existing.id);
227
+ if (user.id) userDb.updateLogin(user.id);
228
+ const sessionKey = generateSessionKey();
229
+ const role = user.role === 'admin' ? 'admin' : 'pro';
230
+ const result = completeLogin(user.username, sessionKey, role);
231
+ return { kind: 'login', ...result };
232
+ }
233
+
234
+ // No identity row yet — auto-create user (if policy allows) and link.
235
+ const policy = providerPolicy(provider);
236
+ if (!policy.autoCreateUser) {
237
+ return { kind: 'error', status: 403, error: 'No matching account. Contact your admin.' };
238
+ }
239
+
240
+ const base = sanitizeUsername(
241
+ identity.email ? identity.email.split('@')[0] : (identity.displayName || provider + '_user')
242
+ );
243
+ const username = uniqueUsername(base);
244
+ const newUser = userDb.createFromAad(
245
+ username,
246
+ identity.email,
247
+ /* aadOid */ null,
248
+ policy.defaultRole,
249
+ identity.displayName || null
250
+ );
251
+
252
+ // Link the identity. createFromAad reuses null aad_oid for non-microsoft, and
253
+ // we always insert into user_identities for the unified model.
254
+ identityDb.create({
255
+ userId: newUser.id,
256
+ provider,
257
+ subject: identity.subject,
258
+ email: identity.email,
259
+ displayName: identity.displayName
260
+ });
261
+
262
+ if (provider === 'microsoft' && identity.subject) {
263
+ // Keep legacy users.aad_oid in sync for backwards compat.
264
+ userDb.updateAadOid(newUser.id, identity.subject);
265
+ }
266
+
267
+ userDb.updateLogin(newUser.id);
268
+ const sessionKey = generateSessionKey();
269
+ const role = newUser.role === 'admin' ? 'admin' : 'pro';
270
+ const result = completeLogin(newUser.username, sessionKey, role);
271
+ return { kind: 'login', ...result };
272
+ }
273
+
274
+ // Test-only: clear the in-memory state store between cases.
275
+ export function _resetStateStore() {
276
+ _stateStore.clear();
277
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Email-based password reset.
3
+ *
4
+ * Two-step flow:
5
+ * 1) POST /api/auth/password-reset/request { email }
6
+ * → server looks up user by email; if found, mints a 6-digit code,
7
+ * stashes it in memory keyed by a random opaque resetToken, and emails
8
+ * the code. Always responds 200 even if email isn't on file (prevents
9
+ * account enumeration). 15-minute TTL.
10
+ * 2) POST /api/auth/password-reset/verify { resetToken, code, newPassword }
11
+ * → validates code, replaces password_hash, returns success.
12
+ *
13
+ * Uses the same SMTP infra as login email verification.
14
+ */
15
+ import { randomBytes, randomInt, timingSafeEqual } from 'crypto';
16
+ import { CONFIG, isEmailConfigured } from '../config.js';
17
+ import { userDb } from '../database.js';
18
+ import { sendVerificationCode } from '../email.js';
19
+ import { hashPassword } from './utils.js';
20
+
21
+ const RESET_TTL_MS = 15 * 60 * 1000;
22
+ const MAX_ATTEMPTS = 5;
23
+ const _pending = new Map(); // resetToken -> { userId, code, expiresAt, attempts }
24
+
25
+ function _gc() {
26
+ const now = Date.now();
27
+ for (const [t, e] of _pending.entries()) if (e.expiresAt < now) _pending.delete(t);
28
+ }
29
+
30
+ function _generateCode() {
31
+ // CSPRNG. Math.random() is unsuitable for security tokens — predictable
32
+ // across attackers who can sample the server's PRNG state.
33
+ return String(randomInt(0, 1_000_000)).padStart(6, '0');
34
+ }
35
+
36
+ function _constantTimeCodeEqual(a, b) {
37
+ const ab = Buffer.from(String(a));
38
+ const bb = Buffer.from(String(b));
39
+ if (ab.length !== bb.length) return false;
40
+ return timingSafeEqual(ab, bb);
41
+ }
42
+
43
+ /**
44
+ * Step 1: request a reset code.
45
+ * Always returns { success: true } regardless of whether the email exists,
46
+ * so an attacker can't enumerate accounts. Returns the resetToken on success
47
+ * (or always — same shape — for the user who actually owns the email).
48
+ */
49
+ export async function requestPasswordReset(email) {
50
+ _gc();
51
+ if (!isEmailConfigured()) {
52
+ return { success: false, error: 'Email is not configured on this server' };
53
+ }
54
+ if (!email || typeof email !== 'string') {
55
+ return { success: false, error: 'Email is required' };
56
+ }
57
+ const normalized = email.trim().toLowerCase();
58
+
59
+ // Look up user by email. We scan getAll() because there's no index — this is
60
+ // an admin-rare action so the cost is acceptable.
61
+ const all = userDb.getAll();
62
+ const user = all.find(u => (u.email || '').toLowerCase() === normalized);
63
+
64
+ if (!user) {
65
+ // Pretend success to prevent enumeration. No code sent, no token issued.
66
+ // The client gets a fake-looking token so the UI can still proceed to
67
+ // the "enter code" step — verification will simply fail.
68
+ //
69
+ // Sleep a small randomized window so the response time roughly matches
70
+ // the existing-email path (which awaits an SMTP round-trip). Without
71
+ // this, request latency is itself an enumeration oracle.
72
+ const fakeDelay = 200 + Math.floor(Math.random() * 400);
73
+ await new Promise(r => setTimeout(r, fakeDelay));
74
+ return { success: true, resetToken: 'fake_' + randomBytes(16).toString('hex') };
75
+ }
76
+
77
+ const code = _generateCode();
78
+ const resetToken = 'rst_' + randomBytes(24).toString('hex');
79
+ _pending.set(resetToken, {
80
+ userId: user.id,
81
+ code,
82
+ expiresAt: Date.now() + RESET_TTL_MS,
83
+ attempts: 0
84
+ });
85
+ try {
86
+ await sendVerificationCode(user.email, code, user.username);
87
+ } catch (err) {
88
+ console.error('[password-reset] sendVerificationCode failed:', err.message);
89
+ _pending.delete(resetToken);
90
+ return { success: false, error: 'Failed to send reset email' };
91
+ }
92
+ return { success: true, resetToken };
93
+ }
94
+
95
+ /**
96
+ * Step 2: verify code and set new password.
97
+ *
98
+ * Per-token attempt counter caps brute-force at MAX_ATTEMPTS regardless of
99
+ * IP rotation — the IP rate-limit alone is bypassable via residential
100
+ * proxies.
101
+ */
102
+ export async function verifyPasswordReset(resetToken, code, newPassword) {
103
+ _gc();
104
+ const entry = _pending.get(resetToken);
105
+ if (!entry) return { success: false, error: 'Invalid or expired reset token' };
106
+ if (Date.now() > entry.expiresAt) {
107
+ _pending.delete(resetToken);
108
+ return { success: false, error: 'Reset code has expired' };
109
+ }
110
+ if (entry.attempts >= MAX_ATTEMPTS) {
111
+ _pending.delete(resetToken);
112
+ return { success: false, error: 'Too many attempts; request a new reset code' };
113
+ }
114
+ if (!_constantTimeCodeEqual(entry.code, code || '')) {
115
+ entry.attempts += 1;
116
+ if (entry.attempts >= MAX_ATTEMPTS) _pending.delete(resetToken);
117
+ return { success: false, error: 'Invalid reset code' };
118
+ }
119
+ if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 6) {
120
+ return { success: false, error: 'New password must be at least 6 characters' };
121
+ }
122
+ const user = userDb.get(entry.userId);
123
+ if (!user) {
124
+ _pending.delete(resetToken);
125
+ return { success: false, error: 'User no longer exists' };
126
+ }
127
+ const hash = await hashPassword(newPassword);
128
+ userDb.updatePassword(user.id, hash);
129
+ _pending.delete(resetToken);
130
+ return { success: true };
131
+ }
132
+
133
+ // Test-only
134
+ export function _resetPasswordResetStore() { _pending.clear(); }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Alipay 网页授权 — RSA2-signed OAuth-like flow.
3
+ *
4
+ * Authorize URL: https://openauth.alipay.com/oauth2/publicAppAuthorize.htm
5
+ * Gateway: https://openapi.alipay.com/gateway.do (alipay.system.oauth.token, alipay.user.info.share)
6
+ *
7
+ * Subject = `user_id` returned by alipay.user.info.share.
8
+ *
9
+ * Alipay's "OAuth" is non-standard — every API call is signed with the merchant
10
+ * RSA private key per Alipay's signing rules. Node's built-in `crypto` module
11
+ * covers RSA2 (SHA256withRSA), so no extra dependency is needed.
12
+ */
13
+ import { createSign } from 'crypto';
14
+ import { readFileSync } from 'fs';
15
+ import { CONFIG } from '../../config.js';
16
+
17
+ export const name = 'alipay';
18
+
19
+ const GATEWAY = 'https://openapi.alipay.com/gateway.do';
20
+
21
+ /**
22
+ * Resolve the configured Alipay private key. Accepts either a PEM string
23
+ * (containing "BEGIN") or a filesystem path (starts with "/" or "file://").
24
+ * File mode keeps the secret out of env vars and process listings.
25
+ */
26
+ function resolvePrivateKey() {
27
+ const raw = CONFIG.sso.alipay.privateKey || '';
28
+ if (raw.includes('BEGIN')) return raw;
29
+ if (raw.startsWith('file://')) return readFileSync(raw.slice(7), 'utf8');
30
+ if (raw.startsWith('/')) return readFileSync(raw, 'utf8');
31
+ // Bare base64 — wrap as PKCS#1 (legacy fallback for compactness in env vars).
32
+ return `-----BEGIN RSA PRIVATE KEY-----\n${raw}\n-----END RSA PRIVATE KEY-----`;
33
+ }
34
+
35
+ export function isEnabled() {
36
+ const c = CONFIG.sso?.alipay;
37
+ return !!(c?.enabled && c.appId && c.privateKey && c.callbackUrl);
38
+ }
39
+
40
+ export function getAuthorizeUrl(state /* , intent */) {
41
+ const c = CONFIG.sso.alipay;
42
+ const url = new URL('https://openauth.alipay.com/oauth2/publicAppAuthorize.htm');
43
+ url.searchParams.set('app_id', c.appId);
44
+ url.searchParams.set('scope', 'auth_user');
45
+ url.searchParams.set('redirect_uri', c.callbackUrl);
46
+ url.searchParams.set('state', state);
47
+ return url.toString();
48
+ }
49
+
50
+ /**
51
+ * Build an Alipay request payload, sign it with RSA2, and POST to the gateway.
52
+ */
53
+ async function alipayCall(method, bizContent) {
54
+ const c = CONFIG.sso.alipay;
55
+ const params = {
56
+ app_id: c.appId,
57
+ method,
58
+ format: 'JSON',
59
+ charset: 'utf-8',
60
+ sign_type: 'RSA2',
61
+ timestamp: new Date().toISOString().slice(0, 19).replace('T', ' '),
62
+ version: '1.0',
63
+ ...bizContent
64
+ };
65
+
66
+ // Alipay signing rule: sort keys alphabetically, build k=v&k=v string, exclude
67
+ // `sign` itself and any empty values.
68
+ const signStr = Object.keys(params)
69
+ .filter(k => k !== 'sign' && params[k] !== undefined && params[k] !== null && params[k] !== '')
70
+ .sort()
71
+ .map(k => `${k}=${params[k]}`)
72
+ .join('&');
73
+
74
+ const signer = createSign('RSA-SHA256');
75
+ signer.update(signStr, 'utf8');
76
+ const sign = signer.sign(resolvePrivateKey(), 'base64');
77
+
78
+ const body = new URLSearchParams({ ...params, sign });
79
+ const res = await fetch(GATEWAY, {
80
+ method: 'POST',
81
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8' },
82
+ body
83
+ });
84
+ if (!res.ok) throw new Error(`Alipay ${method} HTTP ${res.status}`);
85
+ return res.json();
86
+ }
87
+
88
+ export async function exchangeCode(code /* , state */) {
89
+ // Step 1: code -> access_token
90
+ const tokRes = await alipayCall('alipay.system.oauth.token', {
91
+ grant_type: 'authorization_code',
92
+ code
93
+ });
94
+ const tokInner = tokRes.alipay_system_oauth_token_response;
95
+ if (!tokInner) throw new Error(`Alipay token exchange unexpected response: ${JSON.stringify(tokRes)}`);
96
+ if (tokInner.code && tokInner.code !== '10000') {
97
+ throw new Error(`Alipay token error ${tokInner.code}: ${tokInner.msg || ''}`);
98
+ }
99
+ if (!tokInner.access_token) throw new Error('Alipay token response missing access_token');
100
+
101
+ // Step 2: access_token -> profile
102
+ const userRes = await alipayCall('alipay.user.info.share', {
103
+ auth_token: tokInner.access_token
104
+ });
105
+ const userInner = userRes.alipay_user_info_share_response;
106
+ if (!userInner) throw new Error('Alipay user info missing response wrapper');
107
+ if (userInner.code && userInner.code !== '10000') {
108
+ throw new Error(`Alipay user info error ${userInner.code}: ${userInner.msg || ''}`);
109
+ }
110
+
111
+ // Prefer open_id (per-app stable ID under Alipay's privacy/openid mode, on by
112
+ // default for apps created since 2023). Fall back to user_id for legacy apps
113
+ // that haven't migrated. Either is a stable per-user subject for binding.
114
+ const subject = userInner.open_id || tokInner.open_id || userInner.user_id || tokInner.user_id;
115
+ if (!subject) throw new Error('Alipay profile missing open_id/user_id');
116
+
117
+ return {
118
+ subject,
119
+ email: null, // Alipay does not return email by default
120
+ displayName: userInner.nick_name || userInner.user_name || null,
121
+ raw: userInner
122
+ };
123
+ }
124
+
125
+ export default { name, isEnabled, getAuthorizeUrl, exchangeCode };