Extract the authentication database from the File Service #103
|
@ -25,14 +25,15 @@ checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.3"
|
version = "0.8.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f"
|
checksum = "91429305e9f0a25f6205c5b8e0d2db09e0708a7a6df0f42212bb56c32c8ac97a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"getrandom",
|
"getrandom",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"version_check 0.9.4",
|
"version_check 0.9.4",
|
||||||
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -134,6 +135,21 @@ dependencies = [
|
||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "authdb"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"clap",
|
||||||
|
"cool_asserts",
|
||||||
|
"serde 1.0.188",
|
||||||
|
"sha2",
|
||||||
|
"sqlx",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
"uuid 0.4.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "0.1.8"
|
version = "0.1.8"
|
||||||
|
@ -562,9 +578,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc-catalog"
|
name = "crc-catalog"
|
||||||
version = "2.2.0"
|
version = "2.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9cace84e55f07e7301bae1c519df89cdad8cc3cd868413d3fdbdeca9ff3db484"
|
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
|
@ -911,6 +927,7 @@ dependencies = [
|
||||||
name = "file-service"
|
name = "file-service"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"authdb",
|
||||||
"base64ct",
|
"base64ct",
|
||||||
"build_html",
|
"build_html",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
@ -930,7 +947,6 @@ dependencies = [
|
||||||
"serde 1.0.188",
|
"serde 1.0.188",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"sqlx",
|
|
||||||
"tempdir",
|
"tempdir",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
@ -3293,16 +3309,14 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rsa"
|
name = "rsa"
|
||||||
version = "0.9.2"
|
version = "0.9.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ab43bb47d23c1a631b4b680199a45255dce26fa9ab2fa902581f624ff13e6a8"
|
checksum = "86ef35bf3e7fe15a53c4ab08a998e42271eab13eb0db224126bc7bc4c4bad96d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
|
||||||
"const-oid",
|
"const-oid",
|
||||||
"digest",
|
"digest",
|
||||||
"num-bigint-dig",
|
"num-bigint-dig",
|
||||||
"num-integer",
|
"num-integer",
|
||||||
"num-iter",
|
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"pkcs1",
|
"pkcs1",
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
|
@ -3577,9 +3591,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signature"
|
name = "signature"
|
||||||
version = "2.1.0"
|
version = "2.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e1788eed21689f9cf370582dfc467ef36ed9c707f073528ddafa8d83e3b8500"
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
|
@ -3993,18 +4007,18 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.49"
|
version = "1.0.50"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1177e8c6d7ede7afde3585fd2513e611227efd6481bd78d2e82ba1ce16557ed4"
|
checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror-impl"
|
name = "thiserror-impl"
|
||||||
version = "1.0.49"
|
version = "1.0.50"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc"
|
checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
@ -4239,9 +4253,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-attributes"
|
name = "tracing-attributes"
|
||||||
version = "0.1.26"
|
version = "0.1.27"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab"
|
checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
@ -4817,10 +4831,30 @@ dependencies = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zeroize"
|
name = "zerocopy"
|
||||||
version = "1.6.0"
|
version = "0.7.26"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9"
|
checksum = "e97e415490559a91254a2979b4829267a57d2fcd741a98eee8b722fb57289aa0"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.7.26"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd7e48ccf166952882ca8bd778a43502c64f33bf94c12ebe2a7f08e5a0f6689f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.37",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zune-inflate"
|
name = "zune-inflate"
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
|
"authdb",
|
||||||
"changeset",
|
"changeset",
|
||||||
"config",
|
"config",
|
||||||
"config-derive",
|
"config-derive",
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
[package]
|
||||||
|
name = "authdb"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "authdb"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "auth-cli"
|
||||||
|
path = "src/bin/cli.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
base64ct = { version = "1", features = [ "alloc" ] }
|
||||||
|
clap = { version = "4", features = [ "derive" ] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
sha2 = { version = "0.10" }
|
||||||
|
sqlx = { version = "0.7", features = [ "runtime-tokio", "sqlite" ] }
|
||||||
|
thiserror = { version = "1" }
|
||||||
|
tokio = { version = "1", features = [ "full" ] }
|
||||||
|
uuid = { version = "0.4", features = [ "serde", "v4" ] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
cool_asserts = "*"
|
|
@ -1,5 +1,5 @@
|
||||||
|
use authdb::{AuthDB, Username};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use file_service::{AuthDB, Username};
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
|
@ -0,0 +1,302 @@
|
||||||
|
use base64ct::{Base64, Encoding};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use sqlx::{
|
||||||
|
sqlite::{SqlitePool, SqliteRow},
|
||||||
|
Row,
|
||||||
|
};
|
||||||
|
use std::ops::Deref;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use thiserror::Error;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum AuthError {
|
||||||
|
#[error("authentication token is duplicated")]
|
||||||
|
DuplicateAuthToken,
|
||||||
|
|
||||||
|
#[error("session token is duplicated")]
|
||||||
|
DuplicateSessionToken,
|
||||||
|
|
||||||
|
#[error("database failed")]
|
||||||
|
SqlError(sqlx::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for AuthError {
|
||||||
|
fn from(err: sqlx::Error) -> AuthError {
|
||||||
|
AuthError::SqlError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
|
||||||
|
pub struct Username(String);
|
||||||
|
|
||||||
|
impl From<String> for Username {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
Self(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for Username {
|
||||||
|
fn from(s: &str) -> Self {
|
||||||
|
Self(s.to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Username> for String {
|
||||||
|
fn from(s: Username) -> Self {
|
||||||
|
Self::from(&s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Username> for String {
|
||||||
|
fn from(s: &Username) -> Self {
|
||||||
|
let Username(s) = s;
|
||||||
|
Self::from(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for Username {
|
||||||
|
type Target = String;
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl sqlx::FromRow<'_, SqliteRow> for Username {
|
||||||
|
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
|
||||||
|
let name: String = row.try_get("username")?;
|
||||||
|
Ok(Username::from(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
|
||||||
|
pub struct AuthToken(String);
|
||||||
|
|
||||||
|
impl From<String> for AuthToken {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
Self(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for AuthToken {
|
||||||
|
fn from(s: &str) -> Self {
|
||||||
|
Self(s.to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthToken> for PathBuf {
|
||||||
|
fn from(s: AuthToken) -> Self {
|
||||||
|
Self::from(&s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&AuthToken> for PathBuf {
|
||||||
|
fn from(s: &AuthToken) -> Self {
|
||||||
|
let AuthToken(s) = s;
|
||||||
|
Self::from(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for AuthToken {
|
||||||
|
type Target = String;
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
|
||||||
|
pub struct SessionToken(String);
|
||||||
|
|
||||||
|
impl From<String> for SessionToken {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
Self(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for SessionToken {
|
||||||
|
fn from(s: &str) -> Self {
|
||||||
|
Self(s.to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SessionToken> for PathBuf {
|
||||||
|
fn from(s: SessionToken) -> Self {
|
||||||
|
Self::from(&s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&SessionToken> for PathBuf {
|
||||||
|
fn from(s: &SessionToken) -> Self {
|
||||||
|
let SessionToken(s) = s;
|
||||||
|
Self::from(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for SessionToken {
|
||||||
|
type Target = String;
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthDB {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthDB {
|
||||||
|
pub async fn new(path: PathBuf) -> Result<Self, sqlx::Error> {
|
||||||
|
let migrator = sqlx::migrate!("./migrations");
|
||||||
|
let pool = SqlitePool::connect(&format!("sqlite://{}", path.to_str().unwrap())).await?;
|
||||||
|
migrator.run(&pool).await?;
|
||||||
|
Ok(Self { pool })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_user(&self, username: Username) -> Result<AuthToken, AuthError> {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(Uuid::new_v4().hyphenated().to_string());
|
||||||
|
hasher.update(username.to_string());
|
||||||
|
let auth_token = Base64::encode_string(&hasher.finalize());
|
||||||
|
|
||||||
|
let _ = sqlx::query("INSERT INTO users (username, token) VALUES ($1, $2)")
|
||||||
|
.bind(username.to_string())
|
||||||
|
.bind(auth_token.clone())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(AuthToken::from(auth_token))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_users(&self) -> Result<Vec<Username>, AuthError> {
|
||||||
|
let usernames = sqlx::query_as::<_, Username>("SELECT (username) FROM users")
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(usernames)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn authenticate(&self, token: AuthToken) -> Result<Option<SessionToken>, AuthError> {
|
||||||
|
let results = sqlx::query("SELECT * FROM users WHERE token = $1")
|
||||||
|
.bind(token.to_string())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if results.len() > 1 {
|
||||||
|
return Err(AuthError::DuplicateAuthToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if results.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_id: i64 = results[0].try_get("id")?;
|
||||||
|
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(Uuid::new_v4().hyphenated().to_string());
|
||||||
|
hasher.update(token.to_string());
|
||||||
|
let session_token = Base64::encode_string(&hasher.finalize());
|
||||||
|
|
||||||
|
let _ = sqlx::query("INSERT INTO sessions (token, user_id) VALUES ($1, $2)")
|
||||||
|
.bind(session_token.clone())
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Some(SessionToken::from(session_token)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn validate_session(
|
||||||
|
&self,
|
||||||
|
token: SessionToken,
|
||||||
|
) -> Result<Option<Username>, AuthError> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT users.username FROM sessions INNER JOIN users ON sessions.user_id = users.id WHERE sessions.token = $1",
|
||||||
|
)
|
||||||
|
.bind(token.to_string())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if rows.len() > 1 {
|
||||||
|
return Err(AuthError::DuplicateSessionToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let username: String = rows[0].try_get("username")?;
|
||||||
|
Ok(Some(Username::from(username)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use cool_asserts::assert_matches;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn can_create_and_list_users() {
|
||||||
|
let db = AuthDB::new(PathBuf::from(":memory:"))
|
||||||
|
.await
|
||||||
|
.expect("a memory-only database will be created");
|
||||||
|
let _ = db
|
||||||
|
.add_user(Username::from("savanni"))
|
||||||
|
.await
|
||||||
|
.expect("user to be created");
|
||||||
|
assert_matches!(db.list_users().await, Ok(names) => {
|
||||||
|
let names = names.into_iter().collect::<HashSet<Username>>();
|
||||||
|
assert!(names.contains(&Username::from("savanni")));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_auth_token_returns_nothing() {
|
||||||
|
let db = AuthDB::new(PathBuf::from(":memory:"))
|
||||||
|
.await
|
||||||
|
.expect("a memory-only database will be created");
|
||||||
|
let _ = db
|
||||||
|
.add_user(Username::from("savanni"))
|
||||||
|
.await
|
||||||
|
.expect("user to be created");
|
||||||
|
|
||||||
|
let token = AuthToken::from("0000000000");
|
||||||
|
|
||||||
|
assert_matches!(db.authenticate(token).await, Ok(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auth_token_becomes_session_token() {
|
||||||
|
let db = AuthDB::new(PathBuf::from(":memory:"))
|
||||||
|
.await
|
||||||
|
.expect("a memory-only database will be created");
|
||||||
|
let token = db
|
||||||
|
.add_user(Username::from("savanni"))
|
||||||
|
.await
|
||||||
|
.expect("user to be created");
|
||||||
|
|
||||||
|
assert_matches!(db.authenticate(token).await, Ok(_));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn can_validate_session_token() {
|
||||||
|
let db = AuthDB::new(PathBuf::from(":memory:"))
|
||||||
|
.await
|
||||||
|
.expect("a memory-only database will be created");
|
||||||
|
let token = db
|
||||||
|
.add_user(Username::from("savanni"))
|
||||||
|
.await
|
||||||
|
.expect("user to be created");
|
||||||
|
let session = db
|
||||||
|
.authenticate(token)
|
||||||
|
.await
|
||||||
|
.expect("token authentication should succeed")
|
||||||
|
.expect("session token should be found");
|
||||||
|
|
||||||
|
assert_matches!(
|
||||||
|
db.validate_session(session).await,
|
||||||
|
Ok(Some(username)) => {
|
||||||
|
assert_eq!(username, Username::from("savanni"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,13 +14,10 @@ path = "src/lib.rs"
|
||||||
name = "file-service"
|
name = "file-service"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "auth-cli"
|
|
||||||
path = "src/bin/cli.rs"
|
|
||||||
|
|
||||||
[target.auth-cli.dependencies]
|
[target.auth-cli.dependencies]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
authdb = { path = "../authdb/" }
|
||||||
base64ct = { version = "1", features = [ "alloc" ] }
|
base64ct = { version = "1", features = [ "alloc" ] }
|
||||||
build_html = { version = "2" }
|
build_html = { version = "2" }
|
||||||
bytes = { version = "1" }
|
bytes = { version = "1" }
|
||||||
|
@ -38,9 +35,8 @@ mime_guess = "2.0.3"
|
||||||
pretty_env_logger = { version = "0.5" }
|
pretty_env_logger = { version = "0.5" }
|
||||||
serde_json = "*"
|
serde_json = "*"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
sha2 = "0.10"
|
sha2 = { version = "0.10" }
|
||||||
sqlx = { version = "0.7", features = [ "runtime-tokio", "sqlite" ] }
|
thiserror = { version = "1" }
|
||||||
thiserror = "1.0.20"
|
|
||||||
tokio = { version = "1", features = [ "full" ] }
|
tokio = { version = "1", features = [ "full" ] }
|
||||||
uuid = { version = "0.4", features = [ "serde", "v4" ] }
|
uuid = { version = "0.4", features = [ "serde", "v4" ] }
|
||||||
warp = { version = "0.3" }
|
warp = { version = "0.3" }
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
mod store;
|
mod store;
|
||||||
|
|
||||||
pub use store::{
|
pub use store::{
|
||||||
AuthDB, AuthError, AuthToken, DeleteFileError, FileHandle, FileId, FileInfo, ReadFileError,
|
DeleteFileError, FileHandle, FileId, FileInfo, ReadFileError, Store, WriteFileError,
|
||||||
SessionToken, Store, Username, WriteFileError,
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -18,9 +18,10 @@ mod pages;
|
||||||
|
|
||||||
const MAX_UPLOAD: u64 = 15 * 1024 * 1024;
|
const MAX_UPLOAD: u64 = 15 * 1024 * 1024;
|
||||||
|
|
||||||
pub use file_service::{
|
use authdb::{AuthDB, AuthError, AuthToken, SessionToken, Username};
|
||||||
AuthDB, AuthError, AuthToken, DeleteFileError, FileHandle, FileId, FileInfo, ReadFileError,
|
|
||||||
SessionToken, Store, Username, WriteFileError,
|
use file_service::{
|
||||||
|
DeleteFileError, FileHandle, FileId, FileInfo, ReadFileError, Store, WriteFileError,
|
||||||
};
|
};
|
||||||
pub use handlers::handle_index;
|
pub use handlers::handle_index;
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,6 @@
|
||||||
use base64ct::{Base64, Encoding};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use sqlx::{
|
|
||||||
sqlite::{SqlitePool, SqliteRow},
|
|
||||||
Row,
|
|
||||||
};
|
|
||||||
use std::{collections::HashSet, ops::Deref, path::PathBuf};
|
use std::{collections::HashSet, ops::Deref, path::PathBuf};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
mod filehandle;
|
mod filehandle;
|
||||||
mod fileinfo;
|
mod fileinfo;
|
||||||
|
@ -90,136 +83,6 @@ impl From<ReadFileError> for DeleteFileError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum AuthError {
|
|
||||||
#[error("authentication token is duplicated")]
|
|
||||||
DuplicateAuthToken,
|
|
||||||
|
|
||||||
#[error("session token is duplicated")]
|
|
||||||
DuplicateSessionToken,
|
|
||||||
|
|
||||||
#[error("database failed")]
|
|
||||||
SqlError(sqlx::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<sqlx::Error> for AuthError {
|
|
||||||
fn from(err: sqlx::Error) -> AuthError {
|
|
||||||
AuthError::SqlError(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
|
|
||||||
pub struct Username(String);
|
|
||||||
|
|
||||||
impl From<String> for Username {
|
|
||||||
fn from(s: String) -> Self {
|
|
||||||
Self(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&str> for Username {
|
|
||||||
fn from(s: &str) -> Self {
|
|
||||||
Self(s.to_owned())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Username> for String {
|
|
||||||
fn from(s: Username) -> Self {
|
|
||||||
Self::from(&s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&Username> for String {
|
|
||||||
fn from(s: &Username) -> Self {
|
|
||||||
let Username(s) = s;
|
|
||||||
Self::from(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Deref for Username {
|
|
||||||
type Target = String;
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl sqlx::FromRow<'_, SqliteRow> for Username {
|
|
||||||
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
|
|
||||||
let name: String = row.try_get("username")?;
|
|
||||||
Ok(Username::from(name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
|
|
||||||
pub struct AuthToken(String);
|
|
||||||
|
|
||||||
impl From<String> for AuthToken {
|
|
||||||
fn from(s: String) -> Self {
|
|
||||||
Self(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&str> for AuthToken {
|
|
||||||
fn from(s: &str) -> Self {
|
|
||||||
Self(s.to_owned())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<AuthToken> for PathBuf {
|
|
||||||
fn from(s: AuthToken) -> Self {
|
|
||||||
Self::from(&s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&AuthToken> for PathBuf {
|
|
||||||
fn from(s: &AuthToken) -> Self {
|
|
||||||
let AuthToken(s) = s;
|
|
||||||
Self::from(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Deref for AuthToken {
|
|
||||||
type Target = String;
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
|
|
||||||
pub struct SessionToken(String);
|
|
||||||
|
|
||||||
impl From<String> for SessionToken {
|
|
||||||
fn from(s: String) -> Self {
|
|
||||||
Self(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&str> for SessionToken {
|
|
||||||
fn from(s: &str) -> Self {
|
|
||||||
Self(s.to_owned())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<SessionToken> for PathBuf {
|
|
||||||
fn from(s: SessionToken) -> Self {
|
|
||||||
Self::from(&s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&SessionToken> for PathBuf {
|
|
||||||
fn from(s: &SessionToken) -> Self {
|
|
||||||
let SessionToken(s) = s;
|
|
||||||
Self::from(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Deref for SessionToken {
|
|
||||||
type Target = String;
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
|
||||||
pub struct FileId(String);
|
pub struct FileId(String);
|
||||||
|
|
||||||
|
@ -267,95 +130,6 @@ impl FileRoot for Context {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct AuthDB {
|
|
||||||
pool: SqlitePool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AuthDB {
|
|
||||||
pub async fn new(path: PathBuf) -> Result<Self, sqlx::Error> {
|
|
||||||
let migrator = sqlx::migrate!("./migrations");
|
|
||||||
let pool = SqlitePool::connect(&format!("sqlite://{}", path.to_str().unwrap())).await?;
|
|
||||||
migrator.run(&pool).await?;
|
|
||||||
Ok(Self { pool })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn add_user(&self, username: Username) -> Result<AuthToken, AuthError> {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(Uuid::new_v4().hyphenated().to_string());
|
|
||||||
hasher.update(username.to_string());
|
|
||||||
let auth_token = Base64::encode_string(&hasher.finalize());
|
|
||||||
|
|
||||||
let _ = sqlx::query("INSERT INTO users (username, token) VALUES ($1, $2)")
|
|
||||||
.bind(username.to_string())
|
|
||||||
.bind(auth_token.clone())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(AuthToken::from(auth_token))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_users(&self) -> Result<Vec<Username>, AuthError> {
|
|
||||||
let usernames = sqlx::query_as::<_, Username>("SELECT (username) FROM users")
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(usernames)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn authenticate(&self, token: AuthToken) -> Result<Option<SessionToken>, AuthError> {
|
|
||||||
let results = sqlx::query("SELECT * FROM users WHERE token = $1")
|
|
||||||
.bind(token.to_string())
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if results.len() > 1 {
|
|
||||||
return Err(AuthError::DuplicateAuthToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
if results.is_empty() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let user_id: i64 = results[0].try_get("id")?;
|
|
||||||
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(Uuid::new_v4().hyphenated().to_string());
|
|
||||||
hasher.update(token.to_string());
|
|
||||||
let session_token = Base64::encode_string(&hasher.finalize());
|
|
||||||
|
|
||||||
let _ = sqlx::query("INSERT INTO sessions (token, user_id) VALUES ($1, $2)")
|
|
||||||
.bind(session_token.clone())
|
|
||||||
.bind(user_id)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Some(SessionToken::from(session_token)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn validate_session(
|
|
||||||
&self,
|
|
||||||
token: SessionToken,
|
|
||||||
) -> Result<Option<Username>, AuthError> {
|
|
||||||
let rows = sqlx::query(
|
|
||||||
"SELECT users.username FROM sessions INNER JOIN users ON sessions.user_id = users.id WHERE sessions.token = $1",
|
|
||||||
)
|
|
||||||
.bind(token.to_string())
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await?;
|
|
||||||
if rows.len() > 1 {
|
|
||||||
return Err(AuthError::DuplicateSessionToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
if rows.is_empty() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let username: String = rows[0].try_get("username")?;
|
|
||||||
Ok(Some(Username::from(username)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Store {
|
pub struct Store {
|
||||||
files_root: PathBuf,
|
files_root: PathBuf,
|
||||||
}
|
}
|
||||||
|
@ -493,74 +267,3 @@ mod test {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod authdb_test {
|
|
||||||
use super::*;
|
|
||||||
use cool_asserts::assert_matches;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn can_create_and_list_users() {
|
|
||||||
let db = AuthDB::new(PathBuf::from(":memory:"))
|
|
||||||
.await
|
|
||||||
.expect("a memory-only database will be created");
|
|
||||||
let _ = db
|
|
||||||
.add_user(Username::from("savanni"))
|
|
||||||
.await
|
|
||||||
.expect("user to be created");
|
|
||||||
assert_matches!(db.list_users().await, Ok(names) => {
|
|
||||||
let names = names.into_iter().collect::<HashSet<Username>>();
|
|
||||||
assert!(names.contains(&Username::from("savanni")));
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn unknown_auth_token_returns_nothing() {
|
|
||||||
let db = AuthDB::new(PathBuf::from(":memory:"))
|
|
||||||
.await
|
|
||||||
.expect("a memory-only database will be created");
|
|
||||||
let _ = db
|
|
||||||
.add_user(Username::from("savanni"))
|
|
||||||
.await
|
|
||||||
.expect("user to be created");
|
|
||||||
|
|
||||||
let token = AuthToken::from("0000000000");
|
|
||||||
|
|
||||||
assert_matches!(db.authenticate(token).await, Ok(None));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn auth_token_becomes_session_token() {
|
|
||||||
let db = AuthDB::new(PathBuf::from(":memory:"))
|
|
||||||
.await
|
|
||||||
.expect("a memory-only database will be created");
|
|
||||||
let token = db
|
|
||||||
.add_user(Username::from("savanni"))
|
|
||||||
.await
|
|
||||||
.expect("user to be created");
|
|
||||||
|
|
||||||
assert_matches!(db.authenticate(token).await, Ok(_));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn can_validate_session_token() {
|
|
||||||
let db = AuthDB::new(PathBuf::from(":memory:"))
|
|
||||||
.await
|
|
||||||
.expect("a memory-only database will be created");
|
|
||||||
let token = db
|
|
||||||
.add_user(Username::from("savanni"))
|
|
||||||
.await
|
|
||||||
.expect("user to be created");
|
|
||||||
let session = db
|
|
||||||
.authenticate(token)
|
|
||||||
.await
|
|
||||||
.expect("token authentication should succeed")
|
|
||||||
.expect("session token should be found");
|
|
||||||
|
|
||||||
assert_matches!(
|
|
||||||
db.validate_session(session).await,
|
|
||||||
Ok(Some(username)) => {
|
|
||||||
assert_eq!(username, Username::from("savanni"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in New Issue