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(-)
@@ -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"
@@ -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 = { git = "https://github.com/tower-rs/tower-http", branch = "cors", features = ["trace", "set-header", "cors"] }
toml = "0.5"
@@ -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
@@ -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
@@ -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 () => {
@@ -71,6 +120,8 @@
login,
logout,
getAuthKey,
oauthLogin,
setAuth,
};
}
@@ -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)]
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();
@@ -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="••••••••••••"
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>
);
}
}