headless_lms_server/controllers/tmc_server/
users.rs

1/*!
2Handlers for HTTP requests to `/api/v0/tmc-server/users/`.
3
4Exposes three endpoints used exclusively by the TMC server, all of which require a valid
5shared-secret authorization header:
6
7- `POST /create` – fetches user details from tmc.mooc.fi, creates the user in this system if
8  they don't exist, sets the provided password, and notifies TMC that password management has
9  moved to courses.mooc.fi.
10- `POST /authenticate` – verifies a user_id/password pair against the locally stored hash.
11- `POST /change-password` – updates the stored password hash, optionally verifying the old one
12  first.
13*/
14
15use 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/**
43POST `/api/v0/tmc-server/users/create`
44
45Endpoint used by the TMC server to create a new user in this system.
46
47Fetches the user details from tmc.mooc.fi and creates the user if they don't already exist.
48Sets the provided password for the user.
49
50Returns the created user and a boolean indicating whether the password was successfully set.
51
52Only works if the authorization header is set to a valid shared secret between systems.
53*/
54#[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    // A transaction ensures user creation and password hash are written atomically.
82    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    // Notify TMC that password is now managed by courses.mooc.fi.
106    // Try a few times inline to handle common transient failures without blocking too long.
107    // If all inline attempts fail, hand off to a background task so the HTTP response
108    // is returned promptly while longer retries proceed.
109    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/**
192POST `/api/v0/tmc-server/users/authenticate`
193
194Endpoint used by the TMC server to authenticate a user using user_id and password.
195
196Returns `true` if the credentials match a known user in this system, otherwise returns `false`.
197
198Only works if the authorization header is set to a valid shared secret between systems.
199*/
200#[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/**
227POST `/api/v0/tmc-server/users/change-password`
228
229Endpoint called by the TMC server when a user's password is changed.
230
231Only works if the authorization header is set to a valid shared secret between systems.
232*/
233#[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    // Verify old password if it is not None
250    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}