- Add sftpgo_admins table to auth.sqlite (synced from PostgreSQL admins) - Add PgAdmin struct + sync_admins() method in sync.rs - Add fetch_admins() method in pg_client.rs - Add AdminLoginRequest/Response + admin_login() + verify_admin_token() in auth.rs - Add POST /api/v2/admin/login + GET /api/v2/admin/verify endpoints in server.rs - Add AdminLoginModal UI with password input + localStorage token in page.html - Test password: admin123 (bcrypt hash updated in PostgreSQL admins table) Architecture: - Independent admin auth system (matches SFTPGo design) - Admin sessions stored in-memory (24h validity) - bcrypt password verification (cost=10) - localStorage token persistence for UI - Settings panel requires admin authentication Files changed: - data/init_auth_db.sql: +20 lines - src/sync.rs: +100 lines - src/pg_client.rs: +50 lines - src/auth.rs: +60 lines - src/server.rs: +50 lines - src/page.html: +70 lines Total: ~290 lines added Tested: Admin sync, login, verify, UI modal all working
370 lines
11 KiB
Rust
370 lines
11 KiB
Rust
use anyhow::Result;
|
|
use chrono::Utc;
|
|
use rusqlite::{Connection, params};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::Path;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PgUser {
|
|
pub username: String,
|
|
pub password_hash: String,
|
|
pub email: Option<String>,
|
|
pub status: i32,
|
|
pub home_dir: String,
|
|
pub permissions: String,
|
|
pub uid: i64,
|
|
pub gid: i64,
|
|
pub last_login: i64,
|
|
pub created_at: i64,
|
|
pub updated_at: i64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PgGroup {
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub created_at: i64,
|
|
pub updated_at: i64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PgUserGroupMapping {
|
|
pub username: String,
|
|
pub group_name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PgAdmin {
|
|
pub username: String,
|
|
pub password_hash: String,
|
|
pub email: Option<String>,
|
|
pub description: Option<String>,
|
|
pub status: i32,
|
|
pub permissions: String,
|
|
pub filters: Option<String>,
|
|
pub role_id: Option<i32>,
|
|
pub last_login: i64,
|
|
pub created_at: i64,
|
|
pub updated_at: i64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SyncResult {
|
|
pub sync_type: String,
|
|
pub sync_time: i64,
|
|
pub users_synced: usize,
|
|
pub users_failed: usize,
|
|
pub groups_synced: usize,
|
|
pub groups_failed: usize,
|
|
pub mappings_synced: usize,
|
|
pub mappings_failed: usize,
|
|
pub admins_synced: usize,
|
|
pub admins_failed: usize,
|
|
pub status: String,
|
|
pub errors: Vec<String>,
|
|
}
|
|
|
|
impl Default for SyncResult {
|
|
fn default() -> Self {
|
|
Self {
|
|
sync_type: "unknown".to_string(),
|
|
sync_time: Utc::now().timestamp(),
|
|
users_synced: 0,
|
|
users_failed: 0,
|
|
groups_synced: 0,
|
|
groups_failed: 0,
|
|
mappings_synced: 0,
|
|
mappings_failed: 0,
|
|
admins_synced: 0,
|
|
admins_failed: 0,
|
|
status: "pending".to_string(),
|
|
errors: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SyncResult {
|
|
pub fn success() -> Self {
|
|
Self {
|
|
status: "success".to_string(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
pub fn cached() -> Self {
|
|
Self {
|
|
status: "cached".to_string(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
pub fn failed(error: String) -> Self {
|
|
Self {
|
|
status: "failed".to_string(),
|
|
errors: vec![error],
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
pub fn merge(&mut self, other: SyncResult) {
|
|
self.users_synced += other.users_synced;
|
|
self.users_failed += other.users_failed;
|
|
self.groups_synced += other.groups_synced;
|
|
self.groups_failed += other.groups_failed;
|
|
self.mappings_synced += other.mappings_synced;
|
|
self.mappings_failed += other.mappings_failed;
|
|
self.errors.extend(other.errors);
|
|
|
|
if self.users_failed > 0 || self.groups_failed > 0 || self.mappings_failed > 0 {
|
|
if self.users_synced > 0 || self.groups_synced > 0 || self.mappings_synced > 0 {
|
|
self.status = "partial_success".to_string();
|
|
} else {
|
|
self.status = "failed".to_string();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct AuthDb {
|
|
pub path: String,
|
|
}
|
|
|
|
impl AuthDb {
|
|
pub fn new(path: &str) -> Result<Self> {
|
|
if !Path::new(path).exists() {
|
|
Self::init_db(path)?;
|
|
}
|
|
Ok(Self { path: path.to_string() })
|
|
}
|
|
|
|
pub fn init_db(path: &str) -> Result<()> {
|
|
let conn = Connection::open(path)?;
|
|
conn.execute_batch(include_str!("../data/init_auth_db.sql"))?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn open(&self) -> Result<Connection> {
|
|
Ok(Connection::open(&self.path)?)
|
|
}
|
|
|
|
pub fn save_user(&self, user: &PgUser) -> Result<()> {
|
|
let conn = self.open()?;
|
|
let now = Utc::now().timestamp();
|
|
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO sftpgo_users
|
|
(username, password_hash, email, status, home_dir, permissions,
|
|
uid, gid, last_login, created_at, updated_at, last_sync_at, sync_status)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
|
params![
|
|
user.username,
|
|
user.password_hash,
|
|
user.email,
|
|
user.status,
|
|
user.home_dir,
|
|
user.permissions,
|
|
user.uid,
|
|
user.gid,
|
|
user.last_login,
|
|
user.created_at,
|
|
user.updated_at,
|
|
now,
|
|
1, // sync_status = synced
|
|
]
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn save_group(&self, group: &PgGroup) -> Result<()> {
|
|
let conn = self.open()?;
|
|
let now = Utc::now().timestamp();
|
|
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO sftpgo_groups
|
|
(name, description, created_at, updated_at, last_sync_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
params![
|
|
group.name,
|
|
group.description,
|
|
group.created_at,
|
|
group.updated_at,
|
|
now,
|
|
]
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn save_mapping(&self, mapping: &PgUserGroupMapping) -> Result<()> {
|
|
let conn = self.open()?;
|
|
let now = Utc::now().timestamp();
|
|
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO users_groups_mapping
|
|
(username, group_name, created_at)
|
|
VALUES (?1, ?2, ?3)",
|
|
params![
|
|
mapping.username,
|
|
mapping.group_name,
|
|
now,
|
|
]
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn save_sync_log(&self, result: &SyncResult) -> Result<()> {
|
|
let conn = self.open()?;
|
|
|
|
conn.execute(
|
|
"INSERT INTO sync_log
|
|
(sync_type, sync_time, users_synced, users_failed,
|
|
groups_synced, groups_failed, mappings_synced,
|
|
mappings_failed, admins_synced, admins_failed,
|
|
status, error_message)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
|
params![
|
|
result.sync_type,
|
|
result.sync_time,
|
|
result.users_synced,
|
|
result.users_failed,
|
|
result.groups_synced,
|
|
result.groups_failed,
|
|
result.mappings_synced,
|
|
result.mappings_failed,
|
|
result.admins_synced,
|
|
result.admins_failed,
|
|
result.status,
|
|
result.errors.join(";"),
|
|
]
|
|
)?;
|
|
|
|
log::info!(
|
|
"Sync log saved: users={}, groups={}, mappings={}, admins={}, status={}",
|
|
result.users_synced,
|
|
result.groups_synced,
|
|
result.mappings_synced,
|
|
result.admins_synced,
|
|
result.status
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn save_admin(&self, admin: &PgAdmin) -> Result<()> {
|
|
let conn = self.open()?;
|
|
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO sftpgo_admins
|
|
(username, password_hash, email, description, status,
|
|
permissions, filters, role_id, last_login,
|
|
created_at, updated_at, last_sync_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
|
params![
|
|
admin.username,
|
|
admin.password_hash,
|
|
admin.email,
|
|
admin.description,
|
|
admin.status,
|
|
admin.permissions,
|
|
admin.filters,
|
|
admin.role_id,
|
|
admin.last_login,
|
|
admin.created_at,
|
|
admin.updated_at,
|
|
Utc::now().timestamp(),
|
|
],
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn get_admin(&self, username: &str) -> Result<Option<PgAdmin>> {
|
|
let conn = self.open()?;
|
|
|
|
let result = conn.query_row(
|
|
"SELECT username, password_hash, email, description, status,
|
|
permissions, filters, role_id, last_login,
|
|
created_at, updated_at
|
|
FROM sftpgo_admins WHERE username = ?1 AND status = 1",
|
|
params![username],
|
|
|row| Ok(PgAdmin {
|
|
username: row.get(0)?,
|
|
password_hash: row.get(1)?,
|
|
email: row.get(2)?,
|
|
description: row.get(3)?,
|
|
status: row.get(4)?,
|
|
permissions: row.get(5)?,
|
|
filters: row.get(6)?,
|
|
role_id: row.get(7)?,
|
|
last_login: row.get(8)?,
|
|
created_at: row.get(9)?,
|
|
updated_at: row.get(10)?,
|
|
}),
|
|
);
|
|
|
|
match result {
|
|
Ok(admin) => Ok(Some(admin)),
|
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
}
|
|
|
|
pub fn sync_admins(&self, admins: Vec<PgAdmin>) -> Result<usize> {
|
|
let mut synced = 0;
|
|
|
|
for admin in admins {
|
|
match self.save_admin(&admin) {
|
|
Ok(_) => synced += 1,
|
|
Err(e) => log::warn!("Failed to sync admin {}: {}", admin.username, e),
|
|
}
|
|
}
|
|
|
|
Ok(synced)
|
|
}
|
|
|
|
pub fn get_user(&self, username: &str) -> Result<Option<PgUser>> {
|
|
let conn = self.open()?;
|
|
|
|
let result = conn.query_row(
|
|
"SELECT username, password_hash, email, status, home_dir, permissions,
|
|
uid, gid, last_login, created_at, updated_at
|
|
FROM sftpgo_users WHERE username = ?1 AND status = 1",
|
|
params![username],
|
|
|row| Ok(PgUser {
|
|
username: row.get(0)?,
|
|
password_hash: row.get(1)?,
|
|
email: row.get(2)?,
|
|
status: row.get(3)?,
|
|
home_dir: row.get(4)?,
|
|
permissions: row.get(5)?,
|
|
uid: row.get(6)?,
|
|
gid: row.get(7)?,
|
|
last_login: row.get(8)?,
|
|
created_at: row.get(9)?,
|
|
updated_at: row.get(10)?,
|
|
})
|
|
);
|
|
|
|
match result {
|
|
Ok(user) => Ok(Some(user)),
|
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
}
|
|
|
|
pub fn get_user_groups(&self, username: &str) -> Result<Vec<String>> {
|
|
let conn = self.open()?;
|
|
|
|
let groups: Vec<String> = conn
|
|
.prepare(
|
|
"SELECT group_name FROM users_groups_mapping WHERE username = ?1"
|
|
)?
|
|
.query_map(params![username], |row| row.get(0))?
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
|
|
Ok(groups)
|
|
}
|
|
} |