53e5ef4d0b
- Updated error messages and comments in main.rs, openapi.rs, portfolio.rs, predictive.rs, ai.rs, health.rs, middleware.rs, models.rs, token_limits.rs, and webhooks.rs to Spanish. - Enhanced user experience by providing localized content for Spanish-speaking users.
314 lines
11 KiB
Rust
314 lines
11 KiB
Rust
use axum::{
|
|
extract::{Query, State},
|
|
http::StatusCode,
|
|
response::{Redirect},
|
|
Form,
|
|
};
|
|
use jsonwebtoken::{decode, decode_header, jwk::JwkSet, DecodingKey, Validation};
|
|
use serde::{Deserialize};
|
|
use sqlx::{PgPool};
|
|
use uuid::Uuid;
|
|
use common::models::{LtiLaunchClaims, LtiRegistration, User};
|
|
use common::auth::Claims;
|
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct LtiLoginParams {
|
|
pub iss: String,
|
|
pub login_hint: String,
|
|
pub target_link_uri: String,
|
|
pub lti_message_hint: Option<String>,
|
|
pub client_id: Option<String>,
|
|
}
|
|
|
|
pub async fn lti_login_initiation(
|
|
State(pool): State<PgPool>,
|
|
Query(params): Query<LtiLoginParams>,
|
|
) -> Result<Redirect, (StatusCode, String)> {
|
|
// 1. Buscar registro
|
|
let registration = sqlx::query_as::<_, LtiRegistration>(
|
|
"SELECT * FROM lti_registrations WHERE issuer = $1 AND ($2::text IS NULL OR client_id = $2)"
|
|
)
|
|
.bind(¶ms.iss)
|
|
.bind(¶ms.client_id)
|
|
.fetch_optional(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
|
.ok_or((StatusCode::BAD_REQUEST, "Registro LTI no encontrado".to_string()))?;
|
|
|
|
// 2. Generar estado y nonce
|
|
let state = Uuid::new_v4().to_string();
|
|
let nonce = Uuid::new_v4().to_string();
|
|
|
|
// 3. Almacenar nonce
|
|
sqlx::query("INSERT INTO lti_nonces (nonce) VALUES ($1)")
|
|
.bind(&nonce)
|
|
.execute(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
// 4. Construir URL de redirección
|
|
let mut url = format!(
|
|
"{}?scope=openid&response_type=id_token&client_id={}&redirect_uri={}&login_hint={}&state={}&nonce={}&response_mode=form_post",
|
|
registration.auth_login_url,
|
|
registration.client_id,
|
|
urlencoding::encode(¶ms.target_link_uri),
|
|
urlencoding::encode(¶ms.login_hint),
|
|
state,
|
|
nonce
|
|
);
|
|
|
|
if let Some(hint) = params.lti_message_hint {
|
|
url.push_str(&format!("<i_message_hint={}", urlencoding::encode(&hint)));
|
|
}
|
|
|
|
Ok(Redirect::to(&url))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct LtiLaunchParams {
|
|
pub id_token: String,
|
|
}
|
|
|
|
pub async fn validate_lti_jwt(
|
|
id_token: &str,
|
|
jwks_url: &str,
|
|
client_id: &str,
|
|
) -> Result<LtiLaunchClaims, String> {
|
|
let header = decode_header(id_token).map_err(|e| e.to_string())?;
|
|
let kid = header.kid.ok_or("Falta kid en el encabezado JWT")?;
|
|
|
|
// Obtener JWKS
|
|
let jwks: JwkSet = reqwest::get(jwks_url)
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.json()
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let jwk = jwks.find(&kid).ok_or("JWK no encontrado para kid")?;
|
|
let decoding_key = DecodingKey::from_jwk(jwk).map_err(|e| e.to_string())?;
|
|
|
|
let mut validation = Validation::new(jsonwebtoken::Algorithm::RS256);
|
|
validation.set_audience(&[client_id]);
|
|
|
|
let token_data = decode::<LtiLaunchClaims>(id_token, &decoding_key, &validation)
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
Ok(token_data.claims)
|
|
}
|
|
|
|
pub async fn lti_launch(
|
|
State(pool): State<PgPool>,
|
|
Form(payload): Form<LtiLaunchParams>,
|
|
) -> Result<Redirect, (StatusCode, String)> {
|
|
// 1. Decodificar claims manualmente para encontrar el registro (ya que aún no tenemos la clave)
|
|
let parts: Vec<&str> = payload.id_token.split('.').collect();
|
|
if parts.len() != 3 {
|
|
return Err((StatusCode::BAD_REQUEST, "Formato JWT inválido".to_string()));
|
|
}
|
|
|
|
let decoded_claims = URL_SAFE_NO_PAD.decode(parts[1])
|
|
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Base64 inválido en el payload del JWT: {}", e)))?;
|
|
|
|
let claims: serde_json::Value = serde_json::from_slice(&decoded_claims)
|
|
.map_err(|e| (StatusCode::BAD_REQUEST, format!("JSON inválido en el payload del JWT: {}", e)))?;
|
|
|
|
let iss = claims["iss"].as_str().ok_or((StatusCode::BAD_REQUEST, "Falta el claim iss".to_string()))?;
|
|
let aud_val = &claims["aud"];
|
|
let aud = match aud_val {
|
|
serde_json::Value::String(s) => s.as_str(),
|
|
serde_json::Value::Array(arr) => arr[0].as_str().ok_or((StatusCode::BAD_REQUEST, "aud inválido en el array".to_string()))?,
|
|
_ => return Err((StatusCode::BAD_REQUEST, "Claim aud inválido".to_string())),
|
|
};
|
|
|
|
// 2. Buscar registro
|
|
let registration = sqlx::query_as::<_, LtiRegistration>(
|
|
"SELECT * FROM lti_registrations WHERE issuer = $1 AND client_id = $2"
|
|
)
|
|
.bind(iss)
|
|
.bind(aud)
|
|
.fetch_optional(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
|
.ok_or((StatusCode::NOT_FOUND, "Registro LTI no encontrado para emisor/audiencia".to_string()))?;
|
|
|
|
// 3. Validar JWT
|
|
let lti_claims = validate_lti_jwt(&payload.id_token, ®istration.jwks_url, ®istration.client_id)
|
|
.await
|
|
.map_err(|e| (StatusCode::UNAUTHORIZED, format!("Validación de JWT fallida: {}", e)))?;
|
|
|
|
// 4. Verificar nonce
|
|
let nonce_exists = sqlx::query("DELETE FROM lti_nonces WHERE nonce = $1")
|
|
.bind(<i_claims.nonce)
|
|
.execute(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
|
.rows_affected() > 0;
|
|
|
|
if !nonce_exists {
|
|
return Err((StatusCode::BAD_REQUEST, "Nonce inválido o expirado".to_string()));
|
|
}
|
|
|
|
// 5. Buscar o crear usuario
|
|
let email = lti_claims.email.clone().unwrap_or_else(|| format!("lti_{}@{}", lti_claims.subject, iss.replace("http://", "").replace("https://", "")));
|
|
let full_name = lti_claims.name.clone().unwrap_or_else(|| "LTI User".to_string());
|
|
|
|
let mut user = sqlx::query_as::<_, User>(
|
|
"SELECT * FROM users WHERE email = $1 AND organization_id = $2"
|
|
)
|
|
.bind(&email)
|
|
.bind(registration.organization_id)
|
|
.fetch_optional(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
if user.is_none() {
|
|
let new_user_id = Uuid::new_v4();
|
|
let role = if lti_claims.roles.iter().any(|r| r.contains("Instructor") || r.contains("Administrator")) {
|
|
"instructor"
|
|
} else {
|
|
"student"
|
|
};
|
|
|
|
sqlx::query(
|
|
"INSERT INTO users (id, organization_id, email, password_hash, full_name, role) VALUES ($1, $2, $3, $4, $5, $6)"
|
|
)
|
|
.bind(new_user_id)
|
|
.bind(registration.organization_id)
|
|
.bind(&email)
|
|
.bind("")
|
|
.bind(&full_name)
|
|
.bind(role)
|
|
.execute(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
user = Some(User {
|
|
id: new_user_id,
|
|
organization_id: registration.organization_id,
|
|
email: email.clone(),
|
|
password_hash: "".to_string(),
|
|
full_name: full_name.clone(),
|
|
role: role.to_string(),
|
|
xp: 0,
|
|
level: 1,
|
|
avatar_url: None,
|
|
bio: None,
|
|
language: None,
|
|
is_public_profile: Some(true),
|
|
linkedin_url: None,
|
|
github_url: None,
|
|
created_at: chrono::Utc::now(),
|
|
updated_at: chrono::Utc::now(),
|
|
});
|
|
}
|
|
|
|
let user = user.unwrap();
|
|
|
|
// 8. Redirigir según el tipo de mensaje
|
|
let experience_url = std::env::var("NEXT_PUBLIC_EXPERIENCE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
|
|
let studio_url = std::env::var("NEXT_PUBLIC_STUDIO_URL").unwrap_or_else(|_| "http://localhost:3001".to_string());
|
|
|
|
let token = common::auth::create_jwt(user.id, user.organization_id, &user.role)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al crear el token: {}", e)))?;
|
|
let redirect_target = lti_claims.resource_link.as_ref().map(|rl| rl.id.clone()).unwrap_or_default();
|
|
|
|
if lti_claims.message_type == "LtiDeepLinkingRequest" {
|
|
let settings = lti_claims.deep_linking_settings.ok_or((StatusCode::BAD_REQUEST, "Faltan deep_linking_settings".to_string()))?;
|
|
|
|
let dl_request_id = Uuid::new_v4();
|
|
sqlx::query(
|
|
"INSERT INTO lti_deep_linking_requests (id, registration_id, deployment_id, return_url, data) VALUES ($1, $2, $3, $4, $5)"
|
|
)
|
|
.bind(dl_request_id)
|
|
.bind(registration.id)
|
|
.bind(<i_claims.deployment_id)
|
|
.bind(&settings.deep_link_return_url)
|
|
.bind(&settings.data)
|
|
.execute(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
Ok(Redirect::to(&format!("{}/lti/deep-linking?token={}&dl_token={}", studio_url, token, dl_request_id)))
|
|
} else {
|
|
Ok(Redirect::to(&format!("{}/lti/launch?token={}&target={}", experience_url, token, urlencoding::encode(&redirect_target))))
|
|
}
|
|
}
|
|
|
|
use serde_json::json;
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct LtiDeepLinkingResponsePayload {
|
|
pub dl_token: String,
|
|
pub items: Vec<common::models::LtiDeepLinkingContentItem>,
|
|
}
|
|
|
|
pub async fn lti_deep_linking_response(
|
|
State(pool): State<PgPool>,
|
|
claims: Claims,
|
|
Json(payload): Json<LtiDeepLinkingResponsePayload>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
// 1. Recuperar y eliminar la solicitud de DL
|
|
let dl_id = Uuid::parse_str(&payload.dl_token).map_err(|_| (StatusCode::BAD_REQUEST, "Token de DL inválido".to_string()))?;
|
|
|
|
let dl_request = sqlx::query(
|
|
"DELETE FROM lti_deep_linking_requests WHERE id = $1 RETURNING registration_id, deployment_id, return_url, data"
|
|
)
|
|
.bind(dl_id)
|
|
.fetch_optional(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
|
.ok_or((StatusCode::UNAUTHORIZED, "Solicitud de DL inválida o expirada".to_string()))?;
|
|
|
|
// Mapeo manual ya que no podemos usar query!/query_as! fácilmente para RETURNING sin una estructura
|
|
let registration_id: Uuid = dl_request.get("registration_id");
|
|
let deployment_id: String = dl_request.get("deployment_id");
|
|
let _return_url: String = dl_request.get::<String, _>("return_url");
|
|
let dl_data: Option<String> = dl_request.get("data");
|
|
|
|
// 2. Buscar registro
|
|
let registration = sqlx::query_as::<_, LtiRegistration>(
|
|
"SELECT * FROM lti_registrations WHERE id = $1",
|
|
)
|
|
.bind(registration_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let now = chrono::Utc::now().timestamp();
|
|
let response_claims = common::models::LtiDeepLinkingResponseClaims {
|
|
issuer: registration.client_id,
|
|
subject: claims.sub.to_string(),
|
|
audience: registration.issuer,
|
|
expires_at: now + 3600,
|
|
issued_at: now,
|
|
nonce: Uuid::new_v4().to_string(),
|
|
message_type: "LtiDeepLinkingResponse".to_string(),
|
|
version: "1.3.0".to_string(),
|
|
deployment_id,
|
|
content_items: payload.items,
|
|
data: dl_data,
|
|
};
|
|
|
|
let private_key = crate::jwks::get_lti_private_key();
|
|
let response_jwt = jsonwebtoken::encode(
|
|
&jsonwebtoken::Header {
|
|
kid: Some("openccb-lti-key-1".to_string()),
|
|
alg: jsonwebtoken::Algorithm::RS256,
|
|
..Default::default()
|
|
},
|
|
&response_claims,
|
|
&private_key,
|
|
)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
Ok(Json(json!({
|
|
"jwt": response_jwt,
|
|
"return_url": dl_request.get::<String, _>("return_url")
|
|
})))
|
|
}
|
|
|
|
use axum::Json;
|
|
use sqlx::Row;
|