Better oauth, appview groundwork

This commit is contained in:
lewis
2025-12-17 23:29:48 +02:00
parent dea6c09aa0
commit 2cf87e2cfb
45 changed files with 4586 additions and 906 deletions
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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()
}