mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-07-31 14:36:09 +00:00
Better oauth, appview groundwork
This commit is contained in:
@@ -16,10 +16,10 @@ pub use app_password::{create_app_password, list_app_passwords, revoke_app_passw
|
||||
pub use email::{confirm_email, request_email_update, update_email};
|
||||
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
|
||||
pub use meta::{describe_server, health, robots_txt};
|
||||
pub use password::{request_password_reset, reset_password};
|
||||
pub use password::{change_password, request_password_reset, reset_password};
|
||||
pub use service_auth::get_service_auth;
|
||||
pub use session::{
|
||||
confirm_signup, create_session, delete_session, get_session, refresh_session,
|
||||
resend_verification,
|
||||
confirm_signup, create_session, delete_session, get_session, list_sessions, refresh_session,
|
||||
resend_verification, revoke_session,
|
||||
};
|
||||
pub use signing_key::reserve_signing_key;
|
||||
|
||||
+108
-1
@@ -1,3 +1,4 @@
|
||||
use crate::auth::BearerAuth;
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -5,8 +6,9 @@ use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use bcrypt::{DEFAULT_COST, hash};
|
||||
use bcrypt::{DEFAULT_COST, hash, verify};
|
||||
use chrono::{Duration, Utc};
|
||||
use uuid::Uuid;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
@@ -297,3 +299,108 @@ pub async fn reset_password(
|
||||
info!("Password reset completed for user {}", user_id);
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChangePasswordInput {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<ChangePasswordInput>,
|
||||
) -> Response {
|
||||
let current_password = &input.current_password;
|
||||
let new_password = &input.new_password;
|
||||
if current_password.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "currentPassword is required"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if new_password.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "newPassword is required"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if new_password.len() < 8 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Password must be at least 8 characters"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let user = sqlx::query_as::<_, (Uuid, String)>(
|
||||
"SELECT id, password_hash FROM users WHERE did = $1",
|
||||
)
|
||||
.bind(&auth.0.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let (user_id, password_hash) = match user {
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in change_password: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let valid = match verify(current_password, &password_hash) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("Password verification error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if !valid {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "InvalidPassword", "message": "Current password is incorrect"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let new_hash = match hash(new_password, DEFAULT_COST) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
error!("Failed to hash password: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if let Err(e) = sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
|
||||
.bind(&new_hash)
|
||||
.bind(user_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error updating password: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
info!(did = %auth.0.did, "Password changed successfully");
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
+130
-2
@@ -185,7 +185,7 @@ pub async fn get_session(
|
||||
) -> Response {
|
||||
match sqlx::query!(
|
||||
r#"SELECT
|
||||
handle, email, email_confirmed,
|
||||
handle, email, email_confirmed, is_admin,
|
||||
preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel",
|
||||
discord_verified, telegram_verified, signal_verified
|
||||
FROM users WHERE did = $1"#,
|
||||
@@ -210,6 +210,7 @@ pub async fn get_session(
|
||||
"emailConfirmed": row.email_confirmed,
|
||||
"preferredChannel": preferred_channel,
|
||||
"preferredChannelVerified": preferred_channel_verified,
|
||||
"isAdmin": row.is_admin,
|
||||
"active": true,
|
||||
"didDoc": {}
|
||||
})).into_response()
|
||||
@@ -406,7 +407,7 @@ pub async fn refresh_session(
|
||||
}
|
||||
match sqlx::query!(
|
||||
r#"SELECT
|
||||
handle, email, email_confirmed,
|
||||
handle, email, email_confirmed, is_admin,
|
||||
preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel",
|
||||
discord_verified, telegram_verified, signal_verified
|
||||
FROM users WHERE did = $1"#,
|
||||
@@ -433,6 +434,7 @@ pub async fn refresh_session(
|
||||
"emailConfirmed": u.email_confirmed,
|
||||
"preferredChannel": preferred_channel,
|
||||
"preferredChannelVerified": preferred_channel_verified,
|
||||
"isAdmin": u.is_admin,
|
||||
"active": true
|
||||
})).into_response()
|
||||
}
|
||||
@@ -702,3 +704,129 @@ pub async fn resend_verification(
|
||||
}
|
||||
Json(json!({"success": true})).into_response()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionInfo {
|
||||
pub id: String,
|
||||
pub created_at: String,
|
||||
pub expires_at: String,
|
||||
pub is_current: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListSessionsOutput {
|
||||
pub sessions: Vec<SessionInfo>,
|
||||
}
|
||||
|
||||
pub async fn list_sessions(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
auth: BearerAuth,
|
||||
) -> Response {
|
||||
let current_jti = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.and_then(|token| crate::auth::get_jti_from_token(token).ok());
|
||||
let result = sqlx::query_as::<_, (i32, String, chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>(
|
||||
r#"
|
||||
SELECT id, access_jti, created_at, refresh_expires_at
|
||||
FROM session_tokens
|
||||
WHERE did = $1 AND refresh_expires_at > NOW()
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(&auth.0.did)
|
||||
.fetch_all(&state.db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(rows) => {
|
||||
let sessions: Vec<SessionInfo> = rows
|
||||
.into_iter()
|
||||
.map(|(id, access_jti, created_at, expires_at)| SessionInfo {
|
||||
id: id.to_string(),
|
||||
created_at: created_at.to_rfc3339(),
|
||||
expires_at: expires_at.to_rfc3339(),
|
||||
is_current: current_jti.as_ref().map_or(false, |j| j == &access_jti),
|
||||
})
|
||||
.collect();
|
||||
(StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in list_sessions: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RevokeSessionInput {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
pub async fn revoke_session(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<RevokeSessionInput>,
|
||||
) -> Response {
|
||||
let session_id: i32 = match input.session_id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let session = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2",
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(&auth.0.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let access_jti = match session {
|
||||
Ok(Some((jti,))) => jti,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "SessionNotFound", "message": "Session not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in revoke_session: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE id = $1")
|
||||
.bind(session_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error deleting session: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let cache_key = format!("auth:session:{}:{}", auth.0.did, access_jti);
|
||||
if let Err(e) = state.cache.delete(&cache_key).await {
|
||||
warn!("Failed to invalidate session cache: {:?}", e);
|
||||
}
|
||||
info!(did = %auth.0.did, session_id = %session_id, "Session revoked");
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user