feat: add auth
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1 +1,4 @@
|
|||||||
/target
|
/target
|
||||||
|
data.db
|
||||||
|
.env
|
||||||
|
data.db*
|
||||||
|
|||||||
44
.sqlx/query-5b381986f605cdd82b2aaa32cdb1ddbb0c95d602f70ce58761192e11fedfa145.json
generated
Normal file
44
.sqlx/query-5b381986f605cdd82b2aaa32cdb1ddbb0c95d602f70ce58761192e11fedfa145.json
generated
Normal 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
13
.zed/settings.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"lsp": {
|
||||||
|
"rust-analyzer": {
|
||||||
|
"initialization_options": {
|
||||||
|
"cargo": {
|
||||||
|
"extraEnv": {
|
||||||
|
"DATABASE_URL": "sqlite://./data.db",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
1091
Cargo.lock
generated
1091
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -6,5 +6,12 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
actix-files = "0.6.9"
|
actix-files = "0.6.9"
|
||||||
actix-web = "4.12.1"
|
actix-web = "4.12.1"
|
||||||
|
chrono = "0.4.42"
|
||||||
|
quartz = "0.0.4"
|
||||||
|
reqwest = "0.12.28"
|
||||||
scp_core = { path = "./scp_core" }
|
scp_core = { path = "./scp_core" }
|
||||||
|
serde = { version = "1.0.228", features = ["serde_derive", "derive"] }
|
||||||
|
serde_derive = "1.0.228"
|
||||||
serde_json = "1.0.147"
|
serde_json = "1.0.147"
|
||||||
|
sqlx = { version = "0.8", features = [ "runtime-tokio", "tls-native-tls", "sqlite" ] }
|
||||||
|
tokio = "1.48.0"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
IconCamera,
|
IconCamera,
|
||||||
IconChartBar,
|
IconChartBar,
|
||||||
@@ -15,12 +15,12 @@ import {
|
|||||||
IconSearch,
|
IconSearch,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
import { NavDocuments } from "@/components/nav-documents"
|
import { NavDocuments } from "@/components/nav-documents";
|
||||||
import { NavMain } from "@/components/nav-main"
|
import { NavMain } from "@/components/nav-main";
|
||||||
import { NavSecondary } from "@/components/nav-secondary"
|
import { NavSecondary } from "@/components/nav-secondary";
|
||||||
import { NavUser } from "@/components/nav-user"
|
import { NavUser } from "@/components/nav-user";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -29,14 +29,11 @@ import {
|
|||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { User } from "@/models/user";
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
user: {
|
|
||||||
name: "shadcn",
|
|
||||||
email: "m@example.com",
|
|
||||||
avatar: "/avatars/shadcn.jpg",
|
|
||||||
},
|
|
||||||
navMain: [
|
navMain: [
|
||||||
{
|
{
|
||||||
title: "Dashboard",
|
title: "Dashboard",
|
||||||
@@ -146,9 +143,18 @@ const data = {
|
|||||||
icon: IconFileWord,
|
icon: IconFileWord,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
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 (
|
return (
|
||||||
<Sidebar collapsible="offcanvas" {...props}>
|
<Sidebar collapsible="offcanvas" {...props}>
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
@@ -172,8 +178,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NavUser user={data.user} />
|
<NavUser
|
||||||
|
user={{
|
||||||
|
avatar: `https://i.pravatar.cc/200?u=${user?.id || ""}`,
|
||||||
|
email: user?.email || "",
|
||||||
|
name: user?.name || "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,76 @@
|
|||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { ModeToggle } from "./mode-toggle";
|
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() {
|
export function SiteHeader() {
|
||||||
|
const [oldLoginState, setOldLoginState] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: pingResult } = useQuery({
|
const { data: pingResult } = useQuery({
|
||||||
queryKey: ["ping"],
|
queryKey: ["ping"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
let res = await fetch("/api/scp/ping");
|
let res = await fetch("/api/scp/ping");
|
||||||
return await res.text();
|
return (await res.text()) === "true";
|
||||||
},
|
},
|
||||||
refetchInterval: 30000,
|
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 (
|
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)">
|
<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">
|
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<div className="flex flex-row gap-2 justify-center items-center">
|
<div className="flex flex-row gap-2 justify-center items-center">
|
||||||
<span>Netcup</span>
|
<span>Netcup</span>
|
||||||
{pingResult === "true" ? (
|
{pingResult ? (
|
||||||
<div className="bg-green-500 rounded-[100%] animate-pulse w-4 h-4 ">
|
isLoggedIn ? (
|
||||||
|
<div className="bg-green-500 rounded-[100%] animate-pulse w-4 h-4 ">
|
||||||
</div>
|
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-yellow-500 rounded-[100%] animate-pulse w-4 h-4 ">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-red-700 rounded-[100%] animate-pulse w-4 h-4">
|
<div className="bg-red-700 rounded-[100%] animate-pulse w-4 h-4">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!isLoggedIn && pingResult && (
|
||||||
|
<Button variant="outline" size="sm" onClick={startFlow}>
|
||||||
|
Login to Netcup SCP
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Separator
|
<Separator
|
||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
className="mx-2 data-[orientation=vertical]:h-4"
|
className="mx-2 data-[orientation=vertical]:h-4"
|
||||||
|
|||||||
14
frontend/src/models/user.ts
Normal file
14
frontend/src/models/user.ts
Normal 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;
|
||||||
|
}
|
||||||
3
migrations/20251225181315_init.down.sql
Normal file
3
migrations/20251225181315_init.down.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- Add down migration script here
|
||||||
|
DROP TABLE tokens;
|
||||||
|
DROP TABLE job_events;
|
||||||
16
migrations/20251225181315_init.up.sql
Normal file
16
migrations/20251225181315_init.up.sql
Normal 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
187
src/auth.rs
Normal 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
45
src/db.rs
Normal 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
32
src/helper.rs
Normal 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
53
src/jobs.rs
Normal 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(())
|
||||||
|
}
|
||||||
24
src/main.rs
24
src/main.rs
@@ -1,10 +1,16 @@
|
|||||||
use std::{env, error::Error};
|
use std::env;
|
||||||
|
|
||||||
use actix_files::Files;
|
use actix_files::Files;
|
||||||
use actix_web::{App, HttpResponse, HttpServer, Responder, get, web};
|
use actix_web::{App, HttpResponse, HttpServer, Responder, get, web};
|
||||||
use scp_core::apis::configuration::Configuration;
|
use scp_core::apis::configuration::Configuration;
|
||||||
use scp_core::apis::default_api;
|
use scp_core::apis::default_api;
|
||||||
|
|
||||||
|
mod auth;
|
||||||
|
mod db;
|
||||||
|
mod helper;
|
||||||
|
mod jobs;
|
||||||
|
mod models;
|
||||||
|
|
||||||
#[get("/api/hello")]
|
#[get("/api/hello")]
|
||||||
async fn hello() -> impl Responder {
|
async fn hello() -> impl Responder {
|
||||||
HttpResponse::Ok().json(serde_json::json!({ "message": "hello" }))
|
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 {
|
async fn ping_netcup() -> impl Responder {
|
||||||
let config = Configuration::default();
|
let config = Configuration::default();
|
||||||
let res = default_api::api_ping_get(&config).await;
|
let res = default_api::api_ping_get(&config).await;
|
||||||
dbg!(&res);
|
|
||||||
|
|
||||||
res.is_ok().to_string()
|
res.is_ok().to_string()
|
||||||
}
|
}
|
||||||
@@ -30,10 +35,18 @@ async fn main() -> std::io::Result<()> {
|
|||||||
.parse::<u16>()
|
.parse::<u16>()
|
||||||
.unwrap();
|
.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()
|
App::new()
|
||||||
.service(hello)
|
.service(hello)
|
||||||
.service(ping_netcup)
|
.service(ping_netcup)
|
||||||
|
.service(auth::is_scp_logged_in)
|
||||||
|
.service(auth::start_flow)
|
||||||
|
.service(auth::get_user)
|
||||||
.service(
|
.service(
|
||||||
Files::new("/", "./frontend/dist")
|
Files::new("/", "./frontend/dist")
|
||||||
.index_file("index.html")
|
.index_file("index.html")
|
||||||
@@ -43,5 +56,8 @@ async fn main() -> std::io::Result<()> {
|
|||||||
})
|
})
|
||||||
.bind(("127.0.0.1", port))?
|
.bind(("127.0.0.1", port))?
|
||||||
.run()
|
.run()
|
||||||
.await
|
.await;
|
||||||
|
|
||||||
|
scheduler.shutdown();
|
||||||
|
res
|
||||||
}
|
}
|
||||||
|
|||||||
60
src/models/auth.rs
Normal file
60
src/models/auth.rs
Normal 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
1
src/models/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod auth;
|
||||||
Reference in New Issue
Block a user