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