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

3
.gitignore vendored
View File

@@ -1 +1,4 @@
/target
data.db
.env
data.db*

View File

@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM tokens WHERE is_refresh = $1",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "token",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "is_refresh",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "created_at",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "expires_at",
"ordinal": 4,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true
]
},
"hash": "5b381986f605cdd82b2aaa32cdb1ddbb0c95d602f70ce58761192e11fedfa145"
}

13
.zed/settings.json Normal file
View File

@@ -0,0 +1,13 @@
{
"lsp": {
"rust-analyzer": {
"initialization_options": {
"cargo": {
"extraEnv": {
"DATABASE_URL": "sqlite://./data.db",
},
},
},
},
},
}

1091
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,5 +6,12 @@ edition = "2024"
[dependencies]
actix-files = "0.6.9"
actix-web = "4.12.1"
chrono = "0.4.42"
quartz = "0.0.4"
reqwest = "0.12.28"
scp_core = { path = "./scp_core" }
serde = { version = "1.0.228", features = ["serde_derive", "derive"] }
serde_derive = "1.0.228"
serde_json = "1.0.147"
sqlx = { version = "0.8", features = [ "runtime-tokio", "tls-native-tls", "sqlite" ] }
tokio = "1.48.0"

View File

@@ -1,4 +1,4 @@
import * as React from "react"
import * as React from "react";
import {
IconCamera,
IconChartBar,
@@ -15,12 +15,12 @@ import {
IconSearch,
IconSettings,
IconUsers,
} from "@tabler/icons-react"
} from "@tabler/icons-react";
import { NavDocuments } from "@/components/nav-documents"
import { NavMain } from "@/components/nav-main"
import { NavSecondary } from "@/components/nav-secondary"
import { NavUser } from "@/components/nav-user"
import { NavDocuments } from "@/components/nav-documents";
import { NavMain } from "@/components/nav-main";
import { NavSecondary } from "@/components/nav-secondary";
import { NavUser } from "@/components/nav-user";
import {
Sidebar,
SidebarContent,
@@ -29,14 +29,11 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
} from "@/components/ui/sidebar";
import { useQuery } from "@tanstack/react-query";
import type { User } from "@/models/user";
const data = {
user: {
name: "shadcn",
email: "m@example.com",
avatar: "/avatars/shadcn.jpg",
},
navMain: [
{
title: "Dashboard",
@@ -146,9 +143,18 @@ const data = {
icon: IconFileWord,
},
],
}
};
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { data: user } = useQuery({
queryKey: ["user"],
queryFn: async () => {
const response = await fetch("/api/auth/user");
const user = (await response.json()) as User;
return user;
},
});
return (
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>
@@ -172,8 +178,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<NavSecondary items={data.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
<NavUser user={data.user} />
<NavUser
user={{
avatar: `https://i.pravatar.cc/200?u=${user?.id || ""}`,
email: user?.email || "",
name: user?.name || "",
}}
/>
</SidebarFooter>
</Sidebar>
)
);
}

View File

@@ -1,34 +1,76 @@
import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { ModeToggle } from "./mode-toggle";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "./ui/button";
import { useEffect, useState } from "react";
export function SiteHeader() {
const [oldLoginState, setOldLoginState] = useState(false);
const queryClient = useQueryClient();
const { data: pingResult } = useQuery({
queryKey: ["ping"],
queryFn: async () => {
let res = await fetch("/api/scp/ping");
return await res.text();
return (await res.text()) === "true";
},
refetchInterval: 30000,
});
const { data: isLoggedIn } = useQuery({
queryKey: ["is_scp_logged_in"],
queryFn: async () => {
let res = await fetch("/api/scp/auth/is_logged_in");
return (await res.text()) === "true";
},
refetchInterval: 5000,
});
useEffect(() => {
if (isLoggedIn) {
if (!oldLoginState) {
queryClient.invalidateQueries({ queryKey: ["user"] });
setOldLoginState(true);
}
}
}, [isLoggedIn]);
async function startFlow() {
let res = await fetch("/api/scp/auth/start_flow");
if (res.ok && window) {
let url = await res.text();
window.open(url, "_blank")?.focus();
}
}
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<div className="flex flex-row gap-2 justify-center items-center">
<span>Netcup</span>
{pingResult === "true" ? (
<div className="bg-green-500 rounded-[100%] animate-pulse w-4 h-4 ">
&nbsp;
</div>
{pingResult ? (
isLoggedIn ? (
<div className="bg-green-500 rounded-[100%] animate-pulse w-4 h-4 ">
&nbsp;
</div>
) : (
<div className="bg-yellow-500 rounded-[100%] animate-pulse w-4 h-4 ">
&nbsp;
</div>
)
) : (
<div className="bg-red-700 rounded-[100%] animate-pulse w-4 h-4">
&nbsp;
</div>
)}
</div>
{!isLoggedIn && pingResult && (
<Button variant="outline" size="sm" onClick={startFlow}>
Login to Netcup SCP
</Button>
)}
<Separator
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4"

View File

@@ -0,0 +1,14 @@
export interface User {
autologinTokenUsed: string;
sub: string;
email_verified: boolean;
webservicePasswordUsed: string;
secureModeActive: string;
roles: string[];
name: string;
id: string;
preferred_username: string;
given_name: string;
family_name: string;
email: string;
}

View File

@@ -0,0 +1,3 @@
-- Add down migration script here
DROP TABLE tokens;
DROP TABLE job_events;

View File

@@ -0,0 +1,16 @@
-- Add up migration script here
CREATE TABLE tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
is_refresh INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT
);
CREATE TABLE job_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_name TEXT NOT NULL,
event_type TEXT NOT NULL,
event_data TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);

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;