feat: add auth

This commit is contained in:
2025-12-25 22:32:04 +01:00
parent 71d915ff0f
commit e7fe00c48d
17 changed files with 1654 additions and 35 deletions

187
src/auth.rs Normal file
View File

@@ -0,0 +1,187 @@
use std::{
error::Error,
time::{Duration, SystemTime},
};
use actix_web::{Responder, get};
use chrono::{DateTime, Utc};
use serde::{Serialize, Serializer};
use sqlx::query;
use tokio::task::JoinHandle;
use crate::{db, helper, models::auth};
#[get("/api/scp/auth/is_logged_in")]
pub async fn is_scp_logged_in() -> impl Responder {
if let Ok(pool) = db::get_pool().await {
let token = query!("SELECT * FROM tokens WHERE is_refresh = ?", 1)
.fetch_one(&pool)
.await;
if let Ok(_token) = token {
return true.to_string();
}
}
false.to_string()
}
#[get("/api/scp/auth/start_flow")]
pub async fn start_flow() -> impl Responder {
let url = start_device_flow().await.unwrap();
url
}
async fn start_device_flow() -> Result<String, Box<dyn Error>> {
let url = "https://www.servercontrolpanel.de/realms/scp/protocol/openid-connect/auth/device";
let client = reqwest::Client::new();
let response = client
.post(url)
.form(&[("client_id", "scp"), ("scope", "offline_access openid")])
.send()
.await?;
let openid_response = response.json::<auth::OpenidResponse>().await?;
let _handle = start_polling_thread(openid_response.clone());
Ok(openid_response.verification_uri_complete)
}
fn start_polling_thread(openid_response: auth::OpenidResponse) -> JoinHandle<()> {
let client = reqwest::Client::new();
let device_code = openid_response.device_code;
let interval = openid_response.interval;
let token_url = "https://www.servercontrolpanel.de/realms/scp/protocol/openid-connect/token";
let expiration_time =
SystemTime::now() + Duration::from_secs(openid_response.expires_in as u64);
let thread = tokio::spawn(async move {
loop {
if expiration_time <= SystemTime::now() {
println!("Token expired!");
break;
}
tokio::time::sleep(std::time::Duration::from_secs(interval as u64)).await;
let response = client
.post(token_url)
.form(&[
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("device_code", &device_code),
("client_id", "scp"),
])
.send()
.await
.expect("Could not poll login!");
if !response.status().is_success() {
continue;
}
let openid_response = response
.json::<auth::OpenidTokenResponse>()
.await
.expect("Could not deserialize response!");
let pool = db::get_pool().await.expect("Could not get DB Pool");
let token_expiry =
SystemTime::now() + Duration::from_secs(openid_response.expires_in as u64);
let token_expiry: DateTime<Utc> = token_expiry.into();
let token_expiry = token_expiry.to_rfc3339();
query!(
"INSERT INTO tokens (token, is_refresh,expires_at) VALUES (?, 0, ?)",
openid_response.access_token,
token_expiry
)
.execute(&pool)
.await
.expect("Could not insert token into database");
let refresh_token_expiry = SystemTime::now() + Duration::from_hours(24 * 30);
let refresh_token_expiry: DateTime<Utc> = refresh_token_expiry.into();
let refresh_token_expiry = refresh_token_expiry.to_rfc3339();
query!(
"INSERT INTO tokens (token, is_refresh,expires_at) VALUES (?, 1, ?)",
openid_response.refresh_token,
refresh_token_expiry
)
.execute(&pool)
.await
.expect("Could not insert token into database");
}
});
thread
}
pub async fn create_new_access_token() -> Result<(), Box<dyn Error>> {
let client = reqwest::Client::new();
let url = "https://www.servercontrolpanel.de/realms/scp/protocol/openid-connect/token";
let refresh_token = helper::get_refresh_token().await?;
let response = client
.post(url)
.form(&[
("client_id", "scp"),
("refresh_token", &refresh_token),
("grant_type", "refresh_token"),
])
.send()
.await?;
let token_response = response.json::<auth::OpenidTokenResponse>().await?;
let pool = db::get_pool().await.expect("Could not get DB Pool");
let token_expiry = SystemTime::now() + Duration::from_secs(token_response.expires_in as u64);
let token_expiry: DateTime<Utc> = token_expiry.into();
let token_expiry = token_expiry.to_rfc3339();
query!(
"INSERT INTO tokens (token, is_refresh,expires_at) VALUES (?, 0, ?)",
token_response.access_token,
token_expiry
)
.execute(&pool)
.await
.expect("Could not insert token into database");
let refresh_token_expiry = SystemTime::now() + Duration::from_hours(24 * 30);
let refresh_token_expiry: DateTime<Utc> = refresh_token_expiry.into();
let refresh_token_expiry = refresh_token_expiry.to_rfc3339();
query!(
"UPDATE tokens SET token = ?, expires_at = ? WHERE is_refresh = 1 AND token = ?",
token_response.refresh_token,
refresh_token_expiry,
refresh_token
)
.execute(&pool)
.await
.expect("Could not update refresh token in database");
Ok(())
}
#[get("/api/auth/user")]
pub async fn get_user() -> impl Responder {
let client = reqwest::Client::new();
let url = "https://www.servercontrolpanel.de/realms/scp/protocol/openid-connect/userinfo";
let token = helper::get_access_token()
.await
.expect("Could not get access token");
let response = client
.get(url)
.bearer_auth(token)
.send()
.await
.expect("Could not get user info");
let user = response
.json::<auth::User>()
.await
.expect("Could not parse user info");
let str: String = serde_json::to_string(&user).unwrap();
str
}

45
src/db.rs Normal file
View File

@@ -0,0 +1,45 @@
use std::{env, str::FromStr, sync::Mutex, time::Duration};
use sqlx::{
ConnectOptions, Sqlite, SqlitePool,
pool::PoolConnection,
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
};
static DATABASE_POOL: Mutex<Option<SqlitePool>> = Mutex::new(None);
pub async fn get_pool() -> Result<SqlitePool, sqlx::Error> {
let existing_pool = {
let lock = DATABASE_POOL.lock().unwrap();
lock.clone()
};
if let Some(pool) = existing_pool {
return Ok(pool);
}
let dburl = env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite://./data.db".to_string());
let mut options = SqliteConnectOptions::from_str(&dburl)?;
options = options
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal)
.busy_timeout(Duration::from_secs(5));
let pool = SqlitePool::connect_with(options).await?;
{
let mut lock = DATABASE_POOL.lock().unwrap();
if lock.is_none() {
*lock = Some(pool.clone());
}
}
Ok(pool)
}
pub async fn migrate() -> Result<(), sqlx::Error> {
let pool = get_pool().await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(())
}

32
src/helper.rs Normal file
View File

@@ -0,0 +1,32 @@
use std::error::Error;
use scp_core::apis::configuration;
use sqlx::query;
use crate::db;
pub async fn get_refresh_token() -> Result<String, Box<dyn Error>> {
let pool = db::get_pool().await?;
let tok = query!("SELECT * FROM tokens WHERE is_refresh = 1 ORDER BY expires_at DESC LIMIT 1")
.fetch_one(&pool)
.await?
.token;
Ok(tok)
}
pub async fn get_access_token() -> Result<String, Box<dyn Error>> {
let pool = db::get_pool().await?;
let tok = query!("SELECT * FROM tokens WHERE is_refresh = 0 ORDER BY expires_at DESC LIMIT 1")
.fetch_one(&pool)
.await?
.token;
Ok(tok)
}
pub async fn get_authed_api_config() -> Result<configuration::Configuration, Box<dyn Error>> {
let mut conf = configuration::Configuration::default();
conf.bearer_access_token = Some(get_access_token().await?);
Ok(conf)
}

53
src/jobs.rs Normal file
View File

@@ -0,0 +1,53 @@
use std::{error::Error, time::Duration};
use quartz::{Job, Scheduler, Trigger};
use sqlx::query;
use tokio::runtime::Handle;
use crate::{auth, db};
pub fn init_scheduler(rt: Handle) -> Scheduler {
let scheduler = Scheduler::new();
let trigger_every_5min =
Trigger::with_identity("5min_trigger", "default_group").every(Duration::from_mins(5));
let trigger_every_3min =
Trigger::with_identity("3min_trigger", "default_group").every(Duration::from_mins(3));
let rt_cleanup = rt.clone();
let cleanup_tokens_job = Job::with_identity("cleanup_tokens_job", "default_group", move || {
let rt = rt_cleanup.clone();
rt.spawn(async move {
let _ = cleanup_old_tokens().await;
});
});
let rt_refresh = rt.clone();
let refresh_access_token_job =
Job::with_identity("refresh_access_token_job", "default_group", move || {
let rt = rt_refresh.clone();
rt.spawn(async move {
let _ = refresh_access_token().await;
});
});
scheduler.schedule_job(cleanup_tokens_job, trigger_every_5min);
scheduler.schedule_job(refresh_access_token_job, trigger_every_3min);
scheduler
}
pub async fn cleanup_old_tokens() -> Result<(), Box<dyn Error>> {
let pool = db::get_pool().await?;
let now = chrono::Utc::now();
let now = now.to_rfc3339();
query!("DELETE FROM tokens WHERE expires_at < ?", now)
.execute(&pool)
.await?;
Ok(())
}
pub async fn refresh_access_token() -> Result<(), Box<dyn Error>> {
auth::create_new_access_token().await?;
Ok(())
}

View File

@@ -1,10 +1,16 @@
use std::{env, error::Error};
use std::env;
use actix_files::Files;
use actix_web::{App, HttpResponse, HttpServer, Responder, get, web};
use scp_core::apis::configuration::Configuration;
use scp_core::apis::default_api;
mod auth;
mod db;
mod helper;
mod jobs;
mod models;
#[get("/api/hello")]
async fn hello() -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({ "message": "hello" }))
@@ -18,7 +24,6 @@ async fn spa_fallback() -> actix_web::Result<actix_files::NamedFile> {
async fn ping_netcup() -> impl Responder {
let config = Configuration::default();
let res = default_api::api_ping_get(&config).await;
dbg!(&res);
res.is_ok().to_string()
}
@@ -30,10 +35,18 @@ async fn main() -> std::io::Result<()> {
.parse::<u16>()
.unwrap();
HttpServer::new(|| {
db::migrate().await.expect("Error migrating!");
let rt_handle = tokio::runtime::Handle::current();
let scheduler = jobs::init_scheduler(rt_handle.clone());
let res = HttpServer::new(|| {
App::new()
.service(hello)
.service(ping_netcup)
.service(auth::is_scp_logged_in)
.service(auth::start_flow)
.service(auth::get_user)
.service(
Files::new("/", "./frontend/dist")
.index_file("index.html")
@@ -43,5 +56,8 @@ async fn main() -> std::io::Result<()> {
})
.bind(("127.0.0.1", port))?
.run()
.await
.await;
scheduler.shutdown();
res
}

60
src/models/auth.rs Normal file
View File

@@ -0,0 +1,60 @@
use serde::Deserialize;
use serde_derive::Serialize;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct User {
pub autologin_token_used: String,
pub sub: String,
#[serde(rename = "email_verified")]
pub email_verified: bool,
pub webservice_password_used: String,
pub secure_mode_active: String,
pub roles: Vec<String>,
pub name: String,
pub id: String,
#[serde(rename = "preferred_username")]
pub preferred_username: String,
#[serde(rename = "given_name")]
pub given_name: String,
#[serde(rename = "family_name")]
pub family_name: String,
pub email: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenidResponse {
#[serde(rename = "device_code")]
pub device_code: String,
#[serde(rename = "user_code")]
pub user_code: String,
#[serde(rename = "verification_uri")]
pub verification_uri: String,
#[serde(rename = "verification_uri_complete")]
pub verification_uri_complete: String,
#[serde(rename = "expires_in")]
pub expires_in: i64,
pub interval: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenidTokenResponse {
#[serde(rename = "access_token")]
pub access_token: String,
#[serde(rename = "expires_in")]
pub expires_in: i64,
#[serde(rename = "refresh_expires_in")]
pub refresh_expires_in: i64,
#[serde(rename = "refresh_token")]
pub refresh_token: String,
#[serde(rename = "token_type")]
pub token_type: String,
#[serde(rename = "not-before-policy")]
pub not_before_policy: i64,
#[serde(rename = "session_state")]
pub session_state: String,
pub scope: String,
}

1
src/models/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod auth;