New OAuth based login system

This commit is contained in:
Melon 2024-02-14 16:50:17 +00:00
parent 11848b2d97
commit f3d4b0e0a2
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
10 changed files with 257 additions and 119 deletions

View File

@ -1,4 +1,5 @@
VITE_SSO_ORIGIN=http://localhost:9090 VITE_SSO_ORIGIN=http://localhost:9090
VITE_OAUTH2_CLIENT_ID=abc123
VITE_API_VIOLET=http://localhost:9095/v1/violet VITE_API_VIOLET=http://localhost:9095/v1/violet
VITE_API_ORCHID=http://localhost:9095/v1/orchid VITE_API_ORCHID=http://localhost:9095/v1/orchid

View File

@ -1,4 +1,5 @@
VITE_SSO_ORIGIN=https://sso.1f349.com 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_VIOLET=https://api.1f349.com/v1/violet
VITE_API_ORCHID=https://api.1f349.com/v1/orchid VITE_API_ORCHID=https://api.1f349.com/v1/orchid

View File

@ -6,9 +6,8 @@
import CertificatesView from "./views/CertificatesView.svelte"; import CertificatesView from "./views/CertificatesView.svelte";
import SitesView from "./views/SitesView.svelte"; import SitesView from "./views/SitesView.svelte";
import {loginStore, parseJwt, type LoginStore} from "./stores/login"; import {loginStore, parseJwt, type LoginStore} from "./stores/login";
import {openLoginPopup} from "./utils/login-popup";
import {domainOption} from "./stores/domain-option"; 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<{}>}> = [ let sidebarOptions: Array<{name: string; view: typeof SvelteComponent<{}>}> = [
{name: "General", view: GeneralView}, {name: "General", view: GeneralView},
@ -34,7 +33,8 @@
} }
onMount(() => { onMount(() => {
apiVerify().catch(() => {}); LOGIN.init();
LOGIN.userinfo(false);
}); });
</script> </script>
@ -48,7 +48,7 @@
</div> </div>
{#if $loginStore == null} {#if $loginStore == null}
<div class="login-view"> <div class="login-view">
<button on:click={() => openLoginPopup()}>Login</button> <button on:click={() => LOGIN.userinfo(true)}>Login</button>
</div> </div>
{:else} {:else}
<div class="user-view"> <div class="user-view">
@ -58,6 +58,7 @@
on:click={() => { on:click={() => {
$loginStore = null; $loginStore = null;
localStorage.removeItem("login-session"); localStorage.removeItem("login-session");
localStorage.removeItem("pop2_access_token");
}} }}
> >
Logout Logout

View File

@ -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<Response> {
// 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",
});
}

View File

@ -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);
}

33
src/utils/login.ts Normal file
View File

@ -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);
},
};

187
src/utils/pop2.js Normal file
View File

@ -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;
})();

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {domainOption} from "../stores/domain-option"; import {domainOption} from "../stores/domain-option";
import {type Cert, certsTable} from "../stores/certs"; import {type Cert, certsTable} from "../stores/certs";
import {apiRequest} from "../utils/api-request"; import {LOGIN} from "../utils/login";
const apiOrchid = import.meta.env.VITE_API_ORCHID; const apiOrchid = import.meta.env.VITE_API_ORCHID;
@ -30,7 +30,7 @@
let promiseForTable: Promise<void> = reloadTable(); let promiseForTable: Promise<void> = reloadTable();
async function reloadTable(): Promise<void> { async function reloadTable(): Promise<void> {
let f = await apiRequest(apiOrchid + "/owned"); let f = await LOGIN.clientRequest(apiOrchid + "/owned", {}, false);
if (f.status !== 200) throw new Error("Unexpected status code: " + f.status); if (f.status !== 200) throw new Error("Unexpected status code: " + f.status);
let fJson = await f.json(); let fJson = await f.json();
let rows = fJson as Map<number, Cert>; let rows = fJson as Map<number, Cert>;

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {domainOption} from "../stores/domain-option"; import {domainOption} from "../stores/domain-option";
import {type Site, sitesTable} from "../stores/sites"; import {type Site, sitesTable} from "../stores/sites";
import {apiRequest} from "../utils/api-request"; import {LOGIN} from "../utils/login";
const apiSiteHosting = import.meta.env.VITE_API_SITE_HOSTING; const apiSiteHosting = import.meta.env.VITE_API_SITE_HOSTING;
@ -23,7 +23,7 @@
let promiseForTable: Promise<void> = reloadTable(); let promiseForTable: Promise<void> = reloadTable();
async function reloadTable(): Promise<void> { async function reloadTable(): Promise<void> {
let f = await apiRequest(apiSiteHosting); let f = await LOGIN.clientRequest(apiSiteHosting, {}, false);
if (f.status !== 200) throw new Error("Unexpected status code: " + f.status); if (f.status !== 200) throw new Error("Unexpected status code: " + f.status);
let fJson = await f.json(); let fJson = await f.json();
let rows = fJson as Site[]; let rows = fJson as Site[];
@ -33,19 +33,27 @@
} }
async function deleteBranch(site: Site, branch: string) { async function deleteBranch(site: Site, branch: string) {
let f = await apiRequest(apiSiteHosting, { let f = await LOGIN.clientRequest(
method: "POST", apiSiteHosting,
body: JSON.stringify({submit: "delete-branch", site: site.domain, branch}), {
}); method: "POST",
body: JSON.stringify({submit: "delete-branch", site: site.domain, branch}),
},
false,
);
if (f.status !== 200) throw new Error("Unexpected status code: " + f.status); if (f.status !== 200) throw new Error("Unexpected status code: " + f.status);
promiseForTable = reloadTable(); promiseForTable = reloadTable();
} }
async function resetSiteSecret(site: Site) { async function resetSiteSecret(site: Site) {
let f = await apiRequest(apiSiteHosting, { let f = await LOGIN.clientRequest(
method: "POST", apiSiteHosting,
body: JSON.stringify({submit: "secret", site: site.domain}), {
}); method: "POST",
body: JSON.stringify({submit: "secret", site: site.domain}),
},
false,
);
if (f.status !== 200) throw new Error("Unexpected status code: " + f.status); if (f.status !== 200) throw new Error("Unexpected status code: " + f.status);
let fJson = await f.json(); let fJson = await f.json();
alert("New secret: " + fJson.secret); alert("New secret: " + fJson.secret);

View File

@ -5,7 +5,7 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import {apiRequest} from "../utils/api-request"; import {LOGIN} from "../utils/login";
import {writable, type Writable} from "svelte/store"; import {writable, type Writable} from "svelte/store";
import type {CSPair} from "../types/cspair"; import type {CSPair} from "../types/cspair";
import {domainOption} from "../stores/domain-option"; import {domainOption} from "../stores/domain-option";
@ -42,7 +42,7 @@
let promiseForTable: Promise<void> = reloadTable(); let promiseForTable: Promise<void> = reloadTable();
async function reloadTable(): Promise<void> { async function reloadTable(): Promise<void> {
let f = await apiRequest(apiUrl); let f = await LOGIN.clientRequest(apiUrl, {}, false);
if (f.status !== 200) throw new Error("Unexpected status code: " + f.status); if (f.status !== 200) throw new Error("Unexpected status code: " + f.status);
let fJson = await f.json(); let fJson = await f.json();
@ -77,10 +77,14 @@
}) })
.sort((a, _) => (a.type === "del" ? -1 : a.type === "ins" ? 1 : 0)) .sort((a, _) => (a.type === "del" ? -1 : a.type === "ins" ? 1 : 0))
.map(x => { .map(x => {
x.v.p = apiRequest(apiUrl, { x.v.p = LOGIN.clientRequest(
method: x.type == "del" ? "DELETE" : "POST", apiUrl,
body: JSON.stringify(x.type == "del" ? {src: (x.v.server as T).src} : x.v.client), {
}).then(x => { method: x.type == "del" ? "DELETE" : "POST",
body: JSON.stringify(x.type == "del" ? {src: (x.v.server as T).src} : x.v.client),
},
false,
).then(x => {
if (x.status !== 200) throw new Error("Unexpected status code: " + x.status); if (x.status !== 200) throw new Error("Unexpected status code: " + x.status);
}); });
return x.v.p; return x.v.p;