feat: Add admin authentication for Settings panel
- 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
This commit is contained in:
BIN
data/auth.sqlite
BIN
data/auth.sqlite
Binary file not shown.
@@ -61,3 +61,21 @@ CREATE TABLE IF NOT EXISTS sync_log (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sync_time ON sync_log(sync_time);
|
CREATE INDEX IF NOT EXISTS idx_sync_time ON sync_log(sync_time);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sync_status ON sync_log(status);
|
CREATE INDEX IF NOT EXISTS idx_sync_status ON sync_log(status);
|
||||||
|
|
||||||
|
-- 5. Admins table (synced from sftpgo.admins)
|
||||||
|
CREATE TABLE IF NOT EXISTS sftpgo_admins (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
description TEXT,
|
||||||
|
status INTEGER DEFAULT 1,
|
||||||
|
permissions TEXT NOT NULL,
|
||||||
|
filters TEXT,
|
||||||
|
role_id INTEGER,
|
||||||
|
last_login INTEGER DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
last_sync_at INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admins_status ON sftpgo_admins(status);
|
||||||
|
|||||||
98
src/auth.rs
98
src/auth.rs
@@ -39,11 +39,33 @@ pub struct LoginResponse {
|
|||||||
pub permissions: String,
|
pub permissions: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AdminLoginRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AdminSession {
|
||||||
|
pub token: String,
|
||||||
|
pub username: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub expires_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AdminLoginResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub expires_at: String,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AuthState {
|
pub struct AuthState {
|
||||||
pub sessions: Arc<Mutex<HashMap<String, Session>>>,
|
pub sessions: Arc<Mutex<HashMap<String, Session>>>,
|
||||||
pub users: Arc<Mutex<HashMap<String, User>>>,
|
pub users: Arc<Mutex<HashMap<String, User>>>,
|
||||||
pub auth_db: Option<crate::sync::AuthDb>,
|
pub auth_db: Option<crate::sync::AuthDb>,
|
||||||
|
pub admin_sessions: Arc<Mutex<HashMap<String, AdminSession>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuthState {
|
impl AuthState {
|
||||||
@@ -66,6 +88,7 @@ impl AuthState {
|
|||||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||||
users: Arc::new(Mutex::new(users)),
|
users: Arc::new(Mutex::new(users)),
|
||||||
auth_db: None,
|
auth_db: None,
|
||||||
|
admin_sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +99,7 @@ impl AuthState {
|
|||||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||||
users: Arc::new(Mutex::new(HashMap::new())),
|
users: Arc::new(Mutex::new(HashMap::new())),
|
||||||
auth_db,
|
auth_db,
|
||||||
|
admin_sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,11 +133,81 @@ impl AuthState {
|
|||||||
permissions: "{}".to_string(),
|
permissions: "{}".to_string(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn admin_login(&self, username: &str, password: &str) -> Option<AdminLoginResponse> {
|
||||||
|
if let Some(auth_db) = &self.auth_db {
|
||||||
|
match auth_db.get_admin(username) {
|
||||||
|
Ok(Some(admin)) if admin.status == 1 => {
|
||||||
|
if verify(password, &admin.password_hash).unwrap_or(false) {
|
||||||
|
let token = Uuid::new_v4().to_string();
|
||||||
|
let now = Utc::now();
|
||||||
|
let expires_at = now + Duration::hours(24);
|
||||||
|
|
||||||
|
let session = AdminSession {
|
||||||
|
token: token.clone(),
|
||||||
|
username: username.to_string(),
|
||||||
|
created_at: now.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||||
|
expires_at: expires_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut admin_sessions = self.admin_sessions.lock().unwrap();
|
||||||
|
admin_sessions.insert(token.clone(), session);
|
||||||
|
|
||||||
|
log::info!("Admin {} logged in successfully", username);
|
||||||
|
|
||||||
|
Some(AdminLoginResponse {
|
||||||
|
token,
|
||||||
|
expires_at: expires_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||||
|
username: username.to_string(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
log::warn!("Invalid password for admin {}", username);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Some(_)) => {
|
||||||
|
log::warn!("Admin {} is not active", username);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
log::warn!("Admin {} not found", username);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to get admin {}: {}", username, e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log::warn!("Auth DB not available for admin login");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn login_with_sync(&self, username: &str, password: &str) -> Option<LoginResponse> {
|
pub fn verify_admin_token(&self, token: &str) -> Option<AdminSession> {
|
||||||
|
let admin_sessions = self.admin_sessions.lock().unwrap();
|
||||||
|
|
||||||
|
if let Some(session) = admin_sessions.get(token) {
|
||||||
|
let expires_at = chrono::DateTime::parse_from_rfc3339(&session.expires_at)
|
||||||
|
.ok()
|
||||||
|
.map(|dt| dt.with_timezone(&Utc));
|
||||||
|
|
||||||
|
if let Some(exp) = expires_at {
|
||||||
|
if Utc::now() < exp {
|
||||||
|
return Some(session.clone());
|
||||||
|
} else {
|
||||||
|
log::warn!("Admin token {} has expired", token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn login_with_sync(&self, username: &str, password: &str) -> Option<LoginResponse> {
|
||||||
if let Some(auth_db) = &self.auth_db {
|
if let Some(auth_db) = &self.auth_db {
|
||||||
// Get user from auth.sqlite
|
// Get user from auth.sqlite
|
||||||
let user = match auth_db.get_user(username) {
|
let user = match auth_db.get_user(username) {
|
||||||
@@ -135,7 +229,7 @@ pub fn login_with_sync(&self, username: &str, password: &str) -> Option<LoginRes
|
|||||||
|
|
||||||
if verify(password, &user.password_hash).unwrap_or(false) {
|
if verify(password, &user.password_hash).unwrap_or(false) {
|
||||||
let groups = auth_db.get_user_groups(username).unwrap_or_default();
|
let groups = auth_db.get_user_groups(username).unwrap_or_default();
|
||||||
let permissions = user.permissions.clone();
|
let permissions = user.permissions.clone();
|
||||||
|
|
||||||
let token = Uuid::new_v4().to_string();
|
let token = Uuid::new_v4().to_string();
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|||||||
112
src/page.html
112
src/page.html
@@ -84,6 +84,18 @@ body.mb-locked .mb-tree-node:hover .mb-folder-actions{display:none!important}
|
|||||||
.mb-config-cancel-btn{background:#451a03;border:1px solid #fbbf24;color:#fbbf24;padding:2px 8px;border-radius:4px;cursor:pointer;font-size:11px}
|
.mb-config-cancel-btn{background:#451a03;border:1px solid #fbbf24;color:#fbbf24;padding:2px 8px;border-radius:4px;cursor:pointer;font-size:11px}
|
||||||
.mb-password-toggle{background:none;border:none;color:#60a5fa;cursor:pointer;font-size:14px;padding:0 4px}
|
.mb-password-toggle{background:none;border:none;color:#60a5fa;cursor:pointer;font-size:14px;padding:0 4px}
|
||||||
.mb-password-toggle:hover{color:#3b82f6}
|
.mb-password-toggle:hover{color:#3b82f6}
|
||||||
|
#mb-admin-modal{display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
|
||||||
|
background:#1e293b;border:1px solid #334155;padding:24px;border-radius:8px;z-index:10000;min-width:280px}
|
||||||
|
#mb-admin-modal.active{display:block}
|
||||||
|
.mb-admin-title{color:#60a5fa;font-size:16px;font-weight:600;margin-bottom:16px}
|
||||||
|
.mb-admin-input{background:#0f172a;border:1px solid #60a5fa;border-radius:4px;
|
||||||
|
color:#e2e8f0;padding:8px 12px;width:100%;margin-bottom:12px;font-size:13px}
|
||||||
|
.mb-admin-btn{background:#064e3b;border:1px solid #4ade80;color:#4ade80;
|
||||||
|
padding:8px 16px;border-radius:4px;cursor:pointer;width:100%;font-size:13px}
|
||||||
|
.mb-admin-btn:hover{background:#4ade80;color:#064e3b}
|
||||||
|
.mb-admin-error{color:#ef4444;font-size:12px;margin-top:8px}
|
||||||
|
.mb-admin-close{position:absolute;top:12px;right:12px;background:none;border:none;
|
||||||
|
color:#64748b;font-size:18px;cursor:pointer}
|
||||||
</style>
|
</style>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
|
||||||
<script>mermaid.initialize({startOnLoad:false,theme:'dark',themeVariables:{primaryColor:'#60a5fa',primaryTextColor:'#e2e8f0',lineColor:'#94a3b8'}})</script>
|
<script>mermaid.initialize({startOnLoad:false,theme:'dark',themeVariables:{primaryColor:'#60a5fa',primaryTextColor:'#e2e8f0',lineColor:'#94a3b8'}})</script>
|
||||||
@@ -124,9 +136,85 @@ body.mb-locked .mb-tree-node:hover .mb-folder-actions{display:none!important}
|
|||||||
var _sv=false;
|
var _sv=false;
|
||||||
|
|
||||||
function toggleSettings(){
|
function toggleSettings(){
|
||||||
_sv=!_sv;
|
var token=localStorage.getItem('admin_token');
|
||||||
document.getElementById("mb-settings-panel").classList.toggle("active",_sv);
|
|
||||||
if(_sv)loadSettings();
|
if(token){
|
||||||
|
// Verify token validity
|
||||||
|
fetch('/api/v2/admin/verify',{
|
||||||
|
headers:{'Authorization':'Bearer '+token}
|
||||||
|
})
|
||||||
|
.then(function(r){return r.json()})
|
||||||
|
.then(function(d){
|
||||||
|
if(d.ok){
|
||||||
|
// Token valid, open settings
|
||||||
|
_sv=!_sv;
|
||||||
|
document.getElementById("mb-settings-panel").classList.toggle("active",_sv);
|
||||||
|
if(_sv)loadSettings();
|
||||||
|
}else{
|
||||||
|
// Token invalid, remove and show login
|
||||||
|
localStorage.removeItem('admin_token');
|
||||||
|
showAdminLoginModal();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(e){
|
||||||
|
localStorage.removeItem('admin_token');
|
||||||
|
showAdminLoginModal();
|
||||||
|
});
|
||||||
|
}else{
|
||||||
|
// No token, show login
|
||||||
|
showAdminLoginModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAdminLoginModal(){
|
||||||
|
var m=document.getElementById('mb-admin-modal');
|
||||||
|
|
||||||
|
if(!m){
|
||||||
|
m=document.createElement('div');
|
||||||
|
m.id='mb-admin-modal';
|
||||||
|
m.innerHTML='<button class=mb-admin-close onclick=this.parentElement.classList.remove("active")>✕</button>'+
|
||||||
|
'<div class=mb-admin-title>Admin Authentication Required</div>'+
|
||||||
|
'<input class=mb-admin-input type=password id=admin-password placeholder="Enter admin password">'+
|
||||||
|
'<button class=mb-admin-btn onclick=submitAdminLogin()>Login</button>'+
|
||||||
|
'<div class=mb-admin-error id=admin-error></div>';
|
||||||
|
document.body.appendChild(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('admin-password').value='';
|
||||||
|
document.getElementById('admin-error').textContent='';
|
||||||
|
m.classList.add('active');
|
||||||
|
document.getElementById('admin-password').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitAdminLogin(){
|
||||||
|
var pwd=document.getElementById('admin-password').value;
|
||||||
|
|
||||||
|
if(!pwd){
|
||||||
|
document.getElementById('admin-error').textContent='Password required';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/api/v2/admin/login',{
|
||||||
|
method:'POST',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify({username:'admin',password:pwd})
|
||||||
|
})
|
||||||
|
.then(function(r){return r.json()})
|
||||||
|
.then(function(d){
|
||||||
|
if(d.token){
|
||||||
|
localStorage.setItem('admin_token',d.token);
|
||||||
|
document.getElementById('mb-admin-modal').classList.remove('active');
|
||||||
|
toast('Admin authenticated ✓');
|
||||||
|
toggleSettings(); // Re-open settings
|
||||||
|
}else{
|
||||||
|
document.getElementById('admin-error').textContent=d.error||'Login failed';
|
||||||
|
document.getElementById('admin-password').value='';
|
||||||
|
document.getElementById('admin-password').focus();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(e){
|
||||||
|
document.getElementById('admin-error').textContent='Connection error: '+e;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSettings(){
|
function loadSettings(){
|
||||||
@@ -204,7 +292,7 @@ function editSetting(key,currentVal){
|
|||||||
editBtn.outerHTML="<button class=mb-config-save-btn onclick=saveSetting(\""+key+"\",\""+safeId+"\",\""+isPassword+"\")>Save</button><button class=mb-config-cancel-btn onclick=cancelEdit(\""+key+"\",\""+currentVal+"\")>Cancel</button>";
|
editBtn.outerHTML="<button class=mb-config-save-btn onclick=saveSetting(\""+key+"\",\""+safeId+"\",\""+isPassword+"\")>Save</button><button class=mb-config-cancel-btn onclick=cancelEdit(\""+key+"\",\""+currentVal+"\")>Cancel</button>";
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSetting(key,safeId){
|
function saveSetting(key,safeId,isPassword){
|
||||||
var input=document.getElementById("config-input-"+safeId);
|
var input=document.getElementById("config-input-"+safeId);
|
||||||
if(!input)return;
|
if(!input)return;
|
||||||
|
|
||||||
@@ -225,6 +313,22 @@ function cancelEdit(key,currentVal){
|
|||||||
loadSettings();
|
loadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function togglePassword(safeId,actualVal){
|
||||||
|
var valEl=document.getElementById("config-value-"+safeId);
|
||||||
|
if(!valEl)return;
|
||||||
|
|
||||||
|
var toggleBtn=valEl.parentElement.querySelector(".mb-password-toggle");
|
||||||
|
if(!toggleBtn)return;
|
||||||
|
|
||||||
|
if(valEl.textContent==="••••••••"){
|
||||||
|
valEl.textContent=decodeURIComponent(actualVal);
|
||||||
|
toggleBtn.textContent="🙈";
|
||||||
|
}else{
|
||||||
|
valEl.textContent="••••••••";
|
||||||
|
toggleBtn.textContent="👁";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function validateSettings(){
|
function validateSettings(){
|
||||||
fetch("/api/v2/config/validate").then(function(r){return r.json()}).then(function(d){
|
fetch("/api/v2/config/validate").then(function(r){return r.json()}).then(function(d){
|
||||||
if(d.ok)toast("✓ Settings valid");
|
if(d.ok)toast("✓ Settings valid");
|
||||||
|
|||||||
105
src/pg_client.rs
105
src/pg_client.rs
@@ -1,6 +1,6 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tokio_postgres::{NoTls, Client};
|
use tokio_postgres::{NoTls, Client};
|
||||||
use crate::sync::{PgUser, PgGroup, PgUserGroupMapping};
|
use crate::sync::{PgUser, PgGroup, PgUserGroupMapping, PgAdmin, SyncResult};
|
||||||
|
|
||||||
pub struct PgClient {
|
pub struct PgClient {
|
||||||
host: String,
|
host: String,
|
||||||
@@ -105,6 +105,36 @@ impl PgClient {
|
|||||||
Ok(groups)
|
Ok(groups)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_admins(&self) -> Result<Vec<PgAdmin>> {
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
"SELECT username, password as password_hash,
|
||||||
|
email, description, status, permissions, filters,
|
||||||
|
role_id, last_login, created_at, updated_at
|
||||||
|
FROM admins WHERE status = 1"
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let admins = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| PgAdmin {
|
||||||
|
username: row.username,
|
||||||
|
password_hash: row.password_hash,
|
||||||
|
email: row.email,
|
||||||
|
description: row.description,
|
||||||
|
status: row.status,
|
||||||
|
permissions: row.permissions,
|
||||||
|
filters: row.filters,
|
||||||
|
role_id: row.role_id.map(|v| v as i32),
|
||||||
|
last_login: row.last_login,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(admins)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn fetch_mappings(&self) -> Result<Vec<PgUserGroupMapping>> {
|
pub async fn fetch_mappings(&self) -> Result<Vec<PgUserGroupMapping>> {
|
||||||
let client = self.connect().await?;
|
let client = self.connect().await?;
|
||||||
|
|
||||||
@@ -127,6 +157,37 @@ impl PgClient {
|
|||||||
|
|
||||||
Ok(mappings)
|
Ok(mappings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_admins(&self, client: &Client) -> Result<Vec<PgAdmin>> {
|
||||||
|
let rows = client
|
||||||
|
.query(
|
||||||
|
"SELECT username, password, email, description, status,
|
||||||
|
permissions, filters, role_id, last_login,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM admins WHERE status = 1",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let admins = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| PgAdmin {
|
||||||
|
username: row.get::<_, String>(0),
|
||||||
|
password_hash: row.get::<_, String>(1),
|
||||||
|
email: row.get::<_, Option<String>>(2),
|
||||||
|
description: row.get::<_, Option<String>>(3),
|
||||||
|
status: row.get::<_, i32>(4),
|
||||||
|
permissions: row.get::<_, String>(5),
|
||||||
|
filters: row.get::<_, Option<String>>(6),
|
||||||
|
role_id: row.get::<_, Option<i32>>(7),
|
||||||
|
last_login: row.get::<_, i64>(8),
|
||||||
|
created_at: row.get::<_, i64>(9),
|
||||||
|
updated_at: row.get::<_, i64>(10),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(admins)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SftpGoSync {
|
pub struct SftpGoSync {
|
||||||
@@ -221,9 +282,40 @@ impl SftpGoSync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Determine final status
|
// 4. Sync admins
|
||||||
if result.users_failed > 0 || result.groups_failed > 0 || result.mappings_failed > 0 {
|
match self.pg_client.connect().await {
|
||||||
if result.users_synced > 0 || result.groups_synced > 0 || result.mappings_synced > 0 {
|
Ok(client) => {
|
||||||
|
match self.pg_client.fetch_admins(&client).await {
|
||||||
|
Ok(admins) => {
|
||||||
|
log::info!("Fetched {} admins from PostgreSQL", admins.len());
|
||||||
|
for admin in admins {
|
||||||
|
match self.auth_db.sync_admins(vec![admin.clone()]) {
|
||||||
|
Ok(_) => result.admins_synced += 1,
|
||||||
|
Err(e) => {
|
||||||
|
result.admins_failed += 1;
|
||||||
|
result.errors.push(format!("Admin {} sync failed: {}", admin.username, e));
|
||||||
|
log::error!("Failed to sync admin {}: {}", admin.username, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to fetch admins from PostgreSQL: {}", e);
|
||||||
|
result.errors.push(format!("PG admins fetch failed: {}", e));
|
||||||
|
result.admins_failed = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to connect to PostgreSQL for admins sync: {}", e);
|
||||||
|
result.errors.push(format!("PG connection failed for admins: {}", e));
|
||||||
|
result.admins_failed = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Determine final status
|
||||||
|
if result.users_failed > 0 || result.groups_failed > 0 || result.mappings_failed > 0 || result.admins_failed > 0 {
|
||||||
|
if result.users_synced > 0 || result.groups_synced > 0 || result.mappings_synced > 0 || result.admins_synced > 0 {
|
||||||
result.status = "partial_success".to_string();
|
result.status = "partial_success".to_string();
|
||||||
} else {
|
} else {
|
||||||
result.status = "cached".to_string();
|
result.status = "cached".to_string();
|
||||||
@@ -232,14 +324,15 @@ impl SftpGoSync {
|
|||||||
result.status = "success".to_string();
|
result.status = "success".to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Save sync log
|
// 6. Save sync log
|
||||||
self.auth_db.save_sync_log(&result)?;
|
self.auth_db.save_sync_log(&result)?;
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Sync completed: users={}, groups={}, mappings={}, status={}",
|
"Sync completed: users={}, groups={}, mappings={}, admins={}, status={}",
|
||||||
result.users_synced,
|
result.users_synced,
|
||||||
result.groups_synced,
|
result.groups_synced,
|
||||||
result.mappings_synced,
|
result.mappings_synced,
|
||||||
|
result.admins_synced,
|
||||||
result.status
|
result.status
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ let state = AppState {
|
|||||||
.route("/api/v2/config", get(get_config_handler))
|
.route("/api/v2/config", get(get_config_handler))
|
||||||
.route("/api/v2/config/edit", post(edit_config_handler))
|
.route("/api/v2/config/edit", post(edit_config_handler))
|
||||||
.route("/api/v2/config/validate", get(validate_config_handler))
|
.route("/api/v2/config/validate", get(validate_config_handler))
|
||||||
|
// Admin authentication API endpoints (public)
|
||||||
|
.route("/api/v2/admin/login", post(admin_login_handler))
|
||||||
|
.route("/api/v2/admin/verify", get(admin_verify_handler))
|
||||||
// Protected endpoints (require auth)
|
// Protected endpoints (require auth)
|
||||||
.route("/api/v2/tree/:user_id", get(get_tree))
|
.route("/api/v2/tree/:user_id", get(get_tree))
|
||||||
.route("/api/v2/tree/:user_id/node", post(create_node))
|
.route("/api/v2/tree/:user_id/node", post(create_node))
|
||||||
@@ -1563,3 +1566,44 @@ async fn validate_config_handler() -> impl IntoResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn admin_login_handler(
|
||||||
|
State(state): State<crate::auth::AuthState>,
|
||||||
|
Json(body): Json<crate::auth::AdminLoginRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
match state.admin_login(&body.username, &body.password) {
|
||||||
|
Some(response) => (StatusCode::OK, Json(response)).into_response(),
|
||||||
|
None => (
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(serde_json::json!({"error": "Invalid admin credentials"})),
|
||||||
|
).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn admin_verify_handler(
|
||||||
|
State(state): State<crate::auth::AuthState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let auth_header = headers
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.strip_prefix("Bearer "));
|
||||||
|
|
||||||
|
if let Some(token) = auth_header {
|
||||||
|
if let Some(session) = state.verify_admin_token(token) {
|
||||||
|
return (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"ok": true,
|
||||||
|
"username": session.username,
|
||||||
|
"expires_at": session.expires_at
|
||||||
|
})),
|
||||||
|
).into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(serde_json::json!({"ok": false, "error": "Invalid admin token"})),
|
||||||
|
).into_response()
|
||||||
|
}
|
||||||
|
|||||||
100
src/sync.rs
100
src/sync.rs
@@ -33,6 +33,21 @@ pub struct PgUserGroupMapping {
|
|||||||
pub group_name: 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct SyncResult {
|
pub struct SyncResult {
|
||||||
pub sync_type: String,
|
pub sync_type: String,
|
||||||
@@ -43,6 +58,8 @@ pub struct SyncResult {
|
|||||||
pub groups_failed: usize,
|
pub groups_failed: usize,
|
||||||
pub mappings_synced: usize,
|
pub mappings_synced: usize,
|
||||||
pub mappings_failed: usize,
|
pub mappings_failed: usize,
|
||||||
|
pub admins_synced: usize,
|
||||||
|
pub admins_failed: usize,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub errors: Vec<String>,
|
pub errors: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -58,6 +75,8 @@ impl Default for SyncResult {
|
|||||||
groups_failed: 0,
|
groups_failed: 0,
|
||||||
mappings_synced: 0,
|
mappings_synced: 0,
|
||||||
mappings_failed: 0,
|
mappings_failed: 0,
|
||||||
|
admins_synced: 0,
|
||||||
|
admins_failed: 0,
|
||||||
status: "pending".to_string(),
|
status: "pending".to_string(),
|
||||||
errors: Vec::new(),
|
errors: Vec::new(),
|
||||||
}
|
}
|
||||||
@@ -203,8 +222,9 @@ impl AuthDb {
|
|||||||
"INSERT INTO sync_log
|
"INSERT INTO sync_log
|
||||||
(sync_type, sync_time, users_synced, users_failed,
|
(sync_type, sync_time, users_synced, users_failed,
|
||||||
groups_synced, groups_failed, mappings_synced,
|
groups_synced, groups_failed, mappings_synced,
|
||||||
|
mappings_failed, admins_synced, admins_failed,
|
||||||
status, error_message)
|
status, error_message)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||||
params![
|
params![
|
||||||
result.sync_type,
|
result.sync_type,
|
||||||
result.sync_time,
|
result.sync_time,
|
||||||
@@ -213,22 +233,98 @@ impl AuthDb {
|
|||||||
result.groups_synced,
|
result.groups_synced,
|
||||||
result.groups_failed,
|
result.groups_failed,
|
||||||
result.mappings_synced,
|
result.mappings_synced,
|
||||||
|
result.mappings_failed,
|
||||||
|
result.admins_synced,
|
||||||
|
result.admins_failed,
|
||||||
result.status,
|
result.status,
|
||||||
result.errors.join(";"),
|
result.errors.join(";"),
|
||||||
]
|
]
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Sync log saved: users={}, groups={}, mappings={}, status={}",
|
"Sync log saved: users={}, groups={}, mappings={}, admins={}, status={}",
|
||||||
result.users_synced,
|
result.users_synced,
|
||||||
result.groups_synced,
|
result.groups_synced,
|
||||||
result.mappings_synced,
|
result.mappings_synced,
|
||||||
|
result.admins_synced,
|
||||||
result.status
|
result.status
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
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>> {
|
pub fn get_user(&self, username: &str) -> Result<Option<PgUser>> {
|
||||||
let conn = self.open()?;
|
let conn = self.open()?;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user