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