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(&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    // Notify TMC that password is now managed by courses.mooc.fi.
101    // Try a few times inline to handle common transient failures without blocking too long.
102    // If all inline attempts fail, hand off to a background task so the HTTP response
103    // is returned promptly while longer retries proceed.
104    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/**
187POST `/api/v0/tmc-server/users/authenticate`
188
189Endpoint used by the TMC server to authenticate a user using user_id and password.
190
191Returns `true` if the credentials match a known user in this system, otherwise returns `false`.
192
193Only works if the authorization header is set to a valid shared secret between systems.
194*/
195#[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/**
222POST `/api/v0/tmc-server/users/change-password`
223
224Endpoint called by the TMC server when a user's password is changed.
225
226Only works if the authorization header is set to a valid shared secret between systems.
227*/
228#[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    // Verify old password if it is not None
245    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}