563bb99
use export::pagination::{Page, PageQuery};
use models::admin::admin_user::{self, AdminRole};
use sea_orm::PaginatorTrait;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use session::Session;
use time::OffsetDateTime;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::errors::AdminError;
use crate::services::audit::{AuditCtx, AuditRecord};
use crate::services::service::AdminService;
use crate::share::roles::AdminActor;
use crate::share::session::{apply_admin_login, clear_admin};
const LOGIN_ATTEMPT_MAX: u64 = 5;
const LOGIN_ATTEMPT_WINDOW_SECS: u64 = 300;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AdminLoginCmd {
pub username: String,
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AdminDto {
pub id: Uuid,
pub username: String,
pub display_name: String,
pub role: AdminRole,
pub is_active: bool,
pub must_change_password: bool,
pub last_login_at: Option<OffsetDateTime>,
pub last_login_ip: Option<String>,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AdminLoginResp {
pub admin: AdminDto,
pub must_change_password: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ChangePasswordCmd {
pub current_password: String,
pub new_password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateAdminCmd {
pub username: String,
pub display_name: Option<String>,
pub password: String,
pub role: AdminRole,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateAdminCmd {
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub role: Option<AdminRole>,
#[serde(default)]
pub is_active: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ResetAdminPasswordCmd {
pub new_password: String,
}
fn admin_dto(row: &admin_user::Model) -> AdminDto {
AdminDto {
id: row.id,
username: row.username.clone(),
display_name: row.display_name.clone(),
role: row.role,
is_active: row.is_active,
must_change_password: row.must_change_password,
last_login_at: row.last_login_at,
last_login_ip: row.last_login_ip.clone(),
created_at: row.created_at,
updated_at: row.updated_at,
}
}
fn actor_from(row: &admin_user::Model) -> AdminActor {
AdminActor {
id: row.id,
username: row.username.clone(),
display_name: row.display_name.clone(),
role: row.role,
must_change_password: row.must_change_password,
}
}
fn dummy_hash() -> &'static str {
static HASH: std::sync::OnceLock<String> = std::sync::OnceLock::new();
HASH.get_or_init(|| {
auth::share::crypto::hash_password("admin-dummy-password").unwrap_or_default()
})
}
fn normalize_username(raw: &str) -> String {
raw.trim().to_ascii_lowercase()
}
fn validate_username(username: &str) -> Result<(), AdminError> {
let len = username.chars().count();
if !(3..=32).contains(&len) {
return Err(AdminError::BadRequest(
"username must be 3 to 32 characters".to_string(),
));
}
if !username
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
{
return Err(AdminError::BadRequest(
"username may contain letters, digits, underscore, dash and dot only".to_string(),
));
}
Ok(())
}
impl AdminService {
async fn other_active_super_exists(&self, excluded: Uuid) -> Result<bool, AdminError> {
let count = admin_user::Entity::find()
.filter(admin_user::Column::Role.eq(AdminRole::SuperAdmin))
.filter(admin_user::Column::IsActive.eq(true))
.filter(admin_user::Column::Id.ne(excluded))
.count(self.db().read())
.await?;
Ok(count > 0)
}
async fn ensure_super_admin_remains(&self, target: Uuid) -> Result<(), AdminError> {
if self.other_active_super_exists(target).await? {
Ok(())
} else {
Err(AdminError::Conflict(
"at least one active super admin is required".to_string(),
))
}
}
pub async fn admin_login(
&self,
session: &Session,
cmd: AdminLoginCmd,
ctx: &AuditCtx,
) -> Result<AdminLoginResp, AdminError> {
let username = normalize_username(&cmd.username);
if username.is_empty() || cmd.password.is_empty() {
return Err(AdminError::BadRequest(
"username and password are required".to_string(),
));
}
let attempts_key = format!("admin:login_attempts:{username}");
if !auth::share::rate::attempts_track(
self.redis(),
&attempts_key,
LOGIN_ATTEMPT_MAX,
LOGIN_ATTEMPT_WINDOW_SECS,
)
.await?
{
return Err(AdminError::RateLimited(
"too many login attempts, try again later".to_string(),
));
}
let row = admin_user::Entity::find()
.filter(admin_user::Column::LowerName.eq(&username))
.one(self.db().read())
.await?;
let Some(row) = row else {
let _ = auth::share::crypto::verify_password(&cmd.password, dummy_hash());
self.audit(
None,
AuditRecord {
action: "admin.login_failed",
target: None,
detail: json!({ "username": username }),
},
ctx,
)
.await?;
return Err(AdminError::InvalidCredentials);
};
if !row.is_active {
let _ = auth::share::crypto::verify_password(&cmd.password, dummy_hash());
self.audit(
None,
AuditRecord {
action: "admin.login_failed",
target: Some(("admin_user", row.id.to_string())),
detail: json!({ "username": username, "reason": "inactive" }),
},
ctx,
)
.await?;
return Err(AdminError::InvalidCredentials);
}
if !auth::share::crypto::verify_password(&cmd.password, &row.passwd_hash) {
self.audit(
None,
AuditRecord {
action: "admin.login_failed",
target: Some(("admin_user", row.id.to_string())),
detail: json!({ "username": username }),
},
ctx,
)
.await?;
return Err(AdminError::InvalidCredentials);
}
auth::share::rate::attempts_reset(self.redis(), &attempts_key).await?;
let now = OffsetDateTime::now_utc();
let mut am: admin_user::ActiveModel = row.into();
am.last_login_at = Set(Some(now));
am.last_login_ip = Set(ctx.ip.clone());
am.updated_at = Set(now);
let row = am.update(self.db().write()).await?;
apply_admin_login(session, &row);
let actor = actor_from(&row);
self.audit(
Some(&actor),
AuditRecord {
action: "admin.login",
target: Some(("admin_user", row.id.to_string())),
detail: json!({ "username": row.username }),
},
ctx,
)
.await?;
Ok(AdminLoginResp {
admin: admin_dto(&row),
must_change_password: row.must_change_password,
})
}
pub async fn admin_logout(
&self,
session: &Session,
actor: Option<&AdminActor>,
ctx: &AuditCtx,
) -> Result<(), AdminError> {
clear_admin(session);
if let Some(actor) = actor {
self.audit(
Some(actor),
AuditRecord {
action: "admin.logout",
target: Some(("admin_user", actor.id.to_string())),
detail: json!({}),
},
ctx,
)
.await?;
}
Ok(())
}
pub async fn admin_me(&self, actor: &AdminActor) -> Result<AdminDto, AdminError> {
let row = admin_user::Entity::find_by_id(actor.id)
.one(self.db().read())
.await?
.ok_or(AdminError::NotFound)?;
Ok(admin_dto(&row))
}
pub async fn change_admin_password(
&self,
session: &Session,
actor: &AdminActor,
cmd: ChangePasswordCmd,
ctx: &AuditCtx,
) -> Result<(), AdminError> {
let row = admin_user::Entity::find_by_id(actor.id)
.one(self.db().read())
.await?
.ok_or(AdminError::NotFound)?;
if !auth::share::crypto::verify_password(&cmd.current_password, &row.passwd_hash) {
return Err(AdminError::BadRequest(
"current password is incorrect".to_string(),
));
}
auth::share::crypto::validate_password(
&cmd.new_password,
&self.config().auth.password_policy,
)?;
let now = OffsetDateTime::now_utc();
let hash = auth::share::crypto::hash_password(&cmd.new_password)?;
let mut am: admin_user::ActiveModel = row.into();
am.passwd_hash = Set(hash);
am.must_change_password = Set(false);
am.passwd_version = Set(now.unix_timestamp());
am.updated_at = Set(now);
let row = am.update(self.db().write()).await?;
apply_admin_login(session, &row);
self.audit(
Some(actor),
AuditRecord {
action: "admin.password_changed",
target: Some(("admin_user", row.id.to_string())),
detail: json!({}),
},
ctx,
)
.await?;
Ok(())
}
pub async fn list_admins(
&self,
actor: &AdminActor,
page: PageQuery,
) -> Result<Page<AdminDto>, AdminError> {
actor.require(AdminRole::SuperAdmin)?;
let (limit, offset) = page.page();
let select = admin_user::Entity::find().order_by_asc(admin_user::Column::CreatedAt);
let total = select.clone().count(self.db().read()).await? as i64;
let rows = select
.limit(limit)
.offset(offset)
.all(self.db().read())
.await?;
Ok(Page {
items: rows.iter().map(admin_dto).collect(),
total,
limit,
offset,
})
}
pub async fn create_admin(
&self,
actor: &AdminActor,
cmd: CreateAdminCmd,
ctx: &AuditCtx,
) -> Result<AdminDto, AdminError> {
actor.require(AdminRole::SuperAdmin)?;
let username = normalize_username(&cmd.username);
validate_username(&username)?;
auth::share::crypto::validate_password(&cmd.password, &self.config().auth.password_policy)?;
let existing = admin_user::Entity::find()
.filter(admin_user::Column::LowerName.eq(&username))
.one(self.db().read())
.await?;
if existing.is_some() {
return Err(AdminError::Conflict(
"admin username already exists".to_string(),
));
}
let now = OffsetDateTime::now_utc();
let hash = auth::share::crypto::hash_password(&cmd.password)?;
let display_name = cmd
.display_name
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| username.clone());
let row = admin_user::ActiveModel {
id: Set(Uuid::now_v7()),
username: Set(username.clone()),
lower_name: Set(username),
display_name: Set(display_name),
passwd_hash: Set(hash),
role: Set(cmd.role),
is_active: Set(true),
must_change_password: Set(false),
passwd_version: Set(now.unix_timestamp()),
last_login_at: Set(None),
last_login_ip: Set(None),
created_at: Set(now),
updated_at: Set(now),
}
.insert(self.db().write())
.await?;
self.audit(
Some(actor),
AuditRecord {
action: "admin.created",
target: Some(("admin_user", row.id.to_string())),
detail: json!({ "username": row.username, "role": row.role }),
},
ctx,
)
.await?;
Ok(admin_dto(&row))
}
pub async fn get_admin(&self, actor: &AdminActor, id: Uuid) -> Result<AdminDto, AdminError> {
actor.require(AdminRole::SuperAdmin)?;
let row = admin_user::Entity::find_by_id(id)
.one(self.db().read())
.await?
.ok_or(AdminError::NotFound)?;
Ok(admin_dto(&row))
}
pub async fn update_admin(
&self,
actor: &AdminActor,
id: Uuid,
cmd: UpdateAdminCmd,
ctx: &AuditCtx,
) -> Result<AdminDto, AdminError> {
actor.require(AdminRole::SuperAdmin)?;
let row = admin_user::Entity::find_by_id(id)
.one(self.db().read())
.await?
.ok_or(AdminError::NotFound)?;
let mut changed: Vec<String> = Vec::new();
let mut am: admin_user::ActiveModel = row.clone().into();
if let Some(display_name) = cmd.display_name {
let display_name = display_name.trim().to_string();
if display_name.is_empty() {
return Err(AdminError::BadRequest(
"display_name must not be empty".to_string(),
));
}
am.display_name = Set(display_name);
changed.push("display_name".to_string());
}
if let Some(role) = cmd.role
&& role != row.role
{
if id == actor.id {
return Err(AdminError::Conflict(
"cannot change your own role".to_string(),
));
}
if row.role == AdminRole::SuperAdmin {
self.ensure_super_admin_remains(id).await?;
}
am.role = Set(role);
changed.push("role".to_string());
}
if let Some(is_active) = cmd.is_active
&& is_active != row.is_active
{
if !is_active {
if id == actor.id {
return Err(AdminError::Conflict(
"cannot deactivate your own account".to_string(),
));
}
if row.role == AdminRole::SuperAdmin {
self.ensure_super_admin_remains(id).await?;
}
}
am.is_active = Set(is_active);
changed.push("is_active".to_string());
}
if changed.is_empty() {
return Ok(admin_dto(&row));
}
am.updated_at = Set(OffsetDateTime::now_utc());
let row = am.update(self.db().write()).await?;
self.audit(
Some(actor),
AuditRecord {
action: "admin.updated",
target: Some(("admin_user", row.id.to_string())),
detail: json!({ "changed": changed, "role": row.role, "is_active": row.is_active }),
},
ctx,
)
.await?;
Ok(admin_dto(&row))
}
pub async fn delete_admin(
&self,
actor: &AdminActor,
id: Uuid,
ctx: &AuditCtx,
) -> Result<(), AdminError> {
actor.require(AdminRole::SuperAdmin)?;
if id == actor.id {
return Err(AdminError::Conflict(
"cannot delete your own account".to_string(),
));
}
let row = admin_user::Entity::find_by_id(id)
.one(self.db().read())
.await?
.ok_or(AdminError::NotFound)?;
if row.role == AdminRole::SuperAdmin && row.is_active {
self.ensure_super_admin_remains(id).await?;
}
admin_user::Entity::delete_by_id(id)
.exec(self.db().write())
.await?;
self.audit(
Some(actor),
AuditRecord {
action: "admin.deleted",
target: Some(("admin_user", id.to_string())),
detail: json!({ "username": row.username, "role": row.role }),
},
ctx,
)
.await?;
Ok(())
}
pub async fn reset_admin_password(
&self,
actor: &AdminActor,
id: Uuid,
cmd: ResetAdminPasswordCmd,
ctx: &AuditCtx,
) -> Result<(), AdminError> {
actor.require(AdminRole::SuperAdmin)?;
auth::share::crypto::validate_password(
&cmd.new_password,
&self.config().auth.password_policy,
)?;
let row = admin_user::Entity::find_by_id(id)
.one(self.db().read())
.await?
.ok_or(AdminError::NotFound)?;
let now = OffsetDateTime::now_utc();
let hash = auth::share::crypto::hash_password(&cmd.new_password)?;
let mut am: admin_user::ActiveModel = row.into();
am.passwd_hash = Set(hash);
am.must_change_password = Set(true);
am.passwd_version = Set(now.unix_timestamp());
am.updated_at = Set(now);
am.update(self.db().write()).await?;
self.audit(
Some(actor),
AuditRecord {
action: "admin.password_reset",
target: Some(("admin_user", id.to_string())),
detail: json!({}),
},
ctx,
)
.await?;
Ok(())
}
pub async fn seed_admin_if_empty(&self) -> Result<Option<Uuid>, AdminError> {
let count = admin_user::Entity::find().count(self.db().read()).await?;
if count > 0 {
return Ok(None);
}
let (Some(username), Some(password)) = (
self.admin_config().seed_username(),
self.admin_config().seed_password(),
) else {
tracing::warn!(
"admin_user table is empty and [admin] seed_username/seed_password is not configured"
);
return Ok(None);
};
let username = normalize_username(username);
validate_username(&username)?;
auth::share::crypto::validate_password(password, &self.config().auth.password_policy)?;
let now = OffsetDateTime::now_utc();
let hash = auth::share::crypto::hash_password(password)?;
let row = admin_user::ActiveModel {
id: Set(Uuid::now_v7()),
username: Set(username.clone()),
lower_name: Set(username.clone()),
display_name: Set(username),
passwd_hash: Set(hash),
role: Set(AdminRole::SuperAdmin),
is_active: Set(true),
must_change_password: Set(true),
passwd_version: Set(now.unix_timestamp()),
last_login_at: Set(None),
last_login_ip: Set(None),
created_at: Set(now),
updated_at: Set(now),
}
.insert(self.db().write())
.await?;
tracing::info!(
admin_id = %row.id,
username = %row.username,
"seeded initial super admin; password change required on first login"
);
Ok(Some(row.id))
}
}
impl AdminService {
pub async fn revoke_admin_sessions(
&self,
actor: &AdminActor,
id: Uuid,
ctx: &AuditCtx,
) -> Result<(), AdminError> {
actor.require(AdminRole::Operator)?;
if id != actor.id {
actor.require(AdminRole::SuperAdmin)?;
}
let row = admin_user::Entity::find_by_id(id)
.one(self.db().read())
.await?
.ok_or(AdminError::NotFound)?;
let now = OffsetDateTime::now_utc();
let next_version = row.passwd_version + 1;
let mut am: admin_user::ActiveModel = row.into();
am.passwd_version = Set(next_version);
am.updated_at = Set(now);
am.update(self.db().write()).await?;
self.audit(
Some(actor),
AuditRecord {
action: "admin.sessions_revoked",
target: Some(("admin_user", id.to_string())),
detail: json!({}),
},
ctx,
)
.await?;
Ok(())
}
}