casdoor 1.4.0 → 1.5.1

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/.eslintrc.js CHANGED
@@ -1,6 +1,7 @@
1
1
  module.exports = {
2
2
  env: {
3
3
  browser: true,
4
+ webextensions: true,
4
5
  es2021: true,
5
6
  },
6
7
  extends: [
package/README.md CHANGED
@@ -1,16 +1,28 @@
1
1
  # casdoor-chrome-extension
2
2
 
3
- The Casdoor extension can help browser users automatically log into applications protected by [Casdoor](https://casdoor.org). The extension's code is open-sourced at: https://github.com/casdoor/casdoor-chrome-extension
4
-
5
- This extension is very simple to use. We will show you the steps below.
3
+ The Casdoor browser extension signs users into the systems that have no single sign-on, by filling their sign-in forms with the accounts kept in [Casdoor](https://casdoor.ai). The code is open-sourced at: https://github.com/casdoor/casdoor-chrome-extension
6
4
 
7
5
  ## Demo video
8
6
 
9
7
  ![AutoLogin](./img/demo.gif)
10
8
 
9
+ ## How it works
10
+
11
+ 1. The admin turns on **Form autofill** in the application of a legacy system (the "Form autofill" tab of the application's edit page) and enters its sign-in page URLs, e.g. `https://oa.example.com/login`. The host must be the same, `*.example.com` covers its subdomains, and a path without `*` matches as a prefix. The fields are found automatically, or set with CSS selectors.
12
+ 2. Each user's account of the system is a managed account on the user's page, or saved by the user: when **Let users save their accounts** is on, the extension offers to save the account that the user signs in with by hand.
13
+ 3. When the user opens a sign-in page, the extension asks Casdoor for the account of that page and fills it in, and clicks the sign-in button when **Submit automatically** is on. A sign-in page that comes back within 30 seconds is filled but not submitted again.
14
+
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
+
11
17
  ## Usage
12
18
 
13
19
  ### Install the extension
14
20
 
15
21
  1. Go to `chrome://extensions` and check the box for **Developer mode** in the top right.
16
22
  2. Click `Load unpacked` button, then choose this project folder.
23
+
24
+ ### Sign in to Casdoor
25
+
26
+ 1. Open the extension, enter the Casdoor URL and the name of the application to sign in with.
27
+ 2. Add the address shown at the bottom of the extension (`https://<extension id>.chromiumapp.org/`) to the redirect URLs of that application, and allow the `token` grant type in it.
28
+ 3. Click **Sign in to Casdoor**. The extension then lists the systems it fills for you.
package/manifest.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
- "name": "Casdoor Browser Plugin",
3
- "description": "Casdoor Browser Plugin can help the users automatically log into applications protected by Casdoor identity provider.",
4
- "version": "1.0.0",
2
+ "name": "Casdoor",
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.0.0",
5
5
  "manifest_version": 3,
6
6
  "permissions": [
7
7
  "storage",
8
8
  "identity"
9
9
  ],
10
+ "host_permissions": [
11
+ "https://*/*",
12
+ "http://*/*"
13
+ ],
10
14
  "action": {
11
15
  "default_popup": "/src/popup.html",
12
16
  "default_icon": {
@@ -22,6 +26,9 @@
22
26
  "48": "/images/icon48.png",
23
27
  "128": "/images/icon128.png"
24
28
  },
29
+ "background": {
30
+ "service_worker": "/src/background.js"
31
+ },
25
32
  "content_scripts": [
26
33
  {
27
34
  "matches": [
@@ -29,18 +36,10 @@
29
36
  "http://*/*"
30
37
  ],
31
38
  "js": [
32
- "/src/content-script.js",
33
- "/src/sdk.js",
34
- "/src/oauth.js",
35
- "/src/config.js"
36
- ]
39
+ "/src/content-script.js"
40
+ ],
41
+ "all_frames": true,
42
+ "run_at": "document_idle"
37
43
  }
38
- ],
39
- "background": {
40
- "service_worker": "/src/background.js"
41
- },
42
- "host_permissions": [
43
- "https://*/*",
44
- "http://*/*"
45
44
  ]
46
45
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "casdoor",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "The Casdoor extension can help browser users automatically log into applications protected by Casdoor single-sign-on (SSO) system.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/background.js CHANGED
@@ -12,101 +12,357 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
 
15
- let autoLoginTabs = new Set();
16
-
17
- chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
18
- if (tab.url && tab.status === "complete") {
19
- const [UserLoggedIn, DisableAutoLogin] = await Promise.all([
20
- isUserLoggedIn(tabId),
21
- isDisableAutoLogin(tabId),
22
- ]);
23
- if (UserLoggedIn && !DisableAutoLogin && !autoLoginTabs.has(tabId)) {
24
- autoLogin(tabId, tab.url);
25
- }
15
+ /* global importScripts, matchFormFillSite, matchLegacySigninUrl */
16
+
17
+ importScripts("match.js");
18
+
19
+ // The service worker is the only part that talks to Casdoor and holds the access token. The content scripts ask it
20
+ // for the account of the page they are on, and it only answers for a frame whose own URL is a sign-in page.
21
+
22
+ const SITES_TTL = 60 * 1000;
23
+ const PENDING_TTL = 2 * 60 * 1000;
24
+ const AUTO_SUBMIT_GAP = 30 * 1000;
25
+
26
+ let sitesCache = null;
27
+ const lastAutoSubmits = new Map();
28
+
29
+ function getSettings() {
30
+ return chrome.storage.local.get(["endpoint", "applicationName", "accessToken", "userProfile", "disableAutoFill"]);
31
+ }
32
+
33
+ // the settings of the first versions were kept in the synced storage
34
+ async function migrateSettings() {
35
+ const local = await chrome.storage.local.get(["migrated"]);
36
+ if (local.migrated) {
37
+ return;
38
+ }
39
+ const synced = await chrome.storage.sync.get(["endpoint", "applicationName", "disableAutoLogin"]);
40
+ const current = await chrome.storage.local.get(["endpoint", "applicationName", "disableAutoFill"]);
41
+ await chrome.storage.local.set({
42
+ endpoint: current.endpoint ?? synced.endpoint ?? "",
43
+ applicationName: current.applicationName ?? synced.applicationName ?? "",
44
+ disableAutoFill: current.disableAutoFill ?? !!synced.disableAutoLogin,
45
+ migrated: true,
46
+ });
47
+ await chrome.storage.sync.remove(["accessToken", "userProfile"]);
48
+ }
49
+
50
+ chrome.runtime.onInstalled.addListener(() => {
51
+ migrateSettings();
52
+ });
53
+
54
+ chrome.storage.onChanged.addListener((changes, area) => {
55
+ if (area === "local" && (changes.accessToken || changes.endpoint)) {
56
+ sitesCache = null;
26
57
  }
27
58
  });
28
59
 
29
- async function isUserLoggedIn(tabId) {
30
- return new Promise((resolve, reject) => {
31
- chrome.tabs.sendMessage(
32
- tabId,
33
- { action: "isUserLoggedIn" },
34
- function (UserLoggedIn) {
35
- if (chrome.runtime.lastError) {
36
- reject(chrome.runtime.lastError.message);
37
- } else {
38
- resolve(UserLoggedIn);
39
- }
40
- }
41
- );
42
- });
60
+ function normalizeEndpoint(endpoint) {
61
+ return (endpoint || "").trim().replace(/\/+$/, "");
43
62
  }
44
63
 
45
- function getManagedAccounts(tabId) {
46
- return new Promise((resolve, reject) => {
47
- chrome.tabs.sendMessage(
48
- tabId,
49
- { action: "getManagedAccounts" },
50
- function (managedAccounts) {
51
- if (chrome.runtime.lastError) {
52
- reject(chrome.runtime.lastError.message);
53
- } else {
54
- resolve(managedAccounts);
55
- }
56
- }
57
- );
58
- });
64
+ async function signOut() {
65
+ sitesCache = null;
66
+ await chrome.storage.local.set({accessToken: "", userProfile: null});
59
67
  }
60
68
 
61
- function testUrlWithRegex(url, regexPattern) {
69
+ async function callApi(path, options = {}) {
70
+ const {endpoint, accessToken} = await getSettings();
71
+ if (!endpoint || !accessToken) {
72
+ return {status: "error", msg: "", data: "Please login first"};
73
+ }
74
+ let response;
62
75
  try {
63
- const regex = new RegExp(regexPattern);
64
- const isMatched = regex.test(url);
65
- console.log(`testUrlWithRegex(), url = ${url}, regexPattern = ${regexPattern}, isMatched = ${isMatched}`);
66
- return isMatched;
76
+ response = await fetch(`${endpoint}${path}`, {
77
+ ...options,
78
+ headers: {...(options.headers || {}), Authorization: `Bearer ${accessToken}`},
79
+ });
67
80
  } catch (error) {
68
- alert(`testUrlWithRegex() error, the regex pattern: "${regexPattern}" is invalid`);
69
- return false;
81
+ return {status: "error", msg: error.message, unreachable: true};
82
+ }
83
+ let res;
84
+ try {
85
+ res = await response.json();
86
+ } catch (error) {
87
+ // a Casdoor without the API answers with a page
88
+ return {status: "error", msg: `HTTP ${response.status}`, missing: true};
89
+ }
90
+ if (res.status !== "ok" && res.data === "Please login first") {
91
+ await signOut();
70
92
  }
93
+ return res;
71
94
  }
72
95
 
73
- async function autoLogin(tabId, url) {
74
- const managedAccounts = await getManagedAccounts(tabId);
75
- if (managedAccounts) {
76
- for (const managedAccount of managedAccounts) {
77
- if (managedAccount && managedAccount.signinUrl && url) {
78
- const isMatched = testUrlWithRegex(url, managedAccount.signinUrl);
79
- if (isMatched) {
80
- autoLoginTabs.add(tabId);
81
- chrome.tabs.sendMessage(tabId, {
82
- action: "autoLogin",
83
- managedAccount: managedAccount,
84
- });
85
- break;
86
- }
87
- }
96
+ // getSites returns the sign-in pages that the user's accounts are filled into. A Casdoor without an enterprise license
97
+ // or without form autofill falls back to the managed accounts with their sign-in URLs as regular expressions.
98
+ async function getSites() {
99
+ const settings = await getSettings();
100
+ if (!settings.endpoint || !settings.accessToken) {
101
+ return null;
102
+ }
103
+ if (sitesCache && sitesCache.endpoint === settings.endpoint && Date.now() - sitesCache.time < SITES_TTL) {
104
+ return sitesCache;
105
+ }
106
+
107
+ const res = await callApi("/api/get-form-fill-sites");
108
+ let cache;
109
+ if (res.status === "ok") {
110
+ cache = {mode: "sites", sites: res.data || []};
111
+ } else if (res.code === "edition-required" || res.missing) {
112
+ const accountRes = await callApi("/api/get-account?managedAccounts=1");
113
+ if (accountRes.status !== "ok") {
114
+ return null;
88
115
  }
116
+ const accounts = (accountRes.data && accountRes.data.managedAccounts) || [];
117
+ cache = {mode: "legacy", accounts, message: res.code === "edition-required" ? res.msg : ""};
118
+ } else {
119
+ return null;
120
+ }
121
+ sitesCache = {...cache, endpoint: settings.endpoint, time: Date.now()};
122
+ return sitesCache;
123
+ }
124
+
125
+ function findSite(cache, pageUrl) {
126
+ if (cache.mode === "sites") {
127
+ return cache.sites.find((site) => matchFormFillSite(site, pageUrl)) || null;
128
+ }
129
+ const accounts = cache.accounts.filter((account) => account.signinUrl && matchLegacySigninUrl(account.signinUrl, pageUrl));
130
+ if (accounts.length === 0) {
131
+ return null;
132
+ }
133
+ // the first versions always signed in
134
+ return {application: accounts[0].application, displayName: accounts[0].application, accounts: accounts.map((account) => account.username), autoSubmit: true};
135
+ }
136
+
137
+ async function getFill(sender) {
138
+ const settings = await getSettings();
139
+ if (settings.disableAutoFill) {
140
+ return {};
141
+ }
142
+ const cache = await getSites();
143
+ if (!cache) {
144
+ return {};
89
145
  }
146
+ const site = findSite(cache, sender.url);
147
+ if (!site) {
148
+ return {};
149
+ }
150
+ const key = `${sender.tab ? sender.tab.id : 0}/${site.application}`;
151
+ const recentlySubmitted = Date.now() - (lastAutoSubmits.get(key) || 0) < AUTO_SUBMIT_GAP;
152
+ return {
153
+ site: {
154
+ application: site.application,
155
+ displayName: site.displayName || site.application,
156
+ usernameSelector: site.usernameSelector || "",
157
+ passwordSelector: site.passwordSelector || "",
158
+ submitSelector: site.submitSelector || "",
159
+ autoSubmit: !!site.autoSubmit && !recentlySubmitted,
160
+ urls: site.urls || [],
161
+ },
162
+ accounts: site.accounts || [],
163
+ canSave: !!site.allowSave && (site.accounts || []).length === 0,
164
+ recentlySubmitted,
165
+ };
90
166
  }
91
167
 
92
- function isDisableAutoLogin(tabId) {
93
- return new Promise((resolve, reject) => {
94
- chrome.tabs.sendMessage(
95
- tabId,
96
- { action: "isDisableAutoLogin" },
97
- function (DisableAutoLogin) {
98
- if (chrome.runtime.lastError) {
99
- reject(chrome.runtime.lastError);
100
- } else {
101
- resolve(DisableAutoLogin);
102
- }
103
- }
104
- );
168
+ async function getAccount(message, sender) {
169
+ const cache = await getSites();
170
+ if (!cache) {
171
+ return {error: "not signed in"};
172
+ }
173
+ const site = findSite(cache, sender.url);
174
+ if (!site || site.application !== message.application) {
175
+ return {error: "not a sign-in page"};
176
+ }
177
+
178
+ if (cache.mode === "legacy") {
179
+ const account = cache.accounts.find((item) => item.application === site.application && (!message.username || item.username === message.username));
180
+ return account ? {username: account.username, password: account.password} : {error: "no account"};
181
+ }
182
+
183
+ const query = new URLSearchParams({application: site.application, username: message.username || "", url: sender.url});
184
+ const res = await callApi(`/api/get-form-fill-account?${query.toString()}`);
185
+ if (res.status !== "ok") {
186
+ return {error: res.msg};
187
+ }
188
+ if (message.autoSubmit && sender.tab) {
189
+ lastAutoSubmits.set(`${sender.tab.id}/${site.application}`, Date.now());
190
+ }
191
+ return {username: res.data.username, password: res.data.password};
192
+ }
193
+
194
+ function pendingKey(tabId) {
195
+ return `pending-${tabId}`;
196
+ }
197
+
198
+ async function capture(message, sender) {
199
+ if (!sender.tab || !message.username || !message.password) {
200
+ return {};
201
+ }
202
+ const cache = await getSites();
203
+ const site = cache && findSite(cache, sender.url);
204
+ if (!site || !site.allowSave || site.application !== message.application) {
205
+ return {};
206
+ }
207
+ await chrome.storage.session.set({
208
+ [pendingKey(sender.tab.id)]: {
209
+ application: site.application,
210
+ displayName: site.displayName || site.application,
211
+ username: message.username,
212
+ password: message.password,
213
+ url: sender.url,
214
+ time: Date.now(),
215
+ },
216
+ });
217
+ return {};
218
+ }
219
+
220
+ async function getPending(sender) {
221
+ if (!sender.tab) {
222
+ return null;
223
+ }
224
+ const key = pendingKey(sender.tab.id);
225
+ const pending = (await chrome.storage.session.get([key]))[key];
226
+ if (!pending) {
227
+ return null;
228
+ }
229
+ if (Date.now() - pending.time > PENDING_TTL) {
230
+ await chrome.storage.session.remove([key]);
231
+ return null;
232
+ }
233
+ return {application: pending.application, displayName: pending.displayName, username: pending.username};
234
+ }
235
+
236
+ async function resolvePending(message, sender) {
237
+ if (!sender.tab) {
238
+ return {};
239
+ }
240
+ const key = pendingKey(sender.tab.id);
241
+ const pending = (await chrome.storage.session.get([key]))[key];
242
+ await chrome.storage.session.remove([key]);
243
+ if (!pending || !message.save) {
244
+ return {};
245
+ }
246
+ const res = await callApi("/api/save-form-fill-account", {
247
+ method: "POST",
248
+ headers: {"Content-Type": "application/json"},
249
+ body: JSON.stringify({application: pending.application, username: pending.username, password: pending.password, url: pending.url}),
105
250
  });
251
+ sitesCache = null;
252
+ return res.status === "ok" ? {saved: true} : {error: res.msg};
106
253
  }
107
254
 
108
- function clearAutoLoginTabs() {
109
- autoLoginTabs.clear();
255
+ function getRedirectUri() {
256
+ return chrome.identity.getRedirectURL();
110
257
  }
111
258
 
112
- setInterval(clearAutoLoginTabs, 10000);
259
+ async function login(message) {
260
+ const endpoint = normalizeEndpoint(message.endpoint);
261
+ const applicationName = (message.applicationName || "").trim();
262
+ if (!/^https?:\/\//.test(endpoint) || !applicationName) {
263
+ return {error: "endpoint"};
264
+ }
265
+ await chrome.storage.local.set({endpoint, applicationName});
266
+
267
+ let application;
268
+ try {
269
+ const response = await fetch(`${endpoint}/api/get-application?id=admin/${encodeURIComponent(applicationName)}`);
270
+ application = (await response.json()).data;
271
+ } catch (error) {
272
+ return {error: error.message};
273
+ }
274
+ if (!application || !application.clientId) {
275
+ return {error: "application"};
276
+ }
277
+
278
+ const state = crypto.randomUUID();
279
+ const authorizeUrl = `${endpoint}/login/oauth/authorize?` + new URLSearchParams({
280
+ client_id: application.clientId,
281
+ response_type: "token",
282
+ redirect_uri: getRedirectUri(),
283
+ scope: "openid profile",
284
+ state,
285
+ }).toString();
286
+
287
+ let redirectUrl;
288
+ try {
289
+ redirectUrl = await chrome.identity.launchWebAuthFlow({url: authorizeUrl, interactive: true});
290
+ } catch (error) {
291
+ return {error: error.message};
292
+ }
293
+ const params = new URLSearchParams(new URL(redirectUrl).hash.slice(1));
294
+ if (params.get("state") !== state || !params.get("access_token")) {
295
+ return {error: "token"};
296
+ }
297
+
298
+ sitesCache = null;
299
+ await chrome.storage.local.set({accessToken: params.get("access_token")});
300
+ const accountRes = await callApi("/api/get-account");
301
+ if (accountRes.status !== "ok") {
302
+ await signOut();
303
+ return {error: accountRes.msg};
304
+ }
305
+ const user = accountRes.data;
306
+ await chrome.storage.local.set({userProfile: {name: user.name, displayName: user.displayName, avatar: user.avatar, owner: user.owner}});
307
+ return {ok: true};
308
+ }
309
+
310
+ async function getStatus() {
311
+ await migrateSettings();
312
+ const settings = await getSettings();
313
+ const status = {
314
+ endpoint: settings.endpoint || "",
315
+ applicationName: settings.applicationName || "",
316
+ user: settings.accessToken ? settings.userProfile : null,
317
+ autoFill: !settings.disableAutoFill,
318
+ redirectUri: getRedirectUri(),
319
+ };
320
+ if (status.user) {
321
+ const cache = await getSites();
322
+ if (cache) {
323
+ status.mode = cache.mode;
324
+ status.message = cache.message || "";
325
+ status.sites = cache.mode === "sites"
326
+ ? cache.sites.map((site) => ({name: site.displayName || site.application, accounts: site.accounts.length}))
327
+ : cache.accounts.map((account) => ({name: account.application, accounts: 1}));
328
+ }
329
+ }
330
+ return status;
331
+ }
332
+
333
+ const handlers = {
334
+ getFill: (message, sender) => getFill(sender),
335
+ getAccount: (message, sender) => getAccount(message, sender),
336
+ capture: (message, sender) => capture(message, sender),
337
+ getPending: (message, sender) => getPending(sender),
338
+ resolvePending: (message, sender) => resolvePending(message, sender),
339
+ login: (message) => login(message),
340
+ logout: () => signOut().then(() => ({ok: true})),
341
+ getStatus: () => getStatus(),
342
+ setAutoFill: (message) => chrome.storage.local.set({disableAutoFill: !message.enabled}).then(() => ({ok: true})),
343
+ };
344
+
345
+ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
346
+ const handler = handlers[message && message.type];
347
+ if (!handler) {
348
+ return false;
349
+ }
350
+ // the content scripts only reach the handlers that act on their own frame, the rest are for the popup
351
+ const fromExtension = (sender.url || "").startsWith(chrome.runtime.getURL(""));
352
+ if (!fromExtension && ["login", "logout", "getStatus", "setAutoFill"].includes(message.type)) {
353
+ return false;
354
+ }
355
+ handler(message, sender)
356
+ .then(sendResponse)
357
+ .catch((error) => sendResponse({error: error.message}));
358
+ return true;
359
+ });
360
+
361
+ chrome.tabs.onRemoved.addListener((tabId) => {
362
+ chrome.storage.session.remove([pendingKey(tabId)]);
363
+ for (const key of lastAutoSubmits.keys()) {
364
+ if (key.startsWith(`${tabId}/`)) {
365
+ lastAutoSubmits.delete(key);
366
+ }
367
+ }
368
+ });