use async_trait::async_trait;
use sea_orm::schema::SchemaBuilder;
use sea_orm::sea_query::Expr;
use sea_orm::{
ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, DatabaseConnection, EntityTrait,
QueryFilter, QueryOrder, Set, SqlErr, TransactionTrait,
};
use crate::access::{Access, Action};
use crate::auth::{AdminUser, ApiKeyOwner, UserStore, hash_password_async};
use crate::error::AdminError;
const MIN_PASSWORD_CHARS: usize = 8;
pub mod user {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "auth_user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub username: String,
pub password_hash: String,
pub totp_secret: Option<String>,
pub is_active: bool,
pub is_staff: bool,
#[sea_orm(default_value = false)]
pub is_superuser: bool,
pub totp_last_step: Option<i64>,
#[sea_orm(unique_key = "auth_user_email")]
pub email: Option<String>,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub phone: Option<String>,
pub last_login: Option<DateTimeUtc>,
pub date_joined: Option<DateTimeUtc>,
}
impl Model {
#[must_use]
pub fn display_name(&self) -> String {
let name = [self.first_name.as_deref(), self.last_name.as_deref()]
.into_iter()
.flatten()
.map(str::trim)
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(" ");
if name.is_empty() {
self.username.clone()
} else {
name
}
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub mod group {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "auth_group")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub name: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub(crate) mod membership {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "auth_user_group")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(indexed)]
pub user_id: i32,
#[sea_orm(indexed)]
pub group_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub(crate) mod user_permission {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "auth_user_permission")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(indexed)]
pub user_id: i32,
pub model: String,
pub action: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub(crate) mod group_permission {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "auth_group_permission")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(indexed)]
pub group_id: i32,
pub model: String,
pub action: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub mod identity {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "auth_identity")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(indexed)]
pub user_id: i32,
#[sea_orm(unique_key = "auth_identity_provider_subject")]
pub provider: String,
#[sea_orm(unique_key = "auth_identity_provider_subject")]
pub subject: String,
pub email: Option<String>,
pub date_linked: DateTimeUtc,
pub last_used: Option<DateTimeUtc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub(crate) mod recovery_code {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "auth_recovery_code")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(indexed)]
pub user_id: i32,
pub digest: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub(crate) mod api_key {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "auth_api_key")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(indexed)]
pub user_id: i32,
pub name: String,
pub prefix: String,
#[sea_orm(unique)]
pub digest: String,
pub read_only: bool,
pub created: DateTimeUtc,
pub last_used: Option<DateTimeUtc>,
pub expires: Option<DateTimeUtc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub(crate) fn register_schema(builder: SchemaBuilder) -> SchemaBuilder {
builder
.register(identity::Entity)
.register(recovery_code::Entity)
.register(api_key::Entity)
.register(user::Entity)
.register(group::Entity)
.register(membership::Entity)
.register(user_permission::Entity)
.register(group_permission::Entity)
}
fn with_grants(access: Access, rows: impl IntoIterator<Item = (String, String)>) -> Access {
rows.into_iter().fold(access, |access, (model, action)| {
match Action::parse(&action) {
Some(action) => access.grant(model, action),
None => access,
}
})
}
#[derive(Clone)]
pub struct DbUserStore {
db: DatabaseConnection,
}
impl DbUserStore {
#[must_use]
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
}
#[async_trait]
impl UserStore for DbUserStore {
async fn find(&self, login: &str) -> Result<Option<AdminUser>, AdminError> {
let account = find_by_login(&self.db, login).await?;
Ok(account
.filter(|a| a.is_active && a.is_staff)
.map(|a| AdminUser {
username: a.username,
password_hash: a.password_hash,
totp_secret: a.totp_secret,
}))
}
async fn logged_in(&self, username: &str) -> Result<(), AdminError> {
user::Entity::update_many()
.col_expr(user::Column::LastLogin, Expr::value(chrono::Utc::now()))
.filter(user::Column::Username.eq(username))
.exec(&self.db)
.await?;
Ok(())
}
async fn accept_totp_step(&self, username: &str, step: u64) -> Result<bool, AdminError> {
let step = i64::try_from(step).unwrap_or(i64::MAX);
let updated = user::Entity::update_many()
.col_expr(user::Column::TotpLastStep, Expr::value(step))
.filter(user::Column::Username.eq(username))
.filter(
Condition::any()
.add(user::Column::TotpLastStep.is_null())
.add(user::Column::TotpLastStep.lt(step)),
)
.exec(&self.db)
.await?;
Ok(updated.rows_affected == 1)
}
async fn use_recovery_code(&self, username: &str, digest: &str) -> Result<bool, AdminError> {
let Some(account) = find_account(&self.db, username).await? else {
return Ok(false);
};
let used = recovery_code::Entity::delete_many()
.filter(recovery_code::Column::UserId.eq(account.id))
.filter(recovery_code::Column::Digest.eq(digest))
.exec(&self.db)
.await?;
Ok(used.rows_affected == 1)
}
async fn api_key(&self, digest: &str) -> Result<Option<ApiKeyOwner>, AdminError> {
let Some(key) = api_key::Entity::find()
.filter(api_key::Column::Digest.eq(digest))
.one(&self.db)
.await?
else {
return Ok(None);
};
let now = chrono::Utc::now();
if key.expires.is_some_and(|expires| expires <= now) {
return Ok(None);
}
let Some(account) = user::Entity::find_by_id(key.user_id).one(&self.db).await? else {
return Ok(None);
};
if key
.last_used
.is_none_or(|used| now - used > chrono::Duration::seconds(60))
{
api_key::Entity::update_many()
.col_expr(api_key::Column::LastUsed, Expr::value(now))
.filter(api_key::Column::Id.eq(key.id))
.exec(&self.db)
.await?;
}
Ok(Some(ApiKeyOwner {
username: account.username,
read_only: key.read_only,
}))
}
async fn access(&self, username: &str) -> Result<Option<Access>, AdminError> {
let account = user::Entity::find()
.filter(user::Column::Username.eq(username))
.one(&self.db)
.await?;
let Some(account) = account.filter(|a| a.is_active && a.is_staff) else {
return Ok(None);
};
if account.is_superuser {
return Ok(Some(Access::superuser()));
}
let access = user_grants(&self.db, account.id).await?;
let groups = user_groups(&self.db, account.id).await?;
if groups.is_empty() {
return Ok(Some(access));
}
let shared = group_permission::Entity::find()
.filter(group_permission::Column::GroupId.is_in(groups))
.all(&self.db)
.await?;
Ok(Some(with_grants(
access,
shared.into_iter().map(|p| (p.model, p.action)),
)))
}
}
pub async fn enable_totp(db: &DatabaseConnection, username: &str) -> Result<String, AdminError> {
let secret = crate::auth::new_totp_secret();
store_totp(db, username, Some(secret.clone()), None).await?;
Ok(secret)
}
pub async fn disable_totp(db: &DatabaseConnection, username: &str) -> Result<(), AdminError> {
store_totp(db, username, None, None).await
}
pub(crate) async fn store_totp(
db: &DatabaseConnection,
username: &str,
secret: Option<String>,
used_step: Option<u64>,
) -> Result<(), AdminError> {
let account = find_account(db, username)
.await?
.ok_or_else(|| AdminError::NotFound(format!("user `{username}`")))?;
recovery_code::Entity::delete_many()
.filter(recovery_code::Column::UserId.eq(account.id))
.exec(db)
.await?;
let mut account: user::ActiveModel = account.into();
account.totp_secret = Set(secret);
account.totp_last_step = Set(used_step.and_then(|step| i64::try_from(step).ok()));
account.update(db).await?;
Ok(())
}
pub async fn new_recovery_codes(
db: &DatabaseConnection,
username: &str,
) -> Result<Vec<String>, AdminError> {
let account = find_account(db, username)
.await?
.ok_or_else(|| AdminError::NotFound(format!("user `{username}`")))?;
let codes = crate::auth::new_recovery_codes();
let txn = db.begin().await?;
recovery_code::Entity::delete_many()
.filter(recovery_code::Column::UserId.eq(account.id))
.exec(&txn)
.await?;
for code in &codes {
recovery_code::ActiveModel {
user_id: Set(account.id),
digest: Set(crate::auth::recovery_digest(code).expect("generated codes are valid")),
..Default::default()
}
.insert(&txn)
.await?;
}
txn.commit().await?;
Ok(codes)
}
pub(crate) async fn recovery_codes_left(
db: &DatabaseConnection,
username: &str,
) -> Result<u64, AdminError> {
use sea_orm::PaginatorTrait;
let Some(account) = find_account(db, username).await? else {
return Ok(0);
};
Ok(recovery_code::Entity::find()
.filter(recovery_code::Column::UserId.eq(account.id))
.count(db)
.await?)
}
pub(crate) async fn find_by_login(
db: &DatabaseConnection,
login: &str,
) -> Result<Option<user::Model>, AdminError> {
let login = login.trim();
if let Some(account) = find_account(db, login).await? {
return Ok(Some(account));
}
if !login.contains('@') {
return Ok(None);
}
Ok(user::Entity::find()
.filter(user::Column::Email.eq(login.to_lowercase()))
.one(db)
.await?)
}
pub(crate) async fn find_by_email(
db: &DatabaseConnection,
email: &str,
) -> Result<Option<user::Model>, AdminError> {
Ok(user::Entity::find()
.filter(user::Column::Email.eq(email.trim().to_lowercase()))
.one(db)
.await?)
}
pub(crate) async fn find_account(
db: &DatabaseConnection,
username: &str,
) -> Result<Option<user::Model>, AdminError> {
Ok(user::Entity::find()
.filter(user::Column::Username.eq(username))
.one(db)
.await?)
}
pub(crate) async fn user_grants(
db: &impl ConnectionTrait,
user_id: i32,
) -> Result<Access, AdminError> {
let rows = user_permission::Entity::find()
.filter(user_permission::Column::UserId.eq(user_id))
.all(db)
.await?;
Ok(with_grants(
Access::none(),
rows.into_iter().map(|p| (p.model, p.action)),
))
}
pub(crate) async fn user_groups(
db: &impl ConnectionTrait,
user_id: i32,
) -> Result<Vec<i32>, AdminError> {
let rows = membership::Entity::find()
.filter(membership::Column::UserId.eq(user_id))
.all(db)
.await?;
Ok(rows.into_iter().map(|m| m.group_id).collect())
}
pub(crate) async fn group_grants(
db: &impl ConnectionTrait,
group_id: i32,
) -> Result<Access, AdminError> {
let rows = group_permission::Entity::find()
.filter(group_permission::Column::GroupId.eq(group_id))
.all(db)
.await?;
Ok(with_grants(
Access::none(),
rows.into_iter().map(|p| (p.model, p.action)),
))
}
pub(crate) async fn all_groups(db: &DatabaseConnection) -> Result<Vec<group::Model>, AdminError> {
Ok(group::Entity::find()
.order_by_asc(group::Column::Name)
.all(db)
.await?)
}
pub(crate) struct AccountInput {
pub(crate) username: String,
pub(crate) password: Option<String>,
pub(crate) is_active: bool,
pub(crate) is_staff: bool,
pub(crate) is_superuser: bool,
pub(crate) groups: Vec<i32>,
pub(crate) grants: Vec<(String, Action)>,
pub(crate) reset_totp: bool,
pub(crate) email: Option<String>,
pub(crate) first_name: Option<String>,
pub(crate) last_name: Option<String>,
pub(crate) phone: Option<String>,
}
fn normalise_email(email: Option<&str>) -> Result<Option<String>, AdminError> {
let Some(email) = email.map(str::trim).filter(|e| !e.is_empty()) else {
return Ok(None);
};
let valid = email.len() <= 254
&& !email.chars().any(char::is_whitespace)
&& email.split_once('@').is_some_and(|(local, domain)| {
!local.is_empty()
&& domain.contains('.')
&& !domain.starts_with('.')
&& !domain.ends_with('.')
});
if !valid {
return Err(bad("email", "enter an address like name@example.com"));
}
Ok(Some(email.to_lowercase()))
}
fn normalise_phone(phone: Option<&str>) -> Result<Option<String>, AdminError> {
let Some(phone) = phone.map(str::trim).filter(|p| !p.is_empty()) else {
return Ok(None);
};
let digits = phone.chars().filter(char::is_ascii_digit).count();
let valid = (5..=20).contains(&digits)
&& phone
.chars()
.all(|c| c.is_ascii_digit() || matches!(c, '+' | ' ' | '-' | '(' | ')' | '.'));
if !valid {
return Err(bad("phone", "use digits, spaces and + - ( ) only"));
}
Ok(Some(phone.to_owned()))
}
fn text(value: Option<String>) -> Option<String> {
value.map(|v| v.trim().to_owned()).filter(|v| !v.is_empty())
}
pub(crate) async fn save_user(
db: &DatabaseConnection,
id: Option<i32>,
input: AccountInput,
) -> Result<i32, AdminError> {
check_username(&input.username)?;
let email = normalise_email(input.email.as_deref())?;
let phone = normalise_phone(input.phone.as_deref())?;
if let Some(address) = &email {
let holder = user::Entity::find()
.filter(user::Column::Email.eq(address.as_str()))
.one(db)
.await?;
if holder.is_some_and(|h| Some(h.id) != id) {
return Err(bad("email", "another account already uses this address"));
}
}
let password_hash = match (&input.password, id) {
(Some(password), _) => {
check_password(password)?;
Some(hash_password_async(password).await?)
}
(None, Some(_)) => None,
(None, None) => return Err(bad("password", "a new account needs a password")),
};
let txn = db.begin().await?;
let mut account = match id {
Some(id) => user::Entity::find_by_id(id)
.one(&txn)
.await?
.ok_or_else(|| AdminError::NotFound(format!("user `{id}`")))?
.into(),
None => user::ActiveModel {
totp_secret: Set(None),
totp_last_step: Set(None),
last_login: Set(None),
date_joined: Set(Some(chrono::Utc::now())),
..Default::default()
},
};
account.username = Set(input.username);
account.email = Set(email);
account.first_name = Set(text(input.first_name));
account.last_name = Set(text(input.last_name));
account.phone = Set(phone);
account.is_active = Set(input.is_active);
account.is_staff = Set(input.is_staff);
account.is_superuser = Set(input.is_superuser);
if let Some(hash) = password_hash {
account.password_hash = Set(hash);
}
if input.reset_totp {
account.totp_secret = Set(None);
account.totp_last_step = Set(None);
}
let saved = match id {
Some(_) => account.update(&txn).await,
None => account.insert(&txn).await,
};
let user_id = saved.map_err(taken("username", "an account"))?.id;
if input.reset_totp {
recovery_code::Entity::delete_many()
.filter(recovery_code::Column::UserId.eq(user_id))
.exec(&txn)
.await?;
}
membership::Entity::delete_many()
.filter(membership::Column::UserId.eq(user_id))
.exec(&txn)
.await?;
for group_id in input.groups {
membership::ActiveModel {
user_id: Set(user_id),
group_id: Set(group_id),
..Default::default()
}
.insert(&txn)
.await?;
}
user_permission::Entity::delete_many()
.filter(user_permission::Column::UserId.eq(user_id))
.exec(&txn)
.await?;
for (model, action) in input.grants {
user_permission::ActiveModel {
user_id: Set(user_id),
model: Set(model),
action: Set(action.as_str().to_owned()),
..Default::default()
}
.insert(&txn)
.await?;
}
txn.commit().await?;
Ok(user_id)
}
pub(crate) async fn delete_user(db: &DatabaseConnection, id: i32) -> Result<(), AdminError> {
let txn = db.begin().await?;
let deleted = user::Entity::delete_by_id(id).exec(&txn).await?;
if deleted.rows_affected == 0 {
return Err(AdminError::NotFound(format!("user `{id}`")));
}
membership::Entity::delete_many()
.filter(membership::Column::UserId.eq(id))
.exec(&txn)
.await?;
user_permission::Entity::delete_many()
.filter(user_permission::Column::UserId.eq(id))
.exec(&txn)
.await?;
identity::Entity::delete_many()
.filter(identity::Column::UserId.eq(id))
.exec(&txn)
.await?;
recovery_code::Entity::delete_many()
.filter(recovery_code::Column::UserId.eq(id))
.exec(&txn)
.await?;
api_key::Entity::delete_many()
.filter(api_key::Column::UserId.eq(id))
.exec(&txn)
.await?;
txn.commit().await?;
Ok(())
}
pub(crate) const MAX_API_KEYS: u64 = 50;
pub async fn new_api_key(
db: &DatabaseConnection,
username: &str,
name: &str,
expires_in_days: Option<u32>,
read_only: bool,
) -> Result<String, AdminError> {
use sea_orm::PaginatorTrait;
let account = find_account(db, username)
.await?
.ok_or_else(|| AdminError::NotFound(format!("user `{username}`")))?;
let name = name.trim();
if name.is_empty() || name.chars().count() > 100 {
return Err(AdminError::BadInput {
field: "name".into(),
message: "give the key a name of up to 100 characters".into(),
});
}
let existing = api_key::Entity::find()
.filter(api_key::Column::UserId.eq(account.id))
.count(db)
.await?;
if existing >= MAX_API_KEYS {
return Err(AdminError::BadInput {
field: "name".into(),
message: format!("an account can have at most {MAX_API_KEYS} keys; revoke one first"),
});
}
let (key, prefix) = crate::auth::new_api_key();
let now = chrono::Utc::now();
api_key::ActiveModel {
user_id: Set(account.id),
name: Set(name.to_owned()),
prefix: Set(prefix),
digest: Set(crate::auth::api_key_digest(&key).expect("generated keys are valid")),
read_only: Set(read_only),
created: Set(now),
last_used: Set(None),
expires: Set(expires_in_days.map(|days| now + chrono::Duration::days(days.into()))),
..Default::default()
}
.insert(db)
.await?;
Ok(key)
}
pub(crate) async fn api_keys(
db: &DatabaseConnection,
user_id: i32,
) -> Result<Vec<api_key::Model>, AdminError> {
Ok(api_key::Entity::find()
.filter(api_key::Column::UserId.eq(user_id))
.order_by_desc(api_key::Column::Id)
.all(db)
.await?)
}
pub(crate) async fn revoke_api_key(
db: &DatabaseConnection,
user_id: i32,
key_id: i32,
) -> Result<bool, AdminError> {
let deleted = api_key::Entity::delete_many()
.filter(api_key::Column::Id.eq(key_id))
.filter(api_key::Column::UserId.eq(user_id))
.exec(db)
.await?;
Ok(deleted.rows_affected == 1)
}
pub(crate) async fn user_identities(
db: &DatabaseConnection,
user_id: i32,
) -> Result<Vec<identity::Model>, AdminError> {
Ok(identity::Entity::find()
.filter(identity::Column::UserId.eq(user_id))
.order_by_asc(identity::Column::Id)
.all(db)
.await?)
}
pub(crate) async fn unlink_identity(
db: &DatabaseConnection,
user_id: i32,
identity_id: i32,
) -> Result<(), AdminError> {
identity::Entity::delete_many()
.filter(identity::Column::Id.eq(identity_id))
.filter(identity::Column::UserId.eq(user_id))
.exec(db)
.await?;
Ok(())
}
pub(crate) async fn resolve_identity(
db: &DatabaseConnection,
methods: &crate::oauth::Methods,
label: &str,
found: &crate::oauth::Identity,
) -> Result<Result<user::Model, String>, AdminError> {
let now = chrono::Utc::now();
let link = identity::Entity::find()
.filter(identity::Column::Provider.eq(found.provider.as_str()))
.filter(identity::Column::Subject.eq(found.subject.as_str()))
.one(db)
.await?;
if let Some(link) = link {
if let Some(account) = user::Entity::find_by_id(link.user_id).one(db).await? {
let mut link: identity::ActiveModel = link.into();
link.email = Set(found.email.clone());
link.last_used = Set(Some(now));
link.update(db).await?;
return Ok(Ok(account));
}
identity::Entity::delete_by_id(link.id).exec(db).await?;
}
let Some(email) = found
.email
.as_deref()
.filter(|_| found.email_verified)
.map(|email| email.trim().to_lowercase())
.filter(|email| email.contains('@'))
else {
return Ok(Err(format!(
"{label} did not confirm an e-mail address for this account, so it cannot be matched to an account here."
)));
};
let existing = user::Entity::find()
.filter(user::Column::Email.eq(email.as_str()))
.one(db)
.await?;
let account = match existing {
Some(account) => account,
None if methods.signup_allows(&email) => {
signup(
db,
&email,
found.first_name.clone(),
found.last_name.clone(),
&methods.signup_groups,
&found.provider,
)
.await?
}
None => {
return Ok(Err(format!(
"No account here uses {email}. Ask an administrator to add you."
)));
}
};
identity::ActiveModel {
user_id: Set(account.id),
provider: Set(found.provider.clone()),
subject: Set(found.subject.clone()),
email: Set(Some(email)),
date_linked: Set(now),
last_used: Set(Some(now)),
..Default::default()
}
.insert(db)
.await?;
Ok(Ok(account))
}
pub(crate) async fn signup(
db: &DatabaseConnection,
email: &str,
first_name: Option<String>,
last_name: Option<String>,
groups: &[String],
source: &str,
) -> Result<user::Model, AdminError> {
let mut username = email.to_owned();
let mut suffix = 2;
while find_account(db, &username).await?.is_some() {
username = format!("{email}-{suffix}");
suffix += 1;
}
let unguessable: String = rand::random::<[u8; 32]>()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
let password_hash = hash_password_async(&unguessable).await?;
let txn = db.begin().await?;
let account = user::ActiveModel {
username: Set(username),
password_hash: Set(password_hash),
totp_secret: Set(None),
is_active: Set(true),
is_staff: Set(true),
is_superuser: Set(false),
totp_last_step: Set(None),
email: Set(Some(email.to_owned())),
first_name: Set(first_name),
last_name: Set(last_name),
phone: Set(None),
last_login: Set(None),
date_joined: Set(Some(chrono::Utc::now())),
..Default::default()
}
.insert(&txn)
.await?;
for name in groups {
match group::Entity::find()
.filter(group::Column::Name.eq(name.as_str()))
.one(&txn)
.await?
{
Some(group) => {
membership::ActiveModel {
user_id: Set(account.id),
group_id: Set(group.id),
..Default::default()
}
.insert(&txn)
.await?;
}
None => {
tracing::warn!(group = %name, "auth.signup_groups names a group that does not exist")
}
}
}
txn.commit().await?;
tracing::info!(username = %account.username, %source, "account created at first login");
Ok(account)
}
pub(crate) async fn save_group(
db: &DatabaseConnection,
id: Option<i32>,
name: &str,
grants: Vec<(String, Action)>,
) -> Result<i32, AdminError> {
let name = name.trim();
if name.is_empty() {
return Err(bad("name", "a group needs a name"));
}
let txn = db.begin().await?;
let mut row: group::ActiveModel = match id {
Some(id) => group::Entity::find_by_id(id)
.one(&txn)
.await?
.ok_or_else(|| AdminError::NotFound(format!("group `{id}`")))?
.into(),
None => <group::ActiveModel as Default>::default(),
};
row.name = Set(name.to_owned());
let saved = match id {
Some(_) => row.update(&txn).await,
None => row.insert(&txn).await,
};
let group_id = saved.map_err(taken("name", "a group"))?.id;
group_permission::Entity::delete_many()
.filter(group_permission::Column::GroupId.eq(group_id))
.exec(&txn)
.await?;
for (model, action) in grants {
group_permission::ActiveModel {
group_id: Set(group_id),
model: Set(model),
action: Set(action.as_str().to_owned()),
..Default::default()
}
.insert(&txn)
.await?;
}
txn.commit().await?;
Ok(group_id)
}
pub(crate) async fn delete_group(db: &DatabaseConnection, id: i32) -> Result<(), AdminError> {
let txn = db.begin().await?;
let deleted = group::Entity::delete_by_id(id).exec(&txn).await?;
if deleted.rows_affected == 0 {
return Err(AdminError::NotFound(format!("group `{id}`")));
}
membership::Entity::delete_many()
.filter(membership::Column::GroupId.eq(id))
.exec(&txn)
.await?;
group_permission::Entity::delete_many()
.filter(group_permission::Column::GroupId.eq(id))
.exec(&txn)
.await?;
txn.commit().await?;
Ok(())
}
fn taken(field: &'static str, what: &'static str) -> impl Fn(sea_orm::DbErr) -> AdminError {
move |error| {
if matches!(error.sql_err(), Some(SqlErr::UniqueConstraintViolation(_))) {
bad(field, &format!("{what} with this name already exists"))
} else {
error.into()
}
}
}
fn check_username(username: &str) -> Result<(), AdminError> {
let valid = !username.is_empty()
&& username
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '@'));
if valid {
Ok(())
} else {
Err(bad("username", "use letters, digits and any of `_ - . @`"))
}
}
fn bad(field: &str, message: &str) -> AdminError {
AdminError::BadInput {
field: field.to_owned(),
message: message.to_owned(),
}
}
fn check_password(password: &str) -> Result<(), AdminError> {
if password.chars().count() < MIN_PASSWORD_CHARS {
return Err(bad(
"password",
&format!("must be at least {MIN_PASSWORD_CHARS} characters"),
));
}
Ok(())
}
pub async fn create_superuser(
db: &DatabaseConnection,
username: &str,
password: &str,
) -> Result<(), AdminError> {
let input = AccountInput {
username: username.to_owned(),
password: Some(password.to_owned()),
is_active: true,
is_staff: true,
is_superuser: true,
groups: Vec::new(),
grants: Vec::new(),
reset_totp: false,
email: None,
first_name: None,
last_name: None,
phone: None,
};
save_user(db, None, input).await.map(|_| ())
}
pub async fn set_email(
db: &DatabaseConnection,
username: &str,
email: &str,
) -> Result<(), AdminError> {
let address = normalise_email(Some(email))?
.ok_or_else(|| bad("email", "enter an address like name@example.com"))?;
let account = find_account(db, username)
.await?
.ok_or_else(|| AdminError::NotFound(format!("user `{username}`")))?;
let holder = user::Entity::find()
.filter(user::Column::Email.eq(address.as_str()))
.one(db)
.await?;
if holder.is_some_and(|h| h.id != account.id) {
return Err(bad("email", "another account already uses this address"));
}
let mut account: user::ActiveModel = account.into();
account.email = Set(Some(address));
account.update(db).await?;
Ok(())
}
pub async fn promote(db: &DatabaseConnection, username: &str) -> Result<(), AdminError> {
let account = user::Entity::find()
.filter(user::Column::Username.eq(username))
.one(db)
.await?
.ok_or_else(|| AdminError::NotFound(format!("user `{username}`")))?;
let mut account: user::ActiveModel = account.into();
account.is_active = Set(true);
account.is_staff = Set(true);
account.is_superuser = Set(true);
account.update(db).await?;
Ok(())
}
pub async fn change_password(
db: &DatabaseConnection,
username: &str,
password: &str,
) -> Result<(), AdminError> {
check_password(password)?;
let account = user::Entity::find()
.filter(user::Column::Username.eq(username))
.one(db)
.await?
.ok_or_else(|| AdminError::NotFound(format!("user `{username}`")))?;
let mut account: user::ActiveModel = account.into();
account.password_hash = Set(hash_password_async(password).await?);
account.update(db).await?;
Ok(())
}