headless_lms_server/controllers/tmc_server/
users.rs1use crate::domain::authorization::{
16 authorize_access_from_tmc_server_to_course_mooc_fi,
17 get_or_create_user_from_tmc_mooc_fi_response,
18};
19use crate::prelude::*;
20use headless_lms_utils::tmc::TmcClient;
21use models::users::User;
22use secrecy::SecretString;
23
24#[derive(Debug, Deserialize)]
25pub struct CreateUserRequest {
26 upstream_id: i32,
27 password: SecretString,
28}
29
30#[derive(Debug, Serialize)]
31pub struct CreateUserResponse {
32 pub user: User,
33 pub password_set: bool,
34}
35
36#[derive(Debug, Deserialize)]
37pub struct LoginRequest {
38 user_id: Uuid,
39 password: SecretString,
40}
41
42#[instrument(skip(pool, tmc_client))]
55pub async fn create_user(
56 request: HttpRequest,
57 pool: web::Data<PgPool>,
58 payload: web::Json<CreateUserRequest>,
59 tmc_client: web::Data<TmcClient>,
60) -> ControllerResult<web::Json<CreateUserResponse>> {
61 let token = authorize_access_from_tmc_server_to_course_mooc_fi(&request).await?;
62
63 let CreateUserRequest {
64 upstream_id,
65 password,
66 } = payload.into_inner();
67
68 let tmc_user = tmc_client
69 .get_user_from_tmc_mooc_fi_by_tmc_access_token_and_upstream_id(&upstream_id)
70 .await?;
71
72 info!(
73 "Creating or fetching user with TMC id {} and mooc.fi UUID {}",
74 tmc_user.id,
75 tmc_user
76 .courses_mooc_fi_user_id
77 .map(|uuid| uuid.to_string())
78 .unwrap_or_else(|| "None (will generate new UUID)".to_string())
79 );
80
81 let mut tx = pool.begin().await?;
83
84 let user = get_or_create_user_from_tmc_mooc_fi_response(&mut tx, tmc_user).await?;
85
86 info!("User {} created or fetched successfully", user.id);
87
88 let password_hash = models::user_passwords::hash_password(&password).map_err(|e| {
89 ControllerError::new(
90 ControllerErrorType::InternalServerError,
91 "Failed to hash password",
92 Some(anyhow::Error::msg(e.to_string())),
93 )
94 })?;
95 let password_set =
96 models::user_passwords::upsert_user_password(&mut tx, user.id, &password_hash).await?;
97
98 tx.commit().await?;
99
100 const MAX_ATTEMPTS_INLINE: u32 = 3;
105 const MAX_DELAY_MS_INLINE: u64 = 2_000;
106 let mut inline_succeeded = false;
107 for attempt in 1..=MAX_ATTEMPTS_INLINE {
108 match tmc_client
109 .set_user_password_managed_by_courses_mooc_fi(upstream_id.to_string(), user.id)
110 .await
111 {
112 Ok(_) => {
113 inline_succeeded = true;
114 break;
115 }
116 Err(e) if attempt < MAX_ATTEMPTS_INLINE => {
117 let delay = std::time::Duration::from_millis(
118 200u64
119 .saturating_mul(2u64.pow(attempt - 1))
120 .min(MAX_DELAY_MS_INLINE),
121 );
122 warn!(
123 "Failed to notify TMC that user's password is saved in courses.mooc.fi (inline attempt {}/{}), retrying in {:?}: upstream_id={}, user_id={}, error={}",
124 attempt, MAX_ATTEMPTS_INLINE, delay, upstream_id, user.id, e
125 );
126 tokio::time::sleep(delay).await;
127 }
128 Err(e) => {
129 warn!(
130 "Inline TMC notification attempts exhausted, handing off to background task: upstream_id={}, user_id={}, error={}",
131 upstream_id, user.id, e
132 );
133 }
134 }
135 }
136 if !inline_succeeded {
137 let tmc_client = tmc_client.clone();
138 let user_id = user.id;
139 tokio::spawn(async move {
140 const MAX_ATTEMPTS_BG: u32 = 10;
141 const MAX_DELAY_MS_BG: u64 = 30_000;
142 for attempt in 1..=MAX_ATTEMPTS_BG {
143 match tmc_client
144 .set_user_password_managed_by_courses_mooc_fi(upstream_id.to_string(), user_id)
145 .await
146 {
147 Ok(_) => {
148 info!(
149 "Background TMC notification succeeded on attempt {}: upstream_id={}, user_id={}",
150 attempt, upstream_id, user_id
151 );
152 break;
153 }
154 Err(e) if attempt < MAX_ATTEMPTS_BG => {
155 let delay = std::time::Duration::from_millis(
156 200u64
157 .saturating_mul(2u64.pow(attempt - 1))
158 .min(MAX_DELAY_MS_BG),
159 );
160 warn!(
161 "Background TMC notification failed (attempt {}/{}), retrying in {:?}: upstream_id={}, user_id={}, error={}",
162 attempt, MAX_ATTEMPTS_BG, delay, upstream_id, user_id, e
163 );
164 tokio::time::sleep(delay).await;
165 }
166 Err(e) => {
167 error!(
168 "Background TMC notification exhausted all {} retries at {}: upstream_id={}, user_id={}, error={}",
169 MAX_ATTEMPTS_BG,
170 chrono::Utc::now(),
171 upstream_id,
172 user_id,
173 e
174 );
175 }
176 }
177 }
178 });
179 }
180
181 info!("Password set: {}", password_set);
182
183 token.authorized_ok(web::Json(CreateUserResponse { user, password_set }))
184}
185
186#[instrument(skip(pool))]
196pub async fn courses_moocfi_password_login(
197 request: HttpRequest,
198 pool: web::Data<PgPool>,
199 payload: web::Json<LoginRequest>,
200) -> ControllerResult<web::Json<bool>> {
201 let token = authorize_access_from_tmc_server_to_course_mooc_fi(&request).await?;
202
203 let mut conn = pool.acquire().await?;
204
205 let LoginRequest { user_id, password } = payload.into_inner();
206
207 let is_valid = models::user_passwords::verify_user_password(&mut conn, user_id, &password)
208 .await
209 .unwrap_or(false);
210
211 token.authorized_ok(web::Json(is_valid))
212}
213
214#[derive(Debug, Deserialize)]
215pub struct PasswordChangeRequest {
216 user_id: Uuid,
217 old_password: Option<SecretString>,
218 new_password: SecretString,
219}
220
221#[instrument(skip(pool))]
229pub async fn courses_moocfi_password_change(
230 request: HttpRequest,
231 pool: web::Data<PgPool>,
232 payload: web::Json<PasswordChangeRequest>,
233) -> ControllerResult<web::Json<bool>> {
234 let token = authorize_access_from_tmc_server_to_course_mooc_fi(&request).await?;
235
236 let mut conn = pool.acquire().await?;
237
238 let PasswordChangeRequest {
239 user_id,
240 old_password,
241 new_password,
242 } = payload.into_inner();
243
244 if let Some(old) = old_password {
246 let is_user_valid = models::user_passwords::verify_user_password(&mut conn, user_id, &old)
247 .await
248 .unwrap_or(false);
249 if !is_user_valid {
250 return token.authorized_ok(web::Json(false));
251 }
252 }
253
254 let new_password_hash = match models::user_passwords::hash_password(&new_password) {
255 Ok(hash) => hash,
256 Err(_) => return token.authorized_ok(web::Json(false)),
257 };
258
259 let update_ok =
260 models::user_passwords::upsert_user_password(&mut conn, user_id, &new_password_hash)
261 .await
262 .unwrap_or(false);
263
264 token.authorized_ok(web::Json(update_ok))
265}
266
267pub fn _add_routes(cfg: &mut ServiceConfig) {
268 cfg.route("/create", web::post().to(create_user))
269 .route(
270 "/authenticate",
271 web::post().to(courses_moocfi_password_login),
272 )
273 .route(
274 "/change-password",
275 web::post().to(courses_moocfi_password_change),
276 );
277}