@yeaft/webchat-agent 1.0.188 → 1.0.190
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/cli.js +12 -0
- package/index.js +10 -2
- package/local-run.js +218 -0
- package/local-runtime/server/.env.example +54 -0
- package/local-runtime/server/api.js +111 -0
- package/local-runtime/server/auth/aad.js +235 -0
- package/local-runtime/server/auth/login.js +156 -0
- package/local-runtime/server/auth/oauth-flow.js +277 -0
- package/local-runtime/server/auth/password-reset.js +134 -0
- package/local-runtime/server/auth/providers/alipay.js +125 -0
- package/local-runtime/server/auth/providers/github.js +82 -0
- package/local-runtime/server/auth/providers/google.js +60 -0
- package/local-runtime/server/auth/providers/microsoft.js +71 -0
- package/local-runtime/server/auth/providers/types.js +57 -0
- package/local-runtime/server/auth/providers/wechat.js +68 -0
- package/local-runtime/server/auth/register.js +91 -0
- package/local-runtime/server/auth/session-store.js +57 -0
- package/local-runtime/server/auth/token.js +85 -0
- package/local-runtime/server/auth/totp-auth.js +133 -0
- package/local-runtime/server/auth/utils.js +42 -0
- package/local-runtime/server/auth.js +8 -0
- package/local-runtime/server/check-node-version.js +74 -0
- package/local-runtime/server/config.js +298 -0
- package/local-runtime/server/context.js +140 -0
- package/local-runtime/server/create-user.js +59 -0
- package/local-runtime/server/database.js +12 -0
- package/local-runtime/server/db/connection.js +963 -0
- package/local-runtime/server/db/expert-db.js +171 -0
- package/local-runtime/server/db/identity-db.js +92 -0
- package/local-runtime/server/db/invitation-db.js +38 -0
- package/local-runtime/server/db/message-db.js +257 -0
- package/local-runtime/server/db/session-db.js +118 -0
- package/local-runtime/server/db/user-db.js +165 -0
- package/local-runtime/server/db/user-stats-db.js +185 -0
- package/local-runtime/server/db/yeaft-session-db.js +258 -0
- package/local-runtime/server/email.js +96 -0
- package/local-runtime/server/encryption.js +105 -0
- package/local-runtime/server/handlers/agent-conversation.js +347 -0
- package/local-runtime/server/handlers/agent-file-terminal.js +99 -0
- package/local-runtime/server/handlers/agent-output.js +854 -0
- package/local-runtime/server/handlers/agent-sync.js +399 -0
- package/local-runtime/server/handlers/agent-work-center.js +27 -0
- package/local-runtime/server/handlers/client-conversation.js +1182 -0
- package/local-runtime/server/handlers/client-misc.js +254 -0
- package/local-runtime/server/handlers/client-work-center.js +269 -0
- package/local-runtime/server/handlers/client-workbench.js +146 -0
- package/local-runtime/server/handlers/session-pin-router.js +61 -0
- package/local-runtime/server/heartbeat-policy.js +46 -0
- package/local-runtime/server/index.js +275 -0
- package/local-runtime/server/package.json +55 -0
- package/local-runtime/server/perf-trace.js +154 -0
- package/local-runtime/server/proxy.js +273 -0
- package/local-runtime/server/routes/admin-routes.js +207 -0
- package/local-runtime/server/routes/auth-routes.js +322 -0
- package/local-runtime/server/routes/expert-routes.js +117 -0
- package/local-runtime/server/routes/invitation-routes.js +60 -0
- package/local-runtime/server/routes/session-routes.js +112 -0
- package/local-runtime/server/routes/upload-routes.js +109 -0
- package/local-runtime/server/routes/user-routes.js +241 -0
- package/local-runtime/server/totp.js +74 -0
- package/local-runtime/server/work-item-attachment-policy.js +56 -0
- package/local-runtime/server/ws-agent.js +319 -0
- package/local-runtime/server/ws-client.js +214 -0
- package/local-runtime/server/ws-utils.js +394 -0
- package/local-runtime/server/yeaft-asset-store.js +339 -0
- package/local-runtime/version.json +1 -0
- package/local-runtime/web/app.bundle.js +7673 -0
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/assets/avatars/README.md +34 -0
- package/local-runtime/web/assets/avatars/ada.svg +1 -0
- package/local-runtime/web/assets/avatars/alan.svg +1 -0
- package/local-runtime/web/assets/avatars/alice.svg +1 -0
- package/local-runtime/web/assets/avatars/anders.svg +1 -0
- package/local-runtime/web/assets/avatars/bezos.svg +1 -0
- package/local-runtime/web/assets/avatars/borges.svg +1 -0
- package/local-runtime/web/assets/avatars/buffett.svg +1 -0
- package/local-runtime/web/assets/avatars/clausewitz.svg +1 -0
- package/local-runtime/web/assets/avatars/dalio.svg +1 -0
- package/local-runtime/web/assets/avatars/dieter.svg +1 -0
- package/local-runtime/web/assets/avatars/drucker.svg +1 -0
- package/local-runtime/web/assets/avatars/einstein.svg +1 -0
- package/local-runtime/web/assets/avatars/grace.svg +1 -0
- package/local-runtime/web/assets/avatars/harari.svg +1 -0
- package/local-runtime/web/assets/avatars/jung.svg +1 -0
- package/local-runtime/web/assets/avatars/kahneman.svg +1 -0
- package/local-runtime/web/assets/avatars/ken.svg +1 -0
- package/local-runtime/web/assets/avatars/kongzi.svg +1 -0
- package/local-runtime/web/assets/avatars/kubrick.svg +1 -0
- package/local-runtime/web/assets/avatars/linus.svg +1 -0
- package/local-runtime/web/assets/avatars/luxun.svg +1 -0
- package/local-runtime/web/assets/avatars/margaret.svg +1 -0
- package/local-runtime/web/assets/avatars/martin.svg +1 -0
- package/local-runtime/web/assets/avatars/miyazaki.svg +1 -0
- package/local-runtime/web/assets/avatars/munger.svg +1 -0
- package/local-runtime/web/assets/avatars/nietzsche.svg +1 -0
- package/local-runtime/web/assets/avatars/norman.svg +1 -0
- package/local-runtime/web/assets/avatars/shannon.svg +1 -0
- package/local-runtime/web/assets/avatars/simaqian.svg +1 -0
- package/local-runtime/web/assets/avatars/socrates.svg +1 -0
- package/local-runtime/web/assets/avatars/steve.svg +1 -0
- package/local-runtime/web/assets/avatars/sudongpo.svg +1 -0
- package/local-runtime/web/assets/avatars/sunzi.svg +1 -0
- package/local-runtime/web/docx-preview.min.js +2 -0
- package/local-runtime/web/docx-preview.min.js.gz +0 -0
- package/local-runtime/web/html-to-image.min.js +2 -0
- package/local-runtime/web/html-to-image.min.js.gz +0 -0
- package/local-runtime/web/index.html +21 -0
- package/local-runtime/web/jszip.min.js +13 -0
- package/local-runtime/web/jszip.min.js.gz +0 -0
- package/local-runtime/web/mermaid.min.js +2843 -0
- package/local-runtime/web/mermaid.min.js.gz +0 -0
- package/local-runtime/web/msal-browser.min.js +69 -0
- package/local-runtime/web/msal-browser.min.js.gz +0 -0
- package/local-runtime/web/style.bundle.css +10 -0
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/local-runtime/web/vendor.bundle.js +1522 -0
- package/local-runtime/web/vendor.bundle.js.gz +0 -0
- package/local-runtime/web/xlsx.min.js +24 -0
- package/local-runtime/web/xlsx.min.js.gz +0 -0
- package/package.json +13 -3
- 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 };
|