From f3d4b0e0a25c5ef867c097cbb76d4b539d91a6cb Mon Sep 17 00:00:00 2001 From: MrMelon54 Date: Wed, 14 Feb 2024 16:50:17 +0000 Subject: [PATCH] New OAuth based login system --- .env.development | 1 + .env.production | 1 + src/App.svelte | 9 +- src/utils/api-request.ts | 44 ------ src/utils/login-popup.ts | 53 -------- src/utils/login.ts | 33 +++++ src/utils/pop2.js | 187 ++++++++++++++++++++++++++ src/views/CertificatesView.svelte | 4 +- src/views/SitesView.svelte | 28 ++-- src/views/TargetManagementView.svelte | 16 ++- 10 files changed, 257 insertions(+), 119 deletions(-) delete mode 100644 src/utils/api-request.ts delete mode 100644 src/utils/login-popup.ts create mode 100644 src/utils/login.ts create mode 100644 src/utils/pop2.js diff --git a/.env.development b/.env.development index 6126860..764926d 100644 --- a/.env.development +++ b/.env.development @@ -1,4 +1,5 @@ VITE_SSO_ORIGIN=http://localhost:9090 +VITE_OAUTH2_CLIENT_ID=abc123 VITE_API_VIOLET=http://localhost:9095/v1/violet VITE_API_ORCHID=http://localhost:9095/v1/orchid diff --git a/.env.production b/.env.production index 88f5f35..bedb04d 100644 --- a/.env.production +++ b/.env.production @@ -1,4 +1,5 @@ VITE_SSO_ORIGIN=https://sso.1f349.com +VITE_OAUTH2_CLIENT_ID=9b11a141-bcb8-4140-9c88-531a5d7bf15d VITE_API_VIOLET=https://api.1f349.com/v1/violet VITE_API_ORCHID=https://api.1f349.com/v1/orchid diff --git a/src/App.svelte b/src/App.svelte index fb8cf94..9ae3ba9 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -6,9 +6,8 @@ import CertificatesView from "./views/CertificatesView.svelte"; import SitesView from "./views/SitesView.svelte"; import {loginStore, parseJwt, type LoginStore} from "./stores/login"; - import {openLoginPopup} from "./utils/login-popup"; import {domainOption} from "./stores/domain-option"; - import {apiVerify} from "./utils/api-request"; + import {LOGIN} from "./utils/login"; let sidebarOptions: Array<{name: string; view: typeof SvelteComponent<{}>}> = [ {name: "General", view: GeneralView}, @@ -34,7 +33,8 @@ } onMount(() => { - apiVerify().catch(() => {}); + LOGIN.init(); + LOGIN.userinfo(false); }); @@ -48,7 +48,7 @@ {#if $loginStore == null}
- +
{:else}
@@ -58,6 +58,7 @@ on:click={() => { $loginStore = null; localStorage.removeItem("login-session"); + localStorage.removeItem("pop2_access_token"); }} > Logout diff --git a/src/utils/api-request.ts b/src/utils/api-request.ts deleted file mode 100644 index b0e1167..0000000 --- a/src/utils/api-request.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {get} from "svelte/store"; -import {getBearer, loginStore} from "../stores/login"; - -const TOKEN_VERIFY_API = import.meta.env.VITE_SSO_ORIGIN + "/verify"; -const TOKEN_REFRESH_API = import.meta.env.VITE_SSO_ORIGIN + "/refresh"; - -export async function apiRequest(url: string, init?: RequestInit): Promise { - // setup authorization header - if (init == undefined) init = {}; - init.headers = {...init.headers, Authorization: getBearer()}; - - let f = await fetch(url, init); - if (f.status !== 403) return f; - - let refreshResp = await fetch(TOKEN_REFRESH_API, { - method: "POST", - mode: "cors", - cache: "no-cache", - credentials: "include", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({token: get(loginStore)?.tokens.refresh}), - }); - if (refreshResp.status !== 200) { - loginStore.set(null); - alert("Failed to refresh login session: please login again to continue"); - throw new Error("403 Unauthorized"); - } - let refreshJson = await refreshResp.json(); - loginStore.set(refreshJson); - - // update current authorization header - init.headers = {...init.headers, Authorization: getBearer()}; - return await fetch(url, init); -} - -export async function apiVerify() { - return await apiRequest(TOKEN_VERIFY_API, { - method: "POST", - mode: "cors", - cache: "no-cache", - }); -} diff --git a/src/utils/login-popup.ts b/src/utils/login-popup.ts deleted file mode 100644 index 79064f0..0000000 --- a/src/utils/login-popup.ts +++ /dev/null @@ -1,53 +0,0 @@ -import {loginStore} from "../stores/login"; - -let currentLoginPopup: {close: () => void} | null = null; - -const ssoOrigin = import.meta.env.VITE_SSO_ORIGIN; - -window.addEventListener("message", function (event) { - if (event.origin !== ssoOrigin) return; - if (isObject(event.data)) { - loginStore.set(event.data); - if (currentLoginPopup) currentLoginPopup.close(); - return; - } - alert("Failed to log user in: the login data was probably corrupted"); -}); - -function isObject(obj: Object) { - return obj != null && obj.constructor.name === "Object"; -} - -function popupCenterScreen(url: string, title: string, w: number, h: number, focus: boolean) { - const top = (screen.availHeight - h) / 4, - left = (screen.availWidth - w) / 2; - const popup = openWindow(url, title, `scrollbars=yes,width=${w},height=${h},top=${top},left=${left}`); - if (focus === true && window.focus !== undefined && popup !== null) popup.focus(); - return popup; -} - -function openWindow(url: string | URL, winnm: string, options: string): Window | null { - var wTop = firstAvailableValue([window.screen.availTop, window.screenY, window.screenTop, 0]); - var wLeft = firstAvailableValue([window.screen.availLeft, window.screenX, window.screenLeft, 0]); - let w: Window | null; - var top = 0, - left = 0; - var result; - if ((result = /top=(\d+)/g.exec(options))) top = parseInt(result[1]); - if ((result = /left=(\d+)/g.exec(options))) left = parseInt(result[1]); - if (options) { - options = options.replace("top=" + top, "top=" + (top + wTop)); - options = options.replace("left=" + left, "left=" + (left + wLeft)); - w = window.open(url, winnm, options); - } else w = window.open(url, winnm); - return w; -} - -function firstAvailableValue(arr: any) { - for (var i = 0; i < arr.length; i++) if (typeof arr[i] != "undefined") return arr[i]; -} - -export function openLoginPopup() { - if (currentLoginPopup) currentLoginPopup.close(); - currentLoginPopup = popupCenterScreen(ssoOrigin + "/popup?origin=" + encodeURIComponent(location.origin), "Login with 1f349 SSO", 500, 500, false); -} diff --git a/src/utils/login.ts b/src/utils/login.ts new file mode 100644 index 0000000..985d0b1 --- /dev/null +++ b/src/utils/login.ts @@ -0,0 +1,33 @@ +import {get} from "svelte/store"; +import {getBearer, loginStore} from "../stores/login"; +import {POP2} from "./pop2"; + +const TOKEN_AUTHORIZE_API = import.meta.env.VITE_SSO_ORIGIN + "/authorize"; +const TOKEN_USERINFO_API = import.meta.env.VITE_SSO_ORIGIN + "/userinfo"; +const OAUTH2_CLIENT_ID = import.meta.env.VITE_OAUTH2_CLIENT_ID; + +export const LOGIN = { + init: () => { + POP2.init(TOKEN_AUTHORIZE_API, OAUTH2_CLIENT_ID, "openid profile name", 500, 600); + }, + clientRequest: (resource: string, options: RequestInit, refresh: boolean) => { + return POP2.clientRequest(resource, options, refresh); + }, + userinfo: (popup: boolean) => { + console.info("userinfo", popup); + POP2.getToken((token: string) => { + POP2.clientRequest(TOKEN_USERINFO_API, {}, popup) + .then(x => x.json()) + .then(x => { + console.log(token, x); + loginStore.set({ + userinfo: x, + tokens: {access: token, refresh: ""}, + }); + }) + .catch(x => { + console.error(x); + }); + }, popup); + }, +}; diff --git a/src/utils/pop2.js b/src/utils/pop2.js new file mode 100644 index 0000000..b2b772b --- /dev/null +++ b/src/utils/pop2.js @@ -0,0 +1,187 @@ +/* Simple OAuth 2.0 Client flow library + + Author: MrMelon54, timdream + + Usage: + POP2.init(client_id, scope) + Initialize the library. + redirect_uri is the current page (window.location.href). + This function should be put before Analytics so that the second click won't result a page view register. + POP2.getToken(callback) + Send access token to the callback function as the first argument. + If not logged in this triggers login popup and execute login after logged in. + Be sure to call this function in user-triggered event (such as click) to prevent popup blocker. + If not sure do use isLoggedIn() below to check first. + POP2.isLoggedIn() + boolean + +*/ + +"use strict"; + +export const POP2 = (function (w) { + const windowName = "pop2_oauth2_login_popup"; + + if (window.name === windowName) { + if (window.opener && window.opener.POP2) { + if (window.location.hash.indexOf("access_token") !== -1) { + window.opener.POP2.receiveToken( + window.location.hash.replace(/^.*access_token=([^&]+).*$/, "$1"), + parseInt(window.location.hash.replace(/^.*expires_in=([^&]+).*$/, "$1")), + ); + } + if (window.location.search.indexOf("error=")) { + window.opener.POP2.receiveToken("ERROR"); + } + } + window.close(); + } + + function popupCenterScreen(url, title, w, h) { + const top = (screen.availHeight - h) / 4, + left = (screen.availWidth - w) / 2; + return openWindow(url, title, `scrollbars=yes,width=${w},height=${h},top=${top},left=${left}`); + } + + function openWindow(url, winnm, options) { + const wTop = firstAvailableValue([window.screen.availTop, window.screenY, window.screenTop, 0]); + const wLeft = firstAvailableValue([window.screen.availLeft, window.screenX, window.screenLeft, 0]); + let top = "0", + left = "0", + result; + if ((result = /top=(\d+)/g.exec(options))) top = parseInt(result[1]); + if ((result = /left=(\d+)/g.exec(options))) left = parseInt(result[1]); + let w; + if (options) { + options = options.replace("top=" + top, "top=" + (parseInt(top) + wTop)); + options = options.replace("left=" + left, "left=" + (parseInt(left) + wLeft)); + w = window.open(url, winnm, options); + } else w = window.open(url, winnm); + return w; + } + + function firstAvailableValue(arr) { + for (let i = 0; i < arr.length; i++) if (typeof arr[i] != "undefined") return arr[i]; + } + + let client_endpoint, + client_id, + scope = "", + redirect_uri = window.location.href.substr(0, window.location.href.length - window.location.hash.length).replace(/#$/, ""), + access_token = localStorage.getItem("pop2_access_token"), + callbackWaitForToken, + w_width = 400, + w_height = 360; + + const POP2 = { + // init + init: function (f_client_endpoint, f_client_id, f_scope, width, height) { + if (!f_client_endpoint) return false; + if (!f_client_id) return false; + client_endpoint = f_client_endpoint; + client_id = f_client_id; + if (f_scope) scope = f_scope; + if (width) w_width = width; + if (height) w_height = height; + }, + // receive token from popup + receiveToken: function (token, expires_in) { + if (token !== "ERROR") { + access_token = token; + localStorage.setItem("pop2_access_token", access_token); + if (callbackWaitForToken) callbackWaitForToken(access_token); + setTimeout(function () { + access_token = undefined; + localStorage.removeItem("pop2_access_token"); + }, expires_in * 1000); + } else if (token === false) { + callbackWaitForToken = undefined; + } + }, + // pass the access token to callback + // if not logged in this triggers login popup; + // use isLoggedIn to check login first to prevent popup blocker + getToken: function (callback, popup = true) { + if (!client_id || !redirect_uri || !scope) { + alert("You need init() first. Check the program flow."); + return false; + } + if (!popup) throw Error("missing access token"); + if (!access_token) { + callbackWaitForToken = callback; + popupCenterScreen( + client_endpoint + + "?response_type=token" + + "&redirect_uri=" + + encodeURIComponent(redirect_uri) + + "&scope=" + + encodeURIComponent(scope) + + "&client_id=" + + encodeURIComponent(client_id), + windowName, + w_width, + w_height, + ); + return false; + } else { + callback(access_token); + return true; + } + }, + clientRequest: function (resource, options, refresh = false) { + const sendRequest = function () { + options.credentials = "include"; + if (!options.headers) options.headers = {}; + options.headers["Authorization"] = "Bearer " + access_token; + return new Promise(function (res, rej) { + fetch(resource, options) + .then(function (x) { + if (x.status >= 200 && x.status < 300) res(x); + else rej(x); + }) + .catch(function (x) { + rej(["failed to send request", x]); + }); + }); + }; + const resendRequest = function () { + return new Promise(function (res, rej) { + access_token = undefined; + POP2.getToken(function () { + sendRequest() + .then(function (x) { + res(x); + }) + .catch(function (x) { + rej(["failed to resend request", x]); + }); + }); + }); + }; + + if (!refresh) { + if (!access_token) return Promise.reject("missing access token"); + return sendRequest(); + } else { + return new Promise(function (res, rej) { + sendRequest() + .then(function (x) { + res(x); + }) + .catch(function () { + resendRequest() + .then(function (x) { + res(x); + }) + .catch(function (x) { + rej(x); + }); + }); + }); + } + }, + }; + + window.POP2 = POP2; + return POP2; +})(); diff --git a/src/views/CertificatesView.svelte b/src/views/CertificatesView.svelte index 9a7ed07..b737c7b 100644 --- a/src/views/CertificatesView.svelte +++ b/src/views/CertificatesView.svelte @@ -1,7 +1,7 @@