Working authentication

This commit is contained in:
Андреев Григорий 2026-03-10 14:21:23 +03:00
commit 00cf8bfb4a
20 changed files with 3307 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
deepseek.txt
passcode.txt
config.toml
.idea
target

2553
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "rust_tripping"
version = "0.1.2"
edition = "2021"
[dependencies]
rand = "0.8"
axum = "0.7"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
tower-http = { version = "0.5", features = ["fs"] }
tera = "1.19.1"
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
serde = { version = "1", features = ["derive"] }
axum-extra = { version = "0.9", features = ["cookie"] }
tokio-postgres = "0.7"
toml = "0.8"

3
config.example.toml Normal file
View File

@ -0,0 +1,3 @@
postgres_host="/run/postgresql"
pg_database="DATABASE_NAME"
postgres_user="postgres"

7
shell.nix Normal file
View File

@ -0,0 +1,7 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = [
pkgs.pkg-config
pkgs.openssl
];
}

7
src/bin/init_db.rs Normal file
View File

@ -0,0 +1,7 @@
use rust_tripping::init_db;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
init_db().await?;
Ok(())
}

7
src/bin/run_server.rs Normal file
View File

@ -0,0 +1,7 @@
use rust_tripping::run_server;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
run_server().await?;
Ok(())
}

40
src/config.rs Normal file
View File

@ -0,0 +1,40 @@
use serde::Deserialize;
use std::path::Path;
const DEFAULT_CONFIG_PATH: &str = "config.toml";
#[derive(Debug, Clone, Deserialize)]
pub struct GeneralServiceConfig {
pub postgres_host: String,
pub pg_database: String,
pub postgres_user: String,
}
pub type ConfigResult<T> = Result<T, Box<dyn std::error::Error>>;
pub fn load_config(path: impl AsRef<Path>) -> ConfigResult<GeneralServiceConfig> {
let raw = std::fs::read_to_string(path.as_ref())?;
let config: GeneralServiceConfig = toml::from_str(&raw)?;
if config.pg_database.trim().is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"config database is empty",
)
.into());
}
if config.postgres_user.trim().is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"config user is empty",
)
.into());
}
Ok(config)
}
pub fn load_config_default() -> ConfigResult<GeneralServiceConfig> {
load_config(DEFAULT_CONFIG_PATH)
}

38
src/db.rs Normal file
View File

@ -0,0 +1,38 @@
use tokio_postgres::{Client, NoTls};
const PERSON_SCHEMA_NAME: &str = "public";
use crate::config::GeneralServiceConfig;
pub type DbResult<T> = Result<T, Box<dyn std::error::Error>>;
pub async fn connect_db(config: &GeneralServiceConfig) -> DbResult<Client> {
let mut pg_config = tokio_postgres::Config::new();
pg_config.host(&config.postgres_host);
pg_config.dbname(&config.pg_database);
pg_config.user(&config.postgres_user);
let (client, connection) = pg_config.connect(NoTls).await?;
tokio::spawn(async move {
if let Err(err) = connection.await {
eprintln!("postgres connection error: {err}");
}
});
Ok(client)
}
pub async fn init_database(config: &GeneralServiceConfig) -> DbResult<()> {
let client = connect_db(config).await?;
let create_sql = format!(
"CREATE TABLE IF NOT EXISTS \"{}\".person (\
id SERIAL PRIMARY KEY,\
name TEXT NOT NULL,\
passcode TEXT NOT NULL\
)",
PERSON_SCHEMA_NAME
);
client.execute(create_sql.as_str(), &[]).await?;
Ok(())
}

View File

@ -0,0 +1,59 @@
use std::sync::OnceLock;
use serde::Serialize;
use tokio::fs;
static DEEPSEEK_TOKEN: OnceLock<String> = OnceLock::new();
async fn deepseek_token() -> Result<String, std::io::Error> {
if let Some(token) = DEEPSEEK_TOKEN.get() {
return Ok(token.clone());
}
let raw = fs::read_to_string("deepseek.txt").await?;
let token = raw.trim().to_string();
let _ = DEEPSEEK_TOKEN.set(token.clone());
Ok(token)
}
#[derive(Serialize)]
struct Message<'a> {
role: &'a str,
content: &'a str,
}
#[derive(Serialize)]
struct ChatRequest<'a> {
model: &'a str,
messages: Vec<Message<'a>>,
stream: bool,
}
pub async fn ask_deepseek() -> Result<(), Box<dyn std::error::Error>> {
let token = deepseek_token().await?;
let payload = ChatRequest {
model: "deepseek-chat",
messages: vec![
Message {
role: "system",
content: "You are a helpful assistant.",
},
Message {
role: "user",
content: "Hello!",
},
],
stream: false,
};
let response = reqwest::Client::new()
.post("https://api.deepseek.com/chat/completions")
.bearer_auth(token)
.json(&payload)
.send()
.await?;
let status = response.status();
let body = response.text().await?;
println!("DeepSeek status: {status}");
println!("{body}");
Ok(())
}

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

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

362
src/lib.rs Normal file
View File

@ -0,0 +1,362 @@
#![feature(type_alias_impl_trait)]
use std::prelude::v1::define_opaque;
mod samplers;
mod invoking_llms;
mod db;
mod config;
use axum::{
extract::{Form, Query, State},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post},
Router,
};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use serde::Deserialize;
use std::future::Future;
use std::net::SocketAddr;
use std::pin::Pin;
use std::sync::OnceLock;
use std::task::{Context, Poll};
use tera::{Tera};
use tower_http::services::ServeDir;
use samplers::random_junk::random_string;
pub use config::{ConfigResult, GeneralServiceConfig, load_config, load_config_default};
pub use db::{DbResult, connect_db, init_database};
pub struct AppStateInner {
pub config: GeneralServiceConfig,
pub db: tokio_postgres::Client,
}
pub type AppState = std::sync::Arc<AppStateInner>;
pub async fn init_app_state() -> DbResult<AppState> {
let config = load_config_default()?;
let db = connect_db(&config).await?;
Ok(std::sync::Arc::new(AppStateInner { config, db }))
}
static TEMPLATES: OnceLock<Tera> = OnceLock::new();
fn templates() -> &'static Tera {
TEMPLATES.get_or_init(|| Tera::new("src/pages/**/*.html").expect("load templates"))
}
struct AuthenticatedUserId {
id: i32,
name: String,
}
enum PasscodeAuthenticationResult{
WrongPassword,
Ok(AuthenticatedUserId)
}
enum WebsiteAuthenticationResult {
NoCookie,
SomeCookie(PasscodeAuthenticationResult),
}
async fn website_authentication_with_passcode(
state: &AppStateInner, passcode: &str) -> PasscodeAuthenticationResult {
let row = state.db.query_opt(
"SELECT id, name FROM public.person WHERE passcode = $1 LIMIT 1",
&[&passcode],
).await;
match row {
Ok(Some(row)) => PasscodeAuthenticationResult::Ok(AuthenticatedUserId {
id: row.get(0),
name: row.get(1),
}),
Ok(None) => PasscodeAuthenticationResult::WrongPassword,
Err(err) => {
eprintln!("auth query failed: {err}");
PasscodeAuthenticationResult::WrongPassword
}
}
}
async fn website_authentication_with_cookie(
state: &AppStateInner,
jar: &CookieJar,
) -> WebsiteAuthenticationResult {
let auth_cookie = jar.get("auth");
match auth_cookie {
None => WebsiteAuthenticationResult::NoCookie,
Some(x) => {
let passcode = x.value();
WebsiteAuthenticationResult::SomeCookie(website_authentication_with_passcode(state, passcode).await)
},
}
}
enum AuthResponseFutureState<Fut> {
Redirect(Option<Response>),
Running(Fut),
Done,
}
struct AuthResponseFuture<Fut> {
state: AuthResponseFutureState<Fut>,
}
impl<Fut> AuthResponseFuture<Fut> {
fn redirect(response: Response) -> Self {
Self {
state: AuthResponseFutureState::Redirect(Some(response)),
}
}
fn running(fut: Fut) -> Self {
Self {
state: AuthResponseFutureState::Running(fut),
}
}
}
impl<Fut, Res> Future for AuthResponseFuture<Fut>
where
Fut: Future<Output = Res>,
Res: IntoResponse,
{
type Output = Response;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = unsafe { self.get_unchecked_mut() };
match &mut this.state {
AuthResponseFutureState::Redirect(response) => {
let response = response
.take()
.expect("AuthFuture polled after completion");
this.state = AuthResponseFutureState::Done;
Poll::Ready(response)
}
AuthResponseFutureState::Running(fut) => {
let fut = unsafe { Pin::new_unchecked(fut) };
match fut.poll(cx) {
Poll::Ready(res) => {
this.state = AuthResponseFutureState::Done;
Poll::Ready(res.into_response())
}
Poll::Pending => Poll::Pending,
}
}
AuthResponseFutureState::Done => panic!("AuthFuture polled after completion"),
}
}
}
pub enum EitherResponseOrRedirect<Res>
where
Res: IntoResponse,
{
Redirect(Response),
Response(Res),
}
impl<Res> IntoResponse for EitherResponseOrRedirect<Res>
where Res: IntoResponse,
{
fn into_response(self) -> Response {
match self {
EitherResponseOrRedirect::Redirect(resp) => resp,
EitherResponseOrRedirect::Response(res) => res.into_response(),
}
}
}
// Define a trait using RPITIT (stable since 1.75)
trait AsyncWrapper<Res> {
fn call(self) -> impl Future<Output = Option<Res>>;
}
impl<Res, F, Fut> AsyncWrapper<Res> for F
where
F: Fn() -> Fut,
Fut: Future<Output = Res> + 'static,
{
fn call(self) -> impl Future<Output = Option<Res>> {
async move { Some(self().await) }
}
}
// Your function now returns impl the trait (no nesting in sig)
fn wrap_async<F, Fut, Res>(async_fn: F) -> impl AsyncWrapper<Res>
where
F: Fn() -> Fut + 'static,
Fut: Future<Output = Res> + 'static,
Res: 'static,
{
async_fn
}
fn axum_handler_with_auth<T, H, Res>(
handler: H,
state: AppState,
) -> impl Fn(CookieJar, T) -> std::pin::Pin<Box<dyn Future<Output = Response> + Send>> + Clone + Send + 'static
where
H: for<'a> Fn(T, &'a AppStateInner, AuthenticatedUserId) -> std::pin::Pin<Box<dyn Future<Output = Res> + Send + 'a>>
+ Clone
+ Send
+ 'static,
Res: IntoResponse + 'static,
T: Send + 'static,
{
move |jar: CookieJar, args: T| {
let state = state.clone();
let handler = handler.clone();
Box::pin(async move {
let res = website_authentication_with_cookie(state.as_ref(), &jar).await;
match res {
WebsiteAuthenticationResult::SomeCookie(PasscodeAuthenticationResult::Ok(user)) => {
handler(args, state.as_ref(), user).await.into_response()
}
WebsiteAuthenticationResult::NoCookie => {
Redirect::to("/login").into_response()
}
WebsiteAuthenticationResult::SomeCookie(PasscodeAuthenticationResult::WrongPassword) => {
Redirect::to("/login?error=cookie").into_response()
}
}
})
}
}
#[derive(Deserialize)]
struct LoginPageQuery {
error: Option<String>,
}
#[derive(Deserialize)]
struct LoginPageForm {
passcode: String,
csrf_token: String,
}
async fn login_get(
State(state): State<AppState>,
jar: CookieJar,
Query(query): Query<LoginPageQuery>,
) -> (CookieJar, Html<String>) {
let cur_auth: WebsiteAuthenticationResult =
website_authentication_with_cookie(state.as_ref(), &jar).await;
let csrf = random_string(32);
let jar = jar.add(
Cookie::build(("csrf", csrf.clone()))
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.build(),
);
let error = match query.error.as_deref() {
Some("cookie") => Some("Incorrect session cookie"),
Some("password") => Some("Invalid passcode"),
Some("csrf") => Some("Implicit log in attempt aborted!"),
_ => None,
};
let mut ctx = tera::Context::new();
ctx.insert("csrf", &csrf);
if let Some(msg) = error {
ctx.insert("error", msg);
}
if let WebsiteAuthenticationResult::SomeCookie(PasscodeAuthenticationResult::Ok(cur_user)) = cur_auth {
ctx.insert("logged_in_cur_user", &cur_user.name);
}
let body = templates()
.render("login.html", &ctx)
.expect("render index");
(jar, Html(body))
}
async fn login_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<LoginPageForm>
) -> impl IntoResponse {
let csrf_ok = jar
.get("csrf")
.map(|cookie| cookie.value())
== Some(form.csrf_token.as_str());
if !csrf_ok {
return Redirect::to("/login?error=csrf").into_response();
}
let res = website_authentication_with_passcode(&state, &form.passcode);
match res.await {
PasscodeAuthenticationResult::Ok(_) => {
let jar = jar.add(
Cookie::build(("auth", form.passcode))
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.build(),
);
(jar, Redirect::to("/")).into_response()
}
PasscodeAuthenticationResult::WrongPassword =>
Redirect::to("/login?error=password").into_response()
}
}
fn index(
_: (),
_state: &AppStateInner,
_user: AuthenticatedUserId,
) -> std::pin::Pin<Box<dyn Future<Output = Html<String>> + Send + '_>> {
Box::pin(async move {
let body = templates()
.render("index.html", &tera::Context::new())
.expect("render index");
Html(body)
})
}
fn welcome(
_: (),
_state: &AppStateInner,
_user: AuthenticatedUserId,
) -> std::pin::Pin<Box<dyn Future<Output = Html<String>> + Send + '_>> {
Box::pin(async move {
let body = templates()
.render("welcome.html", &tera::Context::new())
.expect("render welcome");
Html(body)
})
}
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
let state = init_app_state().await.expect("lol");
let app = Router::new()
.route("/login", get(login_get))
.route("/login", post(login_post))
.route("/welcome", get(axum_handler_with_auth(welcome, state.clone())))
.route("/", get(axum_handler_with_auth(index, state.clone())))
.nest_service("/static", ServeDir::new("src/static"))
.with_state(state);
let addr: SocketAddr = "127.0.0.1:3000".parse().expect("valid socket addr");
println!("listening on http://{addr}");
let listener = tokio::net::TcpListener::bind(addr)
.await
.expect("bind failed");
axum::serve(listener, app).await?;
Ok(())
}
pub async fn init_db() -> Result<(), Box<dyn std::error::Error>> {
let config = load_config_default()?;
init_database(&config).await?;
Ok(())
}

12
src/pages/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Main page</title>
<link rel="stylesheet" href="/static/css/site.css" />
</head>
<body>
<h1>Main page</h1>
</body>
</html>

34
src/pages/login.html Normal file
View File

@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Login</title>
<link rel="stylesheet" href="/static/css/site.css" />
</head>
<body>
<main class="login-card">
<h1>Login</h1>
{% if logged_in_cur_user %}
<p class="mild-warning">You are already logged in as {{ logged_in_cur_user }}</p>
{% endif %}
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="/login">
<label class="field">
<span class="label">Passcode</span>
<input
type="password"
name="passcode"
placeholder="Enter passcode"
required
autofocus
/>
</label>
<input type="hidden" name="csrf_token" value="{{ csrf }}" />
<button type="submit">Enter</button>
</form>
</main>
</body>
</html>

14
src/pages/welcome.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Welcome</title>
<link rel="stylesheet" href="/static/css/site.css" />
</head>
<body>
<main class="welcome-card">
<h1>Hello world</h1>
</main>
</body>
</html>

2
src/samplers/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod random_junk;

View File

@ -0,0 +1,58 @@
use rand::distributions::uniform::SampleUniform;
use rand::distributions::{Alphanumeric, DistString};
use rand::Rng;
pub fn random_range<T>(min: T, max: T) -> T
where
T: SampleUniform + PartialOrd + Copy,
{
rand::thread_rng().gen_range(min..=max)
}
pub fn random_string(len: usize) -> String {
Alphanumeric.sample_string(&mut rand::thread_rng(), len)
}
pub fn random_identifier(len: usize) -> String {
let mut rng = rand::thread_rng();
let first = rng.gen_range(b'a'..=b'z') as char;
let rest = Alphanumeric.sample_string(&mut rng, len.saturating_sub(1));
let mut out = String::with_capacity(len.max(1));
out.push(first);
out.push_str(&rest);
out
}
pub fn random_arch_name() -> String {
let syllables = [
"za", "xi", "qo", "vu", "re", "ka", "tri", "mo", "lu", "pha", "nex", "zor", "ish", "ul",
"vek", "tor", "ryn", "qua", "oth", "gar",
];
let mut rng = rand::thread_rng();
let parts = rng.gen_range(2usize..=4usize);
let mut out = String::new();
for _ in 0..parts {
let idx = rng.gen_range(0..syllables.len());
out.push_str(syllables[idx]);
}
let mut chars = out.chars();
let first = chars.next().expect("No first char");
let mut capped = String::with_capacity(out.len());
capped.push(first.to_ascii_uppercase());
capped.push_str(chars.as_str());
capped
}
pub fn main_entry() {
let small_u32 = random_range(1u32, 100u32);
let signed_i32 = random_range(-50i32, 50i32);
let word = random_string(12);
let ident = random_identifier(8);
let arch = random_arch_name();
println!("random_range(1u32, 100u32) = {small_u32}");
println!("random_range(-50i32, 50i32) = {signed_i32}");
println!("random_string(12) = {word}");
println!("random_identifier(8) = {ident}");
println!("random_arch_name() = {arch}");
}

89
src/static/css/site.css Normal file
View File

@ -0,0 +1,89 @@
html,
body {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
display: flex;
align-items: center;
justify-content: center;
font-family: "Georgia", "Times New Roman", serif;
color: #f7f4ef;
background: url("/static/images/wool.jpg") top center / cover no-repeat;
}
.login-card,
.welcome-card {
background: rgba(10, 10, 10, 0.65);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px;
padding: 28px 32px;
width: min(360px, 90vw);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(2px);
}
.login-card h1,
.welcome-card h1 {
margin: 0 0 16px;
font-size: 28px;
letter-spacing: 0.5px;
}
.field {
display: block;
margin-bottom: 16px;
}
.label {
display: block;
margin-bottom: 6px;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
color: #e6e0d7;
}
input[type="password"] {
width: 100%;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 10px 12px;
background: rgba(255, 255, 255, 0.08);
color: #fff;
font-size: 16px;
box-sizing: border-box;
}
button {
width: 100%;
border: none;
border-radius: 8px;
padding: 12px;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.5px;
background: #f0c986;
color: #1c1200;
cursor: pointer;
}
button:hover {
background: #f3d6a4;
}
.error {
margin: 0 0 12px;
color: #ff6b6b;
font-weight: 600;
}
.mild-warning {
margin: 0 0 12px;
color: white;
font-weight: 200;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
src/static/images/wool.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB