feat(backend): users with passwords

feat/plugins-backend
Tomáš Mládek 2024-03-27 19:23:35 +01:00
parent 0e59bc8bd5
commit 02bfe94f39
12 changed files with 280 additions and 71 deletions

View File

@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="dev backend" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="command" value="run -- serve ./example_vault --clean --no-browser --reinitialize --rescan-mode mirror" />
<option name="command" value="run -- serve ./example_vault --clean --no-browser --reinitialize --rescan-mode mirror --secret upend" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<envs />
<option name="emulateTerminal" value="true" />

55
Cargo.lock generated
View File

@ -487,6 +487,18 @@ version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "arrayref"
version = "0.3.7"
@ -555,6 +567,12 @@ version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "414dcefbc63d77c526a76b3afcf6fbb9b5e2791c19c3aa2297733208750c6e53"
[[package]]
name = "base64ct"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
[[package]]
name = "bitflags"
version = "1.3.2"
@ -567,6 +585,15 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "blake2b_simd"
version = "1.0.1"
@ -863,9 +890,9 @@ dependencies = [
[[package]]
name = "cpufeatures"
version = "0.2.9"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a17b76ff3a4162b0b27f354a0c87015ddad39d35f9c0c36607a3bdd175dde1f1"
checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504"
dependencies = [
"libc",
]
@ -1110,6 +1137,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@ -1745,9 +1773,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.147"
version = "0.2.153"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3"
checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"
[[package]]
name = "libsqlite3-sys"
@ -2254,6 +2282,17 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "944553dd59c802559559161f9816429058b869003836120e262e8caec061b7ae"
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.14"
@ -2914,6 +2953,12 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "subtle"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc"
[[package]]
name = "syn"
version = "1.0.109"
@ -3379,6 +3424,7 @@ name = "upend-db"
version = "0.0.2"
dependencies = [
"anyhow",
"argon2",
"chrono",
"diesel",
"diesel_migrations",
@ -3394,6 +3440,7 @@ dependencies = [
"nonempty",
"num_cpus",
"once_cell",
"password-hash",
"rayon",
"regex",
"serde",

View File

@ -4,5 +4,4 @@ pub struct UpEndConfig {
pub desktop_enabled: bool,
pub trust_executables: bool,
pub secret: String,
pub key: Option<String>,
}

View File

@ -16,7 +16,7 @@ use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use tracing::trace;
use tracing::{debug, error, info, warn};
use tracing_subscriber::filter::{EnvFilter, LevelFilter};
@ -80,7 +80,7 @@ enum Commands {
entity: String,
/// The attribute of the entry.
attribute: String,
/// The value; its type will be heurestically determined.
/// The value; its type will be heuristically determined.
value: String,
/// Output format
#[arg(short, long, default_value = "tsv")]
@ -172,10 +172,6 @@ struct ServeArgs {
#[arg(long, env = "UPEND_SECRET")]
secret: Option<String>,
/// Authentication key users must supply.
#[arg(long, env = "UPEND_KEY")]
key: Option<String>,
/// Allowed host/domain name the API can serve.
#[arg(long, env = "UPEND_ALLOW_HOST")]
allow_host: Vec<String>,
@ -415,9 +411,9 @@ async fn main() -> Result<()> {
})),
desktop_enabled: !args.no_desktop,
trust_executables: args.trust_executables,
key: args.key,
secret,
},
public: Arc::new(Mutex::new(upend.connection()?.get_users()?.len() == 0)),
};
// Start HTTP server

View File

@ -26,7 +26,7 @@ use serde_json::json;
use std::collections::HashMap;
use std::convert::{TryFrom, TryInto};
use std::io::Write;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use tempfile::NamedTempFile;
use tracing::{debug, info, trace};
@ -57,69 +57,105 @@ pub struct State {
pub job_container: jobs::JobContainer,
pub preview_store: Option<Arc<PreviewStore>>,
pub preview_thread_pool: Option<Arc<rayon::ThreadPool>>,
pub public: Arc<Mutex<bool>>,
}
#[derive(Debug, Serialize, Deserialize)]
struct JwtClaims {
user: String,
exp: usize,
}
#[derive(Deserialize)]
pub struct LoginRequest {
key: String,
pub struct UserPayload {
username: String,
password: String,
}
#[post("/api/auth/login")]
pub async fn login(
state: web::Data<State>,
payload: web::Json<LoginRequest>,
payload: web::Json<UserPayload>,
) -> Result<HttpResponse, Error> {
if state.config.key.is_none() || Some(&payload.key) == state.config.key.as_ref() {
let claims = JwtClaims {
exp: (SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(ErrorInternalServerError)?
.as_secs()
+ 7 * 24 * 60 * 60) as usize,
};
let conn = state.upend.connection().map_err(ErrorInternalServerError)?;
let token = jsonwebtoken::encode(
&jsonwebtoken::Header::default(),
&claims,
&jsonwebtoken::EncodingKey::from_secret(state.config.secret.as_ref()),
)
.map_err(ErrorInternalServerError)?;
Ok(HttpResponse::Ok().json(json!({ "token": token })))
} else {
Err(ErrorUnauthorized("Incorrect token."))
match conn.authenticate_user(&payload.username, &payload.password) {
Ok(()) => {
let token = create_token(&payload.username, &state.config.secret)?;
Ok(HttpResponse::Ok().json(json!({ "token": token })))
}
Err(e) => Err(ErrorUnauthorized(e)),
}
}
fn check_auth(req: &HttpRequest, state: &State) -> Result<(), actix_web::Error> {
if let Some(key) = &state.config.key {
if let Some(auth_header) = req.headers().get("Authorization") {
let auth_header = auth_header.to_str().map_err(|err| {
ErrorBadRequest(format!("Invalid value in Authorization header: {err:?}"))
})?;
#[post("/api/auth/register")]
pub async fn register(
req: HttpRequest,
state: web::Data<State>,
payload: web::Json<UserPayload>,
) -> Result<HttpResponse, Error> {
check_auth(&req, &state)?;
let token = jsonwebtoken::decode::<JwtClaims>(
auth_header,
&jsonwebtoken::DecodingKey::from_secret(key.as_ref()),
&jsonwebtoken::Validation::default(),
);
let conn = state.upend.connection().map_err(ErrorInternalServerError)?;
token
.map(|_| ())
.map_err(|err| ErrorUnauthorized(format!("Invalid token: {err:?}")))
} else {
Err(ErrorUnauthorized("Authorization required."))
match conn.set_user(&payload.username, &payload.password) {
Ok(_) => {
*state.public.lock().unwrap() = false;
let token = create_token(&payload.username, &state.config.secret)?;
Ok(HttpResponse::Ok().json(json!({ "token": token })))
}
Err(e) => Err(ErrorInternalServerError(e)),
}
}
fn check_auth(req: &HttpRequest, state: &State) -> Result<Option<String>, actix_web::Error> {
if *state.public.lock().unwrap() {
return Ok(None);
}
if let Some(auth_header) = req.headers().get("Authorization") {
let auth_header = auth_header.to_str().map_err(|err| {
ErrorBadRequest(format!("Invalid value in Authorization header: {err:?}"))
})?;
// decode Bearer
if !auth_header.starts_with("Bearer ") {
return Err(ErrorUnauthorized("Invalid token type."));
}
let token = jsonwebtoken::decode::<JwtClaims>(
auth_header.trim_start_matches("Bearer "),
&jsonwebtoken::DecodingKey::from_secret(state.config.secret.as_ref()),
&jsonwebtoken::Validation::default(),
);
match token {
Ok(token) => Ok(Some(token.claims.user)),
Err(err) => Err(ErrorUnauthorized(format!("Invalid token: {err:?}"))),
}
} else {
Ok(())
Err(ErrorUnauthorized("Authorization required."))
}
}
fn create_token(username: &str, secret: &str) -> Result<String, Error> {
let claims = JwtClaims {
user: username.to_string(),
exp: (SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(ErrorInternalServerError)?
.as_secs()
+ 7 * 24 * 60 * 60) as usize,
};
jsonwebtoken::encode(
&jsonwebtoken::Header::default(),
&claims,
&jsonwebtoken::EncodingKey::from_secret(secret.as_ref()),
)
.map_err(ErrorInternalServerError)
}
#[derive(Deserialize)]
pub struct RawRequest {
native: Option<String>,
@ -128,10 +164,13 @@ pub struct RawRequest {
#[get("/api/raw/{hash}")]
pub async fn get_raw(
req: HttpRequest,
state: web::Data<State>,
web::Query(query): web::Query<RawRequest>,
hash: web::Path<String>,
) -> Result<impl Responder, Error> {
check_auth(&req, &state)?;
let address =
Address::decode(&b58_decode(hash.into_inner()).map_err(ErrorInternalServerError)?)
.map_err(ErrorInternalServerError)?;
@ -218,9 +257,12 @@ pub async fn get_raw(
#[head("/api/raw/{hash}")]
pub async fn head_raw(
req: HttpRequest,
state: web::Data<State>,
hash: web::Path<String>,
) -> Result<HttpResponse, Error> {
check_auth(&req, &state)?;
let address =
Address::decode(&b58_decode(hash.into_inner()).map_err(ErrorInternalServerError)?)
.map_err(ErrorInternalServerError)?;
@ -254,10 +296,13 @@ pub async fn head_raw(
#[get("/api/thumb/{hash}")]
pub async fn get_thumbnail(
req: HttpRequest,
state: web::Data<State>,
hash: web::Path<String>,
web::Query(query): web::Query<HashMap<String, String>>,
) -> Result<Either<NamedFile, HttpResponse>, Error> {
check_auth(&req, &state)?;
#[cfg(feature = "previews")]
if let Some(preview_store) = &state.preview_store {
let hash = hash.into_inner();
@ -299,7 +344,13 @@ pub async fn get_thumbnail(
}
#[post("/api/query")]
pub async fn get_query(state: web::Data<State>, query: String) -> Result<HttpResponse, Error> {
pub async fn get_query(
req: HttpRequest,
state: web::Data<State>,
query: String,
) -> Result<HttpResponse, Error> {
check_auth(&req, &state)?;
let connection = state.upend.connection().map_err(ErrorInternalServerError)?;
let in_query: Query = query.parse().map_err(ErrorBadRequest)?;
@ -341,9 +392,12 @@ impl EntriesAsHash for Vec<Entry> {
#[get("/api/obj/{address_str}")]
pub async fn get_object(
req: HttpRequest,
state: web::Data<State>,
address: web::Path<Address>,
) -> Result<HttpResponse, Error> {
check_auth(&req, &state)?;
let connection = state.upend.connection().map_err(ErrorInternalServerError)?;
let address = address.into_inner();
@ -736,7 +790,12 @@ pub async fn get_address(
}
#[get("/api/all/attributes")]
pub async fn get_all_attributes(state: web::Data<State>) -> Result<HttpResponse, Error> {
pub async fn get_all_attributes(
req: HttpRequest,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
check_auth(&req, &state)?;
let connection = state.upend.connection().map_err(ErrorInternalServerError)?;
let attributes = web::block(move || connection.get_all_attributes())
.await?
@ -779,6 +838,8 @@ pub async fn list_hier(
path: web::Path<String>,
req: HttpRequest,
) -> Result<HttpResponse, Error> {
check_auth(&req, &state)?;
let connection = state.upend.connection().map_err(ErrorInternalServerError)?;
if path.is_empty() {
Ok(HttpResponse::MovedPermanently()
@ -802,7 +863,11 @@ pub async fn list_hier(
}
#[get("/api/hier_roots")]
pub async fn list_hier_roots(state: web::Data<State>) -> Result<HttpResponse, Error> {
pub async fn list_hier_roots(
req: HttpRequest,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
check_auth(&req, &state)?;
let connection = state.upend.connection().map_err(ErrorInternalServerError)?;
let result = web::block(move || {
@ -859,13 +924,15 @@ pub async fn api_refresh(
}
#[get("/api/stats/vault")]
pub async fn vault_stats(state: web::Data<State>) -> Result<HttpResponse, Error> {
pub async fn vault_stats(req: HttpRequest, state: web::Data<State>) -> Result<HttpResponse, Error> {
check_auth(&req, &state)?;
let connection = state.upend.connection().map_err(ErrorInternalServerError)?;
Ok(HttpResponse::Ok().json(connection.get_stats().map_err(ErrorInternalServerError)?))
}
#[get("/api/stats/store")]
pub async fn store_stats(state: web::Data<State>) -> Result<HttpResponse, Error> {
pub async fn store_stats(req: HttpRequest, state: web::Data<State>) -> Result<HttpResponse, Error> {
check_auth(&req, &state)?;
Ok(HttpResponse::Ok().json(json!({
"main": state.store.stats().map_err(ErrorInternalServerError)?
})))
@ -878,9 +945,11 @@ pub struct JobsRequest {
#[get("/api/jobs")]
pub async fn get_jobs(
req: HttpRequest,
state: web::Data<State>,
web::Query(query): web::Query<JobsRequest>,
) -> Result<HttpResponse, Error> {
check_auth(&req, &state)?;
let jobs = state
.job_container
.get_jobs()
@ -907,12 +976,14 @@ pub async fn get_info(state: web::Data<State>) -> Result<HttpResponse, Error> {
upend_db::common::build::PKG_VERSION,
build::PKG_VERSION
),
"desktop": state.config.desktop_enabled
"desktop": state.config.desktop_enabled,
"public": *state.public.lock().unwrap(),
})))
}
#[get("/api/options")]
pub async fn get_options(state: web::Data<State>) -> Result<HttpResponse, Error> {
pub async fn get_options(req: HttpRequest, state: web::Data<State>) -> Result<HttpResponse, Error> {
check_auth(&req, &state)?;
let connection = state.upend.connection().map_err(ErrorInternalServerError)?;
Ok(HttpResponse::Ok().json(
connection
@ -940,7 +1011,11 @@ pub async fn put_options(
}
#[get("/api/migration/user-entries")]
pub async fn get_user_entries(state: web::Data<State>) -> Result<HttpResponse, Error> {
pub async fn get_user_entries(
req: HttpRequest,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
check_auth(&req, &state)?;
let connection = state.upend.connection().map_err(ErrorInternalServerError)?;
let result = web::block(move || connection.get_explicit_entries())
@ -1226,11 +1301,11 @@ mod tests {
desktop_enabled: false,
trust_executables: false,
secret: "secret".to_string(),
key: None,
},
job_container,
preview_store: None,
preview_thread_pool: None,
public: Arc::new(Mutex::new(true)),
}
}
}

View File

@ -46,6 +46,7 @@ where
.app_data(actix_web::web::Data::new(state))
.wrap(actix_web::middleware::Logger::default().exclude("/api/jobs"))
.service(routes::login)
.service(routes::register)
.service(routes::get_raw)
.service(routes::head_raw)
.service(routes::get_thumbnail)

View File

@ -26,13 +26,16 @@ once_cell = "1.7.2"
lru = "0.7.0"
diesel = { version = "1.4", features = [
"sqlite",
"r2d2",
"chrono",
"serde_json",
"sqlite",
"r2d2",
"chrono",
"serde_json",
] }
diesel_migrations = "1.4"
libsqlite3-sys = { version = "^0", features = ["bundled"] }
password-hash = "0.5.0"
argon2 = "0.5.3"
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
@ -42,10 +45,10 @@ regex = "1"
multibase = "0.9"
multihash = { version = "*", default-features = false, features = [
"alloc",
"multihash-impl",
"sha2",
"identity",
"alloc",
"multihash-impl",
"sha2",
"identity",
] }
uuid = { version = "1.4", features = ["v4"] }
url = { version = "2", features = ["serde"] }

View File

@ -0,0 +1 @@
DROP TABLE users;

View File

@ -0,0 +1,7 @@
CREATE TABLE users
(
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
username VARCHAR NOT NULL,
password VARCHAR NOT NULL,
UNIQUE (username)
);

View File

@ -1,4 +1,4 @@
use super::schema::{data, meta};
use super::schema::{data, meta, users};
use chrono::NaiveDateTime;
use serde::Serialize;
@ -23,3 +23,11 @@ pub struct MetaValue {
pub key: String,
pub value: String,
}
#[derive(Queryable, Insertable, Serialize, Clone, Debug)]
#[table_name = "users"]
pub struct UserValue {
pub id: i32,
pub username: String,
pub password: String,
}

View File

@ -20,4 +20,10 @@ table! {
}
}
allow_tables_to_appear_in_same_query!(data, meta,);
table! {
users (id) {
id -> Integer,
username -> Text,
password -> Text,
}
}

View File

@ -26,6 +26,7 @@ use crate::inner::models;
use crate::inner::schema::data;
use crate::util::LoggerSink;
use anyhow::{anyhow, Result};
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use diesel::prelude::*;
use diesel::r2d2::{self, ConnectionManager};
use diesel::result::{DatabaseErrorKind, Error};
@ -273,6 +274,55 @@ impl UpEndConnection {
Ok(VaultOptions { blob_mode })
}
pub fn get_users(&self) -> Result<Vec<String>> {
use crate::inner::schema::users::dsl;
let _lock = self.lock.read().unwrap();
let conn = self.pool.get()?;
let result = dsl::users.select(dsl::username).load::<String>(&conn)?;
Ok(result)
}
pub fn set_user(&self, username: &str, password: &str) -> Result<bool> {
use crate::inner::schema::users::dsl;
let salt = password_hash::SaltString::generate(&mut password_hash::rand_core::OsRng);
let argon2 = Argon2::default();
let hashed_password = argon2
.hash_password(password.as_ref(), &salt)
.map_err(|e| anyhow!(e))?
.to_string();
let _lock = self.lock.write().unwrap();
let conn = self.pool.get()?;
let result = diesel::replace_into(dsl::users)
.values((
dsl::username.eq(username),
dsl::password.eq(hashed_password),
))
.execute(&conn)?;
Ok(result > 0)
}
pub fn authenticate_user(&self, username: &str, password: &str) -> Result<()> {
use crate::inner::schema::users::dsl;
let conn = self.pool.get()?;
let user_result = dsl::users
.filter(dsl::username.eq(username))
.load::<models::UserValue>(&conn)?;
let user = user_result.first().ok_or(anyhow!("User not found"))?;
let parsed_hash = PasswordHash::new(&user.password).map_err(|e| anyhow!(e))?;
let argon2 = Argon2::default();
argon2
.verify_password(password.as_ref(), &parsed_hash)
.map_err(|e| anyhow!(e))
}
pub fn retrieve_entry(&self, hash: &UpMultihash) -> Result<Option<Entry>> {
use crate::inner::schema::data::dsl::*;
@ -602,6 +652,22 @@ mod test {
assert_eq!(result[0].entity, edge_entity);
assert_eq!(result[0].value, EntryValue::Address(random_entity));
}
#[test]
fn test_users() {
let tempdir = TempDir::new().unwrap();
let result = UpEndDatabase::open(&tempdir, false).unwrap();
let db = result.db;
let connection = db.connection().unwrap();
assert!(connection.authenticate_user("thm", "hunter2").is_err());
connection.set_user("thm", "hunter2").unwrap();
connection.authenticate_user("thm", "hunter2").unwrap();
assert!(connection.authenticate_user("thm", "password").is_err());
connection.set_user("thm", "password").unwrap();
connection.authenticate_user("thm", "password").unwrap();
}
}
#[derive(Debug, Serialize, Deserialize)]