Compare commits
6 Commits
ad58d2de7e
...
9a4c725b4e
Author | SHA1 | Date | |
---|---|---|---|
9a4c725b4e | |||
f08fddc79c | |||
da74acaed8 | |||
91fd763fe1 | |||
9e72e6a044 | |||
cb4daa1112 |
7
Cargo.lock
generated
7
Cargo.lock
generated
@ -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"
|
||||
|
@ -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"
|
@ -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";
|
@ -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()
|
||||
}
|
||||
|
@ -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())
|
||||
|
@ -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;
|
||||
|
13
src/main.rs
13
src/main.rs
@ -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)
|
||||
|
@ -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
|
||||
|
12
templates/access_denied.html
Normal file
12
templates/access_denied.html
Normal 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>
|
@ -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"
|
||||
|
@ -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>
|
||||
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user