use uuid::Uuid; use diesel::prelude::*; use diesel::result::Error as DieselError; use diesel_derive_enum::DbEnum; use rocket::{State, request::{FromRequest, Request, Outcome}}; use serde::{Serialize, Deserialize}; use chrono::serde::ts_seconds; use chrono::prelude::{DateTime, Utc}; use chrono::Duration; // TODO: Maybe just use argon2 crate directly use djangohashers::{make_password_with_algorithm, check_password, HasherError, Algorithm}; use jsonwebtoken::{ encode, decode, Header, Validation, Algorithm as JwtAlgorithm, EncodingKey, DecodingKey, errors::Result as JwtResult, errors::ErrorKind as JwtErrorKind }; use crate::schema::*; use crate::DbConn; use crate::config::Config; use crate::models::errors::{ErrorResponse, make_500}; use crate::models::dns::AbsoluteName; const BEARER: &str = "Bearer "; const AUTH_HEADER: &str = "Authentication"; #[derive(Debug, DbEnum, Deserialize, Clone)] #[serde(rename_all = "lowercase")] pub enum Role { #[db_rename = "admin"] Admin, #[db_rename = "zoneadmin"] ZoneAdmin, } // TODO: Store Uuid instead of string?? #[derive(Debug, Queryable, Identifiable, Insertable)] #[table_name = "user"] pub struct User { pub id: String, } #[derive(Debug, Queryable, Identifiable, Insertable)] #[table_name = "localuser"] #[primary_key(user_id)] pub struct LocalUser { pub user_id: String, pub username: String, pub password: String, pub role: Role, } #[derive(Debug, Queryable, Identifiable, Insertable)] #[table_name = "user_zone"] #[primary_key(user_id, zone_id)] pub struct UserZone { pub user_id: String, pub zone_id: String, } #[derive(Debug, Serialize, Queryable, Identifiable, Insertable)] #[table_name = "zone"] pub struct Zone { #[serde(skip)] pub id: String, pub name: String, } #[derive(Debug, Deserialize)] pub struct CreateUserRequest { pub username: String, pub password: String, pub email: String, pub role: Option } #[derive(Debug, Deserialize)] pub struct AddZoneMemberRequest { pub id: String, } #[derive(Debug, Deserialize)] pub struct CreateZoneRequest { pub name: AbsoluteName, } // pub struct LdapUserAssociation { // user_id: Uuid, // ldap_id: String // } #[derive(Debug, Serialize, Deserialize)] pub struct AuthClaims { pub jti: String, pub sub: String, #[serde(with = "ts_seconds")] pub exp: DateTime, #[serde(with = "ts_seconds")] pub iat: DateTime, } #[derive(Debug, Serialize)] pub struct AuthTokenResponse { pub token: String } #[derive(Debug, Deserialize)] pub struct AuthTokenRequest { pub username: String, pub password: String, } #[derive(Debug)] pub struct UserInfo { pub id: String, pub role: Role, pub username: String, } impl UserInfo { pub fn is_admin(&self) -> bool { matches!(self.role, Role::Admin) } pub fn check_admin(&self) -> Result<(), UserError> { if self.is_admin() { Ok(()) } else { Err(UserError::PermissionDenied) } } pub fn get_zone(&self, conn: &diesel::SqliteConnection, zone_name: &str) -> Result { use crate::schema::user_zone::dsl::*; use crate::schema::zone::dsl::*; let (res_zone, _): (Zone, UserZone) = zone.inner_join(user_zone) .filter(name.eq(zone_name)) .filter(user_id.eq(&self.id)) .get_result(conn) .map_err(|e| match e { DieselError::NotFound => UserError::ZoneNotFound, other => UserError::DbError(other) })?; Ok(res_zone) } pub fn get_zones(&self, conn: &diesel::SqliteConnection) -> Result, UserError> { use crate::schema::user_zone::dsl::*; use crate::schema::zone::dsl::*; let res: Vec<(Zone, UserZone)> = zone.inner_join(user_zone) .filter(user_id.eq(&self.id)) .get_results(conn) .map_err(UserError::DbError)?; Ok(res.into_iter().map(|(z, _)| z).collect()) } } #[rocket::async_trait] impl<'r> FromRequest<'r> for UserInfo { type Error = ErrorResponse; async fn from_request(request: &'r Request<'_>) -> Outcome { let auth_header = match request.headers().get_one(AUTH_HEADER) { None => return Outcome::Forward(()), Some(auth_header) => auth_header, }; let token = if auth_header.starts_with(BEARER) { auth_header.trim_start_matches(BEARER) } else { return ErrorResponse::from(UserError::MalformedHeader).into() }; let config = try_outcome!(request.guard::>().await.map_failure(make_500)); let conn = try_outcome!(request.guard::().await.map_failure(make_500)); let token_data = AuthClaims::decode( token, &config.web_app.secret ).map_err(|e| match e.into_kind() { JwtErrorKind::ExpiredSignature => UserError::ExpiredToken, _ => UserError::BadToken, }); let token_data = match token_data { Err(e) => return ErrorResponse::from(e).into(), Ok(data) => data }; let user_id = token_data.sub; conn.run(move |c| { match LocalUser::get_user_by_uuid(c, &user_id) { Err(UserError::NotFound) => ErrorResponse::from(UserError::NotFound).into(), Err(e) => ErrorResponse::from(e).into(), Ok(d) => Outcome::Success(d), } }).await } } #[derive(Debug)] pub enum UserError { ZoneNotFound, NotFound, UserConflict, BadCreds, BadToken, ExpiredToken, MalformedHeader, PermissionDenied, DbError(DieselError), PasswordError(HasherError), } impl From for UserError { fn from(e: HasherError) -> Self { UserError::PasswordError(e) } } impl From for UserError { fn from(e: DieselError) -> Self { UserError::DbError(e) } } impl LocalUser { pub fn create_user(conn: &diesel::SqliteConnection, user_request: CreateUserRequest) -> Result { use crate::schema::localuser::dsl::*; use crate::schema::user::dsl::*; let new_user_id = Uuid::new_v4().to_simple().to_string(); let new_user = User { id: new_user_id.clone(), }; let new_localuser = LocalUser { user_id: new_user_id, username: user_request.username.clone(), password: make_password_with_algorithm(&user_request.password, Algorithm::Argon2), // TODO: Use role from request role: Role::ZoneAdmin, }; let res = UserInfo { id: new_user.id.clone(), role: new_localuser.role.clone(), username: new_localuser.username.clone(), }; conn.immediate_transaction(|| -> diesel::QueryResult<()> { diesel::insert_into(user) .values(new_user) .execute(conn)?; diesel::insert_into(localuser) .values(new_localuser) .execute(conn)?; Ok(()) }).map_err(|e| match e { DieselError::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation, _) => UserError::UserConflict, other => UserError::DbError(other) })?; Ok(res) } pub fn get_user_by_creds( conn: &diesel::SqliteConnection, request_username: &str, request_password: &str ) -> Result { use crate::schema::localuser::dsl::*; use crate::schema::user::dsl::*; let (client_user, client_localuser): (User, LocalUser) = user.inner_join(localuser) .filter(username.eq(request_username)) .get_result(conn) .map_err(|e| match e { DieselError::NotFound => UserError::BadCreds, other => UserError::DbError(other) })?; if !check_password(&request_password, &client_localuser.password)? { return Err(UserError::BadCreds); } Ok(UserInfo { id: client_user.id, role: client_localuser.role, username: client_localuser.username, }) } pub fn get_user_by_uuid(conn: &diesel::SqliteConnection, request_user_id: &str) -> Result { use crate::schema::localuser::dsl::*; use crate::schema::user::dsl::*; let (client_user, client_localuser): (User, LocalUser) = user.inner_join(localuser) .filter(id.eq(request_user_id)) .get_result(conn) .map_err(|e| match e { DieselError::NotFound => UserError::NotFound, other => UserError::DbError(other) })?; Ok(UserInfo { id: client_user.id, role: client_localuser.role, username: client_localuser.username, }) } } impl AuthClaims { pub fn new(user_info: &UserInfo, token_duration: Duration) -> AuthClaims { let jti = Uuid::new_v4().to_simple().to_string(); let iat = Utc::now(); let exp = iat + token_duration; AuthClaims { jti, sub: user_info.id.clone(), exp, iat, } } pub fn decode(token: &str, secret: &str) -> JwtResult { decode::( token, &DecodingKey::from_secret(secret.as_ref()), &Validation::new(JwtAlgorithm::HS256) ).map(|data| data.claims) } pub fn encode(self, secret: &str) -> JwtResult { encode(&Header::default(), &self, &EncodingKey::from_secret(secret.as_ref())) } } // NOTE: Should probably not be implemented here // also, "UserError" seems like a misleading name impl Zone { pub fn get_all(conn: &diesel::SqliteConnection) -> Result, UserError> { use crate::schema::zone::dsl::*; zone.get_results(conn) .map_err(UserError::DbError) } pub fn get_by_name(conn: &diesel::SqliteConnection, zone_name: &str) -> Result { use crate::schema::zone::dsl::*; zone.filter(name.eq(zone_name)) .get_result(conn) .map_err(|e| match e { DieselError::NotFound => UserError::ZoneNotFound, other => UserError::DbError(other) }) } pub fn create_zone(conn: &diesel::SqliteConnection, zone_request: CreateZoneRequest) -> Result { use crate::schema::zone::dsl::*; let new_zone = Zone { id: Uuid::new_v4().to_simple().to_string(), name: zone_request.name.to_utf8(), }; diesel::insert_into(zone) .values(&new_zone) .execute(conn) .map_err(|e| match e { DieselError::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation, _) => UserError::UserConflict, other => UserError::DbError(other) })?; Ok(new_zone) } pub fn add_member(&self, conn: &diesel::SqliteConnection, new_member: &UserInfo) -> Result<(), UserError> { use crate::schema::user_zone::dsl::*; let new_user_zone = UserZone { zone_id: self.id.clone(), user_id: new_member.id.clone() }; let res = diesel::insert_into(user_zone) .values(new_user_zone) .execute(conn); match res { // If user has already access to the zone, safely ignore the conflit // TODO: use 'on conflict do nothing' in postgres when we get there Err(DieselError::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation, _)) => (), Err(e) => return Err(e.into()), Ok(_) => () }; Ok(()) } }