From 05ee557d1a4a90873ffbeeed865eabbf36ad2c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Ml=C3=A1dek?= Date: Sat, 30 Mar 2024 16:35:21 +0100 Subject: [PATCH] feat: add user management - no more static keys, full register/login/logout flow - add API error type - refactor API to centralize request calls - minor refactors re: vault options - CSS refactor (buttons don't require classes, input styling) --- cli/src/main.rs | 2 +- cli/src/routes.rs | 47 +++- cli/src/serve.rs | 2 + sdks/js/src/api.ts | 218 ++++++++++++++---- sdks/js/src/types.ts | 1 + webui/src/lib/api.ts | 21 +- webui/src/lib/components/LoginModal.svelte | 90 ++++++++ webui/src/lib/components/layout/Header.svelte | 46 +++- .../lib/components/widgets/EntityList.svelte | 20 +- webui/src/lib/styles/common.scss | 18 ++ webui/src/lib/util/info.ts | 25 +- webui/src/routes/+layout.svelte | 14 ++ webui/src/routes/+page.svelte | 8 - 13 files changed, 435 insertions(+), 77 deletions(-) create mode 100644 webui/src/lib/components/LoginModal.svelte diff --git a/cli/src/main.rs b/cli/src/main.rs index 207fad6..8683e99 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -413,7 +413,7 @@ async fn main() -> Result<()> { trust_executables: args.trust_executables, secret, }, - public: Arc::new(Mutex::new(upend.connection()?.get_users()?.len() == 0)), + public: Arc::new(Mutex::new(upend.connection()?.get_users()?.is_empty())), }; // Start HTTP server diff --git a/cli/src/routes.rs b/cli/src/routes.rs index c9d368d..23fffe1 100644 --- a/cli/src/routes.rs +++ b/cli/src/routes.rs @@ -72,22 +72,40 @@ pub struct UserPayload { password: String, } +#[derive(Deserialize)] +pub struct LoginQueryParams { + via: Option, +} + #[post("/api/auth/login")] pub async fn login( state: web::Data, payload: web::Json, + query: web::Query, ) -> Result { let conn = state.upend.connection().map_err(ErrorInternalServerError)?; match conn.authenticate_user(&payload.username, &payload.password) { Ok(()) => { let token = create_token(&payload.username, &state.config.secret)?; - Ok(HttpResponse::Ok().json(json!({ "token": token }))) + match query.via.as_deref() { + Some("cookie") => Ok(HttpResponse::NoContent() + .append_header((http::header::SET_COOKIE, format!("key={}; Path=/", token))) + .finish()), + _ => Ok(HttpResponse::Ok().json(json!({ "key": token }))), + } } Err(e) => Err(ErrorUnauthorized(e)), } } +#[post("/api/auth/logout")] +pub async fn logout() -> Result { + Ok(HttpResponse::NoContent() + .append_header((http::header::SET_COOKIE, "key=; Path=/; Max-Age=0")) + .finish()) +} + #[post("/api/auth/register")] pub async fn register( req: HttpRequest, @@ -108,27 +126,40 @@ pub async fn register( } } +#[get("/api/auth/whoami")] +pub async fn whoami(req: HttpRequest, state: web::Data) -> Result { + let user = check_auth(&req, &state)?; + Ok(HttpResponse::Ok().json(json!({ "user": user }))) +} + fn check_auth(req: &HttpRequest, state: &State) -> Result, actix_web::Error> { if *state.public.lock().unwrap() { return Ok(None); } - if let Some(auth_header) = req.headers().get("Authorization") { - let auth_header = auth_header.to_str().map_err(|err| { + let key = if let Some(value) = req.headers().get("Authorization") { + let value = value.to_str().map_err(|err| { ErrorBadRequest(format!("Invalid value in Authorization header: {err:?}")) })?; - - // decode Bearer - if !auth_header.starts_with("Bearer ") { + if !value.starts_with("Bearer ") { return Err(ErrorUnauthorized("Invalid token type.")); } + Some(value.trim_start_matches("Bearer ").to_string()) + } else if let Ok(cookies) = req.cookies() { + cookies + .iter() + .find(|c| c.name() == "key") + .map(|cookie| cookie.value().to_string()) + } else { + None + }; + if let Some(key) = key { let token = jsonwebtoken::decode::( - auth_header.trim_start_matches("Bearer "), + &key, &jsonwebtoken::DecodingKey::from_secret(state.config.secret.as_ref()), &jsonwebtoken::Validation::default(), ); - match token { Ok(token) => Ok(Some(token.claims.user)), Err(err) => Err(ErrorUnauthorized(format!("Invalid token: {err:?}"))), diff --git a/cli/src/serve.rs b/cli/src/serve.rs index 294a2b1..1789e54 100644 --- a/cli/src/serve.rs +++ b/cli/src/serve.rs @@ -47,6 +47,8 @@ where .wrap(actix_web::middleware::Logger::default().exclude("/api/jobs")) .service(routes::login) .service(routes::register) + .service(routes::logout) + .service(routes::whoami) .service(routes::get_raw) .service(routes::head_raw) .service(routes::get_thumbnail) diff --git a/sdks/js/src/api.ts b/sdks/js/src/api.ts index 9c4832b..2258dc3 100644 --- a/sdks/js/src/api.ts +++ b/sdks/js/src/api.ts @@ -22,6 +22,11 @@ const dbg = debug("upend:api"); export type { AddressComponents }; +export type UpendApiError = { + kind: "Unauthorized" | "HttpError" | "FetchError" | "Unknown"; + error?: Error; +}; + export class UpEndApi { private instanceUrl = ""; private readonly wasmExtensions: UpEndWasmExtensions | undefined = undefined; @@ -29,15 +34,21 @@ export class UpEndApi { private queryOnceLRU = new LRU({ max: 128 }); private inFlightRequests: { [key: string]: Promise | null } = {}; + private key: string | undefined; + private readonly onError: ((error: UpendApiError) => void) | undefined; constructor(config: { instanceUrl?: string; wasmExtensions?: UpEndWasmExtensions; timeout?: number; + authKey?: string; + onError?: (error: UpendApiError) => void; }) { this.setInstanceUrl(config.instanceUrl || "http://localhost:8093"); this.wasmExtensions = config.wasmExtensions; this.timeout = config.timeout || 30_000; + this.key = config.authKey; + this.onError = config.onError; } public setInstanceUrl(apiUrl: string) { @@ -53,10 +64,10 @@ export class UpEndApi { options?: ApiFetchOptions, ): Promise { dbg("Fetching Entity %s", address); - const signal = this.getAbortSignal(options); - const entityFetch = await fetch(`${this.apiUrl}/obj/${address}`, { - signal, - }); + const entityFetch = await this.fetch( + `${this.apiUrl}/obj/${address}`, + options, + ); const entityResult = (await entityFetch.json()) as EntityListing; const entityListing = new UpListing(entityResult.entries); return entityListing.getObject(address); @@ -64,8 +75,7 @@ export class UpEndApi { public async fetchEntry(address: string, options?: ApiFetchOptions) { dbg("Fetching entry %s", address); - const signal = this.getAbortSignal(options); - const response = await fetch(`${this.apiUrl}/raw/${address}`, { signal }); + const response = await this.fetch(`${this.apiUrl}/raw/${address}`, options); const data = await response.json(); const listing = new UpListing({ address: data }); return listing.entries[0]; @@ -82,12 +92,10 @@ export class UpEndApi { if (!this.inFlightRequests[queryStr]) { dbg(`Querying: ${query}`); this.inFlightRequests[queryStr] = new Promise((resolve, reject) => { - const signal = this.getAbortSignal(options); - fetch(`${this.apiUrl}/query`, { + this.fetch(`${this.apiUrl}/query`, options, { method: "POST", body: queryStr, keepalive: true, - signal, }) .then(async (response) => { if (!response.ok) { @@ -117,12 +125,10 @@ export class UpEndApi { options?: ApiFetchOptions, ): Promise { dbg("Putting %O", input); - const signal = this.getAbortSignal(options); - const response = await fetch(`${this.apiUrl}/obj`, { - method: "PUT", + const response = await this.fetch(`${this.apiUrl}/obj`, options, { headers: { "Content-Type": "application/json" }, + method: "PUT", body: JSON.stringify(input), - signal, }); return await response.json(); @@ -141,12 +147,10 @@ export class UpEndApi { url += `?provenance=${provenance}`; } - const signal = this.getAbortSignal(options); - const response = await fetch(url, { + const response = await this.fetch(url, options, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(value), - signal, }); return await response.json(); @@ -203,10 +207,9 @@ export class UpEndApi { xhr.send(formData); }); } else { - const response = await fetch(`${this.apiUrl}/blob`, { + const response = await this.fetch(`${this.apiUrl}/blob`, options, { method: "PUT", body: formData, - signal, }); if (!response.ok) { @@ -222,8 +225,9 @@ export class UpEndApi { options?: ApiFetchOptions, ): Promise { dbg("Deleting entry %s", address); - const signal = this.getAbortSignal(options); - await fetch(`${this.apiUrl}/obj/${address}`, { method: "DELETE", signal }); + await this.fetch(`${this.apiUrl}/obj/${address}`, options, { + method: "DELETE", + }); } public getRaw(address: Address, preview = false) { @@ -236,26 +240,24 @@ export class UpEndApi { options?: ApiFetchOptions, ) { dbg("Getting %s raw (preview = %s)", address, preview); - const signal = this.getAbortSignal(options); - return await fetch(this.getRaw(address, preview), { signal }); + return await this.fetch(this.getRaw(address, preview), options); } public async refreshVault(options?: ApiFetchOptions) { dbg("Triggering vault refresh"); - const signal = this.getAbortSignal(options); - return await fetch(`${this.apiUrl}/refresh`, { method: "POST", signal }); + return await this.fetch(`${this.apiUrl}/refresh`, options, { + method: "POST", + }); } public async nativeOpen(address: Address, options?: ApiFetchOptions) { dbg("Opening %s natively", address); - const signal = this.getAbortSignal(options); - return fetch(`${this.apiUrl}/raw/${address}?native=1`, { signal }); + return this.fetch(`${this.apiUrl}/raw/${address}?native=1`, options); } public async fetchRoots(options?: ApiFetchOptions): Promise { dbg("Fetching hierarchical roots..."); - const signal = this.getAbortSignal(options); - const response = await fetch(`${this.apiUrl}/hier_roots`, { signal }); + const response = await this.fetch(`${this.apiUrl}/hier_roots`, options); const roots = await response.json(); dbg("Hierarchical roots: %O", roots); return roots; @@ -263,8 +265,7 @@ export class UpEndApi { public async fetchJobs(options?: ApiFetchOptions): Promise { // dbg("Fetching jobs..."); - const signal = this.getAbortSignal(options); - const response = await fetch(`${this.apiUrl}/jobs`, { signal }); + const response = await this.fetch(`${this.apiUrl}/jobs`, options); return await response.json(); } @@ -272,8 +273,7 @@ export class UpEndApi { options?: ApiFetchOptions, ): Promise { dbg("Fetching all attributes..."); - const signal = this.getAbortSignal(options); - const response = await fetch(`${this.apiUrl}/all/attributes`, { signal }); + const response = await this.fetch(`${this.apiUrl}/all/attributes`, options); const result = await response.json(); dbg("All attributes: %O", result); return await result; @@ -281,19 +281,25 @@ export class UpEndApi { public async fetchInfo(options?: ApiFetchOptions): Promise { dbg("Fetching vault info..."); - const signal = this.getAbortSignal(options); - const response = await fetch(`${this.apiUrl}/info`, { signal }); + const response = await this.fetch(`${this.apiUrl}/info`, options); const result = await response.json(); dbg("Vault info: %O", result); return result; } + public async fetchOptions(options?: ApiFetchOptions): Promise { + dbg("Fetching vault options..."); + const response = await this.fetch(`${this.apiUrl}/options`, options); + const result = await response.json(); + dbg("Vault options: %O", result); + return result; + } + public async fetchStoreInfo( options?: ApiFetchOptions, ): Promise<{ [key: string]: StoreInfo }> { dbg("Fetching store info..."); - const signal = this.getAbortSignal(options); - const response = await fetch(`${this.apiUrl}/stats/store`, { signal }); + const response = await this.fetch(`${this.apiUrl}/stats/store`, options); const result = await response.json(); dbg("Store info: %O"); return await result; @@ -309,16 +315,15 @@ export class UpEndApi { await this.wasmExtensions.init(); return this.wasmExtensions.AddressTypeConstants[input]; } - const signal = this.getAbortSignal(options); - response = await fetch(`${this.apiUrl}/address?type=${input}`, { - signal, - }); + response = await this.fetch( + `${this.apiUrl}/address?type=${input}`, + options, + ); } else { if ("urlContent" in input) { - const signal = this.getAbortSignal(options); - response = await fetch( + response = await this.fetch( `${this.apiUrl}/address?url_content=${input.urlContent}`, - { signal }, + options, ); } else { throw new Error("Input cannot be empty."); @@ -352,8 +357,7 @@ export class UpEndApi { public async getVaultOptions( options?: ApiFetchOptions, ): Promise { - const signal = this.getAbortSignal(options); - const response = await fetch(`${this.apiUrl}/options`, { signal }); + const response = await this.fetch(`${this.apiUrl}/options`, options); return await response.json(); } @@ -369,12 +373,10 @@ export class UpEndApi { payload["blob_mode"] = blob_mode; } - const signal = this.getAbortSignal(apiOptions); - const response = await fetch(`${this.apiUrl}/options`, { + const response = await this.fetch(`${this.apiUrl}/options`, apiOptions, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), - signal, }); if (!response.ok) { @@ -382,6 +384,80 @@ export class UpEndApi { } } + public async authenticate( + credentials: { + username: string; + password: string; + }, + mode: "key", + options?: ApiFetchOptions, + ): Promise<{ key: string }>; + public async authenticate( + credentials: { + username: string; + password: string; + }, + mode?: "cookie", + options?: ApiFetchOptions, + ): Promise; + public async authenticate( + credentials: { + username: string; + password: string; + }, + mode: "key" | "cookie" | undefined, + options?: ApiFetchOptions, + ): Promise<{ key: string } | void> { + const via = mode || "cookie"; + const response = await this.fetch( + `${this.apiUrl}/auth/login?via=${via}`, + options, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(credentials), + }, + ); + + if (!response.ok) { + throw Error(await response.text()); + } + + if (mode === "key") { + const data = await response.json(); + if (!data.key) { + throw Error("No key returned from server."); + } + this.key = data.key; + return data.key; + } + } + + public async authStatus( + options?: ApiFetchOptions, + ): Promise<{ user: string } | undefined> { + const response = await this.fetch(`${this.apiUrl}/auth/whoami`, options); + return await response.json(); + } + + public async resetAuth(mode: "key"): Promise; + public async resetAuth( + mode?: "cookie", + options?: ApiFetchOptions, + ): Promise; + public async resetAuth( + mode?: "key" | "cookie", + options?: ApiFetchOptions, + ): Promise { + if (mode === "key") { + this.key = undefined; + } else { + await this.fetch(`${this.apiUrl}/auth/logout`, options, { + method: "POST", + }); + } + } + private getAbortSignal(options: ApiFetchOptions | undefined) { const controller = options?.abortController || new AbortController(); const timeout = options?.timeout || this.timeout; @@ -390,6 +466,51 @@ export class UpEndApi { } return controller.signal; } + + private async fetch( + url: string, + options: ApiFetchOptions | undefined, + requestInit?: RequestInit & { headers?: Record }, + ): Promise { + const signal = this.getAbortSignal(options); + const headers = requestInit?.headers || {}; + if (this.key) { + headers["Authorization"] = `Bearer ${this.key}`; + } + + let result: Response; + let error: UpendApiError | undefined; + try { + result = await fetch(url, { + ...requestInit, + signal, + headers, + }); + if (!result.ok) { + if (result.status === 401) { + error = { kind: "Unauthorized" }; + } else { + error = { + kind: "HttpError", + error: new Error( + `HTTP Error ${result.status}: ${result.statusText}`, + ), + }; + } + } + } catch (e) { + error = { kind: "FetchError", error: e as Error }; + } + + if (error) { + if (this.onError) { + this.onError(error); + } + throw error; + } + + return result!; + } } export interface ApiFetchOptions { @@ -398,6 +519,7 @@ export interface ApiFetchOptions { } export type VaultBlobMode = "Flat" | "Mirror" | "Incoming"; + export interface VaultOptions { blob_mode: VaultBlobMode; } diff --git a/sdks/js/src/types.ts b/sdks/js/src/types.ts index 66b6873..78ed224 100644 --- a/sdks/js/src/types.ts +++ b/sdks/js/src/types.ts @@ -97,6 +97,7 @@ export interface VaultInfo { location: string; version: string; desktop: boolean; + public: boolean; } export interface StoreInfo { diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 0cb7876..674a479 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -1,6 +1,25 @@ import { UpEndApi } from '@upnd/upend'; import { UpEndWasmExtensionsWeb } from '@upnd/upend/wasm/web'; import wasmURL from '@upnd/wasm-web/upend_wasm_bg.wasm?url'; +import { type StartStopNotifier, writable, type Writable } from 'svelte/store'; const wasm = new UpEndWasmExtensionsWeb(wasmURL); -export default new UpEndApi({ instanceUrl: '/', wasmExtensions: wasm }); +const api = new UpEndApi({ instanceUrl: '/', wasmExtensions: wasm }); +export default api; + +export const currentUser: Writable = writable( + undefined as string | undefined, + ((set) => { + api.authStatus().then((result) => set(result?.user)); + }) as StartStopNotifier +); + +export async function login(credentials: { username: string; password: string }) { + await api.authenticate(credentials); + window.location.reload(); +} + +export async function logout() { + await api.resetAuth(); + window.location.reload(); +} diff --git a/webui/src/lib/components/LoginModal.svelte b/webui/src/lib/components/LoginModal.svelte new file mode 100644 index 0000000..20d566c --- /dev/null +++ b/webui/src/lib/components/LoginModal.svelte @@ -0,0 +1,90 @@ + + + + + diff --git a/webui/src/lib/components/layout/Header.svelte b/webui/src/lib/components/layout/Header.svelte index dbe6ef2..bd75290 100644 --- a/webui/src/lib/components/layout/Header.svelte +++ b/webui/src/lib/components/layout/Header.svelte @@ -2,13 +2,16 @@ import { addEmitter } from '../AddModal.svelte'; import Icon from '../utils/Icon.svelte'; import { jobsEmitter } from './Jobs.svelte'; - import api from '$lib/api'; + import api, { currentUser, logout } from '$lib/api'; import Selector, { type SelectorValue } from '../utils/Selector.svelte'; import { i18n } from '$lib/i18n'; import { goto } from '$app/navigation'; import { onMount } from 'svelte'; + import { vaultInfo } from '$lib/util/info'; + import { slide } from 'svelte/transition'; let selector: Selector; + let userDropdown = false; let lastSearched: SelectorValue[] = []; @@ -57,6 +60,7 @@ } let fileInput: HTMLInputElement; + function onFileChange() { if (fileInput.files?.length) { addEmitter.emit('files', Array.from(fileInput.files)); @@ -73,6 +77,12 @@ } + { + userDropdown = false; + }} +/> + - - + + {#if userDropdown} + +
{}}> +
+ + {$currentUser || '???'} +
+
+ +
+ {/if}