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(
85 &mut tx,
86 tmc_user,
87 tmc_client.get_admin_access_token(),
88 )
89 .await?;
90
91 info!("User {} created or fetched successfully", user.id);
92
93 let password_hash = models::user_passwords::hash_password(&password).map_err(|e| {
94 ControllerError::new(
95 ControllerErrorType::InternalServerError,
96 "Failed to hash password",
97 Some(anyhow::Error::msg(e.to_string())),
98 )
99 })?;
100 let password_set =
101 models::user_passwords::upsert_user_password(&mut tx, user.id, &password_hash).await?;
102
103 tx.commit().await?;
104
105 const MAX_ATTEMPTS_INLINE: u32 = 3;
110 const MAX_DELAY_MS_INLINE: u64 = 2_000;
111 let mut inline_succeeded = false;
112 for attempt in 1..=MAX_ATTEMPTS_INLINE {
113 match tmc_client
114 .set_user_password_managed_by_courses_mooc_fi(upstream_id.to_string(), user.id)
115 .await
116 {
117 Ok(_) => {
118 inline_succeeded = true;
119 break;
120 }
121 Err(e) if attempt < MAX_ATTEMPTS_INLINE => {
122 let delay = std::time::Duration::from_millis(
123 200u64
124 .saturating_mul(2u64.pow(attempt - 1))
125 .min(MAX_DELAY_MS_INLINE),
126 );
127 warn!(
128 "Failed to notify TMC that user's password is saved in courses.mooc.fi (inline attempt {}/{}), retrying in {:?}: upstream_id={}, user_id={}, error={}",
129 attempt, MAX_ATTEMPTS_INLINE, delay, upstream_id, user.id, e
130 );
131 tokio::time::sleep(delay).await;
132 }
133 Err(e) => {
134 warn!(
135 "Inline TMC notification attempts exhausted, handing off to background task: upstream_id={}, user_id={}, error={}",
136 upstream_id, user.id, e
137 );
138 }
139 }
140 }
141 if !inline_succeeded {
142 let tmc_client = tmc_client.clone();
143 let user_id = user.id;
144 tokio::spawn(async move {
145 const MAX_ATTEMPTS_BG: u32 = 10;
146 const MAX_DELAY_MS_BG: u64 = 30_000;
147 for attempt in 1..=MAX_ATTEMPTS_BG {
148 match tmc_client
149 .set_user_password_managed_by_courses_mooc_fi(upstream_id.to_string(), user_id)
150 .await
151 {
152 Ok(_) => {
153 info!(
154 "Background TMC notification succeeded on attempt {}: upstream_id={}, user_id={}",
155 attempt, upstream_id, user_id
156 );
157 break;
158 }
159 Err(e) if attempt < MAX_ATTEMPTS_BG => {
160 let delay = std::time::Duration::from_millis(
161 200u64
162 .saturating_mul(2u64.pow(attempt - 1))
163 .min(MAX_DELAY_MS_BG),
164 );
165 warn!(
166 "Background TMC notification failed (attempt {}/{}), retrying in {:?}: upstream_id={}, user_id={}, error={}",
167 attempt, MAX_ATTEMPTS_BG, delay, upstream_id, user_id, e
168 );
169 tokio::time::sleep(delay).await;
170 }
171 Err(e) => {
172 error!(
173 "Background TMC notification exhausted all {} retries at {}: upstream_id={}, user_id={}, error={}",
174 MAX_ATTEMPTS_BG,
175 chrono::Utc::now(),
176 upstream_id,
177 user_id,
178 e
179 );
180 }
181 }
182 }
183 });
184 }
185
186 info!("Password set: {}", password_set);
187
188 token.authorized_ok(web::Json(CreateUserResponse { user, password_set }))
189}
190
191#[instrument(skip(pool))]
201pub async fn courses_moocfi_password_login(
202 request: HttpRequest,
203 pool: web::Data<PgPool>,
204 payload: web::Json<LoginRequest>,
205) -> ControllerResult<web::Json<bool>> {
206 let token = authorize_access_from_tmc_server_to_course_mooc_fi(&request).await?;
207
208 let mut conn = pool.acquire().await?;
209
210 let LoginRequest { user_id, password } = payload.into_inner();
211
212 let is_valid = models::user_passwords::verify_user_password(&mut conn, user_id, &password)
213 .await
214 .unwrap_or(false);
215
216 token.authorized_ok(web::Json(is_valid))
217}
218
219#[derive(Debug, Deserialize)]
220pub struct PasswordChangeRequest {
221 user_id: Uuid,
222 old_password: Option<SecretString>,
223 new_password: SecretString,
224}
225
226#[instrument(skip(pool))]
234pub async fn courses_moocfi_password_change(
235 request: HttpRequest,
236 pool: web::Data<PgPool>,
237 payload: web::Json<PasswordChangeRequest>,
238) -> ControllerResult<web::Json<bool>> {
239 let token = authorize_access_from_tmc_server_to_course_mooc_fi(&request).await?;
240
241 let mut conn = pool.acquire().await?;
242
243 let PasswordChangeRequest {
244 user_id,
245 old_password,
246 new_password,
247 } = payload.into_inner();
248
249 if let Some(old) = old_password {
251 let is_user_valid = models::user_passwords::verify_user_password(&mut conn, user_id, &old)
252 .await
253 .unwrap_or(false);
254 if !is_user_valid {
255 return token.authorized_ok(web::Json(false));
256 }
257 }
258
259 let new_password_hash = match models::user_passwords::hash_password(&new_password) {
260 Ok(hash) => hash,
261 Err(_) => return token.authorized_ok(web::Json(false)),
262 };
263
264 let update_ok =
265 models::user_passwords::upsert_user_password(&mut conn, user_id, &new_password_hash)
266 .await
267 .unwrap_or(false);
268
269 token.authorized_ok(web::Json(update_ok))
270}
271
272pub fn _add_routes(cfg: &mut ServiceConfig) {
273 cfg.route("/create", web::post().to(create_user))
274 .route(
275 "/authenticate",
276 web::post().to(courses_moocfi_password_login),
277 )
278 .route(
279 "/change-password",
280 web::post().to(courses_moocfi_password_change),
281 );
282}