Files
SAP-PLEX-SYNC/backend/src/routes/auth.rs
b0rbor4d 5b447acd1c
Some checks failed
CI/CD Pipeline / Backend Tests (push) Failing after 27s
CI/CD Pipeline / Frontend Tests (push) Failing after 15s
CI/CD Pipeline / Docker Build (push) Has been skipped
CI/CD Pipeline / Security Scan (push) Has been skipped
Initial commit
2026-04-15 01:41:49 +02:00

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(&current_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")
}
}