casdoor 1.6.0 → 1.7.0
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 +6 -0
- package/manifest.json +6 -2
- package/package.json +1 -1
- package/src/background.js +44 -0
- package/src/content-script.js +297 -16
- package/src/offscreen.html +5 -0
- package/src/offscreen.js +125 -0
- package/vendor/README.md +6 -0
- package/vendor/ddddocr/LICENSE +8 -0
- package/vendor/ddddocr/charset.json +1 -0
- package/vendor/ddddocr/common_old.onnx +0 -0
- package/vendor/onnxruntime-web/LICENSE +21 -0
- package/vendor/onnxruntime-web/ort-wasm-simd-threaded.mjs +70 -0
- package/vendor/onnxruntime-web/ort-wasm-simd-threaded.wasm +0 -0
- package/vendor/onnxruntime-web/ort.wasm.min.js +8 -0
package/README.md
CHANGED
|
@@ -14,6 +14,12 @@ The Casdoor browser extension signs users into the systems that have no single s
|
|
|
14
14
|
|
|
15
15
|
Casdoor only hands an account out for a page of the system's own sign-in URLs, records every time it does, and can keep the passwords encrypted (the organization's "Encrypted fields", with `fieldEncryptionKey` set). Form autofill needs a Casdoor Enterprise edition license. On a Casdoor without it, the extension fills the managed accounts on the pages that match the application's "Signin URL", a regular expression, as its first versions did.
|
|
16
16
|
|
|
17
|
+
### Image captchas
|
|
18
|
+
|
|
19
|
+
When **Recognize image captchas** is on in the application, the extension also reads the picture of characters on the sign-in page and types it in. It reads it with the [ddddocr](https://github.com/sml2h3/ddddocr) model in [ONNX Runtime Web](https://github.com/microsoft/onnxruntime), in a hidden page of the extension on the user's own computer: the picture is never sent anywhere, and it works on an intranet without internet access. The captcha input and picture are found automatically, or set with CSS selectors; the characters are digits when the page says so (e.g. "请输入数字验证码"), else letters and digits, or as the admin sets.
|
|
20
|
+
|
|
21
|
+
When the sign-in page says the captcha was wrong, the extension tries a new one (it clicks the picture when the page doesn't change it), up to 3 times in all. When it says the username or password is wrong, it stops at once, so that the account doesn't get locked. A picture from another site can't be read by the extension, and then the user types the captcha. Sliders, picking pictures, SMS codes and third-party human checks (reCAPTCHA, GeeTest and the like) are not supported. Use it only on your own organization's systems.
|
|
22
|
+
|
|
17
23
|
## Usage
|
|
18
24
|
|
|
19
25
|
### Install the extension
|
package/manifest.json
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "Casdoor",
|
|
3
3
|
"description": "Signs you into the systems without single sign-on by filling their sign-in forms with your accounts kept in Casdoor.",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.1.0",
|
|
5
5
|
"manifest_version": 3,
|
|
6
6
|
"permissions": [
|
|
7
7
|
"storage",
|
|
8
|
-
"identity"
|
|
8
|
+
"identity",
|
|
9
|
+
"offscreen"
|
|
9
10
|
],
|
|
10
11
|
"host_permissions": [
|
|
11
12
|
"https://*/*",
|
|
12
13
|
"http://*/*"
|
|
13
14
|
],
|
|
15
|
+
"content_security_policy": {
|
|
16
|
+
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
|
17
|
+
},
|
|
14
18
|
"action": {
|
|
15
19
|
"default_popup": "/src/popup.html",
|
|
16
20
|
"default_icon": {
|
package/package.json
CHANGED
package/src/background.js
CHANGED
|
@@ -156,6 +156,10 @@ async function getFill(sender) {
|
|
|
156
156
|
usernameSelector: site.usernameSelector || "",
|
|
157
157
|
passwordSelector: site.passwordSelector || "",
|
|
158
158
|
submitSelector: site.submitSelector || "",
|
|
159
|
+
captcha: !!site.captcha,
|
|
160
|
+
captchaSelector: site.captchaSelector || "",
|
|
161
|
+
captchaImageSelector: site.captchaImageSelector || "",
|
|
162
|
+
captchaCharset: site.captchaCharset || "",
|
|
159
163
|
autoSubmit: !!site.autoSubmit && !recentlySubmitted,
|
|
160
164
|
urls: site.urls || [],
|
|
161
165
|
},
|
|
@@ -181,6 +185,10 @@ async function getAccount(message, sender) {
|
|
|
181
185
|
}
|
|
182
186
|
|
|
183
187
|
const query = new URLSearchParams({application: site.application, username: message.username || "", url: sender.url});
|
|
188
|
+
// so that the audit record says the captcha of this sign-in was read by the extension
|
|
189
|
+
if (site.captcha) {
|
|
190
|
+
query.set("captcha", "true");
|
|
191
|
+
}
|
|
184
192
|
const res = await callApi(`/api/get-form-fill-account?${query.toString()}`);
|
|
185
193
|
if (res.status !== "ok") {
|
|
186
194
|
return {error: res.msg};
|
|
@@ -191,6 +199,41 @@ async function getAccount(message, sender) {
|
|
|
191
199
|
return {username: res.data.username, password: res.data.password};
|
|
192
200
|
}
|
|
193
201
|
|
|
202
|
+
const OFFSCREEN_URL = "/src/offscreen.html";
|
|
203
|
+
let offscreenCreating = null;
|
|
204
|
+
|
|
205
|
+
async function ensureOffscreen() {
|
|
206
|
+
const contexts = await chrome.runtime.getContexts({contextTypes: ["OFFSCREEN_DOCUMENT"], documentUrls: [chrome.runtime.getURL(OFFSCREEN_URL)]});
|
|
207
|
+
if (contexts.length > 0) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (!offscreenCreating) {
|
|
211
|
+
offscreenCreating = chrome.offscreen.createDocument({
|
|
212
|
+
url: OFFSCREEN_URL,
|
|
213
|
+
reasons: ["WORKERS"],
|
|
214
|
+
justification: "Reads the image captcha of a sign-in page with a local model",
|
|
215
|
+
}).finally(() => {
|
|
216
|
+
offscreenCreating = null;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
await offscreenCreating;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// recognizeCaptcha reads the captcha image of a sign-in page that the admin turned captcha recognition on for
|
|
223
|
+
async function recognizeCaptcha(message, sender) {
|
|
224
|
+
const cache = await getSites();
|
|
225
|
+
const site = cache && findSite(cache, sender.url);
|
|
226
|
+
if (!site || !site.captcha || site.application !== message.application) {
|
|
227
|
+
return {error: "captcha recognition is off for this page"};
|
|
228
|
+
}
|
|
229
|
+
if (typeof message.image !== "string" || !message.image.startsWith("data:image/") || message.image.length > 2 * 1024 * 1024) {
|
|
230
|
+
return {error: "not a captcha image"};
|
|
231
|
+
}
|
|
232
|
+
await ensureOffscreen();
|
|
233
|
+
const res = await chrome.runtime.sendMessage({target: "offscreen", type: "recognizeCaptcha", image: message.image, charset: message.charset || ""});
|
|
234
|
+
return res || {error: "captcha recognition failed"};
|
|
235
|
+
}
|
|
236
|
+
|
|
194
237
|
function pendingKey(tabId) {
|
|
195
238
|
return `pending-${tabId}`;
|
|
196
239
|
}
|
|
@@ -333,6 +376,7 @@ async function getStatus() {
|
|
|
333
376
|
const handlers = {
|
|
334
377
|
getFill: (message, sender) => getFill(sender),
|
|
335
378
|
getAccount: (message, sender) => getAccount(message, sender),
|
|
379
|
+
recognizeCaptcha: (message, sender) => recognizeCaptcha(message, sender),
|
|
336
380
|
capture: (message, sender) => capture(message, sender),
|
|
337
381
|
getPending: (message, sender) => getPending(sender),
|
|
338
382
|
resolvePending: (message, sender) => resolvePending(message, sender),
|
package/src/content-script.js
CHANGED
|
@@ -33,6 +33,19 @@
|
|
|
33
33
|
skip: "不用了",
|
|
34
34
|
saved: "已保存到 Casdoor",
|
|
35
35
|
close: "关闭",
|
|
36
|
+
missingSelector: "管理员配置的{field}选择器“{selector}”在这个页面上没有找到,已改用自动识别",
|
|
37
|
+
usernameField: "用户名输入框",
|
|
38
|
+
passwordField: "密码输入框",
|
|
39
|
+
submitField: "登录按钮",
|
|
40
|
+
captchaField: "验证码输入框",
|
|
41
|
+
captchaImageField: "验证码图片",
|
|
42
|
+
captchaNoInput: "没有找到验证码输入框",
|
|
43
|
+
captchaNoImage: "没有找到验证码图片",
|
|
44
|
+
captchaCrossOrigin: "验证码图片来自其他网站,插件读不到",
|
|
45
|
+
captchaUnread: "没有认出验证码",
|
|
46
|
+
captchaManual: "验证码请手动输入({error})",
|
|
47
|
+
captchaGaveUp: "验证码自动识别了 {n} 次都没有通过,请手动输入后登录",
|
|
48
|
+
signInRefused: "登录没有成功,可能是账号或密码不对,请检查托管账户",
|
|
36
49
|
}
|
|
37
50
|
: {
|
|
38
51
|
filled: "Casdoor filled in the account {user} of {app}",
|
|
@@ -44,6 +57,19 @@
|
|
|
44
57
|
skip: "No thanks",
|
|
45
58
|
saved: "Saved to Casdoor",
|
|
46
59
|
close: "Close",
|
|
60
|
+
missingSelector: "The {field} selector \"{selector}\" set by the admin matches nothing on this page, so it was found automatically",
|
|
61
|
+
usernameField: "username input",
|
|
62
|
+
passwordField: "password input",
|
|
63
|
+
submitField: "sign-in button",
|
|
64
|
+
captchaField: "captcha input",
|
|
65
|
+
captchaImageField: "captcha image",
|
|
66
|
+
captchaNoInput: "no captcha input was found",
|
|
67
|
+
captchaNoImage: "no captcha image was found",
|
|
68
|
+
captchaCrossOrigin: "the captcha image comes from another site, the extension can't read it",
|
|
69
|
+
captchaUnread: "the captcha could not be read",
|
|
70
|
+
captchaManual: "Please type the captcha ({error})",
|
|
71
|
+
captchaGaveUp: "The captcha was read {n} times without success, please type it and sign in",
|
|
72
|
+
signInRefused: "The sign-in failed, the username or the password may be wrong. Please check the managed account",
|
|
47
73
|
};
|
|
48
74
|
|
|
49
75
|
function format(text, values) {
|
|
@@ -78,19 +104,36 @@
|
|
|
78
104
|
return element.getClientRects().length > 0;
|
|
79
105
|
}
|
|
80
106
|
|
|
107
|
+
// a class attribute pasted as it is, like "el-button login-btn el-button--primary", is taken as those classes
|
|
108
|
+
function selectorCandidates(selector) {
|
|
109
|
+
const value = selector.trim();
|
|
110
|
+
const candidates = [value];
|
|
111
|
+
if (/^[\w-]+(\s+[\w-]+)*$/.test(value)) {
|
|
112
|
+
candidates.push("." + value.split(/\s+/).join("."));
|
|
113
|
+
}
|
|
114
|
+
return candidates;
|
|
115
|
+
}
|
|
116
|
+
|
|
81
117
|
function query(selector) {
|
|
82
|
-
if (!selector) {
|
|
118
|
+
if (!selector || !selector.trim()) {
|
|
83
119
|
return null;
|
|
84
120
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
121
|
+
for (const candidate of selectorCandidates(selector)) {
|
|
122
|
+
try {
|
|
123
|
+
const element = document.querySelector(candidate);
|
|
124
|
+
if (element) {
|
|
125
|
+
return element;
|
|
126
|
+
}
|
|
127
|
+
} catch (error) {
|
|
128
|
+
// not a valid selector, try the next way of reading it
|
|
129
|
+
}
|
|
89
130
|
}
|
|
131
|
+
return null;
|
|
90
132
|
}
|
|
91
133
|
|
|
92
134
|
const usernameHints = /user|login|account|email|mail|phone|mobile|tel|name|uid|uname|loginid|工号|账号|帳號|用户|用戶|手机|邮箱/i;
|
|
93
|
-
|
|
135
|
+
// "登录", "登录系统", "立即登录", "Sign in", ...
|
|
136
|
+
const submitHints = /^\s*(?:(?:立即|马上|安全|用户|账号)?\s*(?:登\s*录|登\s*入|登\s*陆)\s*(?:系统|平台)?|进入系统|log\s*in|log\s*on|sign\s*in|login|logon|signin|submit|确\s*定|提\s*交)\s*$/i;
|
|
94
137
|
|
|
95
138
|
function findPassword(site) {
|
|
96
139
|
const configured = query(site.passwordSelector);
|
|
@@ -136,8 +179,14 @@
|
|
|
136
179
|
return button;
|
|
137
180
|
}
|
|
138
181
|
}
|
|
139
|
-
|
|
140
|
-
|
|
182
|
+
// buttons first, so that a link or a tab with the same words loses to the real button
|
|
183
|
+
for (const selector of ["button, input[type=button], input[type=submit]", "[role=button]", "a, div[onclick], span[onclick]"]) {
|
|
184
|
+
const found = [...document.querySelectorAll(selector)].find((element) => isVisible(element) && submitHints.test(element.value || element.textContent || ""));
|
|
185
|
+
if (found) {
|
|
186
|
+
return found;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
141
190
|
}
|
|
142
191
|
|
|
143
192
|
function setValue(input, value) {
|
|
@@ -154,8 +203,11 @@
|
|
|
154
203
|
for (const type of ["keydown", "keypress", "keyup"]) {
|
|
155
204
|
input.dispatchEvent(new KeyboardEvent(type, {key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true}));
|
|
156
205
|
}
|
|
157
|
-
|
|
158
|
-
|
|
206
|
+
// a single-page app (Vue, React, ...) signs in from its own Enter handler, submitting its <form> would only reload
|
|
207
|
+
// the page; a server-side form says where it goes
|
|
208
|
+
const form = input.form;
|
|
209
|
+
if (form && (form.hasAttribute("action") || form.hasAttribute("method"))) {
|
|
210
|
+
form.requestSubmit();
|
|
159
211
|
}
|
|
160
212
|
}
|
|
161
213
|
|
|
@@ -187,6 +239,7 @@
|
|
|
187
239
|
title.style.cssText = "font-weight:600;font-size:12px;color:#5c6ac4;margin-bottom:4px;";
|
|
188
240
|
title.textContent = "Casdoor";
|
|
189
241
|
const body = document.createElement("div");
|
|
242
|
+
body.style.whiteSpace = "pre-line";
|
|
190
243
|
body.textContent = text;
|
|
191
244
|
box.append(title, body);
|
|
192
245
|
if (buttons.length > 0) {
|
|
@@ -215,6 +268,199 @@
|
|
|
215
268
|
}
|
|
216
269
|
}
|
|
217
270
|
|
|
271
|
+
// captchas
|
|
272
|
+
|
|
273
|
+
const captchaInputHints = /验证码|校验码|captcha|verif|vcode|checkcode|check_code|yzm|randcode|validcode|valicode|imgcode|authcode|securitycode|kaptcha/i;
|
|
274
|
+
const captchaImageHints = /验证码|captcha|verif|vcode|checkcode|kaptcha|yzm|randcode|validcode|valicode|imgcode|authcode|code/i;
|
|
275
|
+
const captchaErrorHints = /(验证码|校验码|captcha|verification code|security code).{0,12}(错|不正确|不对|有误|无效|失效|过期|incorrect|invalid|wrong|expired)|(错误|不正确|无效|incorrect|invalid|wrong).{0,6}(验证码|captcha)/i;
|
|
276
|
+
const passwordErrorHints = /(密码|口令|账号|用户名|用户|password|username|account).{0,12}(错|不正确|不对|有误|不存在|锁定|incorrect|invalid|wrong|locked)/i;
|
|
277
|
+
const MAX_CAPTCHA_TRIES = 3;
|
|
278
|
+
|
|
279
|
+
function isShown(element) {
|
|
280
|
+
if (!element || !element.isConnected) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
const style = window.getComputedStyle(element);
|
|
284
|
+
return style.visibility !== "hidden" && style.display !== "none" && element.getClientRects().length > 0;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function findCaptchaInput(site, usernameInput, password) {
|
|
288
|
+
const configured = query(site.captchaSelector);
|
|
289
|
+
if (configured) {
|
|
290
|
+
return configured;
|
|
291
|
+
}
|
|
292
|
+
const inputs = [...document.querySelectorAll("input")].filter((input) => isTextInput(input) && isVisible(input) && input !== usernameInput && input !== password);
|
|
293
|
+
return inputs.find((input) => captchaInputHints.test(`${input.name} ${input.id} ${input.placeholder} ${input.className} ${input.getAttribute("aria-label") || ""} ${input.getAttribute("autocomplete") || ""}`) ||
|
|
294
|
+
/(^|[^a-z])code([^a-z]|$)/i.test(`${input.name} ${input.id}`)) || null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function center(element) {
|
|
298
|
+
const rect = element.getBoundingClientRect();
|
|
299
|
+
return [rect.left + rect.width / 2, rect.top + rect.height / 2];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// the captcha picture: the one the admin set, or an image or canvas of a captcha's size, the hinted and closest first
|
|
303
|
+
function findCaptchaImage(site, input) {
|
|
304
|
+
const configured = query(site.captchaImageSelector);
|
|
305
|
+
if (configured) {
|
|
306
|
+
return configured;
|
|
307
|
+
}
|
|
308
|
+
const [x, y] = center(input);
|
|
309
|
+
const candidates = [...document.querySelectorAll("img, canvas")].filter((element) => {
|
|
310
|
+
if (!isShown(element)) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
const rect = element.getBoundingClientRect();
|
|
314
|
+
return rect.width >= 40 && rect.width <= 320 && rect.height >= 15 && rect.height <= 120;
|
|
315
|
+
}).map((element) => {
|
|
316
|
+
const [cx, cy] = center(element);
|
|
317
|
+
const hinted = captchaImageHints.test(`${element.getAttribute("src") || ""} ${element.id} ${element.className} ${element.getAttribute("alt") || ""} ${element.getAttribute("title") || ""}`);
|
|
318
|
+
return {element, score: Math.hypot(cx - x, cy - y) - (hinted ? 10000 : 0)};
|
|
319
|
+
});
|
|
320
|
+
candidates.sort((a, b) => a.score - b.score);
|
|
321
|
+
return candidates.length > 0 ? candidates[0].element : null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function waitForImage(image, timeout) {
|
|
325
|
+
if (!(image instanceof HTMLImageElement) || (image.complete && image.naturalWidth > 0)) {
|
|
326
|
+
return Promise.resolve();
|
|
327
|
+
}
|
|
328
|
+
return new Promise((resolve) => {
|
|
329
|
+
const done = () => resolve();
|
|
330
|
+
image.addEventListener("load", done, {once: true});
|
|
331
|
+
image.addEventListener("error", done, {once: true});
|
|
332
|
+
setTimeout(done, timeout);
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// the picture as it is on the page; loading its address again would give a new captcha
|
|
337
|
+
async function readCaptchaImage(image) {
|
|
338
|
+
if (image instanceof HTMLCanvasElement) {
|
|
339
|
+
return image.toDataURL("image/png");
|
|
340
|
+
}
|
|
341
|
+
if (!(image instanceof HTMLImageElement)) {
|
|
342
|
+
throw new Error(strings.captchaNoImage);
|
|
343
|
+
}
|
|
344
|
+
await waitForImage(image, 3000);
|
|
345
|
+
if (!image.naturalWidth) {
|
|
346
|
+
throw new Error(strings.captchaNoImage);
|
|
347
|
+
}
|
|
348
|
+
const canvas = document.createElement("canvas");
|
|
349
|
+
canvas.width = image.naturalWidth;
|
|
350
|
+
canvas.height = image.naturalHeight;
|
|
351
|
+
canvas.getContext("2d").drawImage(image, 0, 0);
|
|
352
|
+
try {
|
|
353
|
+
return canvas.toDataURL("image/png");
|
|
354
|
+
} catch (error) {
|
|
355
|
+
// an image of another site without CORS taints the canvas
|
|
356
|
+
throw new Error(strings.captchaCrossOrigin);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function captchaCharset(site, input) {
|
|
361
|
+
if (site.captchaCharset) {
|
|
362
|
+
return site.captchaCharset;
|
|
363
|
+
}
|
|
364
|
+
const label = input.closest("label, .el-form-item, .ant-form-item, .form-group, tr, li");
|
|
365
|
+
const text = `${input.placeholder} ${input.getAttribute("aria-label") || ""} ${input.title} ${label ? label.textContent : ""}`;
|
|
366
|
+
return /数字|digit|number/i.test(text) ? "Digits" : "Alphanumeric";
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function solveCaptcha(site, input) {
|
|
370
|
+
const image = findCaptchaImage(site, input);
|
|
371
|
+
if (!image) {
|
|
372
|
+
throw new Error(strings.captchaNoImage);
|
|
373
|
+
}
|
|
374
|
+
const data = await readCaptchaImage(image);
|
|
375
|
+
const res = await send({type: "recognizeCaptcha", application: site.application, image: data, charset: captchaCharset(site, input)});
|
|
376
|
+
if (!res || res.error || !res.text) {
|
|
377
|
+
throw new Error((res && res.error) || strings.captchaUnread);
|
|
378
|
+
}
|
|
379
|
+
let text = res.text;
|
|
380
|
+
if (input.maxLength > 0 && text.length > input.maxLength) {
|
|
381
|
+
text = text.slice(0, input.maxLength);
|
|
382
|
+
}
|
|
383
|
+
setValue(input, text);
|
|
384
|
+
return {data};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// a new captcha: most sign-in pages change it when the picture is clicked, some already did on the failed sign-in
|
|
388
|
+
async function refreshCaptcha(site, input, before) {
|
|
389
|
+
const image = findCaptchaImage(site, input);
|
|
390
|
+
if (!image) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const current = await readCaptchaImage(image).catch(() => "");
|
|
394
|
+
if (current && current !== before.data) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
image.click();
|
|
398
|
+
for (let i = 0; i < 15; i++) {
|
|
399
|
+
await sleep(200);
|
|
400
|
+
const latest = findCaptchaImage(site, input);
|
|
401
|
+
if (latest) {
|
|
402
|
+
await waitForImage(latest, 1000);
|
|
403
|
+
const data = await readCaptchaImage(latest).catch(() => "");
|
|
404
|
+
if (data && data !== before.data) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function submitForm(site, password) {
|
|
412
|
+
const submit = findSubmit(site, password);
|
|
413
|
+
if (submit) {
|
|
414
|
+
submit.click();
|
|
415
|
+
} else {
|
|
416
|
+
pressEnter(password);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// After submitting with a recognized captcha: still on the sign-in form means it was refused. A wrong captcha is
|
|
421
|
+
// tried again with a new one, a wrong password is not, so that the account doesn't get locked.
|
|
422
|
+
async function watchCaptchaSignIn(site, account, password, input, attempt, solved, textBefore) {
|
|
423
|
+
const url = location.href;
|
|
424
|
+
await sleep(2500);
|
|
425
|
+
const stillHere = location.href === url && (isShown(password) || isShown(findPassword(site)));
|
|
426
|
+
if (!stillHere) {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const now = document.body.innerText;
|
|
430
|
+
const added = now.split(textBefore).join(" ");
|
|
431
|
+
const captchaWrong = captchaErrorHints.test(added) || captchaErrorHints.test(now);
|
|
432
|
+
const passwordWrong = !captchaWrong && passwordErrorHints.test(added);
|
|
433
|
+
if (passwordWrong || attempt >= MAX_CAPTCHA_TRIES || (!captchaWrong && attempt >= 2)) {
|
|
434
|
+
const currentInput = isShown(input) ? input : findCaptchaInput(site, null, findPassword(site));
|
|
435
|
+
if (currentInput) {
|
|
436
|
+
setValue(currentInput, "");
|
|
437
|
+
currentInput.focus();
|
|
438
|
+
}
|
|
439
|
+
showNotice(passwordWrong ? strings.signInRefused : format(strings.captchaGaveUp, {n: attempt}), [], 0);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const currentPassword = isShown(password) ? password : findPassword(site);
|
|
444
|
+
const currentInput = isShown(input) ? input : findCaptchaInput(site, findUsername(site, currentPassword), currentPassword);
|
|
445
|
+
if (!currentPassword || !currentInput) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (!currentPassword.value) {
|
|
449
|
+
setValue(currentPassword, account.password);
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
await refreshCaptcha(site, currentInput, solved);
|
|
453
|
+
const next = await solveCaptcha(site, currentInput);
|
|
454
|
+
const text = document.body.innerText;
|
|
455
|
+
await sleep(300);
|
|
456
|
+
submitForm(site, currentPassword);
|
|
457
|
+
await watchCaptchaSignIn(site, account, currentPassword, currentInput, attempt + 1, next, text);
|
|
458
|
+
} catch (error) {
|
|
459
|
+
currentInput.focus();
|
|
460
|
+
showNotice(format(strings.captchaManual, {error: error.message}), [], 0);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
218
464
|
async function fill(info, username) {
|
|
219
465
|
const site = info.site;
|
|
220
466
|
// a page of the system without a sign-in form, e.g. when the URLs end with "*"
|
|
@@ -233,21 +479,56 @@
|
|
|
233
479
|
}
|
|
234
480
|
setValue(password, account.password);
|
|
235
481
|
|
|
482
|
+
// the captcha is read on the user's computer, see offscreen.js
|
|
483
|
+
let captchaInput = null;
|
|
484
|
+
let solved = null;
|
|
485
|
+
let captchaError = "";
|
|
486
|
+
if (site.captcha) {
|
|
487
|
+
captchaInput = findCaptchaInput(site, usernameInput, password);
|
|
488
|
+
if (!captchaInput) {
|
|
489
|
+
captchaError = strings.captchaNoInput;
|
|
490
|
+
} else {
|
|
491
|
+
try {
|
|
492
|
+
solved = await solveCaptcha(site, captchaInput);
|
|
493
|
+
} catch (error) {
|
|
494
|
+
captchaError = error.message;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
236
499
|
const others = info.accounts.filter((item) => item !== account.username);
|
|
237
500
|
const buttons = others.slice(0, 3).map((item) => ({label: `${strings.switchAccount} ${item}`, onClick: () => fill(info, item)}));
|
|
238
501
|
if (info.recentlySubmitted && info.accounts.length > 0) {
|
|
239
502
|
showNotice(strings.retry, buttons, 0);
|
|
240
503
|
return;
|
|
241
504
|
}
|
|
242
|
-
|
|
505
|
+
// the admin's selectors that match nothing, so that a wrong one doesn't go unnoticed
|
|
506
|
+
const missing = [
|
|
507
|
+
[site.usernameSelector, strings.usernameField],
|
|
508
|
+
[site.passwordSelector, strings.passwordField],
|
|
509
|
+
[site.autoSubmit ? site.submitSelector : "", strings.submitField],
|
|
510
|
+
[site.captcha ? site.captchaSelector : "", strings.captchaField],
|
|
511
|
+
[site.captcha ? site.captchaImageSelector : "", strings.captchaImageField],
|
|
512
|
+
].filter(([selector]) => selector && selector.trim() && !query(selector));
|
|
513
|
+
const warnings = missing.map(([selector, field]) => format(strings.missingSelector, {field, selector}));
|
|
514
|
+
if (captchaError) {
|
|
515
|
+
warnings.push(format(strings.captchaManual, {error: captchaError}));
|
|
516
|
+
}
|
|
517
|
+
showNotice([format(strings.filled, {app: site.displayName, user: account.username}), ...warnings].join("\n"), buttons, warnings.length > 0 ? 15000 : 8000);
|
|
243
518
|
|
|
519
|
+
if (captchaError) {
|
|
520
|
+
// nothing is submitted without the captcha, the user types it
|
|
521
|
+
if (captchaInput) {
|
|
522
|
+
captchaInput.focus();
|
|
523
|
+
}
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
244
526
|
if (site.autoSubmit && !username) {
|
|
245
527
|
await sleep(400);
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
pressEnter(password);
|
|
528
|
+
const textBefore = document.body.innerText;
|
|
529
|
+
submitForm(site, password);
|
|
530
|
+
if (solved) {
|
|
531
|
+
watchCaptchaSignIn(site, account, password, captchaInput, 1, solved, textBefore);
|
|
251
532
|
}
|
|
252
533
|
}
|
|
253
534
|
}
|
package/src/offscreen.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
// Reads the image captcha of a sign-in page with ddddocr's model in ONNX Runtime Web, on the user's own computer: the
|
|
16
|
+
// image never leaves it. This hidden page exists because a service worker can't run WebAssembly with a canvas.
|
|
17
|
+
|
|
18
|
+
/* global ort */
|
|
19
|
+
|
|
20
|
+
const MODEL_HEIGHT = 64;
|
|
21
|
+
|
|
22
|
+
let model = null;
|
|
23
|
+
|
|
24
|
+
async function loadModel() {
|
|
25
|
+
if (!model) {
|
|
26
|
+
model = (async() => {
|
|
27
|
+
ort.env.wasm.wasmPaths = chrome.runtime.getURL("/vendor/onnxruntime-web/");
|
|
28
|
+
// threads need a cross-origin isolated page
|
|
29
|
+
ort.env.wasm.numThreads = 1;
|
|
30
|
+
const [session, charset] = await Promise.all([
|
|
31
|
+
ort.InferenceSession.create(chrome.runtime.getURL("/vendor/ddddocr/common_old.onnx"), {executionProviders: ["wasm"]}),
|
|
32
|
+
fetch(chrome.runtime.getURL("/vendor/ddddocr/charset.json")).then((res) => res.json()),
|
|
33
|
+
]);
|
|
34
|
+
return {session, charset};
|
|
35
|
+
})();
|
|
36
|
+
model.catch(() => {
|
|
37
|
+
model = null;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return model;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function loadImage(dataUrl) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const image = new Image();
|
|
46
|
+
image.onload = () => resolve(image);
|
|
47
|
+
image.onerror = () => reject(new Error("the captcha image could not be read"));
|
|
48
|
+
image.src = dataUrl;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// the same input as ddddocr: scaled to 64 pixels high, grey (ITU-R 601-2 luma, as PIL's "L"), from -1 to 1
|
|
53
|
+
async function toTensor(dataUrl) {
|
|
54
|
+
const image = await loadImage(dataUrl);
|
|
55
|
+
const width = Math.max(1, Math.round(image.naturalWidth * MODEL_HEIGHT / image.naturalHeight));
|
|
56
|
+
const canvas = document.createElement("canvas");
|
|
57
|
+
canvas.width = width;
|
|
58
|
+
canvas.height = MODEL_HEIGHT;
|
|
59
|
+
const context = canvas.getContext("2d", {willReadFrequently: true});
|
|
60
|
+
// a transparent captcha is read on white
|
|
61
|
+
context.fillStyle = "#fff";
|
|
62
|
+
context.fillRect(0, 0, width, MODEL_HEIGHT);
|
|
63
|
+
context.imageSmoothingEnabled = true;
|
|
64
|
+
context.imageSmoothingQuality = "high";
|
|
65
|
+
context.drawImage(image, 0, 0, width, MODEL_HEIGHT);
|
|
66
|
+
const pixels = context.getImageData(0, 0, width, MODEL_HEIGHT).data;
|
|
67
|
+
const data = new Float32Array(width * MODEL_HEIGHT);
|
|
68
|
+
for (let i = 0; i < data.length; i++) {
|
|
69
|
+
const grey = Math.round((pixels[i * 4] * 299 + pixels[i * 4 + 1] * 587 + pixels[i * 4 + 2] * 114) / 1000);
|
|
70
|
+
data[i] = (grey / 255 - 0.5) / 0.5;
|
|
71
|
+
}
|
|
72
|
+
return new ort.Tensor("float32", data, [1, 1, MODEL_HEIGHT, width]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const charsetPatterns = {
|
|
76
|
+
Digits: /^[0-9]$/,
|
|
77
|
+
Alphanumeric: /^[0-9a-zA-Z]$/,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// CTC decoding of the model's output [steps, 1, classes], only over the characters the captcha can have
|
|
81
|
+
function decode(output, charset, allowedName) {
|
|
82
|
+
const [steps, , classes] = output.dims;
|
|
83
|
+
const pattern = charsetPatterns[allowedName];
|
|
84
|
+
const allowed = [];
|
|
85
|
+
for (let c = 0; c < classes; c++) {
|
|
86
|
+
if (c === 0 || !pattern || pattern.test(charset[c])) {
|
|
87
|
+
allowed.push(c);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
let result = "";
|
|
91
|
+
let last = 0;
|
|
92
|
+
for (let t = 0; t < steps; t++) {
|
|
93
|
+
let best = 0;
|
|
94
|
+
let bestScore = -Infinity;
|
|
95
|
+
for (const c of allowed) {
|
|
96
|
+
const score = output.data[t * classes + c];
|
|
97
|
+
if (score > bestScore) {
|
|
98
|
+
bestScore = score;
|
|
99
|
+
best = c;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (best !== last && best !== 0) {
|
|
103
|
+
result += charset[best];
|
|
104
|
+
}
|
|
105
|
+
last = best;
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function recognize(image, charsetName) {
|
|
111
|
+
const {session, charset} = await loadModel();
|
|
112
|
+
const input = await toTensor(image);
|
|
113
|
+
const outputs = await session.run({[session.inputNames[0]]: input});
|
|
114
|
+
return decode(outputs[session.outputNames[0]], charset, charsetName);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
118
|
+
if (!message || message.target !== "offscreen" || message.type !== "recognizeCaptcha") {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
recognize(message.image, message.charset)
|
|
122
|
+
.then((text) => sendResponse({text}))
|
|
123
|
+
.catch((error) => sendResponse({error: error.message}));
|
|
124
|
+
return true;
|
|
125
|
+
});
|
package/vendor/README.md
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Third-party files
|
|
2
|
+
|
|
3
|
+
- `onnxruntime-web/`: the WebAssembly build of [ONNX Runtime Web](https://github.com/microsoft/onnxruntime) 1.22.0 (`ort.wasm.min.js`, `ort-wasm-simd-threaded.mjs`, `ort-wasm-simd-threaded.wasm` from the npm package `onnxruntime-web@1.22.0`), MIT License.
|
|
4
|
+
- `ddddocr/`: the captcha recognition model `common_old.onnx` and its character set (`charset.json`, taken from `ddddocr/__init__.py`) of [ddddocr](https://github.com/sml2h3/ddddocr) 1.5.6 (the PyPI wheel), MIT License.
|
|
5
|
+
|
|
6
|
+
They are used by `src/offscreen.js` to read the image captchas of sign-in pages on the user's own computer.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
Copyright © 2022 <copyright holders>
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
5
|
+
|
|
6
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|