From 5fd9217c3374677b35115e63e243f4aaba3afa78 Mon Sep 17 00:00:00 2001 From: Jordan Doyle Date: Sat, 30 Oct 2021 17:53:18 +0100 Subject: [PATCH] Implement password authentication and registration --- Cargo.lock | 12 ++++++++++++ chartered-web/Cargo.toml | 1 + chartered-db/src/lib.rs | 2 ++ chartered-db/src/schema.rs | 1 + chartered-db/src/users.rs | 35 +++++++++++++++++++++++++++++++++++ chartered-frontend/src/dark.sass | 3 +++ chartered-frontend/src/index.tsx | 7 +++++++ chartered-frontend/src/useAuth.tsx | 2 +- migrations/2021-08-31-214501_create_crates_table/up.sql | 1 + chartered-frontend/src/pages/Login.tsx | 41 ++++++++++++++++++++++++++++++----------- chartered-frontend/src/pages/Register.tsx | 196 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ chartered-web/src/endpoints/web_api/auth/mod.rs | 3 ++- chartered-web/src/endpoints/web_api/auth/password.rs | 130 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 13 files changed, 400 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 63f9518..42c3867 100644 --- a/Cargo.lock +++ a/Cargo.lock @@ -299,6 +299,17 @@ checksum = "e6b4d9b1225d28d360ec6a231d65af1fd99a2a095154c8040689617290569c5c" [[package]] +name = "bcrypt" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f691e63585950d8c1c43644d11bab9073e40f5060dd2822734ae7c3dc69a3a80" +dependencies = [ + "base64", + "blowfish", + "getrandom", +] + +[[package]] name = "bcrypt-pbkdf" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -535,6 +546,7 @@ dependencies = [ "axum", "base64", + "bcrypt", "bytes", "chacha20poly1305", "chartered-db", diff --git a/chartered-web/Cargo.toml b/chartered-web/Cargo.toml index 76acea5..b1a5c0c 100644 --- a/chartered-web/Cargo.toml +++ a/chartered-web/Cargo.toml @@ -13,6 +13,7 @@ axum = { version = "0.2", features = ["headers"] } base64 = "0.13" +bcrypt = "0.10" bytes = "1" chacha20poly1305 = { version = "0.9", features = ["std"] } chrono = { version = "0.4", features = ["serde"] } diff --git a/chartered-db/src/lib.rs b/chartered-db/src/lib.rs index 0254930..13d992a 100644 --- a/chartered-db/src/lib.rs +++ a/chartered-db/src/lib.rs @@ -131,6 +131,8 @@ MissingOrganisation, /// Version {0} already exists for this crate VersionConflict(String), + /// Username is already taken + UsernameTaken, } impl Error { diff --git a/chartered-db/src/schema.rs b/chartered-db/src/schema.rs index 8472e62..b519ab2 100644 --- a/chartered-db/src/schema.rs +++ a/chartered-db/src/schema.rs @@ -95,6 +95,7 @@ id -> Integer, uuid -> Binary, username -> Text, + password -> Nullable, name -> Nullable, nick -> Nullable, email -> Nullable, diff --git a/chartered-db/src/users.rs b/chartered-db/src/users.rs index 80391eb..e0866e8 100644 --- a/chartered-db/src/users.rs +++ a/chartered-db/src/users.rs @@ -1,11 +1,14 @@ use super::{ crates::UserCratePermission, permissions::UserPermission, schema::{user_crate_permissions, user_sessions, user_ssh_keys, users}, uuid::SqlUuid, - ConnectionPool, Result, + ConnectionPool, Error, Result, }; -use diesel::{insert_into, prelude::*, Associations, Identifiable, Queryable}; +use diesel::result::DatabaseErrorKind; +use diesel::{ + insert_into, prelude::*, result::Error as DieselError, Associations, Identifiable, Queryable, +}; use rand::{thread_rng, Rng}; use std::sync::Arc; use thrussh_keys::PublicKeyBase64; @@ -15,6 +18,7 @@ pub id: i32, pub uuid: SqlUuid, pub username: String, + pub password: Option, pub name: Option, pub nick: Option, pub email: Option, @@ -168,6 +172,33 @@ Ok(crate::schema::users::table .filter(username.eq(given_username)) .get_result(&conn)?) + }) + .await? + } + + pub async fn register( + conn: ConnectionPool, + username: String, + password_hash: String, + ) -> Result<()> { + tokio::task::spawn_blocking(move || { + let conn = conn.get()?; + + let res = diesel::insert_into(users::table) + .values(( + users::username.eq(&username), + users::uuid.eq(SqlUuid::random()), + users::password.eq(&password_hash), + )) + .execute(&conn); + + match res { + Ok(_) => Ok(()), + Err(DieselError::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => { + Err(Error::UsernameTaken) + } + Err(e) => Err(e.into()), + } }) .await? } diff --git a/chartered-frontend/src/dark.sass b/chartered-frontend/src/dark.sass index 356f030..7cd524e 100644 --- a/chartered-frontend/src/dark.sass +++ a/chartered-frontend/src/dark.sass @@ -52,6 +52,9 @@ &.btn-danger color: white + .btn-outline-primary:hover + color: white + .btn-outline-light border-color: $link-color diff --git a/chartered-frontend/src/index.tsx b/chartered-frontend/src/index.tsx index a983d10..dbba152 100644 --- a/chartered-frontend/src/index.tsx +++ a/chartered-frontend/src/index.tsx @@ -26,6 +26,7 @@ import User from "./pages/User"; import Search from "./pages/Search"; import { backgroundFix } from "./overscrollColourFixer"; +import Register from "./pages/Register"; if ( window.matchMedia && @@ -52,6 +53,12 @@ unauthedOnly path="/login" component={() => } + /> + } /> ); diff --git a/migrations/2021-08-31-214501_create_crates_table/up.sql b/migrations/2021-08-31-214501_create_crates_table/up.sql index 9d19859..7ec5834 100644 --- a/migrations/2021-08-31-214501_create_crates_table/up.sql +++ a/migrations/2021-08-31-214501_create_crates_table/up.sql @@ -1,7 +1,8 @@ CREATE TABLE users ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, uuid BINARY(128) NOT NULL UNIQUE, username VARCHAR(255) NOT NULL UNIQUE, + password CHAR(60), name VARCHAR(255), nick VARCHAR(255), email VARCHAR(255), diff --git a/chartered-frontend/src/pages/Login.tsx b/chartered-frontend/src/pages/Login.tsx index b1f3bc0..5f62be2 100644 --- a/chartered-frontend/src/pages/Login.tsx +++ a/chartered-frontend/src/pages/Login.tsx @@ -5,7 +5,7 @@ SyntheticEvent, MouseEventHandler, } from "react"; -import { useLocation } from "react-router-dom"; +import { useLocation, Link } from "react-router-dom"; import { useAuth } from "../useAuth"; import { useUnauthenticatedRequest } from "../util"; @@ -17,18 +17,25 @@ IconDefinition, } from "@fortawesome/free-brands-svg-icons"; import { faSignInAlt } from "@fortawesome/free-solid-svg-icons"; +import { PersonPlus } from "react-bootstrap-icons"; interface OAuthProviders { providers: string[]; } +interface Prompt { + message: string; + kind: string; +} + export default function Login() { const location = useLocation(); const auth = useAuth(); + const [ackLocation, setAckLocation] = useState(false); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); - const [error, setError] = useState(""); + const [prompt, setPrompt] = useState(null); const [loading, setLoading] = useState(null); const isMountedRef = useRef(null); @@ -38,8 +45,9 @@ }); useEffect(() => { - if (location.state?.error) { - setError(location.state.error); + if (location.state?.prompt && !ackLocation) { + setPrompt(location.state.prompt); + setAckLocation(true); } isMountedRef.current = true; @@ -51,13 +59,13 @@ const handleSubmit = async (evt: SyntheticEvent) => { evt.preventDefault(); - setError(""); + setPrompt(null); setLoading("password"); try { await auth?.login(username, password); } catch (e: any) { - setError(e.message); + setPrompt({ message: e.message, kind: "danger" }); } finally { if (isMountedRef.current) { setLoading(null); @@ -66,13 +74,13 @@ }; const handleOAuthLogin = async (provider: string) => { - setError(""); + setPrompt(null); setLoading(provider); try { await auth?.oauthLogin(provider); } catch (e: any) { - setError(e.message); + setPrompt({ message: e.message, kind: "danger" }); } }; @@ -88,17 +96,17 @@ >
- {error} + {prompt?.message}
@@ -145,10 +153,17 @@ onClick={handleSubmit} /> + + + Register + {oauthProviders?.providers.length > 0 ? ( <> -
or
+
or
{oauthProviders.providers.map((v, i) => ( (false); + const [complete, setComplete] = useState(false); + + const handleSubmit = async (evt: SyntheticEvent) => { + evt.preventDefault(); + + setError(""); + setLoading(true); + + try { + let res = await fetch(unauthenticatedEndpoint("auth/register/password"), { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": window.navigator.userAgent, + }, + body: JSON.stringify({ username, password }), + }); + let json = await res.json(); + + if (json.error) { + throw new Error(json.error); + } else if (!json.success) { + throw new Error("Failed to register, please try again later."); + } else { + setComplete(true); + } + } catch (e: any) { + setError(e.message); + } finally { + setLoading(false); + } + }; + + if (complete) { + return ( + + ); + } + + return ( +
+
+

chartered ✈️

+
a private, authenticated cargo registry
+ +
+
+
+ {error} + +
+ +
+
+ setUsername(e.target.value)} + /> + + +
+ +
+ setPassword(e.target.value)} + /> + + +
+ + + +
+
+
+
+ ); +} + +function ButtonOrSpinner({ + type, + variant, + disabled, + showSpinner, + text, + icon, + background, + onClick, +}: { + type: "button" | "submit"; + variant: string; + disabled: boolean; + showSpinner: boolean; + text: string; + icon?: IconDefinition; + background?: string; + onClick: MouseEventHandler; +}) { + if (showSpinner) { + return ( +
+ Logging in... +
+ ); + } + + if (type) { + return ( + + ); + } + + return <>; +} diff --git a/chartered-web/src/endpoints/web_api/auth/mod.rs b/chartered-web/src/endpoints/web_api/auth/mod.rs index 0e6063e..f8cab0c 100644 --- a/chartered-web/src/endpoints/web_api/auth/mod.rs +++ a/chartered-web/src/endpoints/web_api/auth/mod.rs @@ -40,7 +40,8 @@ + Send, > { crate::axum_box_after_every_route!(Router::new() - .route("/login/password", post(password::handle)) + .route("/register/password", post(password::handle_register)) + .route("/login/password", post(password::handle_login)) .route("/login/oauth/:provider/begin", get(openid::begin_oidc)) .route("/login/oauth/complete", get(openid::complete_oidc)) .route("/login/oauth/providers", get(openid::list_providers))) diff --git a/chartered-web/src/endpoints/web_api/auth/password.rs b/chartered-web/src/endpoints/web_api/auth/password.rs index bb7dcf2..c7a0032 100644 --- a/chartered-web/src/endpoints/web_api/auth/password.rs +++ a/chartered-web/src/endpoints/web_api/auth/password.rs @@ -1,53 +1,149 @@ //! Password-based authentication, including registration and login. +use crate::config::Config; use axum::{extract, Json}; use chartered_db::{users::User, ConnectionPool}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; use thiserror::Error; -pub async fn handle( +pub async fn handle_register( + extract::Extension(config): extract::Extension>, extract::Extension(db): extract::Extension, - extract::Json(req): extract::Json, + extract::Json(req): extract::Json, +) -> Result, RegisterError> { + // some basic validation before we register the user + if !config.auth.password.enabled { + return Err(RegisterError::PasswordAuthDisabled); + } else if !validate_username(&req.username) { + return Err(RegisterError::InvalidUsername); + } else if req.password.len() < 6 { + return Err(RegisterError::PasswordRequirementNotMet); + } + + let password_hash = bcrypt::hash(&req.password, bcrypt::DEFAULT_COST)?; + + match User::register(db, req.username, password_hash).await { + Ok(_) => Ok(Json(RegisterResponse { success: true })), + Err(chartered_db::Error::UsernameTaken) => Err(RegisterError::UsernameTaken), + Err(e) => Err(e.into()), + } +} + +pub async fn handle_login( + extract::Extension(config): extract::Extension>, + extract::Extension(db): extract::Extension, + extract::Json(req): extract::Json, user_agent: Option>, addr: extract::ConnectInfo, -) -> Result, Error> { - // we use `:` as a splitter for openid logins so it isn't legal during password login - if req.username.contains(':') { - return Err(Error::UnknownUser); +) -> Result, LoginError> { + // some basic validation before we attempt a login + if !config.auth.password.enabled { + return Err(LoginError::PasswordAuthDisabled); + } else if !validate_username(&req.username) { + return Err(LoginError::UnknownUser); + } else if req.password.is_empty() { + return Err(LoginError::InvalidPassword); } - // TODO: passwords let user = User::find_by_username(db.clone(), req.username) .await? - .ok_or(Error::UnknownUser)?; + .ok_or(LoginError::UnknownUser)?; - Ok(Json(super::login(db, user, user_agent, addr).await?)) + let password_hash = user + .password + .as_deref() + // password is nullable for openid logins + .ok_or(LoginError::UnknownUser)?; + + if bcrypt::verify(&req.password, password_hash)? { + Ok(Json(super::login(db, user, user_agent, addr).await?)) + } else { + Err(LoginError::InvalidPassword) + } +} + +pub fn validate_username(username: &str) -> bool { + // we use `:` as a splitter for openid logins so it isn't legal during password login + !username.contains(':') + // must have at least 1 character in the username + && !username.is_empty() +} + +#[derive(Deserialize)] +pub struct RegisterRequest { + username: String, + password: String, } -#[allow(dead_code)] // TODO: password not yet read #[derive(Deserialize)] -pub struct Request { +pub struct LoginRequest { username: String, password: String, +} + +#[derive(Serialize)] +pub struct RegisterResponse { + success: bool, +} + +#[derive(Error, Debug)] +pub enum RegisterError { + #[error("Failed to query database")] + Database(#[from] chartered_db::Error), + #[error("Failed to hash password")] + Bcrypt(#[from] bcrypt::BcryptError), + #[error("Username is invalid")] + InvalidUsername, + #[error("Username already taken")] + UsernameTaken, + #[error("Password authentication is disabled")] + PasswordAuthDisabled, + #[error("Password must be at least 6 characters long")] + PasswordRequirementNotMet, +} + +impl RegisterError { + pub fn status_code(&self) -> axum::http::StatusCode { + use axum::http::StatusCode; + + match self { + Self::Database(_) | Self::Bcrypt(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::InvalidUsername | Self::UsernameTaken | Self::PasswordRequirementNotMet => { + StatusCode::BAD_REQUEST + } + Self::PasswordAuthDisabled => StatusCode::FORBIDDEN, + } + } } +define_error_response!(RegisterError); + #[derive(Error, Debug)] -pub enum Error { +pub enum LoginError { #[error("Failed to query database")] Database(#[from] chartered_db::Error), + #[error("Failed to hash password")] + Bcrypt(#[from] bcrypt::BcryptError), #[error("Invalid username/password")] UnknownUser, + #[error("Invalid username/password")] + InvalidPassword, + #[error("Password authentication is disabled")] + PasswordAuthDisabled, } -impl Error { +impl LoginError { pub fn status_code(&self) -> axum::http::StatusCode { use axum::http::StatusCode; match self { - Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR, - Self::UnknownUser => StatusCode::FORBIDDEN, + Self::Database(_) | Self::Bcrypt(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::UnknownUser | Self::InvalidPassword | Self::PasswordAuthDisabled => { + StatusCode::FORBIDDEN + } } } } -define_error_response!(Error); +define_error_response!(LoginError); -- rgit 0.1.3