Skip to main content

mini_apm_admin/web/
auth.rs

1use askama::Template;
2use axum::{
3    extract::{Form, State},
4    http::StatusCode,
5    response::{Html, IntoResponse, Redirect, Response},
6};
7use axum_extra::extract::cookie::{Cookie, CookieJar};
8use serde::Deserialize;
9use time::Duration;
10
11use mini_apm::{DbPool, config::Config, models};
12
13use super::project_context::{WebProjectContext, get_project_context};
14
15const SESSION_COOKIE: &str = "miniapm_session";
16
17// Templates
18
19#[derive(Template)]
20#[template(path = "auth/login.html")]
21pub struct LoginTemplate {
22    pub error: Option<String>,
23}
24
25#[derive(Template)]
26#[template(path = "auth/change_password.html")]
27pub struct ChangePasswordTemplate {
28    pub error: Option<String>,
29    pub username: String,
30}
31
32#[derive(Template)]
33#[template(path = "auth/users.html")]
34pub struct UsersTemplate {
35    pub users: Vec<models::User>,
36    pub current_user_id: i64,
37    pub error: Option<String>,
38    pub success: Option<String>,
39    pub invite_url: Option<String>,
40    pub ctx: WebProjectContext,
41}
42
43#[derive(Template)]
44#[template(path = "auth/invite.html")]
45pub struct InviteTemplate {
46    pub username: String,
47    pub error: Option<String>,
48}
49
50// Form data
51
52#[derive(Deserialize)]
53pub struct LoginForm {
54    pub username: String,
55    pub password: String,
56}
57
58#[derive(Deserialize)]
59pub struct ChangePasswordForm {
60    pub current_password: String,
61    pub new_password: String,
62    pub confirm_password: String,
63}
64
65#[derive(Deserialize)]
66pub struct CreateUserForm {
67    pub username: String,
68    pub is_admin: Option<String>,
69}
70
71// Helper to get current user from cookies
72pub fn get_current_user(pool: &DbPool, jar: &CookieJar) -> Option<models::User> {
73    let token = jar.get(SESSION_COOKIE)?.value();
74    models::user::get_user_from_session(pool, token)
75        .ok()
76        .flatten()
77}
78
79// Handlers
80
81pub async fn login_page(State(pool): State<DbPool>, jar: CookieJar) -> Response {
82    // If already logged in, redirect to home
83    if get_current_user(&pool, &jar).is_some() {
84        return Redirect::to("/").into_response();
85    }
86
87    Html(LoginTemplate { error: None }.render().unwrap_or_default()).into_response()
88}
89
90pub async fn login_submit(
91    State(pool): State<DbPool>,
92    jar: CookieJar,
93    Form(form): Form<LoginForm>,
94) -> Response {
95    match models::user::authenticate(&pool, &form.username, &form.password) {
96        Ok(Some(user)) => {
97            // Create session
98            match models::user::create_session(&pool, user.id) {
99                Ok(token) => {
100                    let cookie = Cookie::build((SESSION_COOKIE, token))
101                        .path("/")
102                        .http_only(true)
103                        .secure(true)
104                        .same_site(axum_extra::extract::cookie::SameSite::Lax)
105                        .max_age(Duration::days(7))
106                        .build();
107
108                    let jar = jar.add(cookie);
109
110                    // Redirect to change password if required
111                    if user.must_change_password {
112                        (jar, Redirect::to("/auth/change-password")).into_response()
113                    } else {
114                        (jar, Redirect::to("/")).into_response()
115                    }
116                }
117                Err(_) => Html(
118                    LoginTemplate {
119                        error: Some("Failed to create session".to_string()),
120                    }
121                    .render()
122                    .unwrap_or_default(),
123                )
124                .into_response(),
125            }
126        }
127        Ok(None) => Html(
128            LoginTemplate {
129                error: Some("Invalid username or password".to_string()),
130            }
131            .render()
132            .unwrap_or_default(),
133        )
134        .into_response(),
135        Err(_) => Html(
136            LoginTemplate {
137                error: Some("Authentication error".to_string()),
138            }
139            .render()
140            .unwrap_or_default(),
141        )
142        .into_response(),
143    }
144}
145
146pub async fn logout(State(pool): State<DbPool>, jar: CookieJar) -> Response {
147    if let Some(cookie) = jar.get(SESSION_COOKIE) {
148        let _ = models::user::delete_session(&pool, cookie.value());
149    }
150
151    let jar = jar.remove(Cookie::from(SESSION_COOKIE));
152    (jar, Redirect::to("/auth/login")).into_response()
153}
154
155pub async fn change_password_page(State(pool): State<DbPool>, jar: CookieJar) -> Response {
156    let Some(user) = get_current_user(&pool, &jar) else {
157        return Redirect::to("/auth/login").into_response();
158    };
159
160    Html(
161        ChangePasswordTemplate {
162            error: None,
163            username: user.username,
164        }
165        .render()
166        .unwrap_or_default(),
167    )
168    .into_response()
169}
170
171pub async fn change_password_submit(
172    State(pool): State<DbPool>,
173    jar: CookieJar,
174    Form(form): Form<ChangePasswordForm>,
175) -> Response {
176    let Some(user) = get_current_user(&pool, &jar) else {
177        return Redirect::to("/auth/login").into_response();
178    };
179
180    // Validate
181    if form.new_password != form.confirm_password {
182        return Html(
183            ChangePasswordTemplate {
184                error: Some("Passwords do not match".to_string()),
185                username: user.username,
186            }
187            .render()
188            .unwrap_or_default(),
189        )
190        .into_response();
191    }
192
193    if form.new_password.len() < 8 {
194        return Html(
195            ChangePasswordTemplate {
196                error: Some("Password must be at least 8 characters".to_string()),
197                username: user.username,
198            }
199            .render()
200            .unwrap_or_default(),
201        )
202        .into_response();
203    }
204
205    // Verify current password
206    let password_valid = user
207        .password_hash
208        .as_ref()
209        .is_some_and(|h| models::user::verify_password(&form.current_password, h));
210    if !password_valid {
211        return Html(
212            ChangePasswordTemplate {
213                error: Some("Current password is incorrect".to_string()),
214                username: user.username,
215            }
216            .render()
217            .unwrap_or_default(),
218        )
219        .into_response();
220    }
221
222    // Change password
223    match models::user::change_password(&pool, user.id, &form.new_password) {
224        Ok(_) => Redirect::to("/").into_response(),
225        Err(_) => Html(
226            ChangePasswordTemplate {
227                error: Some("Failed to change password".to_string()),
228                username: user.username,
229            }
230            .render()
231            .unwrap_or_default(),
232        )
233        .into_response(),
234    }
235}
236
237// Admin-only handlers
238
239pub async fn users_page(
240    State(pool): State<DbPool>,
241    jar: CookieJar,
242    cookies: tower_cookies::Cookies,
243) -> Response {
244    let Some(user) = get_current_user(&pool, &jar) else {
245        return Redirect::to("/auth/login").into_response();
246    };
247
248    if !user.is_admin {
249        return (StatusCode::FORBIDDEN, "Admin access required").into_response();
250    }
251
252    let users = models::user::list_all(&pool).unwrap_or_default();
253    let ctx = get_project_context(&pool, &cookies);
254
255    Html(
256        UsersTemplate {
257            users,
258            current_user_id: user.id,
259            error: None,
260            success: None,
261            invite_url: None,
262            ctx,
263        }
264        .render()
265        .unwrap_or_default(),
266    )
267    .into_response()
268}
269
270pub async fn create_user(
271    State(pool): State<DbPool>,
272    jar: CookieJar,
273    cookies: tower_cookies::Cookies,
274    Form(form): Form<CreateUserForm>,
275) -> Response {
276    let Some(user) = get_current_user(&pool, &jar) else {
277        return Redirect::to("/auth/login").into_response();
278    };
279
280    if !user.is_admin {
281        return (StatusCode::FORBIDDEN, "Admin access required").into_response();
282    }
283
284    let ctx = get_project_context(&pool, &cookies);
285
286    if form.username.is_empty() {
287        let users = models::user::list_all(&pool).unwrap_or_default();
288        return Html(
289            UsersTemplate {
290                users,
291                current_user_id: user.id,
292                error: Some("Username is required".to_string()),
293                success: None,
294                invite_url: None,
295                ctx,
296            }
297            .render()
298            .unwrap_or_default(),
299        )
300        .into_response();
301    }
302
303    let is_admin = form.is_admin.as_deref() == Some("on");
304
305    match models::user::create_with_invite(&pool, &form.username, is_admin) {
306        Ok(invite_token) => {
307            let users = models::user::list_all(&pool).unwrap_or_default();
308            let base_url = std::env::var("MINI_APM_URL")
309                .unwrap_or_else(|_| "http://localhost:3000".to_string());
310            let invite_url = format!(
311                "{}/auth/invite/{}",
312                base_url.trim_end_matches('/'),
313                invite_token
314            );
315            Html(
316                UsersTemplate {
317                    users,
318                    current_user_id: user.id,
319                    error: None,
320                    success: Some(format!("User '{}' created", form.username)),
321                    invite_url: Some(invite_url),
322                    ctx,
323                }
324                .render()
325                .unwrap_or_default(),
326            )
327            .into_response()
328        }
329        Err(_) => {
330            let users = models::user::list_all(&pool).unwrap_or_default();
331            Html(
332                UsersTemplate {
333                    users,
334                    current_user_id: user.id,
335                    error: Some("Failed to create user (username may already exist)".to_string()),
336                    success: None,
337                    invite_url: None,
338                    ctx,
339                }
340                .render()
341                .unwrap_or_default(),
342            )
343            .into_response()
344        }
345    }
346}
347
348#[derive(Deserialize)]
349pub struct DeleteUserForm {
350    pub user_id: i64,
351}
352
353pub async fn delete_user(
354    State(pool): State<DbPool>,
355    jar: CookieJar,
356    cookies: tower_cookies::Cookies,
357    Form(form): Form<DeleteUserForm>,
358) -> Response {
359    let Some(user) = get_current_user(&pool, &jar) else {
360        return Redirect::to("/auth/login").into_response();
361    };
362
363    if !user.is_admin {
364        return (StatusCode::FORBIDDEN, "Admin access required").into_response();
365    }
366
367    let ctx = get_project_context(&pool, &cookies);
368
369    if form.user_id == user.id {
370        let users = models::user::list_all(&pool).unwrap_or_default();
371        return Html(
372            UsersTemplate {
373                users,
374                current_user_id: user.id,
375                error: Some("Cannot delete yourself".to_string()),
376                success: None,
377                invite_url: None,
378                ctx,
379            }
380            .render()
381            .unwrap_or_default(),
382        )
383        .into_response();
384    }
385
386    match models::user::delete(&pool, form.user_id) {
387        Ok(_) => {
388            let users = models::user::list_all(&pool).unwrap_or_default();
389            Html(
390                UsersTemplate {
391                    users,
392                    current_user_id: user.id,
393                    error: None,
394                    success: Some("User deleted".to_string()),
395                    invite_url: None,
396                    ctx,
397                }
398                .render()
399                .unwrap_or_default(),
400            )
401            .into_response()
402        }
403        Err(_) => {
404            let users = models::user::list_all(&pool).unwrap_or_default();
405            Html(
406                UsersTemplate {
407                    users,
408                    current_user_id: user.id,
409                    error: Some("Failed to delete user".to_string()),
410                    success: None,
411                    invite_url: None,
412                    ctx,
413                }
414                .render()
415                .unwrap_or_default(),
416            )
417            .into_response()
418        }
419    }
420}
421
422// Invite handlers
423
424#[derive(Deserialize)]
425pub struct InviteForm {
426    pub password: String,
427    pub confirm_password: String,
428}
429
430pub async fn invite_page(
431    State(pool): State<DbPool>,
432    axum::extract::Path(token): axum::extract::Path<String>,
433) -> Response {
434    match models::user::find_by_invite_token(&pool, &token) {
435        Ok(Some(user)) => Html(
436            InviteTemplate {
437                username: user.username,
438                error: None,
439            }
440            .render()
441            .unwrap_or_default(),
442        )
443        .into_response(),
444        _ => Html(
445            "<h1>Invalid or expired invite link</h1><p><a href=\"/auth/login\">Go to login</a></p>",
446        )
447        .into_response(),
448    }
449}
450
451pub async fn invite_submit(
452    State(pool): State<DbPool>,
453    jar: CookieJar,
454    axum::extract::Path(token): axum::extract::Path<String>,
455    Form(form): Form<InviteForm>,
456) -> Response {
457    let user = match models::user::find_by_invite_token(&pool, &token) {
458        Ok(Some(u)) => u,
459        _ => return Html(
460            "<h1>Invalid or expired invite link</h1><p><a href=\"/auth/login\">Go to login</a></p>",
461        )
462        .into_response(),
463    };
464
465    if form.password != form.confirm_password {
466        return Html(
467            InviteTemplate {
468                username: user.username,
469                error: Some("Passwords do not match".to_string()),
470            }
471            .render()
472            .unwrap_or_default(),
473        )
474        .into_response();
475    }
476
477    if form.password.len() < 8 {
478        return Html(
479            InviteTemplate {
480                username: user.username,
481                error: Some("Password must be at least 8 characters".to_string()),
482            }
483            .render()
484            .unwrap_or_default(),
485        )
486        .into_response();
487    }
488
489    // Accept the invite and set password
490    if models::user::accept_invite(&pool, user.id, &form.password).is_err() {
491        return Html(
492            InviteTemplate {
493                username: user.username,
494                error: Some("Failed to set password".to_string()),
495            }
496            .render()
497            .unwrap_or_default(),
498        )
499        .into_response();
500    }
501
502    // Create session and log them in
503    match models::user::create_session(&pool, user.id) {
504        Ok(session_token) => {
505            let cookie = Cookie::build((SESSION_COOKIE, session_token))
506                .path("/")
507                .http_only(true)
508                .secure(true)
509                .same_site(axum_extra::extract::cookie::SameSite::Lax)
510                .max_age(Duration::days(7))
511                .build();
512
513            (jar.add(cookie), Redirect::to("/")).into_response()
514        }
515        Err(_) => Redirect::to("/auth/login").into_response(),
516    }
517}
518
519// Middleware helper - check if request is authenticated
520pub async fn require_auth(
521    pool: &DbPool,
522    config: &Config,
523    jar: &CookieJar,
524) -> Result<Option<models::User>, Redirect> {
525    // If user accounts are disabled, allow access
526    if !config.enable_user_accounts {
527        return Ok(None);
528    }
529
530    // Check for valid session
531    match get_current_user(pool, jar) {
532        Some(user) => {
533            // Force password change if required
534            if user.must_change_password {
535                Err(Redirect::to("/auth/change-password"))
536            } else {
537                Ok(Some(user))
538            }
539        }
540        None => Err(Redirect::to("/auth/login")),
541    }
542}