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::authentication::{
16    authenticate_tmc_server, get_or_create_user_from_tmc_mooc_fi_response,
17};
18use crate::prelude::*;
19use headless_lms_utils::services::tmc::TmcClient;
20use models::users::User;
21use secrecy::SecretString;
22
23#[derive(Debug, Deserialize)]
24pub struct CreateUserRequest {
25    upstream_id: i32,
26    password: SecretString,
27}
28
29#[derive(Debug, Serialize)]
30pub struct CreateUserResponse {
31    pub user: User,
32    pub password_set: bool,
33}
34
35#[derive(Debug, Deserialize)]
36pub struct LoginRequest {
37    user_id: Uuid,
38    password: SecretString,
39}
40
41/**
42POST `/api/v0/tmc-server/users/create`
43
44Endpoint used by the TMC server to create a new user in this system.
45
46Fetches the user details from tmc.mooc.fi and creates the user if they don't already exist.
47Sets the provided password for the user.
48
49Returns the created user and a boolean indicating whether the password was successfully set.
50
51Only works if the authorization header is set to a valid shared secret between systems.
52*/
53#[instrument(skip(pool, tmc_client))]
54pub async fn create_user(
55    request: HttpRequest,
56    pool: web::Data<PgPool>,
57    payload: web::Json<CreateUserRequest>,
58    tmc_client: web::Data<TmcClient>,
59) -> ControllerResult<web::Json<CreateUserResponse>> {
60    let token = authenticate_tmc_server(&request).await?;
61
62    let CreateUserRequest {
63        upstream_id,
64        password,
65    } = payload.into_inner();
66
67    let tmc_user = tmc_client
68        .get_user_from_tmc_mooc_fi_by_tmc_access_token_and_upstream_id(&upstream_id)
69        .await?;
70
71    info!(
72        "Creating or fetching user with TMC id {} and mooc.fi UUID {}",
73        tmc_user.id,
74        tmc_user
75            .courses_mooc_fi_user_id
76            .map(|uuid| uuid.to_string())
77            .unwrap_or_else(|| "None (will generate new UUID)".to_string())
78    );
79
80    // A transaction ensures user creation and password hash are written atomically.
81    let mut tx = pool.begin().await?;
82
83    let user = get_or_create_user_from_tmc_mooc_fi_response(
84        &mut tx,
85        tmc_user,
86        tmc_client.get_admin_access_token(),
87    )
88    .await?;
89
90    info!("User {} created or fetched successfully", user.id);
91
92    let password_hash = models::user_passwords::hash_password(&password).map_err(|e| {
93        ControllerError::new(
94            ControllerErrorType::InternalServerError,
95            "Failed to hash password",
96            Some(anyhow::Error::msg(e.to_string())),
97        )
98    })?;
99    let password_set =
100        models::user_passwords::upsert_user_password(&mut tx, user.id, &password_hash).await?;
101
102    tx.commit().await?;
103
104    // Notify TMC that the password is now managed by courses.mooc.fi (best-effort, retried in the
105    // background; the user has already been created and the password stored).
106    super::notify_password_managed_with_retry(&tmc_client, upstream_id.to_string(), user.id).await;
107
108    info!("Password set: {}", password_set);
109
110    token.authorized_ok(web::Json(CreateUserResponse { user, password_set }))
111}
112
113/**
114POST `/api/v0/tmc-server/users/authenticate`
115
116Endpoint used by the TMC server to authenticate a user using user_id and password.
117
118Returns `true` if the credentials match a known user in this system, otherwise returns `false`.
119
120Only works if the authorization header is set to a valid shared secret between systems.
121*/
122#[instrument(skip(pool))]
123pub async fn courses_moocfi_password_login(
124    request: HttpRequest,
125    pool: web::Data<PgPool>,
126    payload: web::Json<LoginRequest>,
127) -> ControllerResult<web::Json<bool>> {
128    let token = authenticate_tmc_server(&request).await?;
129
130    let mut conn = pool.acquire().await?;
131
132    let LoginRequest { user_id, password } = payload.into_inner();
133
134    let is_valid = models::user_passwords::verify_user_password(&mut conn, user_id, &password)
135        .await
136        .unwrap_or_else(|e| {
137            // A DB/crypto error must not look identical to a wrong password without a trace.
138            warn!("Password verification errored for user {user_id}: {e}");
139            false
140        });
141
142    token.authorized_ok(web::Json(is_valid))
143}
144
145#[derive(Debug, Deserialize)]
146pub struct PasswordChangeRequest {
147    user_id: Uuid,
148    old_password: Option<SecretString>,
149    new_password: SecretString,
150}
151
152/**
153POST `/api/v0/tmc-server/users/change-password`
154
155Endpoint called by the TMC server when a user's password is changed.
156
157Only works if the authorization header is set to a valid shared secret between systems.
158*/
159#[instrument(skip(pool))]
160pub async fn courses_moocfi_password_change(
161    request: HttpRequest,
162    pool: web::Data<PgPool>,
163    payload: web::Json<PasswordChangeRequest>,
164) -> ControllerResult<web::Json<bool>> {
165    let token = authenticate_tmc_server(&request).await?;
166
167    let mut conn = pool.acquire().await?;
168
169    let PasswordChangeRequest {
170        user_id,
171        old_password,
172        new_password,
173    } = payload.into_inner();
174
175    // Verify old password if it is not None
176    if let Some(old) = old_password {
177        let is_user_valid = models::user_passwords::verify_user_password(&mut conn, user_id, &old)
178            .await
179            .unwrap_or_else(|e| {
180                warn!("Old-password verification errored for user {user_id}: {e}");
181                false
182            });
183        if !is_user_valid {
184            return token.authorized_ok(web::Json(false));
185        }
186    }
187
188    let new_password_hash = match models::user_passwords::hash_password(&new_password) {
189        Ok(hash) => hash,
190        Err(e) => {
191            warn!("Failed to hash new password for user {user_id}: {e}");
192            return token.authorized_ok(web::Json(false));
193        }
194    };
195
196    let update_ok =
197        models::user_passwords::upsert_user_password(&mut conn, user_id, &new_password_hash)
198            .await
199            .unwrap_or_else(|e| {
200                warn!("Failed to store new password for user {user_id}: {e}");
201                false
202            });
203
204    token.authorized_ok(web::Json(update_ok))
205}
206
207pub fn _add_routes(cfg: &mut ServiceConfig) {
208    cfg.route("/create", web::post().to(create_user))
209        .route(
210            "/authenticate",
211            web::post().to(courses_moocfi_password_login),
212        )
213        .route(
214            "/change-password",
215            web::post().to(courses_moocfi_password_change),
216        );
217}