Files
web/src/routes/auth.rs
2026-03-20 20:04:35 -04:00

251 lines
7.6 KiB
Rust

use actix_session::Session;
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use shanty_db::entities::user::UserRole;
use shanty_db::queries;
use crate::auth;
use crate::error::ApiError;
use crate::state::AppState;
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(web::resource("/auth/setup-required").route(web::get().to(setup_required)))
.service(web::resource("/auth/setup").route(web::post().to(setup)))
.service(web::resource("/auth/login").route(web::post().to(login)))
.service(web::resource("/auth/logout").route(web::post().to(logout)))
.service(web::resource("/auth/me").route(web::get().to(me)))
.service(
web::resource("/auth/users")
.route(web::get().to(list_users))
.route(web::post().to(create_user)),
)
.service(web::resource("/auth/users/{id}").route(web::delete().to(delete_user)))
.service(
web::resource("/auth/subsonic-password").route(web::put().to(set_subsonic_password)),
)
.service(
web::resource("/auth/subsonic-password-status")
.route(web::get().to(subsonic_password_status)),
);
}
#[derive(Deserialize)]
struct SetupRequest {
username: String,
password: String,
}
#[derive(Deserialize)]
struct LoginRequest {
username: String,
password: String,
}
#[derive(Deserialize)]
struct CreateUserRequest {
username: String,
password: String,
}
#[derive(Deserialize)]
struct SubsonicPasswordRequest {
password: String,
}
/// Check if initial setup is required (no users in database).
async fn setup_required(state: web::Data<AppState>) -> Result<HttpResponse, ApiError> {
let count = queries::users::count(state.db.conn()).await?;
Ok(HttpResponse::Ok().json(serde_json::json!({ "required": count == 0 })))
}
/// Create the first admin user. Only works when no users exist.
async fn setup(
state: web::Data<AppState>,
session: Session,
body: web::Json<SetupRequest>,
) -> Result<HttpResponse, ApiError> {
let count = queries::users::count(state.db.conn()).await?;
if count > 0 {
return Err(ApiError::BadRequest("setup already completed".into()));
}
if body.username.trim().is_empty() || body.password.len() < 4 {
return Err(ApiError::BadRequest(
"username required, password must be at least 4 characters".into(),
));
}
let password_hash = auth::hash_password(&body.password)?;
let user = queries::users::create(
state.db.conn(),
body.username.trim(),
&password_hash,
UserRole::Admin,
)
.await?;
// Adopt any orphaned wanted items from before auth was added
let adopted = queries::users::adopt_orphaned_wanted_items(state.db.conn(), user.id).await?;
if adopted > 0 {
tracing::info!(
count = adopted,
user_id = user.id,
"adopted orphaned wanted items"
);
}
auth::set_session(&session, user.id, &user.username, "admin");
tracing::info!(username = %user.username, "admin user created via setup");
Ok(HttpResponse::Ok().json(serde_json::json!({
"id": user.id,
"username": user.username,
"role": "admin",
})))
}
/// Log in with username and password.
async fn login(
state: web::Data<AppState>,
session: Session,
body: web::Json<LoginRequest>,
) -> Result<HttpResponse, ApiError> {
let user = queries::users::find_by_username(state.db.conn(), &body.username)
.await?
.ok_or_else(|| ApiError::Unauthorized("invalid username or password".into()))?;
if !auth::verify_password(&body.password, &user.password_hash) {
return Err(ApiError::Unauthorized(
"invalid username or password".into(),
));
}
let role = match user.role {
UserRole::Admin => "admin",
UserRole::User => "user",
};
auth::set_session(&session, user.id, &user.username, role);
tracing::info!(username = %user.username, "user logged in");
Ok(HttpResponse::Ok().json(serde_json::json!({
"id": user.id,
"username": user.username,
"role": role,
})))
}
/// Log out (clear session).
async fn logout(session: Session) -> Result<HttpResponse, ApiError> {
session.purge();
Ok(HttpResponse::Ok().json(serde_json::json!({ "status": "logged out" })))
}
/// Get current user info from session.
async fn me(session: Session) -> Result<HttpResponse, ApiError> {
let (user_id, username, role) = auth::require_auth(&session)?;
Ok(HttpResponse::Ok().json(serde_json::json!({
"id": user_id,
"username": username,
"role": role,
})))
}
/// List all users (admin only).
async fn list_users(
state: web::Data<AppState>,
session: Session,
) -> Result<HttpResponse, ApiError> {
auth::require_admin(&session)?;
let users = queries::users::list(state.db.conn()).await?;
Ok(HttpResponse::Ok().json(users))
}
/// Create a new user (admin only).
async fn create_user(
state: web::Data<AppState>,
session: Session,
body: web::Json<CreateUserRequest>,
) -> Result<HttpResponse, ApiError> {
auth::require_admin(&session)?;
if body.username.trim().is_empty() || body.password.len() < 4 {
return Err(ApiError::BadRequest(
"username required, password must be at least 4 characters".into(),
));
}
if queries::users::find_by_username(state.db.conn(), body.username.trim())
.await?
.is_some()
{
return Err(ApiError::BadRequest("username already taken".into()));
}
let password_hash = auth::hash_password(&body.password)?;
let user = queries::users::create(
state.db.conn(),
body.username.trim(),
&password_hash,
UserRole::User,
)
.await?;
tracing::info!(username = %user.username, "user created by admin");
Ok(HttpResponse::Ok().json(serde_json::json!({
"id": user.id,
"username": user.username,
"role": "user",
})))
}
/// Delete a user (admin only).
async fn delete_user(
state: web::Data<AppState>,
session: Session,
path: web::Path<i32>,
) -> Result<HttpResponse, ApiError> {
let (admin_id, _, _) = auth::require_admin(&session)?;
let user_id = path.into_inner();
if user_id == admin_id {
return Err(ApiError::BadRequest("cannot delete yourself".into()));
}
queries::users::delete(state.db.conn(), user_id).await?;
tracing::info!(user_id = user_id, "user deleted by admin");
Ok(HttpResponse::NoContent().finish())
}
/// Set the Subsonic password for the current user.
async fn set_subsonic_password(
state: web::Data<AppState>,
session: Session,
body: web::Json<SubsonicPasswordRequest>,
) -> Result<HttpResponse, ApiError> {
let (user_id, _, _) = auth::require_auth(&session)?;
if body.password.len() < 4 {
return Err(ApiError::BadRequest(
"password must be at least 4 characters".into(),
));
}
queries::users::set_subsonic_password(state.db.conn(), user_id, &body.password).await?;
tracing::info!(user_id = user_id, "subsonic password set");
Ok(HttpResponse::Ok().json(serde_json::json!({ "status": "ok" })))
}
/// Check whether the current user has a Subsonic password set.
async fn subsonic_password_status(
state: web::Data<AppState>,
session: Session,
) -> Result<HttpResponse, ApiError> {
let (user_id, _, _) = auth::require_auth(&session)?;
let user = queries::users::get_by_id(state.db.conn(), user_id).await?;
Ok(HttpResponse::Ok().json(serde_json::json!({
"set": user.subsonic_password.is_some(),
})))
}