Add multi-user security service with per-app authorization
Control plane: - Users and app grants stored in SQLite (users + user_apps tables) - bcrypt password hashing - Sessions: HashMap<token, user_id> (in-memory, cleared on restart) - Bootstrap: first admin auto-created from HIY_ADMIN_USER/HIY_ADMIN_PASS if DB is empty - /admin/users page: create/delete users, toggle admin, grant/revoke app access - /api/users + /api/users/:id/apps/:app_id REST endpoints (admin-only) Deployed apps: - Every app route now uses Caddy forward_auth pointing at /auth/verify - /auth/verify checks session cookie + user_apps grant (admins have access to all apps) - Unauthenticated -> 302 to /login?next=<original URL> - Authorised but not granted -> /denied page - Session cookie set with Domain=.DOMAIN_SUFFIX for cross-subdomain auth Other: - /denied page for "logged in but not granted" case - Login page skips re-auth if already logged in - Cookie uses SameSite=Lax (required for cross-subdomain redirect flows) https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
This commit is contained in:
parent
4454744cba
commit
2cdbf270f6
9 changed files with 744 additions and 69 deletions
|
|
@ -127,13 +127,44 @@ if curl --silent --fail "${CADDY_API}/config/" >/dev/null 2>&1; then
|
||||||
else
|
else
|
||||||
ROUTES_URL="${CADDY_API}/config/apps/http/servers/${CADDY_SERVER}/routes"
|
ROUTES_URL="${CADDY_API}/config/apps/http/servers/${CADDY_SERVER}/routes"
|
||||||
|
|
||||||
ROUTE_JSON=$(cat <<EOF
|
# Route JSON uses Caddy's forward_auth pattern:
|
||||||
{
|
# 1. HIY server checks the session cookie and app-level permission at /auth/verify
|
||||||
"match": [{"host": ["${APP_ID}.${DOMAIN_SUFFIX}"]}],
|
# 2. On 2xx → Caddy proxies to the app container
|
||||||
"handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "${UPSTREAM}"}]}]
|
# 3. On anything else (e.g. 302 redirect to /login) → Caddy passes through to the client
|
||||||
|
ROUTE_JSON=$(python3 -c "
|
||||||
|
import json, sys
|
||||||
|
upstream = sys.argv[1]
|
||||||
|
app_host = sys.argv[2]
|
||||||
|
hiy_server = 'server:3000'
|
||||||
|
route = {
|
||||||
|
'match': [{'host': [app_host]}],
|
||||||
|
'handle': [{
|
||||||
|
'handler': 'subroute',
|
||||||
|
'routes': [{
|
||||||
|
'handle': [{
|
||||||
|
'handler': 'reverse_proxy',
|
||||||
|
'rewrite': {'method': 'GET', 'uri': '/auth/verify'},
|
||||||
|
'headers': {
|
||||||
|
'request': {
|
||||||
|
'set': {
|
||||||
|
'X-Forwarded-Method': ['{http.request.method}'],
|
||||||
|
'X-Forwarded-Uri': ['{http.request.uri}'],
|
||||||
|
'X-Forwarded-Host': ['{http.request.host}'],
|
||||||
|
'X-Forwarded-Proto': ['{http.request.scheme}'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'upstreams': [{'dial': hiy_server}],
|
||||||
|
'handle_response': [{
|
||||||
|
'match': {'status_code': [2]},
|
||||||
|
'routes': [{'handle': [{'handler': 'reverse_proxy', 'upstreams': [{'dial': upstream}]}]}]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
}
|
}
|
||||||
EOF
|
print(json.dumps(route))
|
||||||
)
|
" "${UPSTREAM}" "${APP_ID}.${DOMAIN_SUFFIX}")
|
||||||
# Upsert the route for this app.
|
# Upsert the route for this app.
|
||||||
ROUTES=$(curl --silent --fail "${ROUTES_URL}" 2>/dev/null || echo "[]")
|
ROUTES=$(curl --silent --fail "${ROUTES_URL}" 2>/dev/null || echo "[]")
|
||||||
# Remove existing route for the same host, rebuild list, keep dashboard as catch-all.
|
# Remove existing route for the same host, rebuild list, keep dashboard as catch-all.
|
||||||
|
|
|
||||||
|
|
@ -23,5 +23,6 @@ tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
|
bcrypt = "0.15"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,48 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Form, Query, Request, State},
|
extract::{Form, Query, Request, State},
|
||||||
http::header,
|
http::{header, HeaderMap, StatusCode},
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
pub type Sessions = Arc<Mutex<HashSet<String>>>;
|
/// token → user_id
|
||||||
|
pub type Sessions = Arc<Mutex<HashMap<String, String>>>;
|
||||||
|
|
||||||
pub fn new_sessions() -> Sessions {
|
pub fn new_sessions() -> Sessions {
|
||||||
Arc::new(Mutex::new(HashSet::new()))
|
Arc::new(Mutex::new(HashMap::new()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Middleware ──────────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub async fn auth_middleware(
|
pub fn session_cookie_header(token: &str, domain_suffix: &str) -> String {
|
||||||
State(state): State<crate::AppState>,
|
let domain = if domain_suffix == "localhost" || domain_suffix.starts_with("localhost:") {
|
||||||
request: Request,
|
String::new()
|
||||||
next: Next,
|
|
||||||
) -> Response {
|
|
||||||
// Auth disabled when credentials are not configured.
|
|
||||||
if state.admin_user.is_none() {
|
|
||||||
return next.run(request).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let token = session_cookie(request.headers());
|
|
||||||
let authenticated = match token {
|
|
||||||
Some(t) => state.sessions.lock().await.contains(&t),
|
|
||||||
None => false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if authenticated {
|
|
||||||
next.run(request).await
|
|
||||||
} else {
|
} else {
|
||||||
let path = request
|
format!("; Domain=.{}", domain_suffix)
|
||||||
.uri()
|
};
|
||||||
.path_and_query()
|
format!(
|
||||||
.map(|p| p.as_str())
|
"session={}; HttpOnly; SameSite=Lax; Path={}{}",
|
||||||
.unwrap_or("/");
|
token, "/", domain
|
||||||
Redirect::to(&format!("/login?next={}", safe_path(path))).into_response()
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn session_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
|
fn clear_session_cookie(domain_suffix: &str) -> String {
|
||||||
|
let domain = if domain_suffix == "localhost" || domain_suffix.starts_with("localhost:") {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("; Domain=.{}", domain_suffix)
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}",
|
||||||
|
domain
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_session_cookie(headers: &HeaderMap) -> Option<String> {
|
||||||
headers
|
headers
|
||||||
.get(header::COOKIE)
|
.get(header::COOKIE)
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
|
|
@ -57,6 +54,12 @@ fn session_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the user_id for the current request, if logged in.
|
||||||
|
pub async fn current_user_id(state: &crate::AppState, headers: &HeaderMap) -> Option<String> {
|
||||||
|
let token = get_session_cookie(headers)?;
|
||||||
|
state.sessions.lock().await.get(&token).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
/// Reject open-redirect: only allow relative paths.
|
/// Reject open-redirect: only allow relative paths.
|
||||||
fn safe_path(next: &str) -> String {
|
fn safe_path(next: &str) -> String {
|
||||||
if next.starts_with('/') && !next.starts_with("//") {
|
if next.starts_with('/') && !next.starts_with("//") {
|
||||||
|
|
@ -66,11 +69,153 @@ fn safe_path(next: &str) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Control-plane middleware (admin users only) ───────────────────────────────
|
||||||
|
|
||||||
|
pub async fn auth_middleware(
|
||||||
|
State(state): State<crate::AppState>,
|
||||||
|
request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
// Auth disabled when credentials are not configured (dev mode).
|
||||||
|
if state.admin_user.is_none() && !state.auth_enabled {
|
||||||
|
return next.run(request).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_id = current_user_id(&state, request.headers()).await;
|
||||||
|
|
||||||
|
let is_admin = match user_id {
|
||||||
|
None => false,
|
||||||
|
Some(uid) => {
|
||||||
|
sqlx::query_scalar::<_, i64>("SELECT is_admin FROM users WHERE id = ?")
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None)
|
||||||
|
.map(|v| v != 0)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_admin {
|
||||||
|
next.run(request).await
|
||||||
|
} else {
|
||||||
|
let path = request
|
||||||
|
.uri()
|
||||||
|
.path_and_query()
|
||||||
|
.map(|p| p.as_str())
|
||||||
|
.unwrap_or("/");
|
||||||
|
Redirect::to(&format!("/login?next={}", safe_path(path))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /auth/verify — called by Caddy forward_auth for every app request ────────
|
||||||
|
|
||||||
|
pub async fn verify(
|
||||||
|
State(state): State<crate::AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Response {
|
||||||
|
let user_id = current_user_id(&state, &headers).await;
|
||||||
|
|
||||||
|
let uid = match user_id {
|
||||||
|
Some(u) => u,
|
||||||
|
None => {
|
||||||
|
// Not logged in — redirect to login, passing the original URL as `next`.
|
||||||
|
let host = headers
|
||||||
|
.get("x-forwarded-host")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
let uri = headers
|
||||||
|
.get("x-forwarded-uri")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("/");
|
||||||
|
let proto = headers
|
||||||
|
.get("x-forwarded-proto")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("https");
|
||||||
|
let next_url = if host.is_empty() {
|
||||||
|
"/".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}://{}{}", proto, host, uri)
|
||||||
|
};
|
||||||
|
let login_url = format!(
|
||||||
|
"https://{}//login?next={}",
|
||||||
|
state.domain_suffix,
|
||||||
|
urlencoding_simple(&next_url)
|
||||||
|
);
|
||||||
|
return Redirect::to(&login_url).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve app_id from the forwarded host header.
|
||||||
|
let host = headers
|
||||||
|
.get("x-forwarded-host")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
let app_id = host
|
||||||
|
.strip_suffix(&format!(".{}", state.domain_suffix))
|
||||||
|
.unwrap_or(host);
|
||||||
|
|
||||||
|
if app_id.is_empty() {
|
||||||
|
return StatusCode::FORBIDDEN.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin users have access to all apps.
|
||||||
|
let is_admin: i64 = sqlx::query_scalar("SELECT is_admin FROM users WHERE id = ?")
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
if is_admin != 0 {
|
||||||
|
return StatusCode::OK.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular users: check user_apps grant.
|
||||||
|
let granted: Option<i64> =
|
||||||
|
sqlx::query_scalar("SELECT 1 FROM user_apps WHERE user_id = ? AND app_id = ?")
|
||||||
|
.bind(&uid)
|
||||||
|
.bind(app_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None);
|
||||||
|
|
||||||
|
if granted.is_some() {
|
||||||
|
StatusCode::OK.into_response()
|
||||||
|
} else {
|
||||||
|
// Logged in but not granted — redirect to a "not authorised" page.
|
||||||
|
let page = format!(
|
||||||
|
"https://{}/denied?app={}",
|
||||||
|
state.domain_suffix, app_id
|
||||||
|
);
|
||||||
|
Redirect::to(&page).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal percent-encoding for use in a query-string value.
|
||||||
|
fn urlencoding_simple(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for b in s.bytes() {
|
||||||
|
match b {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
|
||||||
|
| b'-' | b'_' | b'.' | b'~' | b'/' | b':' => {
|
||||||
|
out.push(b as char);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
out.push('%');
|
||||||
|
out.push(char::from_digit((b >> 4) as u32, 16).unwrap_or('0'));
|
||||||
|
out.push(char::from_digit((b & 0xf) as u32, 16).unwrap_or('0'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
// ── Login / Logout ──────────────────────────────────────────────────────────
|
// ── Login / Logout ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct NextParam {
|
pub struct NextParam {
|
||||||
next: Option<String>,
|
pub next: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
@ -80,9 +225,18 @@ pub struct LoginForm {
|
||||||
next: Option<String>,
|
next: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login_page(Query(params): Query<NextParam>) -> impl IntoResponse {
|
pub async fn login_page(
|
||||||
|
State(state): State<crate::AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(params): Query<NextParam>,
|
||||||
|
) -> Response {
|
||||||
|
// Already logged in → redirect.
|
||||||
|
if current_user_id(&state, &headers).await.is_some() {
|
||||||
|
let next = params.next.as_deref().map(safe_path).unwrap_or_else(|| "/".into());
|
||||||
|
return Redirect::to(&next).into_response();
|
||||||
|
}
|
||||||
let next = params.next.map(|s| safe_path(&s)).unwrap_or_else(|| "/".into());
|
let next = params.next.map(|s| safe_path(&s)).unwrap_or_else(|| "/".into());
|
||||||
Html(login_html(&next, false))
|
Html(login_html(&next, None)).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle_login(
|
pub async fn handle_login(
|
||||||
|
|
@ -91,17 +245,45 @@ pub async fn handle_login(
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let next = form.next.as_deref().map(safe_path).unwrap_or_else(|| "/".into());
|
let next = form.next.as_deref().map(safe_path).unwrap_or_else(|| "/".into());
|
||||||
|
|
||||||
let valid = state.admin_user.as_deref() == Some(form.username.as_str())
|
// --- env-var bootstrap path (used before any DB user exists) ---
|
||||||
&& state.admin_pass.as_deref() == Some(form.password.as_str());
|
let using_bootstrap = matches!(
|
||||||
|
(&state.admin_user, &state.admin_pass),
|
||||||
|
(Some(_), Some(_))
|
||||||
|
) && !state.auth_enabled;
|
||||||
|
|
||||||
if !valid {
|
if using_bootstrap {
|
||||||
return Html(login_html(&next, true)).into_response();
|
let ok = state.admin_user.as_deref() == Some(form.username.as_str())
|
||||||
|
&& state.admin_pass.as_deref() == Some(form.password.as_str());
|
||||||
|
if !ok {
|
||||||
|
return Html(login_html(&next, Some("Invalid username or password."))).into_response();
|
||||||
|
}
|
||||||
|
return set_session_for_bootstrap(&state, &next).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DB path ---
|
||||||
|
let user: Option<crate::models::User> =
|
||||||
|
sqlx::query_as("SELECT * FROM users WHERE username = ?")
|
||||||
|
.bind(&form.username)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None);
|
||||||
|
|
||||||
|
let user = match user {
|
||||||
|
Some(u) => u,
|
||||||
|
None => {
|
||||||
|
return Html(login_html(&next, Some("Invalid username or password."))).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ok = bcrypt::verify(&form.password, &user.password_hash).unwrap_or(false);
|
||||||
|
if !ok {
|
||||||
|
return Html(login_html(&next, Some("Invalid username or password."))).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let token = uuid::Uuid::new_v4().to_string();
|
let token = uuid::Uuid::new_v4().to_string();
|
||||||
state.sessions.lock().await.insert(token.clone());
|
state.sessions.lock().await.insert(token.clone(), user.id);
|
||||||
|
|
||||||
let cookie = format!("session={}; HttpOnly; SameSite=Strict; Path=/", token);
|
let cookie = session_cookie_header(&token, &state.domain_suffix);
|
||||||
let mut response = Redirect::to(&next).into_response();
|
let mut response = Redirect::to(&next).into_response();
|
||||||
response
|
response
|
||||||
.headers_mut()
|
.headers_mut()
|
||||||
|
|
@ -109,30 +291,61 @@ pub async fn handle_login(
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn set_session_for_bootstrap(state: &crate::AppState, next: &str) -> Response {
|
||||||
|
let token = uuid::Uuid::new_v4().to_string();
|
||||||
|
// Bootstrap sessions store "bootstrap" as the user_id sentinel.
|
||||||
|
state.sessions.lock().await.insert(token.clone(), "bootstrap".to_string());
|
||||||
|
let cookie = session_cookie_header(&token, &state.domain_suffix);
|
||||||
|
let mut response = Redirect::to(next).into_response();
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(header::SET_COOKIE, cookie.parse().unwrap());
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn handle_logout(
|
pub async fn handle_logout(
|
||||||
State(state): State<crate::AppState>,
|
State(state): State<crate::AppState>,
|
||||||
headers: axum::http::HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
if let Some(token) = session_cookie(&headers) {
|
if let Some(token) = get_session_cookie(&headers) {
|
||||||
state.sessions.lock().await.remove(&token);
|
state.sessions.lock().await.remove(&token);
|
||||||
}
|
}
|
||||||
let mut response = Redirect::to("/login").into_response();
|
let mut response = Redirect::to("/login").into_response();
|
||||||
response.headers_mut().insert(
|
response.headers_mut().insert(
|
||||||
header::SET_COOKIE,
|
header::SET_COOKIE,
|
||||||
"session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"
|
clear_session_cookie(&state.domain_suffix).parse().unwrap(),
|
||||||
.parse()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
);
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Access denied" page — shown when logged in but not granted the app.
|
||||||
|
pub async fn denied_page(
|
||||||
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let app = params.get("app").map(|s| s.as_str()).unwrap_or("this app");
|
||||||
|
Html(format!(
|
||||||
|
r#"<!doctype html><html><head><meta charset=utf-8><title>Access denied</title><style>
|
||||||
|
*{{box-sizing:border-box;margin:0;padding:0}}
|
||||||
|
body{{font-family:monospace;background:#0f172a;color:#e2e8f0;
|
||||||
|
display:flex;align-items:center;justify-content:center;min-height:100vh}}
|
||||||
|
.card{{background:#1e293b;border-radius:10px;padding:32px;max-width:400px;text-align:center}}
|
||||||
|
h1{{color:#f87171;margin-bottom:16px}}p{{color:#94a3b8;margin-bottom:20px}}
|
||||||
|
a{{color:#818cf8}}</style></head><body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Access denied</h1>
|
||||||
|
<p>You do not have access to <strong>{app}</strong>.</p>
|
||||||
|
<p>Contact your administrator to request access.</p>
|
||||||
|
<a href="/login">← Sign in with a different account</a>
|
||||||
|
</div></body></html>"#
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Login page HTML ─────────────────────────────────────────────────────────
|
// ── Login page HTML ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn login_html(next: &str, error: bool) -> String {
|
fn login_html(next: &str, error: Option<&str>) -> String {
|
||||||
let error_html = if error {
|
let error_html = match error {
|
||||||
r#"<p class="error">Invalid username or password.</p>"#
|
Some(msg) => format!(r#"<p class="error">{}</p>"#, msg),
|
||||||
} else {
|
None => String::new(),
|
||||||
""
|
|
||||||
};
|
};
|
||||||
format!(
|
format!(
|
||||||
r#"<!doctype html><html><head><meta charset=utf-8>
|
r#"<!doctype html><html><head><meta charset=utf-8>
|
||||||
|
|
|
||||||
|
|
@ -55,5 +55,27 @@ pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> {
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"CREATE TABLE IF NOT EXISTS user_apps (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, app_id)
|
||||||
|
)"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
middleware,
|
middleware,
|
||||||
routing::{delete, get, post},
|
routing::{delete, get, post, put},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -22,11 +22,15 @@ pub struct AppState {
|
||||||
/// Queue of deploy IDs waiting to be processed.
|
/// Queue of deploy IDs waiting to be processed.
|
||||||
pub build_queue: Arc<Mutex<VecDeque<String>>>,
|
pub build_queue: Arc<Mutex<VecDeque<String>>>,
|
||||||
pub data_dir: String,
|
pub data_dir: String,
|
||||||
/// In-memory session tokens (cleared on restart).
|
/// token → user_id (in-memory; cleared on restart).
|
||||||
pub sessions: auth::Sessions,
|
pub sessions: auth::Sessions,
|
||||||
/// None means auth is disabled (neither env var set).
|
/// True when at least one user exists in the database.
|
||||||
|
pub auth_enabled: bool,
|
||||||
|
/// Bootstrap credentials (used when users table is empty).
|
||||||
pub admin_user: Option<String>,
|
pub admin_user: Option<String>,
|
||||||
pub admin_pass: Option<String>,
|
pub admin_pass: Option<String>,
|
||||||
|
/// e.g. "yourdomain.com" or "localhost" — used for cookie Domain + redirect URLs.
|
||||||
|
pub domain_suffix: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
@ -51,13 +55,45 @@ async fn main() -> anyhow::Result<()> {
|
||||||
|
|
||||||
let admin_user = std::env::var("HIY_ADMIN_USER").ok().filter(|s| !s.is_empty());
|
let admin_user = std::env::var("HIY_ADMIN_USER").ok().filter(|s| !s.is_empty());
|
||||||
let admin_pass = std::env::var("HIY_ADMIN_PASS").ok().filter(|s| !s.is_empty());
|
let admin_pass = std::env::var("HIY_ADMIN_PASS").ok().filter(|s| !s.is_empty());
|
||||||
|
let domain_suffix =
|
||||||
|
std::env::var("DOMAIN_SUFFIX").unwrap_or_else(|_| "localhost".into());
|
||||||
|
|
||||||
if admin_user.is_none() || admin_pass.is_none() {
|
// Count DB users.
|
||||||
|
let user_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Bootstrap: create initial admin from env vars if no users exist yet.
|
||||||
|
if user_count == 0 {
|
||||||
|
if let (Some(u), Some(p)) = (&admin_user, &admin_pass) {
|
||||||
|
let hash = bcrypt::hash(p, bcrypt::DEFAULT_COST)?;
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (id, username, password_hash, is_admin, created_at) \
|
||||||
|
VALUES (?,?,?,1,?)",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(u.as_str())
|
||||||
|
.bind(&hash)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&db)
|
||||||
|
.await?;
|
||||||
|
tracing::info!("Bootstrap: created admin user '{}'", u);
|
||||||
|
} else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"HIY_ADMIN_USER / HIY_ADMIN_PASS not set — dashboard is unprotected! \
|
"No users in database and HIY_ADMIN_USER/HIY_ADMIN_PASS not set — \
|
||||||
Set both in .env to enable authentication."
|
dashboard is unprotected! Set both in .env to create the first admin."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let auth_enabled: bool = sqlx::query_scalar("SELECT COUNT(*) FROM users")
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0i64)
|
||||||
|
> 0;
|
||||||
|
|
||||||
let build_queue = Arc::new(Mutex::new(VecDeque::<String>::new()));
|
let build_queue = Arc::new(Mutex::new(VecDeque::<String>::new()));
|
||||||
|
|
||||||
|
|
@ -66,8 +102,10 @@ async fn main() -> anyhow::Result<()> {
|
||||||
build_queue,
|
build_queue,
|
||||||
data_dir,
|
data_dir,
|
||||||
sessions: auth::new_sessions(),
|
sessions: auth::new_sessions(),
|
||||||
|
auth_enabled,
|
||||||
admin_user,
|
admin_user,
|
||||||
admin_pass,
|
admin_pass,
|
||||||
|
domain_suffix,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Single background worker — sequential builds to avoid saturating the Pi.
|
// Single background worker — sequential builds to avoid saturating the Pi.
|
||||||
|
|
@ -76,29 +114,40 @@ async fn main() -> anyhow::Result<()> {
|
||||||
builder::build_worker(worker_state).await;
|
builder::build_worker(worker_state).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Protected routes (require login) ──────────────────────────────────────
|
// ── Protected routes (admin login required) ───────────────────────────────
|
||||||
let protected = Router::new()
|
let protected = Router::new()
|
||||||
.route("/", get(routes::ui::index))
|
.route("/", get(routes::ui::index))
|
||||||
.route("/apps/:id", get(routes::ui::app_detail))
|
.route("/apps/:id", get(routes::ui::app_detail))
|
||||||
|
.route("/admin/users", get(routes::ui::users_page))
|
||||||
.route("/api/status", get(routes::ui::status_json))
|
.route("/api/status", get(routes::ui::status_json))
|
||||||
|
// Apps API
|
||||||
.route("/api/apps", get(routes::apps::list).post(routes::apps::create))
|
.route("/api/apps", get(routes::apps::list).post(routes::apps::create))
|
||||||
.route("/api/apps/:id", get(routes::apps::get_one)
|
.route("/api/apps/:id", get(routes::apps::get_one)
|
||||||
.put(routes::apps::update)
|
.put(routes::apps::update)
|
||||||
.delete(routes::apps::delete))
|
.delete(routes::apps::delete))
|
||||||
.route("/api/apps/:id/stop", post(routes::apps::stop))
|
.route("/api/apps/:id/stop", post(routes::apps::stop))
|
||||||
.route("/api/apps/:id/restart", post(routes::apps::restart))
|
.route("/api/apps/:id/restart", post(routes::apps::restart))
|
||||||
|
// Deploys API
|
||||||
.route("/api/apps/:id/deploy", post(routes::deploys::trigger))
|
.route("/api/apps/:id/deploy", post(routes::deploys::trigger))
|
||||||
.route("/api/apps/:id/deploys", get(routes::deploys::list))
|
.route("/api/apps/:id/deploys", get(routes::deploys::list))
|
||||||
.route("/api/deploys/:id", get(routes::deploys::get_one))
|
.route("/api/deploys/:id", get(routes::deploys::get_one))
|
||||||
.route("/api/deploys/:id/logs", get(routes::deploys::logs_sse))
|
.route("/api/deploys/:id/logs", get(routes::deploys::logs_sse))
|
||||||
|
// Env vars API
|
||||||
.route("/api/apps/:id/env", get(routes::envvars::list).post(routes::envvars::set))
|
.route("/api/apps/:id/env", get(routes::envvars::list).post(routes::envvars::set))
|
||||||
.route("/api/apps/:id/env/:key", delete(routes::envvars::remove))
|
.route("/api/apps/:id/env/:key", delete(routes::envvars::remove))
|
||||||
|
// Users API (admin only — already behind admin middleware)
|
||||||
|
.route("/api/users", get(routes::users::list).post(routes::users::create))
|
||||||
|
.route("/api/users/:id", put(routes::users::update).delete(routes::users::delete))
|
||||||
|
.route("/api/users/:id/apps/:app_id", post(routes::users::grant_app).delete(routes::users::revoke_app))
|
||||||
.route_layer(middleware::from_fn_with_state(state.clone(), auth::auth_middleware));
|
.route_layer(middleware::from_fn_with_state(state.clone(), auth::auth_middleware));
|
||||||
|
|
||||||
// ── Public routes (no auth) ───────────────────────────────────────────────
|
// ── Public routes ─────────────────────────────────────────────────────────
|
||||||
let public = Router::new()
|
let public = Router::new()
|
||||||
.route("/login", get(auth::login_page).post(auth::handle_login))
|
.route("/login", get(auth::login_page).post(auth::handle_login))
|
||||||
.route("/logout", get(auth::handle_logout))
|
.route("/logout", get(auth::handle_logout))
|
||||||
|
.route("/denied", get(auth::denied_page))
|
||||||
|
// Called by Caddy forward_auth for every deployed-app request.
|
||||||
|
.route("/auth/verify", get(auth::verify))
|
||||||
// GitHub webhooks use HMAC-SHA256 — no session needed.
|
// GitHub webhooks use HMAC-SHA256 — no session needed.
|
||||||
.route("/webhook/:app_id", post(routes::webhooks::github));
|
.route("/webhook/:app_id", post(routes::webhooks::github));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,3 +52,25 @@ pub struct SetEnvVar {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
pub value: String,
|
pub value: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
pub is_admin: i64,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateUser {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub is_admin: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateUser {
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub is_admin: Option<bool>,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,5 @@ pub mod apps;
|
||||||
pub mod deploys;
|
pub mod deploys;
|
||||||
pub mod envvars;
|
pub mod envvars;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
pub mod users;
|
||||||
pub mod webhooks;
|
pub mod webhooks;
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,7 @@ pub async fn index(State(s): State<AppState>) -> Result<Html<String>, StatusCode
|
||||||
let ram_pct = if stats.ram_total_mb > 0 { stats.ram_used_mb * 100 / stats.ram_total_mb } else { 0 };
|
let ram_pct = if stats.ram_total_mb > 0 { stats.ram_used_mb * 100 / stats.ram_total_mb } else { 0 };
|
||||||
|
|
||||||
let body = format!(
|
let body = format!(
|
||||||
r#"<nav><h1>☕ HostItYourself</h1><span style="display:flex;gap:16px;align-items:center"><span class="muted">{n} app(s)</span><a href="/logout" class="muted" style="font-size:0.82rem">logout</a></span></nav>
|
r#"<nav><h1>☕ HostItYourself</h1><span style="display:flex;gap:16px;align-items:center"><span class="muted">{n} app(s)</span><a href="/admin/users" class="muted" style="font-size:0.82rem">users</a><a href="/logout" class="muted" style="font-size:0.82rem">logout</a></span></nav>
|
||||||
|
|
||||||
<div class="card" style="padding:16px 24px">
|
<div class="card" style="padding:16px 24px">
|
||||||
<h2 style="margin-bottom:14px">System</h2>
|
<h2 style="margin-bottom:14px">System</h2>
|
||||||
|
|
@ -413,6 +413,7 @@ pub async fn app_detail(
|
||||||
<button class="primary" onclick="deploy()">Deploy Now</button>
|
<button class="primary" onclick="deploy()">Deploy Now</button>
|
||||||
<button onclick="stopApp()">Stop</button>
|
<button onclick="stopApp()">Stop</button>
|
||||||
<button onclick="restartApp()">Restart</button>
|
<button onclick="restartApp()">Restart</button>
|
||||||
|
<a href="/admin/users" class="muted" style="font-size:0.82rem;padding:0 4px">users</a>
|
||||||
<a href="/logout" class="muted" style="font-size:0.82rem;padding:0 4px">logout</a>
|
<a href="/logout" class="muted" style="font-size:0.82rem;padding:0 4px">logout</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -602,3 +603,157 @@ pub async fn status_json(
|
||||||
|
|
||||||
Ok(Json(serde_json::Value::Object(map)))
|
Ok(Json(serde_json::Value::Object(map)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Users management page ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn users_page(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
// Fetch all apps for the grant dropdowns.
|
||||||
|
let apps: Vec<(String, String)> =
|
||||||
|
sqlx::query_as("SELECT id, name FROM apps ORDER BY name")
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let app_opts: String = apps
|
||||||
|
.iter()
|
||||||
|
.map(|(id, name)| format!(r#"<option value="{id}">{name}</option>"#))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
let body = format!(
|
||||||
|
r#"<nav>
|
||||||
|
<h1><a href="/" style="color:inherit">HIY</a> / Users</h1>
|
||||||
|
<span style="display:flex;gap:16px;align-items:center">
|
||||||
|
<a href="/" class="muted" style="font-size:0.82rem">dashboard</a>
|
||||||
|
<a href="/logout" class="muted" style="font-size:0.82rem">logout</a>
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom:16px">Add user</h2>
|
||||||
|
<div class="row" id="add-user-form">
|
||||||
|
<input type="text" id="new-username" placeholder="username" style="max-width:200px">
|
||||||
|
<input type="password" id="new-password" placeholder="password" style="max-width:200px">
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;margin:0;color:#e2e8f0">
|
||||||
|
<input type="checkbox" id="new-is-admin"> Admin
|
||||||
|
</label>
|
||||||
|
<button class="primary" onclick="addUser()">Add user</button>
|
||||||
|
</div>
|
||||||
|
<p id="add-user-error" style="color:#f87171;margin-top:8px;display:none"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom:16px">Users</h2>
|
||||||
|
<div id="users-list"><p class="muted">Loading…</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const APP_OPTS = `{app_opts}`;
|
||||||
|
|
||||||
|
async function load() {{
|
||||||
|
const res = await fetch('/api/users');
|
||||||
|
const users = await res.json();
|
||||||
|
const el = document.getElementById('users-list');
|
||||||
|
if (!users.length) {{ el.innerHTML = '<p class="muted">No users yet.</p>'; return; }}
|
||||||
|
el.innerHTML = users.map(u => `
|
||||||
|
<div class="card" style="margin-bottom:12px;padding:16px">
|
||||||
|
<div class="row" style="justify-content:space-between;margin-bottom:10px">
|
||||||
|
<div>
|
||||||
|
<strong>${{u.username}}</strong>
|
||||||
|
${{u.is_admin ? '<span class="badge badge-success" style="margin-left:6px">admin</span>' : ''}}
|
||||||
|
<span class="muted" style="margin-left:10px;font-size:0.8rem">${{u.created_at.slice(0,10)}}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
|
<button onclick="toggleAdmin('${{u.id}}','${{u.username}}',${{u.is_admin}})">
|
||||||
|
${{u.is_admin ? 'Remove admin' : 'Make admin'}}
|
||||||
|
</button>
|
||||||
|
<button onclick="changePassword('${{u.id}}','${{u.username}}')">Change password</button>
|
||||||
|
<button class="danger" onclick="deleteUser('${{u.id}}','${{u.username}}')">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="muted" style="font-size:0.78rem">App access:</span>
|
||||||
|
${{u.apps.length ? u.apps.map(a => `
|
||||||
|
<span class="badge badge-unknown" style="margin:0 4px 4px 4px">
|
||||||
|
${{a}}
|
||||||
|
<a href="javascript:void(0)" style="color:#f87171;margin-left:4px" onclick="revokeApp('${{u.id}}','${{a}}')">✕</a>
|
||||||
|
</span>`).join('') : '<span class="muted" style="font-size:0.82rem"> none</span>'}}
|
||||||
|
${{APP_OPTS ? `
|
||||||
|
<span style="margin-left:8px">
|
||||||
|
<select id="grant-${{u.id}}" style="background:#0f172a;color:#e2e8f0;border:1px solid #334155;
|
||||||
|
padding:3px 8px;border-radius:6px;font-family:monospace;font-size:0.82rem">
|
||||||
|
<option value="">+ grant app…</option>
|
||||||
|
${{APP_OPTS}}
|
||||||
|
</select>
|
||||||
|
<button onclick="grantApp('${{u.id}}')" style="margin-left:4px">Grant</button>
|
||||||
|
</span>` : ''}}
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function addUser() {{
|
||||||
|
const username = document.getElementById('new-username').value.trim();
|
||||||
|
const password = document.getElementById('new-password').value;
|
||||||
|
const is_admin = document.getElementById('new-is-admin').checked;
|
||||||
|
const err = document.getElementById('add-user-error');
|
||||||
|
if (!username || !password) {{ err.textContent = 'Username and password required.'; err.style.display=''; return; }}
|
||||||
|
const res = await fetch('/api/users', {{
|
||||||
|
method:'POST', headers:{{'Content-Type':'application/json'}},
|
||||||
|
body: JSON.stringify({{username, password, is_admin}})
|
||||||
|
}});
|
||||||
|
if (res.ok) {{
|
||||||
|
document.getElementById('new-username').value='';
|
||||||
|
document.getElementById('new-password').value='';
|
||||||
|
document.getElementById('new-is-admin').checked=false;
|
||||||
|
err.style.display='none';
|
||||||
|
load();
|
||||||
|
}} else {{
|
||||||
|
const d = await res.json();
|
||||||
|
err.textContent = d.error || 'Error creating user.';
|
||||||
|
err.style.display = '';
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function deleteUser(id, name) {{
|
||||||
|
if (!confirm('Delete user ' + name + '?')) return;
|
||||||
|
await fetch('/api/users/' + id, {{method:'DELETE'}});
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function toggleAdmin(id, name, currentlyAdmin) {{
|
||||||
|
await fetch('/api/users/' + id, {{
|
||||||
|
method:'PUT', headers:{{'Content-Type':'application/json'}},
|
||||||
|
body: JSON.stringify({{is_admin: !currentlyAdmin}})
|
||||||
|
}});
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function changePassword(id, name) {{
|
||||||
|
const pw = prompt('New password for ' + name + ':');
|
||||||
|
if (!pw) return;
|
||||||
|
await fetch('/api/users/' + id, {{
|
||||||
|
method:'PUT', headers:{{'Content-Type':'application/json'}},
|
||||||
|
body: JSON.stringify({{password: pw}})
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function grantApp(userId) {{
|
||||||
|
const sel = document.getElementById('grant-' + userId);
|
||||||
|
const appId = sel.value;
|
||||||
|
if (!appId) return;
|
||||||
|
await fetch('/api/users/' + userId + '/apps/' + appId, {{method:'POST'}});
|
||||||
|
sel.value = '';
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function revokeApp(userId, appId) {{
|
||||||
|
await fetch('/api/users/' + userId + '/apps/' + appId, {{method:'DELETE'}});
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
|
||||||
|
load();
|
||||||
|
</script>"#
|
||||||
|
);
|
||||||
|
|
||||||
|
Html(page("Users", &body))
|
||||||
|
}
|
||||||
|
|
|
||||||
181
server/src/routes/users.rs
Normal file
181
server/src/routes/users.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{models::CreateUser, models::UpdateUser, AppState};
|
||||||
|
|
||||||
|
// ── List users ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct UserView {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub is_admin: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
pub apps: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
let rows: Vec<(String, String, i64, String)> = sqlx::query_as(
|
||||||
|
"SELECT id, username, is_admin, created_at FROM users ORDER BY created_at",
|
||||||
|
)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut users = Vec::new();
|
||||||
|
for (id, username, is_admin, created_at) in rows {
|
||||||
|
let apps: Vec<String> =
|
||||||
|
sqlx::query_scalar("SELECT app_id FROM user_apps WHERE user_id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
users.push(UserView {
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
is_admin: is_admin != 0,
|
||||||
|
created_at,
|
||||||
|
apps,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Json(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create user ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<CreateUser>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if body.username.trim().is_empty() || body.password.is_empty() {
|
||||||
|
return (StatusCode::BAD_REQUEST, Json(json!({"error": "username and password required"}))).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = match bcrypt::hash(&body.password, bcrypt::DEFAULT_COST) {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(_) => {
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": "failed to hash password"})),
|
||||||
|
).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let is_admin = body.is_admin.unwrap_or(false) as i64;
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
let res = sqlx::query(
|
||||||
|
"INSERT INTO users (id, username, password_hash, is_admin, created_at) VALUES (?,?,?,?,?)",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(body.username.trim())
|
||||||
|
.bind(&hash)
|
||||||
|
.bind(is_admin)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(_) => (
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(json!({"id": id, "username": body.username.trim()})),
|
||||||
|
).into_response(),
|
||||||
|
Err(e) if e.to_string().contains("UNIQUE") => (
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
Json(json!({"error": "username already exists"})),
|
||||||
|
).into_response(),
|
||||||
|
Err(_) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": "db error"})),
|
||||||
|
).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update user (password / is_admin) ────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn update(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(body): Json<UpdateUser>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if let Some(new_pass) = &body.password {
|
||||||
|
if new_pass.is_empty() {
|
||||||
|
return (StatusCode::BAD_REQUEST, Json(json!({"error": "password cannot be empty"}))).into_response();
|
||||||
|
}
|
||||||
|
let hash = match bcrypt::hash(new_pass, bcrypt::DEFAULT_COST) {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(_) => {
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({"error": "failed to hash password"})),
|
||||||
|
).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||||
|
.bind(&hash)
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(admin) = body.is_admin {
|
||||||
|
let _ = sqlx::query("UPDATE users SET is_admin = ? WHERE id = ?")
|
||||||
|
.bind(admin as i64)
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete user ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn delete(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
sqlx::query("DELETE FROM users WHERE id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Grant / revoke app access ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn grant_app(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((user_id, app_id)): Path<(String, String)>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO user_apps (user_id, app_id) VALUES (?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&user_id)
|
||||||
|
.bind(&app_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_app(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((user_id, app_id)): Path<(String, String)>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
sqlx::query("DELETE FROM user_apps WHERE user_id = ? AND app_id = ?")
|
||||||
|
.bind(&user_id)
|
||||||
|
.bind(&app_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue