Skip to main content

arc_auth_session/
lib.rs

1use actix_session::{Session, SessionExt};
2use actix_web::{
3    body::EitherBody,
4    dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
5    get, post, web, Error, HttpResponse,
6};
7use arc_auth_core::{Identity, IdentityStore};
8use arc_web::{ArcAppBuilder, ArcPlugin};
9use futures_util::future::LocalBoxFuture;
10use serde::Deserialize;
11use std::{
12    future::{ready, Ready},
13    sync::{Arc, LazyLock},
14};
15use tera::{Context, Tera};
16
17static TEMPLATES: LazyLock<Tera> = LazyLock::new(|| {
18    let mut tera = Tera::default();
19    tera.add_raw_templates([
20        ("layout.html", include_str!("../templates/layout.html")),
21        ("signin.html", include_str!("../templates/signin.html")),
22        ("profile.html", include_str!("../templates/profile.html")),
23        ("users.html", include_str!("../templates/users.html")),
24    ])
25    .expect("arc-auth-session templates must be valid");
26    tera
27});
28
29fn render(name: &str, context: &Context, status: actix_web::http::StatusCode) -> HttpResponse {
30    match TEMPLATES.render(name, context) {
31        Ok(body) => HttpResponse::build(status)
32            .content_type("text/html; charset=utf-8")
33            .body(body),
34        Err(error) => HttpResponse::InternalServerError().body(error.to_string()),
35    }
36}
37
38fn signin_response(
39    session: &Session,
40    email: &str,
41    error: Option<&str>,
42    status: actix_web::http::StatusCode,
43) -> HttpResponse {
44    let mut context = Context::new();
45    context.insert(
46        "csrf_token",
47        &arc_web::helpers::csrf::get_csrf_token(session),
48    );
49    context.insert("email", email);
50    context.insert("error", &error);
51    render("signin.html", &context, status)
52}
53
54pub const IDENTITY_SESSION_KEY: &str = "arc_auth_identity";
55pub fn identity(session: &Session) -> Option<Identity> {
56    session.get(IDENTITY_SESSION_KEY).ok().flatten()
57}
58
59#[derive(Deserialize)]
60struct SignIn {
61    email: String,
62    password: String,
63    csrf_token: String,
64}
65#[post("/signin")]
66async fn signin(
67    form: web::Form<SignIn>,
68    session: Session,
69    store: web::Data<dyn IdentityStore>,
70) -> HttpResponse {
71    if !arc_web::helpers::csrf::validate_and_regenerate_csrf_token(&session, &form.csrf_token) {
72        return HttpResponse::Forbidden().finish();
73    }
74    match store.authenticate(&form.email, &form.password).await {
75        Ok(user) => {
76            cache_identity(&session, &user);
77            HttpResponse::SeeOther()
78                .insert_header(("Location", "/admin"))
79                .finish()
80        }
81        Err(_) => signin_response(
82            &session,
83            &form.email,
84            Some("Email or password was not recognized."),
85            actix_web::http::StatusCode::UNAUTHORIZED,
86        ),
87    }
88}
89async fn signin_page(session: Session) -> HttpResponse {
90    signin_response(&session, "", None, actix_web::http::StatusCode::OK)
91}
92#[derive(Deserialize)]
93struct SignOut {
94    csrf_token: String,
95}
96#[post("/signout")]
97async fn signout(form: web::Form<SignOut>, session: Session) -> HttpResponse {
98    if !arc_web::helpers::csrf::validate_and_regenerate_csrf_token(&session, &form.csrf_token) {
99        return HttpResponse::Forbidden().finish();
100    }
101    session.remove(IDENTITY_SESSION_KEY);
102    arc_web::helpers::session::clear_session_user(&session);
103    HttpResponse::SeeOther()
104        .insert_header(("Location", "/"))
105        .finish()
106}
107
108fn csrf_ok(session: &Session, token: &str) -> bool {
109    arc_web::helpers::csrf::validate_and_regenerate_csrf_token(session, token)
110}
111
112fn cache_identity(session: &Session, identity: &Identity) {
113    let _ = session.insert(IDENTITY_SESSION_KEY, identity);
114    arc_web::helpers::session::set_session_user(
115        session,
116        &arc_web::helpers::session::SessionUser {
117            id: identity.id.clone(),
118            name: identity.name.clone(),
119            email: identity.email.clone(),
120        },
121    );
122}
123#[get("")]
124async fn profile(session: Session) -> HttpResponse {
125    let Some(user) = identity(&session) else {
126        return HttpResponse::Found()
127            .insert_header(("Location", "/signin"))
128            .finish();
129    };
130    profile_response(&session, &user, None, actix_web::http::StatusCode::OK)
131}
132
133fn profile_response(
134    session: &Session,
135    user: &Identity,
136    error: Option<&str>,
137    status: actix_web::http::StatusCode,
138) -> HttpResponse {
139    let mut context = Context::new();
140    context.insert(
141        "csrf_token",
142        &arc_web::helpers::csrf::get_csrf_token(session),
143    );
144    context.insert("user", user);
145    context.insert("error", &error);
146    render("profile.html", &context, status)
147}
148#[derive(Deserialize)]
149struct ProfileForm {
150    name: String,
151    email: String,
152    csrf_token: String,
153}
154#[post("")]
155async fn profile_save(
156    form: web::Form<ProfileForm>,
157    session: Session,
158    store: web::Data<dyn IdentityStore>,
159) -> HttpResponse {
160    if !csrf_ok(&session, &form.csrf_token) {
161        return HttpResponse::Forbidden().finish();
162    }
163    let Some(user) = identity(&session) else {
164        return HttpResponse::Unauthorized().finish();
165    };
166    match store
167        .update_profile(&user.id, &form.name, &form.email)
168        .await
169    {
170        Ok(updated) => {
171            cache_identity(&session, &updated);
172            HttpResponse::SeeOther()
173                .insert_header(("Location", "/admin/profile"))
174                .finish()
175        }
176        Err(error) => {
177            let message = error.to_string();
178            profile_response(
179                &session,
180                &user,
181                Some(&message),
182                actix_web::http::StatusCode::UNPROCESSABLE_ENTITY,
183            )
184        }
185    }
186}
187#[derive(Deserialize)]
188struct PasswordForm {
189    current_password: String,
190    new_password: String,
191    csrf_token: String,
192}
193#[post("/password")]
194async fn password_save(
195    form: web::Form<PasswordForm>,
196    session: Session,
197    store: web::Data<dyn IdentityStore>,
198) -> HttpResponse {
199    if !csrf_ok(&session, &form.csrf_token) {
200        return HttpResponse::Forbidden().finish();
201    }
202    let Some(user) = identity(&session) else {
203        return HttpResponse::Unauthorized().finish();
204    };
205    if store
206        .authenticate(&user.email, &form.current_password)
207        .await
208        .is_err()
209    {
210        return profile_response(
211            &session,
212            &user,
213            Some("Current password is incorrect."),
214            actix_web::http::StatusCode::UNAUTHORIZED,
215        );
216    }
217    match store.change_password(&user.id, &form.new_password).await {
218        Ok(()) => HttpResponse::SeeOther()
219            .insert_header(("Location", "/admin/profile"))
220            .finish(),
221        Err(error) => {
222            let message = error.to_string();
223            profile_response(
224                &session,
225                &user,
226                Some(&message),
227                actix_web::http::StatusCode::UNPROCESSABLE_ENTITY,
228            )
229        }
230    }
231}
232
233#[get("")]
234async fn users(session: Session, store: web::Data<dyn IdentityStore>) -> HttpResponse {
235    let Some(actor) = identity(&session) else {
236        return HttpResponse::Found()
237            .insert_header(("Location", "/signin"))
238            .finish();
239    };
240    if !actor.has_role("admin") {
241        return HttpResponse::Forbidden().finish();
242    }
243    match store.list().await {
244        Ok(identities) => {
245            users_response(&session, &identities, None, actix_web::http::StatusCode::OK)
246        }
247        Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
248    }
249}
250
251fn users_response(
252    session: &Session,
253    identities: &[Identity],
254    error: Option<&str>,
255    status: actix_web::http::StatusCode,
256) -> HttpResponse {
257    let mut context = Context::new();
258    context.insert("users", identities);
259    context.insert(
260        "csrf_token",
261        &arc_web::helpers::csrf::get_csrf_token(session),
262    );
263    context.insert("error", &error);
264    render("users.html", &context, status)
265}
266
267#[derive(Deserialize)]
268struct CreateUserForm {
269    name: String,
270    email: String,
271    password: String,
272    roles: String,
273    csrf_token: String,
274}
275
276#[post("")]
277async fn user_create(
278    form: web::Form<CreateUserForm>,
279    session: Session,
280    store: web::Data<dyn IdentityStore>,
281) -> HttpResponse {
282    if !csrf_ok(&session, &form.csrf_token) {
283        return HttpResponse::Forbidden().finish();
284    }
285    let Some(actor) = identity(&session) else {
286        return HttpResponse::Unauthorized().finish();
287    };
288    if !actor.has_role("admin") {
289        return HttpResponse::Forbidden().finish();
290    }
291    let roles = form
292        .roles
293        .split(',')
294        .map(str::trim)
295        .filter(|v| !v.is_empty())
296        .map(str::to_owned)
297        .collect::<Vec<_>>();
298    match store
299        .create_user(&form.name, &form.email, &form.password, &roles)
300        .await
301    {
302        Ok(_) => HttpResponse::SeeOther()
303            .insert_header(("Location", "/admin/users"))
304            .finish(),
305        Err(error) => match store.list().await {
306            Ok(identities) => {
307                let message = error.to_string();
308                users_response(
309                    &session,
310                    &identities,
311                    Some(&message),
312                    actix_web::http::StatusCode::UNPROCESSABLE_ENTITY,
313                )
314            }
315            Err(_) => HttpResponse::InternalServerError().finish(),
316        },
317    }
318}
319#[derive(Deserialize)]
320struct RolesForm {
321    roles: String,
322    csrf_token: String,
323}
324#[post("/{id}/roles")]
325async fn roles_save(
326    id: web::Path<String>,
327    form: web::Form<RolesForm>,
328    session: Session,
329    store: web::Data<dyn IdentityStore>,
330) -> HttpResponse {
331    if !csrf_ok(&session, &form.csrf_token) {
332        return HttpResponse::Forbidden().finish();
333    }
334    let Some(actor) = identity(&session) else {
335        return HttpResponse::Unauthorized().finish();
336    };
337    if !actor.has_role("admin") {
338        return HttpResponse::Forbidden().finish();
339    }
340    let roles = form
341        .roles
342        .split(',')
343        .map(str::trim)
344        .filter(|v| !v.is_empty())
345        .map(str::to_owned)
346        .collect::<Vec<_>>();
347    match store.set_roles(&id, &roles).await {
348        Ok(_) => HttpResponse::SeeOther()
349            .insert_header(("Location", "/admin/users"))
350            .finish(),
351        Err(e) => HttpResponse::BadRequest().body(e.to_string()),
352    }
353}
354
355pub fn routes(cfg: &mut web::ServiceConfig) {
356    cfg.route("/signin", web::get().to(signin_page))
357        .service(signin)
358        .service(signout)
359        .service(
360            web::scope("/admin/profile")
361                .wrap(RequireSession)
362                .wrap(
363                    arc_web::http::middlewares::idle_timeout_middleware::IdleTimeoutMiddleware::from_env(),
364                )
365                .service(profile)
366                .service(profile_save)
367                .service(password_save),
368        )
369        .service(
370            web::scope("/admin/users")
371                .wrap(RequireSession)
372                .wrap(
373                    arc_web::http::middlewares::idle_timeout_middleware::IdleTimeoutMiddleware::from_env(),
374                )
375                .service(users)
376                .service(user_create)
377                .service(roles_save),
378        );
379}
380
381pub struct SessionAuthPlugin;
382#[async_trait::async_trait]
383impl ArcPlugin for SessionAuthPlugin {
384    fn name(&self) -> &'static str {
385        "auth-session"
386    }
387    fn register(&self, builder: ArcAppBuilder) -> ArcAppBuilder {
388        builder.register_routes(routes)
389    }
390}
391
392/// Explicit browser-resource opt-in middleware.
393pub struct RequireSession;
394impl<S, B> Transform<S, ServiceRequest> for RequireSession
395where
396    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
397    S::Future: 'static,
398    B: 'static,
399{
400    type Response = ServiceResponse<EitherBody<B>>;
401    type Error = Error;
402    type InitError = ();
403    type Transform = SessionCheck<S>;
404    type Future = Ready<Result<Self::Transform, ()>>;
405    fn new_transform(&self, service: S) -> Self::Future {
406        ready(Ok(SessionCheck {
407            service: Arc::new(service),
408        }))
409    }
410}
411pub struct SessionCheck<S> {
412    service: Arc<S>,
413}
414impl<S, B> Service<ServiceRequest> for SessionCheck<S>
415where
416    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
417    S::Future: 'static,
418    B: 'static,
419{
420    type Response = ServiceResponse<EitherBody<B>>;
421    type Error = Error;
422    type Future = LocalBoxFuture<'static, Result<Self::Response, Error>>;
423    forward_ready!(service);
424    fn call(&self, req: ServiceRequest) -> Self::Future {
425        if req
426            .get_session()
427            .get::<Identity>(IDENTITY_SESSION_KEY)
428            .ok()
429            .flatten()
430            .is_none()
431        {
432            return Box::pin(async move {
433                Ok(req.into_response(
434                    HttpResponse::Found()
435                        .insert_header(("Location", "/signin"))
436                        .finish()
437                        .map_into_right_body(),
438                ))
439            });
440        }
441        let fut = self.service.call(req);
442        Box::pin(async move { fut.await.map(ServiceResponse::map_into_left_body) })
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    #[test]
450    fn signin_uses_accessible_scaffold_fields_and_escapes_values() {
451        let mut context = Context::new();
452        context.insert("csrf_token", "csrf");
453        context.insert("email", "<script>alert(1)</script>");
454        context.insert("error", &Option::<String>::None);
455        let html = TEMPLATES.render("signin.html", &context).unwrap();
456        assert!(html.contains("/public/styles.css"));
457        assert!(html.contains("focused-shell"));
458        assert!(html.contains("class=\"field\""));
459        assert!(html.contains("for=\"signin-email\""));
460        assert!(!html.contains("<script>"));
461        assert!(html.contains("&lt;script&gt;"));
462    }
463
464    #[test]
465    fn profile_is_an_admin_route_with_admin_navigation() {
466        let user = Identity {
467            id: "1".into(),
468            name: "Admin".into(),
469            email: "admin@example.com".into(),
470            active: true,
471            roles: vec!["admin".into()],
472        };
473        let mut context = Context::new();
474        context.insert("csrf_token", "csrf");
475        context.insert("user", &user);
476        context.insert("users", &vec![user]);
477        context.insert("error", &Option::<String>::None);
478        let profile_html = TEMPLATES.render("profile.html", &context).unwrap();
479        let users_html = TEMPLATES.render("users.html", &context).unwrap();
480        assert!(profile_html.contains("href=\"/admin/profile\""));
481        assert!(profile_html.contains("action=\"/admin/profile\""));
482        assert!(profile_html.contains("action=\"/admin/profile/password\""));
483        assert!(users_html.contains("href=\"/admin/profile\""));
484    }
485}