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 lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sign in</title><link rel="stylesheet" href="/public/styles.css"></head><body><main class="focused-shell"><a class="brand" href="/"><span class="brand__mark">A</span><span>Arc</span></a><section class="focused-panel"><p class="eyebrow">Authorized operators</p><h1>Sign in</h1>{}<form method="post" action="/signin"><input type="hidden" name="csrf_token" value="{}"><label>Email<input type="email" name="email" autocomplete="username"></label><label>Password<input type="password" name="password" autocomplete="current-password"></label><button class="button button--primary button--wide">Sign in</button></form></section><p class="build-mark">ARC / INSTRUMENT PANEL</p></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 lang=en><head><meta charset=utf-8><meta name=viewport content=\"width=device-width,initial-scale=1\"><title>{title}</title><link rel=stylesheet href=/public/styles.css></head><body><main class=focused-shell><nav><a href=/admin>Admin</a> <a href=/profile>Profile</a> <a href=/admin/users>Users</a></nav><section class=focused-panel><h1>{title}</h1>{body}</section></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> <{}> 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
241pub 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}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 #[test]
299 fn signin_uses_scaffold_styles() {
300 let html = signin_html("csrf", None);
301 assert!(html.contains("/public/styles.css"));
302 assert!(html.contains("focused-shell"));
303 }
304}