323 lines
10 KiB
Rust
323 lines
10 KiB
Rust
use argon2::{
|
|
password_hash::{PasswordHasher, PasswordVerifier},
|
|
Argon2,
|
|
};
|
|
use rouille::{input::json_input, Request, Response};
|
|
use serde::Deserialize;
|
|
use serde_json::json;
|
|
use std::sync::Arc;
|
|
|
|
use super::helpers::{
|
|
client_ip, get_conn, get_session_cookie, json_error, require_auth, user_agent,
|
|
};
|
|
use super::AppState;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct LoginForm {
|
|
username: String,
|
|
password: String,
|
|
}
|
|
|
|
pub fn login(request: &Request, state: &Arc<AppState>) -> Response {
|
|
let form: LoginForm = match json_input(request) {
|
|
Ok(f) => f,
|
|
Err(_) => return json_error(400, "Invalid JSON"),
|
|
};
|
|
|
|
let mut conn = match get_conn(state) {
|
|
Ok(c) => c,
|
|
Err(e) => return e,
|
|
};
|
|
|
|
let user = match conn.query_opt(
|
|
"SELECT id, username, email, role, password_hash, is_active, mfa_enabled, \
|
|
failed_login_attempts, locked_until FROM users WHERE username = $1",
|
|
&[&form.username],
|
|
) {
|
|
Ok(Some(row)) => {
|
|
let is_active: bool = row.get(5);
|
|
if !is_active {
|
|
return json_error(401, "Invalid credentials");
|
|
}
|
|
|
|
let locked_until: Option<chrono::NaiveDateTime> = row.get(8);
|
|
if locked_until.is_some_and(|locked| locked > chrono::Utc::now().naive_utc()) {
|
|
return json_error(423, "Account temporarily locked");
|
|
}
|
|
|
|
let password_hash: String = row.get(4);
|
|
let is_valid = match argon2::password_hash::PasswordHash::new(&password_hash) {
|
|
Ok(h) => Argon2::default()
|
|
.verify_password(form.password.as_bytes(), &h)
|
|
.is_ok(),
|
|
Err(_) => false,
|
|
};
|
|
|
|
if !is_valid {
|
|
let user_id: i32 = row.get(0);
|
|
let mut attempts: i32 = row.get(7);
|
|
attempts += 1;
|
|
let mut new_locked_until = locked_until;
|
|
|
|
if attempts >= 5 {
|
|
new_locked_until = Some(
|
|
chrono::Utc::now().naive_utc() + chrono::Duration::hours(1),
|
|
);
|
|
attempts = 0;
|
|
}
|
|
|
|
let _ = conn.execute(
|
|
"UPDATE users SET failed_login_attempts = $1, locked_until = $2 WHERE id = $3",
|
|
&[&attempts, &new_locked_until, &user_id],
|
|
);
|
|
return json_error(401, "Invalid credentials");
|
|
}
|
|
|
|
(
|
|
row.get::<_, i32>(0),
|
|
row.get::<_, String>(1),
|
|
row.get::<_, String>(2),
|
|
row.get::<_, String>(3),
|
|
)
|
|
}
|
|
Ok(None) => return json_error(401, "Invalid credentials"),
|
|
Err(_) => return json_error(500, "Database error"),
|
|
};
|
|
|
|
let (user_id, username, email, role) = user;
|
|
let session_id = uuid::Uuid::new_v4().to_string();
|
|
let expires_at = chrono::Utc::now().naive_utc() + chrono::Duration::seconds(1800);
|
|
|
|
let _ = conn.execute(
|
|
"INSERT INTO sessions (id, user_id, expires_at, user_agent) VALUES ($1, $2, $3, $4)",
|
|
&[&session_id, &user_id, &expires_at, &"unknown"],
|
|
);
|
|
let _ = conn.execute(
|
|
"UPDATE users SET failed_login_attempts = 0, locked_until = NULL, \
|
|
last_login = CURRENT_TIMESTAMP WHERE id = $1",
|
|
&[&user_id],
|
|
);
|
|
|
|
let ip = client_ip(request);
|
|
let ua = user_agent(request);
|
|
let _ = conn.execute(
|
|
"INSERT INTO session_audit_log (user_id, session_id, event, ip_address, user_agent, metadata) \
|
|
VALUES ($1, $2, 'login', $3, $4, '{}'::jsonb)",
|
|
&[&user_id, &session_id, &ip, &ua],
|
|
);
|
|
|
|
Response::json(&json!({
|
|
"user": { "id": user_id, "username": username, "email": email, "role": role },
|
|
"session_id": session_id
|
|
}))
|
|
.with_additional_header(
|
|
"Set-Cookie",
|
|
format!(
|
|
"session_id={}; Path=/; HttpOnly; SameSite=Strict; Max-Age=1800",
|
|
session_id
|
|
),
|
|
)
|
|
}
|
|
|
|
pub fn logout(request: &Request, state: &Arc<AppState>) -> Response {
|
|
let session_cookie = match get_session_cookie(request) {
|
|
Some(c) => c,
|
|
None => return json_error(401, "Authentication required"),
|
|
};
|
|
|
|
let mut conn = match get_conn(state) {
|
|
Ok(c) => c,
|
|
Err(e) => return e,
|
|
};
|
|
|
|
if let Ok(Some(row)) = conn.query_opt(
|
|
"SELECT user_id FROM sessions WHERE id = $1",
|
|
&[&session_cookie],
|
|
) {
|
|
let uid: i32 = row.get(0);
|
|
let ip = client_ip(request);
|
|
let ua = user_agent(request);
|
|
let _ = conn.execute(
|
|
"INSERT INTO session_audit_log (user_id, session_id, event, ip_address, user_agent, metadata) \
|
|
VALUES ($1, $2, 'logout', $3, $4, '{}'::jsonb)",
|
|
&[&uid, &session_cookie, &ip, &ua],
|
|
);
|
|
}
|
|
|
|
let _ = conn.execute("DELETE FROM sessions WHERE id = $1", &[&session_cookie]);
|
|
|
|
Response::json(&json!({"message": "Logged out"})).with_additional_header(
|
|
"Set-Cookie",
|
|
"session_id=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0",
|
|
)
|
|
}
|
|
|
|
pub fn me(request: &Request, state: &Arc<AppState>) -> Response {
|
|
let mut conn = match get_conn(state) {
|
|
Ok(c) => c,
|
|
Err(e) => return e,
|
|
};
|
|
match require_auth(request, &mut conn) {
|
|
Ok(user) => Response::json(&json!({
|
|
"id": user.id, "username": user.username,
|
|
"email": user.email, "role": user.role,
|
|
})),
|
|
Err(e) => e,
|
|
}
|
|
}
|
|
|
|
pub fn change_password(request: &Request, state: &Arc<AppState>) -> Response {
|
|
let mut conn = match get_conn(state) {
|
|
Ok(c) => c,
|
|
Err(e) => return e,
|
|
};
|
|
let user = match require_auth(request, &mut conn) {
|
|
Ok(u) => u,
|
|
Err(e) => return e,
|
|
};
|
|
|
|
let form: sap_sync_backend::models::PasswordChangeForm = match json_input(request) {
|
|
Ok(f) => f,
|
|
Err(_) => return json_error(400, "Invalid JSON"),
|
|
};
|
|
|
|
let current_hash: String = match conn.query_opt(
|
|
"SELECT password_hash FROM users WHERE id = $1",
|
|
&[&user.id],
|
|
) {
|
|
Ok(Some(row)) => row.get(0),
|
|
_ => return json_error(500, "Database error"),
|
|
};
|
|
|
|
let is_valid = match argon2::password_hash::PasswordHash::new(¤t_hash) {
|
|
Ok(h) => Argon2::default()
|
|
.verify_password(form.current_password.as_bytes(), &h)
|
|
.is_ok(),
|
|
Err(_) => false,
|
|
};
|
|
if !is_valid {
|
|
return json_error(401, "Current password is incorrect");
|
|
}
|
|
|
|
let salt = argon2::password_hash::SaltString::generate(rand::thread_rng());
|
|
let new_hash = match Argon2::default().hash_password(form.new_password.as_bytes(), &salt) {
|
|
Ok(h) => h.to_string(),
|
|
Err(_) => return json_error(500, "Failed to hash password"),
|
|
};
|
|
|
|
let _ = conn.execute(
|
|
"UPDATE users SET password_hash = $1 WHERE id = $2",
|
|
&[&new_hash, &user.id],
|
|
);
|
|
Response::json(&json!({"message": "Password changed successfully"}))
|
|
}
|
|
|
|
pub fn mfa_setup(request: &Request, state: &Arc<AppState>) -> Response {
|
|
let mut conn = match get_conn(state) {
|
|
Ok(c) => c,
|
|
Err(e) => return e,
|
|
};
|
|
let user = match require_auth(request, &mut conn) {
|
|
Ok(u) => u,
|
|
Err(e) => return e,
|
|
};
|
|
|
|
use rand::RngCore;
|
|
let mut secret_bytes = [0u8; 20];
|
|
rand::thread_rng().fill_bytes(&mut secret_bytes);
|
|
let secret_b32 = super::base32_encode(&secret_bytes);
|
|
|
|
let _ = conn.execute(
|
|
"UPDATE users SET mfa_secret = $1 WHERE id = $2",
|
|
&[&secret_b32, &user.id],
|
|
);
|
|
|
|
// Generate and store hashed backup codes
|
|
let mut backup_codes: Vec<String> = Vec::new();
|
|
for _ in 0..8 {
|
|
let code = format!("{:08}", rand::thread_rng().next_u32() % 100_000_000);
|
|
let salt = argon2::password_hash::SaltString::generate(&mut rand::thread_rng());
|
|
if let Ok(hash) = Argon2::default().hash_password(code.as_bytes(), &salt) {
|
|
let _ = conn.execute(
|
|
"INSERT INTO mfa_backup_codes (user_id, code_hash) VALUES ($1, $2)",
|
|
&[&user.id, &hash.to_string()],
|
|
);
|
|
}
|
|
backup_codes.push(code);
|
|
}
|
|
|
|
let qr_url = format!(
|
|
"otpauth://totp/SAP-PLEX-SYNC:{}?secret={}&issuer=SAP-PLEX-SYNC&digits=6&period=30",
|
|
user.username, secret_b32
|
|
);
|
|
|
|
Response::json(&json!({
|
|
"method": "totp",
|
|
"secret": secret_b32,
|
|
"qr_code_url": qr_url,
|
|
"backup_codes": backup_codes,
|
|
}))
|
|
}
|
|
|
|
pub fn mfa_verify(request: &Request, state: &Arc<AppState>) -> Response {
|
|
#[derive(Deserialize)]
|
|
struct MfaVerifyForm {
|
|
code: String,
|
|
}
|
|
|
|
let form: MfaVerifyForm = match json_input(request) {
|
|
Ok(f) => f,
|
|
Err(_) => return json_error(400, "Invalid JSON"),
|
|
};
|
|
|
|
let mut conn = match get_conn(state) {
|
|
Ok(c) => c,
|
|
Err(e) => return e,
|
|
};
|
|
|
|
let session_cookie = match get_session_cookie(request) {
|
|
Some(c) => c,
|
|
None => return json_error(401, "Not authenticated"),
|
|
};
|
|
|
|
let row = match conn.query_opt(
|
|
"SELECT u.id, u.mfa_secret FROM users u \
|
|
JOIN sessions s ON u.id = s.user_id \
|
|
WHERE s.id = $1 AND s.expires_at > CURRENT_TIMESTAMP",
|
|
&[&session_cookie],
|
|
) {
|
|
Ok(Some(r)) => r,
|
|
Ok(None) => return json_error(401, "Invalid session"),
|
|
Err(_) => return json_error(500, "Database error"),
|
|
};
|
|
|
|
let user_id: i32 = row.get("id");
|
|
let mfa_secret: Option<String> = row.get("mfa_secret");
|
|
|
|
let secret = match mfa_secret {
|
|
Some(s) => s,
|
|
None => return json_error(400, "MFA not set up"),
|
|
};
|
|
|
|
let secret_bytes = match super::base32_decode(&secret) {
|
|
Some(b) => b,
|
|
None => return json_error(500, "Invalid MFA secret"),
|
|
};
|
|
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs();
|
|
let expected = totp_lite::totp_custom::<totp_lite::Sha1>(30, 6, &secret_bytes, now);
|
|
|
|
if form.code == expected {
|
|
let _ = conn.execute(
|
|
"UPDATE users SET mfa_enabled = TRUE WHERE id = $1",
|
|
&[&user_id],
|
|
);
|
|
Response::json(&json!({"message": "MFA enabled successfully"}))
|
|
} else {
|
|
json_error(400, "Invalid verification code")
|
|
}
|
|
}
|