diff --git a/shared/common/newui/modal/index.tsx b/shared/common/newui/modal/index.tsx index 1fb12f7a..6e77177d 100644 --- a/shared/common/newui/modal/index.tsx +++ b/shared/common/newui/modal/index.tsx @@ -65,12 +65,11 @@ export function ModalPanel({
{subheading}
) : null} -
onClose())} - > - -
+ {onClose ? ( +
+ +
+ ) : null} ) : null} {children} diff --git a/web/package.json b/web/package.json index 48750763..3269b767 100644 --- a/web/package.json +++ b/web/package.json @@ -19,7 +19,6 @@ "@fontsource-variable/roboto-flex": "^5.1.0", "@fontsource-variable/roboto-mono": "^5.1.0", "edgedb": "1.6.0-canary.20250120T111308", - "hash.js": "^1.1.7", "mobx": "^6.5.0", "mobx-keystone": "^1.11.0", "mobx-react-lite": "^4.0.0", diff --git a/web/src/components/loginPage/index.tsx b/web/src/components/loginPage/index.tsx index 990fa7bd..9dee8565 100644 --- a/web/src/components/loginPage/index.tsx +++ b/web/src/components/loginPage/index.tsx @@ -1,18 +1,36 @@ import {useEffect, useState} from "react"; import {useForm} from "react-hook-form"; -import Button from "@edgedb/common/ui/button"; -import {ModalTextField} from "@edgedb/common/ui/modal"; +import {getHTTPSCRAMAuth} from "edgedb/dist/httpScram"; +import {cryptoUtils} from "edgedb/dist/browserCrypto"; + +import { + ModalPanel, + ArrowRightIcon, + ModalContent, + TextInput, + SubmitButton, + InfoIcon, +} from "@edgedb/common/newui"; import {serverUrl, setAuthToken} from "../../state/models/app"; -import {SCRAMAuth} from "../../utils/scram"; +import {Logo} from "../header"; import styles from "./loginPage.module.scss"; -import {ArrowRight} from "@edgedb/studio/icons"; -import {Logo} from "../header"; + +const httpSCRAMAuth = getHTTPSCRAMAuth(cryptoUtils); + +const isLocalhost = (() => { + try { + return new URL(serverUrl).hostname === "localhost"; + } catch { + // ignore + } + return false; +})(); export default function LoginPage() { - const [error, setError] = useState(null); + const [error, setError] = useState(null); const {register, handleSubmit, formState, setFocus} = useForm<{ username: string; password: string; @@ -29,47 +47,63 @@ export default function LoginPage() { const onSubmit = handleSubmit(async ({username, password}) => { setError(null); try { - const authToken = await SCRAMAuth(serverUrl, username, password); + const authToken = await httpSCRAMAuth(serverUrl, username, password); setAuthToken(username, authToken); } catch (err) { - setError(err as Error); console.error(err); + setError("Login failed: username or password may be incorrect"); } }); return (
- -
- - -
); } diff --git a/web/src/components/loginPage/loginPage.module.scss b/web/src/components/loginPage/loginPage.module.scss index 539e1177..1e13a363 100644 --- a/web/src/components/loginPage/loginPage.module.scss +++ b/web/src/components/loginPage/loginPage.module.scss @@ -5,57 +5,77 @@ flex-grow: 1; justify-content: center; align-items: center; + padding: 32px; } -.title { +.logo { position: absolute; top: 16px; left: 24px; } -.loginForm { - position: relative; - display: flex; - flex-direction: column; - width: 280px; +.loginPanel { + border-color: var(--Grey85); + box-shadow: 0px 4px 8px 0px rgba(0, 0, 0, 0.12), + 0px 2px 2px 0px rgba(0, 0, 0, 0.04); @include darkTheme { - input { - background: #282828; - } + box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.4), + 0px 2px 4px 0px rgba(0, 0, 0, 0.2); } +} - .loginButton { - --buttonBg: var(--app-accent-green); - --buttonTextColour: #fff; - align-self: flex-end; - padding: 0; +.formContent { + width: 380px; + padding-bottom: 32px; +} - & > div { - padding: 6px 16px; - border-radius: 18px; - font-weight: 600; - } +.loginInfo { + display: flex; + background: #dce9ef; + padding: 12px 16px 12px 12px; + margin: 0 -8px; + margin-bottom: 8px; + border-radius: 8px; + gap: 8px; + line-height: 20px; + font-weight: 425; + color: var(--secondary_text_color); - .loginButtonIcon { - stroke: currentColor; - height: 14px; - } + svg { + flex-shrink: 0; + color: #1a6e9a; } -} - -.loginError { - color: #db5246; - background: rgba(219, 82, 70, 0.1); - padding: 12px 16px; - border-radius: 6px; - position: absolute; - top: 100%; - margin-top: 16px; - left: 0; - right: 0; span { - font-weight: 600; + color: #134c6b; + font-weight: 475; + line-height: 24px; + } + + code { + background: rgba(0, 0, 0, 0.05); + font-family: "Roboto Mono Variable", monospace; + padding: 2px 4px; + border-radius: 6px; + font-weight: 500; + font-size: 13px; + white-space: nowrap; + } + + @include darkTheme { + background: #233945; + + svg { + color: #65a8cb; + } + + span { + color: #83bbd8; + } + + code { + background: rgba(255, 255, 255, 0.1); + } } } diff --git a/web/src/utils/scram.ts b/web/src/utils/scram.ts deleted file mode 100644 index 2c107eea..00000000 --- a/web/src/utils/scram.ts +++ /dev/null @@ -1,289 +0,0 @@ -// @ts-expect-error No types available -import * as SHA256 from "hash.js/lib/hash/sha/256"; -// @ts-expect-error No types available -import * as Hmac from "hash.js/lib/hash/hmac"; - -import {ProtocolError} from "edgedb"; - -const AUTH_ENDPOINT = "/auth/token"; -const RAW_NONCE_LENGTH = 18; - -export async function SCRAMAuth( - baseUrl: string, - username: string, - password: string -): Promise { - const authUrl = baseUrl + AUTH_ENDPOINT; - const clientNonce = generateNonce(); - const [clientFirst, clientFirstBare] = buildClientFirstMessage( - clientNonce, - username - ); - - const serverFirstRes = await fetch(authUrl, { - headers: { - Authorization: `SCRAM-SHA-256 data=${utf8ToB64(clientFirst)}`, - }, - }); - if (serverFirstRes.status === 403) { - throw new Error(`Server doesn't support HTTP SCRAM authentication`); - } - const firstAttrs = parseHeaders(serverFirstRes.headers, "WWW-Authenticate"); - if (firstAttrs.size === 0) { - throw new Error("Invalid credentials"); - } - if (!firstAttrs.has("sid") || !firstAttrs.has("data")) { - throw new ProtocolError( - `server response doesn't contain '${ - !firstAttrs.has("sid") ? "sid" : "data" - }' attribute` - ); - } - const sid = firstAttrs.get("sid")!; - const serverFirst = b64ToUtf8(firstAttrs.get("data")!); - - const [serverNonce, salt, iterCount] = parseServerFirstMessage(serverFirst); - - const [clientFinal, expectedServerSig] = buildClientFinalMessage( - password, - salt, - iterCount, - clientFirstBare, - serverFirst, - serverNonce - ); - - const serverFinalRes = await fetch(authUrl, { - headers: { - Authorization: `SCRAM-SHA-256 sid=${sid}, data=${utf8ToB64( - clientFinal - )}`, - }, - }); - if (!serverFinalRes.ok) { - throw new Error("Invalid credentials"); - } - const finalAttrs = parseHeaders( - serverFinalRes.headers, - "Authentication-Info", - false - ); - if (!firstAttrs.has("sid") || !firstAttrs.has("data")) { - throw new ProtocolError( - `server response doesn't contain '${ - !firstAttrs.has("sid") ? "sid" : "data" - }' attribute` - ); - } - if (finalAttrs.get("sid") !== sid) { - throw new ProtocolError("SCRAM session id does not match"); - } - const serverFinal = b64ToUtf8(finalAttrs.get("data")!); - - const serverSig = parseServerFinalMessage(serverFinal); - - if (!serverSig.equals(expectedServerSig)) { - throw new ProtocolError("server SCRAM proof does not match"); - } - - const authToken = await serverFinalRes.text(); - - return authToken; -} - -function parseHeaders(headers: Headers, headerName: string, checkAlgo = true) { - const header = headers.get(headerName); - if (!header) { - throw new ProtocolError(`response doesn't contain '${headerName}' header`); - } - let rawAttrs: string; - if (checkAlgo) { - const [algo, ..._rawAttrs] = header.split(" "); - if (algo !== "SCRAM-SHA-256") { - throw new ProtocolError(`invalid scram algo '${algo}'`); - } - rawAttrs = _rawAttrs.join(" "); - } else { - rawAttrs = header; - } - return new Map( - rawAttrs - ? rawAttrs.split(",").map((attr) => { - const [key, val] = attr.split("=", 2); - return [key.trim(), val.trim()]; - }) - : [] - ); -} - -function utf8ToB64(str: string): string { - return Buffer.from(str, "utf8").toString("base64"); -} - -function b64ToUtf8(str: string): string { - return Buffer.from(str, "base64").toString("utf8"); -} - -function saslprep(str: string): string { - return str.normalize("NFKC"); -} - -function randomBytes(size: number): Buffer { - const buf = new Uint8Array(size); - const rand = window.crypto.getRandomValues(buf); - - return Buffer.from(rand); -} - -function H(msg: Buffer): Buffer { - const hash = new SHA256(); - hash.update(msg); - return Buffer.from(hash.digest()); -} - -function HMAC(key: Buffer, ...msgs: Buffer[]): Buffer { - const hm = Hmac(SHA256, key); - for (const msg of msgs) { - hm.update(msg); - } - return Buffer.from(hm.digest()); -} - -export function generateNonce(length: number = RAW_NONCE_LENGTH): Buffer { - return randomBytes(length); -} - -function buildClientFirstMessage( - clientNonce: Buffer, - username: string -): [string, string] { - const bare = `n=${saslprep(username)},r=${clientNonce.toString("base64")}`; - return [`n,,${bare}`, bare]; -} - -function parseServerFirstMessage(msg: string): [Buffer, Buffer, number] { - const attrs = msg.split(","); - - if (attrs.length < 3) { - throw new ProtocolError("malformed SCRAM message"); - } - - const nonceAttr = attrs[0]; - if (!nonceAttr || nonceAttr[0] !== "r") { - throw new ProtocolError("malformed SCRAM message"); - } - const nonceB64 = nonceAttr.split("=", 2)[1]; - if (!nonceB64) { - throw new ProtocolError("malformed SCRAM message"); - } - const nonce = Buffer.from(nonceB64, "base64"); - - const saltAttr = attrs[1]; - if (!saltAttr || saltAttr[0] !== "s") { - throw new ProtocolError("malformed SCRAM message"); - } - const saltB64 = saltAttr.split("=", 2)[1]; - if (!saltB64) { - throw new ProtocolError("malformed SCRAM message"); - } - const salt = Buffer.from(saltB64, "base64"); - - const iterAttr = attrs[2]; - if (!iterAttr || iterAttr[0] !== "i") { - throw new ProtocolError("malformed SCRAM message"); - } - const iter = iterAttr.split("=", 2)[1]; - if (!iter || !iter.match(/^[0-9]*$/)) { - throw new ProtocolError("malformed SCRAM message"); - } - const iterCount = parseInt(iter, 10); - if (iterCount <= 0) { - throw new ProtocolError("malformed SCRAM message"); - } - - return [nonce, salt, iterCount]; -} - -function buildClientFinalMessage( - password: string, - salt: Buffer, - iterations: number, - clientFirstBare: string, - serverFirst: string, - serverNonce: Buffer -): [string, Buffer] { - const clientFinal = `c=biws,r=${serverNonce.toString("base64")}`; - const authMessage = Buffer.from( - `${clientFirstBare},${serverFirst},${clientFinal}`, - "utf8" - ); - const saltedPassword = getSaltedPassword( - Buffer.from(saslprep(password), "utf8"), - salt, - iterations - ); - const clientKey = getClientKey(saltedPassword); - const storedKey = H(clientKey); - const clientSignature = HMAC(storedKey, authMessage); - const clientProof = XOR(clientKey, clientSignature); - - const serverKey = getServerKey(saltedPassword); - const serverProof = HMAC(serverKey, authMessage); - - return [`${clientFinal},p=${clientProof.toString("base64")}`, serverProof]; -} - -export function getSaltedPassword( - password: Buffer, - salt: Buffer, - iterations: number -): Buffer { - let Hi = HMAC(password, salt, Buffer.from("00000001", "hex")); - let Ui = Hi; - - for (let _ = 0; _ < iterations - 1; _++) { - Ui = HMAC(password, Ui); - Hi = XOR(Hi, Ui); - } - - return Hi; -} - -export function getClientKey(saltedPassword: Buffer): Buffer { - return HMAC(saltedPassword, Buffer.from("Client Key", "utf8")); -} - -export function getServerKey(saltedPassword: Buffer): Buffer { - return HMAC(saltedPassword, Buffer.from("Server Key", "utf8")); -} - -export function XOR(a: Buffer, b: Buffer): Buffer { - const len = a.length; - if (len !== b.length) { - throw new ProtocolError("scram.XOR: buffers are of different lengths"); - } - const res = Buffer.allocUnsafe(len); - for (let i = 0; i < len; i++) { - res[i] = a[i] ^ b[i]; - } - return res; -} - -function parseServerFinalMessage(msg: string): Buffer { - const attrs = msg.split(","); - - if (attrs.length < 1) { - throw new ProtocolError("malformed SCRAM message"); - } - - const nonceAttr = attrs[0]; - if (!nonceAttr || nonceAttr[0] !== "v") { - throw new ProtocolError("malformed SCRAM message"); - } - const signatureB64 = nonceAttr.split("=", 2)[1]; - if (!signatureB64) { - throw new ProtocolError("malformed SCRAM message"); - } - const sig = Buffer.from(signatureB64, "base64"); - return sig; -} diff --git a/yarn.lock b/yarn.lock index ed218d59..f0c5489e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4955,16 +4955,6 @@ __metadata: languageName: node linkType: hard -"hash.js@npm:^1.1.7": - version: 1.1.7 - resolution: "hash.js@npm:1.1.7" - dependencies: - inherits: ^2.0.3 - minimalistic-assert: ^1.0.1 - checksum: e350096e659c62422b85fa508e4b3669017311aa4c49b74f19f8e1bc7f3a54a584fdfd45326d4964d6011f2b2d882e38bea775a96046f2a61b7779a979629d8f - languageName: node - linkType: hard - "hast-util-to-jsx-runtime@npm:^2.0.0": version: 2.3.0 resolution: "hast-util-to-jsx-runtime@npm:2.3.0" @@ -6669,13 +6659,6 @@ __metadata: languageName: node linkType: hard -"minimalistic-assert@npm:^1.0.1": - version: 1.0.1 - resolution: "minimalistic-assert@npm:1.0.1" - checksum: cc7974a9268fbf130fb055aff76700d7e2d8be5f761fb5c60318d0ed010d839ab3661a533ad29a5d37653133385204c503bfac995aaa4236f4e847461ea32ba7 - languageName: node - linkType: hard - "minimatch@npm:^3.0.4, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" @@ -8748,7 +8731,6 @@ __metadata: eslint-plugin-react-refresh: ^0.4.14 generic-names: ^4.0.0 globals: ^15.11.0 - hash.js: ^1.1.7 jest: ^29.7.0 mobx: ^6.5.0 mobx-keystone: ^1.11.0