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(-)
@@ -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",
@@ -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"] }
@@ -131,6 +131,8 @@
MissingOrganisation,
VersionConflict(String),
UsernameTaken,
}
impl Error {
@@ -95,6 +95,7 @@
id -> Integer,
uuid -> Binary,
username -> Text,
password -> Nullable<Text>,
name -> Nullable<Text>,
nick -> Nullable<Text>,
email -> Nullable<Text>,
@@ -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?
}
@@ -52,6 +52,9 @@
&.btn-danger
color: white
.btn-outline-primary:hover
color: white
.btn-outline-light
border-color: $link-color
@@ -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
@@ -54,7 +54,7 @@
<Redirect
to={{
pathname: "/login",
state: { error: err.message },
state: { prompt: { message: err.message, kind: "danger" } },
}}
/>
);
@@ -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),
@@ -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
@@ -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="••••••••••••"
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 <></>;
}
@@ -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)))
@@ -1,53 +1,149 @@
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> {
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> {
if req.username.contains(':') {
return Err(Error::UnknownUser);
) -> Result<Json<super::LoginResponse>, LoginError> {
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);
}
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()
.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 {
!username.contains(':')
&& !username.is_empty()
}
#[derive(Deserialize)]
pub struct RegisterRequest {
username: String,
password: String,
}
#[allow(dead_code)]
#[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);