use actix_session::{Session, SessionExt};
use actix_web::{
body::EitherBody,
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
get, post, web, Error, HttpResponse,
};
use arc_auth_core::{Identity, IdentityStore};
use arc_web::{ArcAppBuilder, ArcPlugin};
use futures_util::future::LocalBoxFuture;
use serde::Deserialize;
use std::{
future::{ready, Ready},
sync::Arc,
};
pub const IDENTITY_SESSION_KEY: &str = "arc_auth_identity";
pub fn identity(session: &Session) -> Option<Identity> {
session.get(IDENTITY_SESSION_KEY).ok().flatten()
}
#[derive(Deserialize)]
struct SignIn {
email: String,
password: String,
csrf_token: String,
}
#[post("/signin")]
async fn signin(
form: web::Form<SignIn>,
session: Session,
store: web::Data<dyn IdentityStore>,
) -> HttpResponse {
if !arc_web::helpers::csrf::validate_and_regenerate_csrf_token(&session, &form.csrf_token) {
return HttpResponse::Forbidden().finish();
}
match store.authenticate(&form.email, &form.password).await {
Ok(user) => {
let _ = session.insert(IDENTITY_SESSION_KEY, user);
HttpResponse::SeeOther()
.insert_header(("Location", "/admin"))
.finish()
}
Err(_) => HttpResponse::Unauthorized()
.content_type("text/html")
.body(signin_html(
&arc_web::helpers::csrf::get_csrf_token(&session),
Some("Email or password was not recognized."),
)),
}
}
async fn signin_page(session: Session) -> HttpResponse {
HttpResponse::Ok()
.content_type("text/html")
.body(signin_html(
&arc_web::helpers::csrf::get_csrf_token(&session),
None,
))
}
fn signin_html(csrf: &str, error: Option<&str>) -> String {
format!(
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>"#,
error
.map(|e| format!("<p role=alert>{e}</p>"))
.unwrap_or_default(),
csrf
)
}
#[derive(Deserialize)]
struct SignOut {
csrf_token: String,
}
#[post("/signout")]
async fn signout(form: web::Form<SignOut>, session: Session) -> HttpResponse {
if !arc_web::helpers::csrf::validate_and_regenerate_csrf_token(&session, &form.csrf_token) {
return HttpResponse::Forbidden().finish();
}
session.remove(IDENTITY_SESSION_KEY);
HttpResponse::SeeOther()
.insert_header(("Location", "/"))
.finish()
}
fn csrf_ok(session: &Session, token: &str) -> bool {
arc_web::helpers::csrf::validate_and_regenerate_csrf_token(session, token)
}
fn page(title: &str, body: String) -> HttpResponse {
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>"))
}
#[get("/profile")]
async fn profile(session: Session) -> HttpResponse {
let Some(user) = identity(&session) else {
return HttpResponse::Found()
.insert_header(("Location", "/signin"))
.finish();
};
let token = arc_web::helpers::csrf::get_csrf_token(&session);
page(
"Profile",
format!(
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>"#,
user.name, user.email
),
)
}
#[derive(Deserialize)]
struct ProfileForm {
name: String,
email: String,
csrf_token: String,
}
#[post("/profile")]
async fn profile_save(
form: web::Form<ProfileForm>,
session: Session,
store: web::Data<dyn IdentityStore>,
) -> HttpResponse {
if !csrf_ok(&session, &form.csrf_token) {
return HttpResponse::Forbidden().finish();
}
let Some(user) = identity(&session) else {
return HttpResponse::Unauthorized().finish();
};
match store
.update_profile(&user.id, &form.name, &form.email)
.await
{
Ok(updated) => {
let _ = session.insert(IDENTITY_SESSION_KEY, updated);
HttpResponse::SeeOther()
.insert_header(("Location", "/profile"))
.finish()
}
Err(e) => HttpResponse::BadRequest().body(e.to_string()),
}
}
#[derive(Deserialize)]
struct PasswordForm {
current_password: String,
new_password: String,
csrf_token: String,
}
#[post("/profile/password")]
async fn password_save(
form: web::Form<PasswordForm>,
session: Session,
store: web::Data<dyn IdentityStore>,
) -> HttpResponse {
if !csrf_ok(&session, &form.csrf_token) {
return HttpResponse::Forbidden().finish();
}
let Some(user) = identity(&session) else {
return HttpResponse::Unauthorized().finish();
};
if store
.authenticate(&user.email, &form.current_password)
.await
.is_err()
{
return HttpResponse::Unauthorized().body("current password is incorrect");
}
match store.change_password(&user.id, &form.new_password).await {
Ok(()) => HttpResponse::SeeOther()
.insert_header(("Location", "/profile"))
.finish(),
Err(e) => HttpResponse::BadRequest().body(e.to_string()),
}
}
#[get("/admin/users")]
async fn users(session: Session, store: web::Data<dyn IdentityStore>) -> HttpResponse {
let Some(actor) = identity(&session) else {
return HttpResponse::Found()
.insert_header(("Location", "/signin"))
.finish();
};
if !actor.has_role("admin") {
return HttpResponse::Forbidden().finish();
}
let token = arc_web::helpers::csrf::get_csrf_token(&session);
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())}
}
#[derive(Deserialize)]
struct RolesForm {
roles: String,
csrf_token: String,
}
#[post("/admin/users/{id}/roles")]
async fn roles_save(
id: web::Path<String>,
form: web::Form<RolesForm>,
session: Session,
store: web::Data<dyn IdentityStore>,
) -> HttpResponse {
if !csrf_ok(&session, &form.csrf_token) {
return HttpResponse::Forbidden().finish();
}
let Some(actor) = identity(&session) else {
return HttpResponse::Unauthorized().finish();
};
if !actor.has_role("admin") {
return HttpResponse::Forbidden().finish();
}
let roles = form
.roles
.split(',')
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_owned)
.collect::<Vec<_>>();
match store.set_roles(&id, &roles).await {
Ok(_) => HttpResponse::SeeOther()
.insert_header(("Location", "/admin/users"))
.finish(),
Err(e) => HttpResponse::BadRequest().body(e.to_string()),
}
}
pub fn routes(cfg: &mut web::ServiceConfig) {
cfg.route("/signin", web::get().to(signin_page))
.service(signin)
.service(signout)
.service(profile)
.service(profile_save)
.service(password_save)
.service(users)
.service(roles_save);
}
pub struct SessionAuthPlugin;
#[async_trait::async_trait]
impl ArcPlugin for SessionAuthPlugin {
fn name(&self) -> &'static str {
"auth-session"
}
fn register(&self, builder: ArcAppBuilder) -> ArcAppBuilder {
builder.register_routes(routes)
}
}
pub struct RequireSession;
impl<S, B> Transform<S, ServiceRequest> for RequireSession
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = Error;
type InitError = ();
type Transform = SessionCheck<S>;
type Future = Ready<Result<Self::Transform, ()>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(SessionCheck {
service: Arc::new(service),
}))
}
}
pub struct SessionCheck<S> {
service: Arc<S>,
}
impl<S, B> Service<ServiceRequest> for SessionCheck<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Error>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
if req
.get_session()
.get::<Identity>(IDENTITY_SESSION_KEY)
.ok()
.flatten()
.is_none()
{
return Box::pin(async move {
Ok(req.into_response(
HttpResponse::Found()
.insert_header(("Location", "/signin"))
.finish()
.map_into_right_body(),
))
});
}
let fut = self.service.call(req);
Box::pin(async move { fut.await.map(ServiceResponse::map_into_left_body) })
}
}