🏡 index : ~doyle/chartered.git

author Jordan Doyle <jordan@doyle.la> 2021-10-30 17:53:18.0 +01:00:00
committer Jordan Doyle <jordan@doyle.la> 2021-10-30 18:00:26.0 +01:00:00
commit
5fd9217c3374677b35115e63e243f4aaba3afa78 [patch]
tree
ab41e99d6cd0e9ebc8ed88d72fad1536193b242d
parent
9acf6331101d6238362559fdb2ae9b0aa9054717
download
5fd9217c3374677b35115e63e243f4aaba3afa78.tar.gz

Implement password authentication and registration



Diff

 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<Text>,
        name -> Nullable<Text>,
        nick -> Nullable<Text>,
        email -> Nullable<Text>,
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<String>,
    pub name: Option<String>,
    pub nick: Option<String>,
    pub email: Option<String>,
@@ -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={() => <Login />}
          />
          <PublicRoute
            extract
            unauthedOnly
            path="/register"
            component={() => <Register />}
          />
          <PublicRoute
            exact
diff --git a/chartered-frontend/src/useAuth.tsx b/chartered-frontend/src/useAuth.tsx
index b64805f..a45e3a3 100644
--- a/chartered-frontend/src/useAuth.tsx
+++ a/chartered-frontend/src/useAuth.tsx
@@ -54,7 +54,7 @@
        <Redirect
          to={{
            pathname: "/login",
            state: { error: err.message },
            state: { prompt: { message: err.message, kind: "danger" } },
          }}
        />
      );
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<Prompt | null>(null);
  const [loading, setLoading] = useState<string | null>(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 @@
        >
          <div className="card-body">
            <div
              className="alert alert-danger alert-dismissible"
              className={`alert alert-${prompt?.kind} alert-dismissible`}
              role="alert"
              style={{ display: error ? "block" : "none" }}
              style={{ display: prompt ? "block" : "none" }}
            >
              {error}
              {prompt?.message}

              <button
                type="button"
                className="btn-close"
                aria-label="Close"
                onClick={() => setError("")}
                onClick={() => setPrompt(null)}
              />
            </div>

@@ -145,10 +153,17 @@
                onClick={handleSubmit}
              />
            </form>

            <Link
              to="/register"
              className="btn btn-lg w-100 btn-outline-primary mt-2"
            >
              <PersonPlus /> Register
            </Link>

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

                {oauthProviders.providers.map((v, i) => (
                  <ButtonOrSpinner
diff --git a/chartered-frontend/src/pages/Register.tsx b/chartered-frontend/src/pages/Register.tsx
new file mode 100644
index 0000000..2540aec 100644
--- /dev/null
+++ a/chartered-frontend/src/pages/Register.tsx
@@ -1,0 +1,196 @@
import {
  useState,
  useEffect,
  useRef,
  SyntheticEvent,
  MouseEventHandler,
} from "react";
import { useLocation, Link, Redirect } from "react-router-dom";

import { useAuth } from "../useAuth";
import { unauthenticatedEndpoint, useUnauthenticatedRequest } from "../util";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconDefinition } from "@fortawesome/free-brands-svg-icons";
import { faSignInAlt } from "@fortawesome/free-solid-svg-icons";

interface OAuthProviders {
  providers: string[];
}

export default function Register() {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState<boolean>(false);
  const [complete, setComplete] = useState<boolean>(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 (
      <Redirect
        to={{
          pathname: "/login",
          state: {
            prompt: {
              message: "Successfully registered, please login.",
              kind: "success",
            },
          },
        }}
      />
    );
  }

  return (
    <div className="p-4 min-vh-100 d-flex justify-content-center align-items-center">
      <div>
        <h1>chartered ✈️</h1>
        <h6>a private, authenticated cargo registry</h6>

        <div
          className="card border-0 shadow-sm text-black p-2"
          style={{ width: "40rem" }}
        >
          <div className="card-body">
            <div
              className="alert alert-danger alert-dismissible"
              role="alert"
              style={{ display: error ? "block" : "none" }}
            >
              {error}

              <button
                type="button"
                className="btn-close"
                aria-label="Close"
                onClick={() => setError("")}
              />
            </div>

            <form onSubmit={handleSubmit}>
              <div className="form-floating">
                <input
                  type="text"
                  className="form-control"
                  placeholder="john.smith"
                  id="username"
                  disabled={loading}
                  value={username}
                  onChange={(e) => setUsername(e.target.value)}
                />

                <label htmlFor="username" className="form-label">
                  Username
                </label>
              </div>

              <div className="form-floating mt-2">
                <input
                  type="password"
                  className="form-control"
                  placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;"
                  id="password"
                  disabled={loading}
                  value={password}
                  onChange={(e) => setPassword(e.target.value)}
                />

                <label htmlFor="password" className="form-label">
                  Password
                </label>
              </div>

              <ButtonOrSpinner
                type="submit"
                variant="primary"
                disabled={loading}
                showSpinner={loading}
                text={`Register`}
                icon={faSignInAlt}
                onClick={handleSubmit}
              />
            </form>
          </div>
        </div>
      </div>
    </div>
  );
}

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<HTMLButtonElement>;
}) {
  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`}
        style={{ background, borderColor: background }}
      >
        {icon ? <FontAwesomeIcon icon={icon} className="me-2" /> : <></>}
        {text}
      </button>
    );
  }

  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<Arc<Config>>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Json(req): extract::Json<Request>,
    extract::Json(req): extract::Json<RegisterRequest>,
) -> Result<Json<RegisterResponse>, 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<Arc<Config>>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Json(req): extract::Json<LoginRequest>,
    user_agent: Option<extract::TypedHeader<headers::UserAgent>>,
    addr: extract::ConnectInfo<std::net::SocketAddr>,
) -> Result<Json<super::LoginResponse>, 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<Json<super::LoginResponse>, 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);