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
396pub 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("<script>"));
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}