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,
14};
15
16pub const IDENTITY_SESSION_KEY: &str = "arc_auth_identity";
17pub fn identity(session: &Session) -> Option<Identity> {
18    session.get(IDENTITY_SESSION_KEY).ok().flatten()
19}
20
21#[derive(Deserialize)]
22struct SignIn {
23    email: String,
24    password: String,
25    csrf_token: String,
26}
27#[post("/signin")]
28async fn signin(
29    form: web::Form<SignIn>,
30    session: Session,
31    store: web::Data<dyn IdentityStore>,
32) -> HttpResponse {
33    if !arc_web::helpers::csrf::validate_and_regenerate_csrf_token(&session, &form.csrf_token) {
34        return HttpResponse::Forbidden().finish();
35    }
36    match store.authenticate(&form.email, &form.password).await {
37        Ok(user) => {
38            let _ = session.insert(IDENTITY_SESSION_KEY, user);
39            HttpResponse::SeeOther()
40                .insert_header(("Location", "/admin"))
41                .finish()
42        }
43        Err(_) => HttpResponse::Unauthorized()
44            .content_type("text/html")
45            .body(signin_html(
46                &arc_web::helpers::csrf::get_csrf_token(&session),
47                Some("Email or password was not recognized."),
48            )),
49    }
50}
51async fn signin_page(session: Session) -> HttpResponse {
52    HttpResponse::Ok()
53        .content_type("text/html")
54        .body(signin_html(
55            &arc_web::helpers::csrf::get_csrf_token(&session),
56            None,
57        ))
58}
59fn signin_html(csrf: &str, error: Option<&str>) -> String {
60    format!(
61        r#"<!doctype html><html><body><main><h1>Sign in</h1>{}<form method="post" action="/signin"><input type="hidden" name="csrf_token" value="{}"><label>Email<input type="email" name="email"></label><label>Password<input type="password" name="password"></label><button>Sign in</button></form></main></body></html>"#,
62        error
63            .map(|e| format!("<p role=alert>{e}</p>"))
64            .unwrap_or_default(),
65        csrf
66    )
67}
68#[derive(Deserialize)]
69struct SignOut {
70    csrf_token: String,
71}
72#[post("/signout")]
73async fn signout(form: web::Form<SignOut>, session: Session) -> HttpResponse {
74    if !arc_web::helpers::csrf::validate_and_regenerate_csrf_token(&session, &form.csrf_token) {
75        return HttpResponse::Forbidden().finish();
76    }
77    session.remove(IDENTITY_SESSION_KEY);
78    HttpResponse::SeeOther()
79        .insert_header(("Location", "/"))
80        .finish()
81}
82
83fn csrf_ok(session: &Session, token: &str) -> bool {
84    arc_web::helpers::csrf::validate_and_regenerate_csrf_token(session, token)
85}
86fn page(title: &str, body: String) -> HttpResponse {
87    HttpResponse::Ok().content_type("text/html").body(format!("<!doctype html><html><body><nav><a href=/profile>Profile</a> <a href=/admin/users>Users</a></nav><main><h1>{title}</h1>{body}</main></body></html>"))
88}
89
90#[get("/profile")]
91async fn profile(session: Session) -> HttpResponse {
92    let Some(user) = identity(&session) else {
93        return HttpResponse::Found()
94            .insert_header(("Location", "/signin"))
95            .finish();
96    };
97    let token = arc_web::helpers::csrf::get_csrf_token(&session);
98    page(
99        "Profile",
100        format!(
101            r#"<form method=post><input type=hidden name=csrf_token value="{token}"><label>Name<input name=name value="{}"></label><label>Email<input type=email name=email value="{}"></label><button>Save</button></form><form method=post action=/profile/password><input type=hidden name=csrf_token value="{token}"><label>Current password<input type=password name=current_password></label><label>New password<input type=password name=new_password></label><button>Change password</button></form>"#,
102            user.name, user.email
103        ),
104    )
105}
106#[derive(Deserialize)]
107struct ProfileForm {
108    name: String,
109    email: String,
110    csrf_token: String,
111}
112#[post("/profile")]
113async fn profile_save(
114    form: web::Form<ProfileForm>,
115    session: Session,
116    store: web::Data<dyn IdentityStore>,
117) -> HttpResponse {
118    if !csrf_ok(&session, &form.csrf_token) {
119        return HttpResponse::Forbidden().finish();
120    }
121    let Some(user) = identity(&session) else {
122        return HttpResponse::Unauthorized().finish();
123    };
124    match store
125        .update_profile(&user.id, &form.name, &form.email)
126        .await
127    {
128        Ok(updated) => {
129            let _ = session.insert(IDENTITY_SESSION_KEY, updated);
130            HttpResponse::SeeOther()
131                .insert_header(("Location", "/profile"))
132                .finish()
133        }
134        Err(e) => HttpResponse::BadRequest().body(e.to_string()),
135    }
136}
137#[derive(Deserialize)]
138struct PasswordForm {
139    current_password: String,
140    new_password: String,
141    csrf_token: String,
142}
143#[post("/profile/password")]
144async fn password_save(
145    form: web::Form<PasswordForm>,
146    session: Session,
147    store: web::Data<dyn IdentityStore>,
148) -> HttpResponse {
149    if !csrf_ok(&session, &form.csrf_token) {
150        return HttpResponse::Forbidden().finish();
151    }
152    let Some(user) = identity(&session) else {
153        return HttpResponse::Unauthorized().finish();
154    };
155    if store
156        .authenticate(&user.email, &form.current_password)
157        .await
158        .is_err()
159    {
160        return HttpResponse::Unauthorized().body("current password is incorrect");
161    }
162    match store.change_password(&user.id, &form.new_password).await {
163        Ok(()) => HttpResponse::SeeOther()
164            .insert_header(("Location", "/profile"))
165            .finish(),
166        Err(e) => HttpResponse::BadRequest().body(e.to_string()),
167    }
168}
169
170#[get("/admin/users")]
171async fn users(session: Session, store: web::Data<dyn IdentityStore>) -> HttpResponse {
172    let Some(actor) = identity(&session) else {
173        return HttpResponse::Found()
174            .insert_header(("Location", "/signin"))
175            .finish();
176    };
177    if !actor.has_role("admin") {
178        return HttpResponse::Forbidden().finish();
179    }
180    let token = arc_web::helpers::csrf::get_csrf_token(&session);
181    match store.list().await {Ok(users)=>page("Users",users.into_iter().map(|u|format!(r#"<section><strong>{}</strong> &lt;{}&gt; roles: {}<form method=post action="/admin/users/{}/roles"><input type=hidden name=csrf_token value="{}"><input name=roles value="{}"><button>Set roles</button></form></section>"#,u.name,u.email,u.roles.join(", "),u.id,token,u.roles.join(","))).collect()),Err(e)=>HttpResponse::InternalServerError().body(e.to_string())}
182}
183#[derive(Deserialize)]
184struct RolesForm {
185    roles: String,
186    csrf_token: String,
187}
188#[post("/admin/users/{id}/roles")]
189async fn roles_save(
190    id: web::Path<String>,
191    form: web::Form<RolesForm>,
192    session: Session,
193    store: web::Data<dyn IdentityStore>,
194) -> HttpResponse {
195    if !csrf_ok(&session, &form.csrf_token) {
196        return HttpResponse::Forbidden().finish();
197    }
198    let Some(actor) = identity(&session) else {
199        return HttpResponse::Unauthorized().finish();
200    };
201    if !actor.has_role("admin") {
202        return HttpResponse::Forbidden().finish();
203    }
204    let roles = form
205        .roles
206        .split(',')
207        .map(str::trim)
208        .filter(|v| !v.is_empty())
209        .map(str::to_owned)
210        .collect::<Vec<_>>();
211    match store.set_roles(&id, &roles).await {
212        Ok(_) => HttpResponse::SeeOther()
213            .insert_header(("Location", "/admin/users"))
214            .finish(),
215        Err(e) => HttpResponse::BadRequest().body(e.to_string()),
216    }
217}
218
219pub fn routes(cfg: &mut web::ServiceConfig) {
220    cfg.route("/signin", web::get().to(signin_page))
221        .service(signin)
222        .service(signout)
223        .service(profile)
224        .service(profile_save)
225        .service(password_save)
226        .service(users)
227        .service(roles_save);
228}
229
230pub struct SessionAuthPlugin;
231#[async_trait::async_trait]
232impl ArcPlugin for SessionAuthPlugin {
233    fn name(&self) -> &'static str {
234        "auth-session"
235    }
236    fn register(&self, builder: ArcAppBuilder) -> ArcAppBuilder {
237        builder.register_routes(routes)
238    }
239}
240
241/// Explicit browser-resource opt-in middleware.
242pub struct RequireSession;
243impl<S, B> Transform<S, ServiceRequest> for RequireSession
244where
245    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
246    S::Future: 'static,
247    B: 'static,
248{
249    type Response = ServiceResponse<EitherBody<B>>;
250    type Error = Error;
251    type InitError = ();
252    type Transform = SessionCheck<S>;
253    type Future = Ready<Result<Self::Transform, ()>>;
254    fn new_transform(&self, service: S) -> Self::Future {
255        ready(Ok(SessionCheck {
256            service: Arc::new(service),
257        }))
258    }
259}
260pub struct SessionCheck<S> {
261    service: Arc<S>,
262}
263impl<S, B> Service<ServiceRequest> for SessionCheck<S>
264where
265    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
266    S::Future: 'static,
267    B: 'static,
268{
269    type Response = ServiceResponse<EitherBody<B>>;
270    type Error = Error;
271    type Future = LocalBoxFuture<'static, Result<Self::Response, Error>>;
272    forward_ready!(service);
273    fn call(&self, req: ServiceRequest) -> Self::Future {
274        if req
275            .get_session()
276            .get::<Identity>(IDENTITY_SESSION_KEY)
277            .ok()
278            .flatten()
279            .is_none()
280        {
281            return Box::pin(async move {
282                Ok(req.into_response(
283                    HttpResponse::Found()
284                        .insert_header(("Location", "/signin"))
285                        .finish()
286                        .map_into_right_body(),
287                ))
288            });
289        }
290        let fut = self.service.call(req);
291        Box::pin(async move { fut.await.map(ServiceResponse::map_into_left_body) })
292    }
293}