Compare commits

...

6 Commits

11 changed files with 142 additions and 56 deletions

7
Cargo.lock generated
View File

@ -395,6 +395,7 @@ dependencies = [
"mime_guess",
"serde",
"serde_json",
"urlencoding",
"uuid",
]
@ -1625,6 +1626,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "urlencoding"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68b90931029ab9b034b300b797048cf23723400aa757e8a2bfb9d748102f9821"
[[package]]
name = "uuid"
version = "0.8.2"

View File

@ -19,4 +19,5 @@ bcrypt = "0.12.1"
uuid = { version = "0.8.2", features = ["v4"] }
mime_guess = "2.0.4"
askama = "0.11.1"
futures-util = "0.3.21"
futures-util = "0.3.21"
urlencoding = "2.1.0"

View File

@ -9,13 +9,22 @@ pub const DEFAULT_ADMIN_PASSWORD: &str = "admin";
pub const APP_NAME: &str = "Basic OIDC";
/// Maximum session duration after inactivity, in seconds
pub const MAX_INACTIVITY_DURATION: u64 = 60 * 30;
pub const MAX_INACTIVITY_DURATION: i64 = 60 * 30;
/// Minimum interval between each last activity record in session
pub const MIN_ACTIVITY_RECORD_TIME: u64 = 10;
/// Maximum session duration (6 hours)
pub const MAX_SESSION_DURATION: i64 = 3600 * 6;
/// Minimum password length
pub const MIN_PASS_LEN: usize = 4;
/// Maximum session duration (6 hours)
pub const MAX_SESSION_DURATION: u64 = 3600 * 6;
/// The name of the cookie used to store session information
pub const SESSION_COOKIE_NAME: &str = "auth-cookie";
/// Authenticated routes prefix
pub const AUTHENTICATED_ROUTES: &str = "/settings";
/// Admin routes prefix
pub const ADMIN_ROUTES: &str = "/admin";
/// Auth route
pub const LOGIN_ROUTE: &str = "/login";

View File

@ -1,8 +1,22 @@
use std::fmt::Display;
use actix_web::HttpResponse;
use crate::constants::LOGIN_ROUTE;
/// Create a redirect user response
pub fn redirect_user(uri: &str) -> HttpResponse {
HttpResponse::Found()
.append_header(("Location", uri))
.finish()
}
}
/// Redirect user to authenticate him
pub fn redirect_user_for_login<P: Display>(redirect_uri: P) -> HttpResponse {
HttpResponse::Found()
.append_header((
"Location",
format!("{}?redirect={}", LOGIN_ROUTE, urlencoding::encode(&redirect_uri.to_string()))
))
.finish()
}

View File

@ -16,6 +16,7 @@ struct BaseLoginPage {
success: String,
page_title: &'static str,
app_name: &'static str,
redirect_uri: String,
}
#[derive(Template)]
@ -41,6 +42,7 @@ pub struct LoginRequestBody {
#[derive(serde::Deserialize)]
pub struct LoginRequestQuery {
logout: Option<bool>,
redirect: Option<String>,
}
/// Authenticate user
@ -52,6 +54,14 @@ pub async fn login_route(users: web::Data<Addr<UsersActor>>,
let mut success = String::new();
let mut login = String::new();
let redirect_uri = match query.redirect.as_deref() {
None => "/",
Some(s) => match s.starts_with('/') && !s.starts_with("//") {
true => s,
false => "/",
}
};
// Check if user session must be closed
if let Some(true) = query.logout {
id.forget();
@ -60,7 +70,7 @@ pub async fn login_route(users: web::Data<Addr<UsersActor>>,
// Check if user is already authenticated
if SessionIdentity(&id).is_authenticated() {
return redirect_user("/");
return redirect_user(redirect_uri);
}
// Check if user is setting a new password
@ -78,7 +88,7 @@ pub async fn login_route(users: web::Data<Addr<UsersActor>>,
danger = "Failed to change password!".to_string();
} else {
SessionIdentity(&id).set_status(SessionStatus::SignedIn);
return redirect_user("/");
return redirect_user(redirect_uri);
}
}
}
@ -100,7 +110,7 @@ pub async fn login_route(users: web::Data<Addr<UsersActor>>,
if user.need_reset_password {
SessionIdentity(&id).set_status(SessionStatus::NeedNewPassword);
} else {
return redirect_user("/");
return redirect_user(redirect_uri);
}
}
@ -122,6 +132,7 @@ pub async fn login_route(users: web::Data<Addr<UsersActor>>,
danger,
success,
app_name: APP_NAME,
redirect_uri: urlencoding::encode(redirect_uri).to_string(),
},
min_pass_len: MIN_PASS_LEN,
}.render().unwrap());
@ -136,6 +147,7 @@ pub async fn login_route(users: web::Data<Addr<UsersActor>>,
danger,
success,
app_name: APP_NAME,
redirect_uri: urlencoding::encode(redirect_uri).to_string(),
},
login,
}.render().unwrap())

View File

@ -1,9 +1,7 @@
use actix_identity::Identity;
use serde::{Deserialize, Serialize};
use crate::constants::{MAX_INACTIVITY_DURATION, MAX_SESSION_DURATION, MIN_ACTIVITY_RECORD_TIME};
use crate::data::user::{User, UserID};
use crate::utils::time::time;
#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)]
pub enum SessionStatus {
@ -21,30 +19,21 @@ impl Default for SessionStatus {
#[derive(Debug, Serialize, Deserialize, Default)]
struct SessionIdentityData {
pub struct SessionIdentityData {
pub id: UserID,
pub is_admin: bool,
login_time: u64,
last_access: u64,
pub status: SessionStatus,
}
pub struct SessionIdentity<'a>(pub &'a Identity);
impl<'a> SessionIdentity<'a> {
pub fn set_user(&self, user: &User) {
self.set_session_data(&SessionIdentityData {
id: user.uid.clone(),
is_admin: user.admin,
login_time: time(),
last_access: time(),
status: SessionStatus::SignedIn,
});
fn get_session_data(&self) -> Option<SessionIdentityData> {
Self::deserialize_session_data(self.0.identity())
}
fn get_session_data(&self) -> Option<SessionIdentityData> {
let mut res: Option<SessionIdentityData> = self.0.identity()
.as_deref()
pub fn deserialize_session_data(data: Option<String>) -> Option<SessionIdentityData> {
let res: Option<SessionIdentityData> = data.as_deref()
.map(serde_json::from_str)
.map(|f| match f {
Ok(d) => Some(d),
@ -62,26 +51,6 @@ impl<'a> SessionIdentity<'a> {
}
}
if let Some(session) = res.as_mut() {
if session.login_time + MAX_SESSION_DURATION < time() {
log::info!("Session for {} reached max duration timeout", session.id);
self.0.forget();
return None;
}
if session.last_access + MAX_INACTIVITY_DURATION < time() {
log::info!("Session is expired for {}", session.id);
self.0.forget();
return None;
}
if session.last_access + MIN_ACTIVITY_RECORD_TIME < time() {
log::debug!("Refresh last access for session");
session.last_access = time();
self.set_session_data(session);
}
}
res
}
@ -92,6 +61,14 @@ impl<'a> SessionIdentity<'a> {
self.0.remember(s);
}
pub fn set_user(&self, user: &User) {
self.set_session_data(&SessionIdentityData {
id: user.uid.clone(),
is_admin: user.admin,
status: SessionStatus::SignedIn,
});
}
pub fn set_status(&self, status: SessionStatus) {
let mut sess = self.get_session_data().unwrap_or_default();
sess.status = status;

View File

@ -1,11 +1,13 @@
use actix::Actor;
use actix_identity::{CookieIdentityPolicy, IdentityService};
use actix_web::{App, get, HttpServer, web};
use actix_web::cookie::SameSite;
use actix_web::cookie::time::Duration;
use actix_web::middleware::Logger;
use clap::Parser;
use basic_oidc::actors::users_actor::UsersActor;
use basic_oidc::constants::{DEFAULT_ADMIN_PASSWORD, DEFAULT_ADMIN_USERNAME};
use basic_oidc::constants::{DEFAULT_ADMIN_PASSWORD, DEFAULT_ADMIN_USERNAME, MAX_INACTIVITY_DURATION, MAX_SESSION_DURATION, SESSION_COOKIE_NAME};
use basic_oidc::controllers::assets_controller::assets_route;
use basic_oidc::controllers::login_controller::{login_route, logout_route};
use basic_oidc::data::app_config::AppConfig;
@ -63,16 +65,19 @@ async fn main() -> std::io::Result<()> {
HttpServer::new(move || {
let policy = CookieIdentityPolicy::new(config.token_key.as_bytes())
.name("auth-cookie")
.secure(config.secure_auth_cookie);
.name(SESSION_COOKIE_NAME)
.secure(config.secure_auth_cookie)
.visit_deadline(Duration::seconds(MAX_INACTIVITY_DURATION))
.login_deadline(Duration::seconds(MAX_SESSION_DURATION))
.same_site(SameSite::Strict);
App::new()
.app_data(web::Data::new(users_actor.clone()))
.wrap(Logger::default())
.wrap(IdentityService::new(policy))
.wrap(AuthMiddleware {})
.wrap(IdentityService::new(policy))
// /health route
.service(health)

View File

@ -4,8 +4,14 @@ use std::future::{Future, ready, Ready};
use std::pin::Pin;
use std::rc::Rc;
use actix_identity::RequestIdentity;
use actix_web::{dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}, Error, HttpResponse};
use actix_web::body::EitherBody;
use askama::Template;
use crate::constants::{ADMIN_ROUTES, AUTHENTICATED_ROUTES};
use crate::controllers::base_controller::redirect_user_for_login;
use crate::data::session_identity::{SessionIdentity, SessionIdentityData};
// There are two steps in middleware processing.
// 1. Middleware initialization, middleware factory gets called with
@ -33,6 +39,27 @@ impl<S, B> Transform<S, ServiceRequest> for AuthMiddleware
}
}
#[derive(Debug)]
enum SessionStatus {
SignedOut,
RegularUser,
Admin,
}
impl SessionStatus {
pub fn is_auth(&self) -> bool {
!matches!(self, SessionStatus::SignedOut)
}
pub fn is_admin(&self) -> bool {
matches!(self, SessionStatus::Admin)
}
}
#[derive(Template)]
#[template(path = "access_denied.html")]
struct AccessDeniedTemplate {}
pub struct AuthInnerMiddleware<S> {
service: Rc<S>,
}
@ -45,13 +72,13 @@ impl<S, B> Service<ServiceRequest> for AuthInnerMiddleware<S>
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = Error;
#[allow(clippy::type_complexity)]
type Future = Pin<Box<dyn Future<Output=Result<Self::Response, Self::Error>>>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
println!("Hi from start. You requested: {}", req.path());
let service = Rc::clone(&self.service);
// Forward request
@ -64,6 +91,27 @@ impl<S, B> Service<ServiceRequest> for AuthInnerMiddleware<S>
));
}
let identity = match SessionIdentity::deserialize_session_data(req.get_identity()) {
None => SessionStatus::SignedOut,
Some(SessionIdentityData { is_admin: true, .. }) => SessionStatus::Admin,
_ => SessionStatus::RegularUser,
};
// Redirect user to login page
if !identity.is_auth() && (req.path().starts_with(ADMIN_ROUTES) ||
req.path().starts_with(AUTHENTICATED_ROUTES)) {
let path = req.path().to_string();
return Ok(req.into_response(redirect_user_for_login(path))
.map_into_right_body());
}
// Restrict access to admin pages
if !identity.is_admin() && req.path().starts_with(ADMIN_ROUTES) {
return Ok(req.into_response(HttpResponse::Unauthorized()
.body(AccessDeniedTemplate {}.render().unwrap()))
.map_into_right_body());
}
service
.call(req)
.await

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Access denied</title>
<link href="/assets/css/bootstrap.css" rel="stylesheet" crossorigin="anonymous"/>
</head>
<body>
<p>You are not allowed to access this resource.</p>
</body>
</html>

View File

@ -1,6 +1,6 @@
{% extends "base_login_page.html" %}
{% block content %}
<form action="/login" method="post">
<form action="/login?redirect={{ redirect_uri }}" method="post">
<div>
<div class="form-floating">
<input name="login" type="text" required class="form-control" id="floatingName" placeholder="unsername"

View File

@ -1,6 +1,6 @@
{% extends "base_login_page.html" %}
{% block content %}
<form action="/login" method="post" id="reset_password_form">
<form action="/login?redirect={{ redirect_uri }}" method="post" id="reset_password_form">
<div>
<p>You need to configure a new password:</p>
@ -11,13 +11,13 @@
<div class="form-floating">
<input name="password" type="password" required class="form-control" id="pass1"
placeholder="unsername"/>
placeholder="Password"/>
<label for="pass1">New password</label>
</div>
<div class="form-floating">
<input type="password" required class="form-control" id="pass2"
placeholder="Password"/>
placeholder="Password confirmation"/>
<label for="pass2">Confirm new password</label>
</div>
</div>
@ -47,6 +47,7 @@
form.submit();
})
</script>