🏡 index : ~doyle/chartered.git

author Jordan Doyle <jordan@doyle.la> 2021-10-06 0:37:38.0 +01:00:00
committer Jordan Doyle <jordan@doyle.la> 2021-10-06 0:38:56.0 +01:00:00
commit
7a3228d3ce274d216cb13532314315a7e8dd1260 [patch]
tree
1dc836fdf8957d62d87b6fa8db670344e6a8d8b6
parent
bcfeb8a72e0837fdf05db9ef05a76261c224583e
download
7a3228d3ce274d216cb13532314315a7e8dd1260.tar.gz

Frontend for OAuth



Diff

 Cargo.lock                             |  75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 chartered-web/Cargo.toml               |   5 ++++-
 chartered-db/src/users.rs              |   7 +++++--
 chartered-frontend/src/index.tsx       |   8 +++++++-
 chartered-frontend/src/useAuth.tsx     |  51 +++++++++++++++++++++++++++++++++++++++++++++++++++
 chartered-web/src/main.rs              |  15 +++++++++++++++
 chartered-frontend/src/pages/Login.tsx | 102 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
 7 files changed, 235 insertions(+), 28 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 733132f..2ad5784 100644
--- a/Cargo.lock
+++ a/Cargo.lock
@@ -334,6 +334,7 @@
 "chartered-fs",
 "chartered-types",
 "chrono",
 "clap",
 "env_logger",
 "futures",
 "headers",
@@ -350,6 +351,7 @@
 "sha2",
 "thiserror",
 "tokio",
 "toml",
 "tower",
 "tower-http 0.1.1 (git+https://github.com/tower-rs/tower-http?branch=cors)",
]
@@ -378,6 +380,37 @@
]

[[package]]
name = "clap"
version = "3.0.0-beta.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcd70aa5597dbc42f7217a543f9ef2768b2ef823ba29036072d30e1d88e98406"
dependencies = [
 "atty",
 "bitflags",
 "clap_derive",
 "indexmap",
 "lazy_static",
 "os_str_bytes",
 "strsim",
 "termcolor",
 "textwrap",
 "vec_map",
]

[[package]]
name = "clap_derive"
version = "3.0.0-beta.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5bb0d655624a0b8770d1c178fb8ffcb1f91cc722cb08f451e3dc72465421ac"
dependencies = [
 "heck",
 "proc-macro-error",
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "const-sha1"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1346,6 +1379,12 @@
 "heck",
 "serde",
]

[[package]]
name = "os_str_bytes"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6acbef58a60fe69ab50510a55bc8cdd4d6cf2283d27ad338f54cb52747a9cf2d"

[[package]]
name = "parking_lot"
@@ -1841,6 +1880,12 @@
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"

[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"

[[package]]
name = "subtle"
@@ -1886,6 +1931,15 @@
checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4"
dependencies = [
 "winapi-util",
]

[[package]]
name = "textwrap"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0066c8d12af8b5acd21e00547c3797fde4e8677254a7ee429176ccebbe93dd80"
dependencies = [
 "unicode-width",
]

[[package]]
@@ -2064,6 +2118,15 @@
 "log",
 "pin-project-lite",
 "tokio",
]

[[package]]
name = "toml"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa"
dependencies = [
 "serde",
]

[[package]]
@@ -2191,6 +2254,12 @@
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b"

[[package]]
name = "unicode-width"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973"

[[package]]
name = "unicode-xid"
@@ -2287,6 +2356,12 @@
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"

[[package]]
name = "vec_map"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"

[[package]]
name = "version_check"
diff --git a/chartered-web/Cargo.toml b/chartered-web/Cargo.toml
index 7ef5394..623d7f5 100644
--- a/chartered-web/Cargo.toml
+++ a/chartered-web/Cargo.toml
@@ -1,5 +1,6 @@
[package]
name = "chartered-web"
authors = ["Jordan Doyle <jordan@doyle.la>"]
version = "0.1.0"
edition = "2018"

@@ -15,6 +16,7 @@
bytes = "1"
chacha20poly1305 = { version = "0.9", features = ["std"] }
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "3.0.0-beta.4", features = ["std", "suggestions", "color"] }
env_logger = "0.9"
futures = "0.3"
headers = "0.3"
@@ -23,7 +25,7 @@
nom = "7"
once_cell = "1.8"
openid = "0.9"
rand = "*"
rand = "0.8"
regex = "1.5"
reqwest = "0.11"
serde = { version = "1", features = ["derive"] }
@@ -34,3 +36,4 @@
tower = { version = "0.4", features = ["util", "filter"] }
# tower-http = { version = "0.1", features = ["trace", "set-header"] }
tower-http = { git = "https://github.com/tower-rs/tower-http", branch = "cors", features = ["trace", "set-header", "cors"] }
toml = "0.5"
diff --git a/chartered-db/src/users.rs b/chartered-db/src/users.rs
index 2470f2f..9b92cec 100644
--- a/chartered-db/src/users.rs
+++ a/chartered-db/src/users.rs
@@ -114,7 +114,7 @@
    }

    pub async fn find_or_create(conn: ConnectionPool, given_username: String) -> Result<User> {
        use crate::schema::users::dsl::username;
        use crate::schema::users::dsl::{username, uuid};

        tokio::task::spawn_blocking(move || {
            let conn = conn.get()?;
@@ -129,7 +129,10 @@
            }

            diesel::insert_into(users::table)
                .values(username.eq(&given_username))
                .values((
                    username.eq(&given_username),
                    uuid.eq(SqlUuid::random())
                ))
                .execute(&conn)?;

            Ok(crate::schema::users::table
diff --git a/chartered-frontend/src/index.tsx b/chartered-frontend/src/index.tsx
index 51d6620..0e800f6 100644
--- a/chartered-frontend/src/index.tsx
+++ a/chartered-frontend/src/index.tsx
@@ -12,7 +12,7 @@
  useLocation,
} from "react-router-dom";

import { ProvideAuth, useAuth } from "./useAuth";
import { ProvideAuth, HandleOAuthLogin, useAuth } from "./useAuth";

import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
@@ -33,6 +33,12 @@
            unauthedOnly
            path="/login"
            component={() => <Login />}
          />
          <PublicRoute
            exact
            unauthedOnly
            path="/login/oauth"
            component={() => <HandleOAuthLogin />}
          />

          <PrivateRoute
diff --git a/chartered-frontend/src/useAuth.tsx b/chartered-frontend/src/useAuth.tsx
index acc0ccc..2e640d3 100644
--- a/chartered-frontend/src/useAuth.tsx
+++ a/chartered-frontend/src/useAuth.tsx
@@ -1,6 +1,8 @@
import React = require("react");
import { useState, useEffect, useContext, createContext } from "react";
import { useLocation, Redirect } from "react-router-dom";
import { unauthenticatedEndpoint } from "./util";
import LoadingPage from "./pages/Loading";

export interface OAuthProviders {
  providers: string[];
@@ -8,8 +10,10 @@

export interface AuthContext {
  login: (username: string, password: string) => Promise<void>;
  oauthLogin: (provider: string) => Promise<void>;
  logout: () => Promise<void>;
  getAuthKey: () => Promise<string | null>;
  setAuth: ([string, string]) => any;
}

const authContext = createContext<AuthContext | null>(null);
@@ -19,6 +23,34 @@
  return <authContext.Provider value={auth}>{children}</authContext.Provider>;
}

export function HandleOAuthLogin() {
  const location = useLocation();
  const auth = useAuth();
  const [result, setResult] = useState<JSX.Element | null>(null);

  useEffect(async () => {
    try {
      let result = await fetch(unauthenticatedEndpoint(`login/oauth/complete${location.search}`));
      let json = await result.json();

      if (json.error) {
        throw new Error(json.error);
      }

      auth.setAuth([json.key, new Date(json.expires)]);
    } catch (err) {
      setResult(
        <Redirect to={{
          pathname: "/login",
          state: { error: err.message }
        }} />
      );
    }
  });

  return result ?? <LoadingPage />;
}

export const useAuth = (): AuthContext | null => {
  return useContext(authContext);
};
@@ -53,6 +85,23 @@

    setAuth([json.key, new Date(json.expires)]);
  };

  const oauthLogin = async (provider: string) => {
    let res = await fetch(unauthenticatedEndpoint(`login/oauth/${provider}/begin`), {
      method: "GET",
      headers: {
        "Content-Type": "application/json",
        "User-Agent": window.navigator.userAgent,
      }
    });
    let json = await res.json();

    if (json.error) {
      throw new Error(json.error);
    }

    window.location.href = json.redirect_url;
  }

  const logout = async () => {
    // todo call the service so we can purge the key from the db
@@ -71,6 +120,8 @@
    login,
    logout,
    getAuthKey,
    oauthLogin,
    setAuth,
  };
}

diff --git a/chartered-web/src/main.rs b/chartered-web/src/main.rs
index 37fc215..af12155 100644
--- a/chartered-web/src/main.rs
+++ a/chartered-web/src/main.rs
@@ -13,7 +13,19 @@
use std::sync::Arc;
use tower::ServiceBuilder;
use tower_http::cors::{Any, CorsLayer};
use clap::Clap;
use std::path::PathBuf;

#[derive(Clap)]
#[clap(version = clap::crate_version!(), author = clap::crate_authors!())]
#[clap(setting = clap::AppSettings::ColoredHelp)]
pub struct Opts {
    #[clap(short, long, parse(from_occurrences))]
    verbose: i32,
    #[clap(short, long)]
    config: PathBuf,
}

#[allow(clippy::unused_async)]
async fn hello_world() -> &'static str {
    "hello, world!"
@@ -45,6 +57,9 @@
#[tokio::main]
#[allow(clippy::semicolon_if_nothing_returned)] // lint breaks with tokio::main
async fn main() {
    let opts: Opts = Opts::parse();
    let config: config::Config = toml::from_slice(&std::fs::read(&opts.config).unwrap()).unwrap();

    env_logger::init();

    let pool = chartered_db::init().unwrap();
diff --git a/chartered-frontend/src/pages/Login.tsx b/chartered-frontend/src/pages/Login.tsx
index 0a4eb38..41ab0c1 100644
--- a/chartered-frontend/src/pages/Login.tsx
+++ a/chartered-frontend/src/pages/Login.tsx
@@ -1,21 +1,31 @@
import React = require("react");
import { useState, useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";

import { useAuth } from "../useAuth";
import { useUnauthenticatedRequest } from "../util";

interface OAuthProviders {
  providers: string[];
}

export default function Login() {
  const location = useLocation();
  const auth = useAuth();

  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);
  const [loading, setLoading] = useState<string | null>(null);
  const isMountedRef = useRef(null);

  const { response: oauthProviders } = useUnauthenticatedRequest({ endpoint: "login/oauth/providers" });
  const { response: oauthProviders } = useUnauthenticatedRequest<OAuthProviders>({ endpoint: "login/oauth/providers" });

  useEffect(() => {
    if (location.state?.error) {
      setError(location.state.error);
    }

    isMountedRef.current = true;
    return () => (isMountedRef.current = false);
  });
@@ -24,7 +34,7 @@
    evt.preventDefault();

    setError("");
    setLoading(true);
    setLoading("password");

    try {
      await auth.login(username, password);
@@ -32,10 +42,25 @@
      setError(e.message);
    } finally {
      if (isMountedRef.current) {
        setLoading(false);
        setLoading(null);
      }
    }
  };

  const handleOAuthLogin = async (provider) => {
    setError("");
    setLoading(provider);

    try {
      await auth.oauthLogin(provider);
    } catch (e) {
      setError(e.message);
    } finally {
      if (isMountedRef.current) {
        setLoading(null);
      }
    }
  }

  return (
    <div className="bg-primary p-4 text-white min-vh-100 d-flex justify-content-center align-items-center">
@@ -70,7 +95,7 @@
                  className="form-control"
                  placeholder="john.smith"
                  id="username"
                  disabled={loading}
                  disabled={!!loading}
                  value={username}
                  onChange={(e) => setUsername(e.target.value)}
                />
@@ -84,7 +109,7 @@
                  className="form-control"
                  placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;"
                  id="password"
                  disabled={loading}
                  disabled={!!loading}
                  value={password}
                  onChange={(e) => setPassword(e.target.value)}
                />
@@ -92,29 +117,33 @@
                <label htmlFor="password" className="form-label">Password</label>
              </div>

              <div className="mt-2 ml-auto">
                <button
                  type="submit"
                  className="btn btn-lg btn-primary w-100"
                  style={{ display: !loading ? "block" : "none" }}
                >
                  Login
                </button>

                <div
                  className="spinner-border text-primary mt-4"
                  role="status"
                  style={{ display: loading ? "block" : "none" }}
                >
                  <span className="visually-hidden">Logging in...</span>
                </div>
              </div>
              <ButtonOrSpinner
                type="submit"
                variant="primary"
                disabled={!!loading}
                showSpinner={loading === "password"}
                text={`Login`}
                onClick={handleSubmit}
              />
            </form>

            {oauthProviders?.providers.length > 0 ? (<>
              <div className="side-lines mt-3">or</div>

              {oauthProviders.providers.map((v, i) => <a href="#" key={i} className="btn btn-lg btn-dark w-100 mt-3">Login with {v}</a>)}
              {oauthProviders.providers.map((v, i) => (
                <ButtonOrSpinner
                  key={i}
                  type="button"
                  variant="dark"
                  disabled={!!loading}
                  showSpinner={loading === v}
                  text={`Login with ${v}`}
                  onClick={(evt) => {
                    evt.preventDefault();
                    handleOAuthLogin(v);
                  }}
                />
              ))}
            </>): <></>}
          </div>
        </div>
@@ -122,3 +151,28 @@
    </div>
  );
}

function ButtonOrSpinner({ type, variant, disabled, showSpinner, text, onClick }: {
  type: "button" | "submit",
  variant: string,
  disabled: boolean,
  showSpinner: boolean,
  text: string,
  onClick: (evt) => any,
}) {
  if (showSpinner) {
    return (
      <div className="spinner-border text-primary mt-3 m-auto d-block" role="status">
        <span className="visually-hidden">Logging in...</span>
      </div>
    );
  }

  if (type) {
    return (
      <button type={type} disabled={disabled} onClick={onClick} className={`btn btn-lg mt-2 btn-${variant} w-100`}>
        {text}
      </button>
    );
  }
}