Skip to main content

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 the password is now managed by courses.mooc.fi (best-effort, retried in the
106    // background; the user has already been created and the password stored).
107    super::notify_password_managed_with_retry(&tmc_client, upstream_id.to_string(), user.id).await;
108
109    info!("Password set: {}", password_set);
110
111    token.authorized_ok(web::Json(CreateUserResponse { user, password_set }))
112}
113
114/**
115POST `/api/v0/tmc-server/users/authenticate`
116
117Endpoint used by the TMC server to authenticate a user using user_id and password.
118
119Returns `true` if the credentials match a known user in this system, otherwise returns `false`.
120
121Only works if the authorization header is set to a valid shared secret between systems.
122*/
123#[instrument(skip(pool))]
124pub async fn courses_moocfi_password_login(
125    request: HttpRequest,
126    pool: web::Data<PgPool>,
127    payload: web::Json<LoginRequest>,
128) -> ControllerResult<web::Json<bool>> {
129    let token = authorize_access_from_tmc_server_to_course_mooc_fi(&request).await?;
130
131    let mut conn = pool.acquire().await?;
132
133    let LoginRequest { user_id, password } = payload.into_inner();
134
135    let is_valid = models::user_passwords::verify_user_password(&mut conn, user_id, &password)
136        .await
137        .unwrap_or_else(|e| {
138            // A DB/crypto error must not look identical to a wrong password without a trace.
139            warn!("Password verification errored for user {user_id}: {e}");
140            false
141        });
142
143    token.authorized_ok(web::Json(is_valid))
144}
145
146#[derive(Debug, Deserialize)]
147pub struct PasswordChangeRequest {
148    user_id: Uuid,
149    old_password: Option<SecretString>,
150    new_password: SecretString,
151}
152
153/**
154POST `/api/v0/tmc-server/users/change-password`
155
156Endpoint called by the TMC server when a user's password is changed.
157
158Only works if the authorization header is set to a valid shared secret between systems.
159*/
160#[instrument(skip(pool))]
161pub async fn courses_moocfi_password_change(
162    request: HttpRequest,
163    pool: web::Data<PgPool>,
164    payload: web::Json<PasswordChangeRequest>,
165) -> ControllerResult<web::Json<bool>> {
166    let token = authorize_access_from_tmc_server_to_course_mooc_fi(&request).await?;
167
168    let mut conn = pool.acquire().await?;
169
170    let PasswordChangeRequest {
171        user_id,
172        old_password,
173        new_password,
174    } = payload.into_inner();
175
176    // Verify old password if it is not None
177    if let Some(old) = old_password {
178        let is_user_valid = models::user_passwords::verify_user_password(&mut conn, user_id, &old)
179            .await
180            .unwrap_or_else(|e| {
181                warn!("Old-password verification errored for user {user_id}: {e}");
182                false
183            });
184        if !is_user_valid {
185            return token.authorized_ok(web::Json(false));
186        }
187    }
188
189    let new_password_hash = match models::user_passwords::hash_password(&new_password) {
190        Ok(hash) => hash,
191        Err(e) => {
192            warn!("Failed to hash new password for user {user_id}: {e}");
193            return token.authorized_ok(web::Json(false));
194        }
195    };
196
197    let update_ok =
198        models::user_passwords::upsert_user_password(&mut conn, user_id, &new_password_hash)
199            .await
200            .unwrap_or_else(|e| {
201                warn!("Failed to store new password for user {user_id}: {e}");
202                false
203            });
204
205    token.authorized_ok(web::Json(update_ok))
206}
207
208pub fn _add_routes(cfg: &mut ServiceConfig) {
209    cfg.route("/create", web::post().to(create_user))
210        .route(
211            "/authenticate",
212            web::post().to(courses_moocfi_password_login),
213        )
214        .route(
215            "/change-password",
216            web::post().to(courses_moocfi_password_change),
217        );
218}