Skip to content

Commit

Permalink
Add CAS server
Browse files Browse the repository at this point in the history
  • Loading branch information
Mubelotix committed Aug 19, 2024
1 parent 5a41541 commit 692c85d
Show file tree
Hide file tree
Showing 8 changed files with 167 additions and 6 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ urlencoding = "2.1.3"
isahc = "1.7"
string-tools = "0.1.0"
chrono = "0.4.38"
rand = "0.8"
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
A JWT-based authentification system for unofficial INSA Rouen's services.
A JWT-based authentification system for [unofficial INSA Rouen's services](https://insa.lol/).

Provides a simple CAS server.

## Generate crypto keys

```bash
openssl ecparam -genkey -noout -name prime256v1 | openssl pkcs8 -topk8 -nocrypt -out private.pem
openssl pkey -in private.pem -pubout -out public.pem
```

## Put an entire static website behind auth using nginx

Expand Down
15 changes: 11 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@ pub use verify_mod::*;
#[path ="login.rs"]
mod login_mod;
pub use login_mod::*;
mod provider;
pub use provider::*;

#[derive(Debug, Serialize, Deserialize)]
struct Claims {
pub struct Claims {
exp: usize, // Expiration time (as UTC timestamp)
iat: usize, // Issued at (as UTC timestamp)

email: String, // [email protected]
uid: String, // 167900
uid_number: usize, // fname
uid: String, // fname
uid_number: usize, // 167900
groups: Vec<String>, // [ad-etudiants, etudiants, etudiants-cve ...]
given_name: String, // Firstname
family_name: String, // Name
Expand All @@ -43,6 +45,11 @@ mod constants {

use constants::*;

pub fn now() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()
}

#[launch]
fn rocket() -> _ {
let private_key = std::fs::read("private.pem").expect("Failed to read private key");
Expand All @@ -51,5 +58,5 @@ fn rocket() -> _ {
let decoding_key: DecodingKey = DecodingKey::from_ec_pem(&public_key).expect("Invalid public key");
rocket::build()
.manage((encoding_key, decoding_key))
.mount("/", routes![login_callback, verify, login])
.mount("/", routes![login_callback, verify, login, provider_login, provider_validate])
}
39 changes: 39 additions & 0 deletions src/provider/login.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//! Services redirect to this page to initiate the login process.
//! We must redirect the user to their provided callback URL after the login process.
use super::*;

pub struct ProviderLoginResponse(String);

impl <'r, 'o: 'r> Responder<'r, 'o> for ProviderLoginResponse {
fn respond_to(self, _: &'r rocket::Request<'_>) -> rocket::response::Result<'o> {
let mut response = Response::build();
let response = response.status(Status::SeeOther)
.header(Header::new("Location", self.0));
Ok(response.finalize())
}
}

#[get("/cas/login?<service>")]
pub async fn provider_login(keys: &State<(EncodingKey, DecodingKey)>, cookies: &CookieJar<'_>, service: String) -> ProviderLoginResponse {
// TODO: Add a whitelist of services

match verify(keys, cookies) {
Ok(claims) => {
// Generate a random small ticket
let mut ticket = String::new();
for _ in 0..32 {
ticket.push((rand::random::<u8>() % 26 + 97) as char);
}

TICKETS.write().await.insert(ticket.clone(), (now(), service.clone(), claims.0));

ProviderLoginResponse(format!("{service}?ticket={ticket}"))
},
Err(_) => {
let this_url = format!("/cas/login?service={}", urlencoding::encode(&service));
let next = format!("/login?next={}", urlencoding::encode(&this_url));
ProviderLoginResponse(next)
},
}
}
12 changes: 12 additions & 0 deletions src/provider/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
mod login;
mod validate;
pub use login::*;
pub use validate::*;
use crate::*;

use rocket::tokio::sync::RwLock;
use std::{collections::HashMap, sync::LazyLock};

static TICKETS: LazyLock<RwLock<HashMap<String, (u64, String, Claims)>>> = LazyLock::new(|| {
RwLock::new(HashMap::new())
});
92 changes: 92 additions & 0 deletions src/provider/validate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//! This endpoint is called by services to request claims from a short-lived ticket.
use super::*;

pub enum ProviderValidateResponse {
Ok(Claims),
TicketDoesNotExist,
ServiceMismatch { expected: String, got: String },
}

impl <'r, 'o: 'r> Responder<'r, 'o> for ProviderValidateResponse {
fn respond_to(self, _: &'r rocket::Request<'_>) -> rocket::response::Result<'o> {
match self {
ProviderValidateResponse::Ok(claims) => {
let username = claims.email.split_once('@').map(|(username, _)| username).unwrap_or(&claims.email).replace('.', "_");
let email = &claims.email;
let uid = &claims.uid;
let uid_number = &claims.uid_number;
let given_name = &claims.given_name;
let family_name = &claims.family_name;
let picture = format!("https://api.dicebear.com/5.x/identicon/png?seed={uid}");

let mut response = Response::build();
let response = response.status(Status::Ok)
.header(Header::new("Content-Type", "text/xml"));
let body = format!(
r#"
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
<cas:authenticationSuccess>
<cas:user>{username}</cas:user>
<cas:attributes>
<cas:uid>{uid}</cas:uid>
<cas:uidNumber>{uid_number}</cas:uidNumber>
<cas:givenName>{given_name}</cas:givenName>
<cas:sn>{family_name}</cas:sn>
<cas:mail>{email}</cas:mail>
<cas:picture>{picture}</cas:picture>
</cas:attributes>
</cas:authenticationSuccess>
</cas:serviceResponse>
"#);
response.sized_body(body.len(), Cursor::new(body));
Ok(response.finalize())
},
ProviderValidateResponse::TicketDoesNotExist => {
let mut response = Response::build();
let response = response.status(Status::BadRequest)
.header(Header::new("Content-Type", "text/xml"));
let body = r#"
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
<cas:authenticationFailure code="INVALID_TICKET">
Ticket does not exist. Might have expired (5 minutes) or been used already.
</cas:authenticationFailure>
</cas:serviceResponse>
"#;
response.sized_body(body.len(), Cursor::new(body));
Ok(response.finalize())
},
ProviderValidateResponse::ServiceMismatch { expected, got } => {
let mut response = Response::build();
let response = response.status(Status::BadRequest)
.header(Header::new("Content-Type", "text/xml"));
let body = format!(r#"
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
<cas:authenticationFailure code="INVALID_SERVICE">
Service mismatch: expected {expected}, got {got}.
</cas:authenticationFailure>
</cas:serviceResponse>
"#);
response.sized_body(body.len(), Cursor::new(body));
Ok(response.finalize())
},
}
}
}

#[get("/cas/serviceValidate?<ticket>&<service>")]
pub async fn provider_validate(ticket: String, service: String) -> ProviderValidateResponse {
let mut tickets = TICKETS.write().await;
let now = now();
tickets.retain(|_, (time, _, _)| now - *time < 120);

match tickets.remove(&ticket) {
Some((_, expected, claims)) => {
if expected != service {
return ProviderValidateResponse::ServiceMismatch { expected, got: service };
}
ProviderValidateResponse::Ok(claims)
},
None => ProviderValidateResponse::TicketDoesNotExist,
}
}
2 changes: 1 addition & 1 deletion src/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ impl <'r, 'o: 'r> Responder<'r, 'o> for VerificationError {
}
}

pub struct SuccessfulVerification(Claims);
pub struct SuccessfulVerification(pub Claims);

impl <'r, 'o: 'r> Responder<'r, 'o> for SuccessfulVerification {
fn respond_to(self, _: &'r rocket::Request<'_>) -> rocket::response::Result<'o> {
Expand Down

0 comments on commit 692c85d

Please sign in to comment.