mirror of
https://github.com/stoatchat/stoatchat.git
synced 2026-07-14 13:36:59 +00:00
chore: rework oauth2 route scoping
This commit is contained in:
@@ -54,6 +54,8 @@ auto_derived!(
|
||||
ReadIdentify,
|
||||
#[serde(rename = "read:servers")]
|
||||
ReadServers,
|
||||
#[serde(rename = "write:files")]
|
||||
WriteFiles,
|
||||
#[serde(rename = "events")]
|
||||
Events,
|
||||
#[serde(rename = "full")]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use axum::{extract::FromRequestParts, http::request::Parts};
|
||||
|
||||
use revolt_config::config;
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{create_error, Error, Result};
|
||||
|
||||
use crate::{Database, User};
|
||||
use crate::{util::oauth2, Database, OAuth2Scope, User};
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl FromRequestParts<Database> for User {
|
||||
@@ -17,6 +19,24 @@ impl FromRequestParts<Database> for User {
|
||||
{
|
||||
let session = db.fetch_session_by_token(session_token).await?;
|
||||
db.fetch_user(&session.user_id).await
|
||||
} else if let Some(Ok(header_oauth_token)) = parts.headers.get("x-oauth2-token").map(|v| v.to_str()) {
|
||||
let config = config().await;
|
||||
|
||||
let claims = oauth2::decode_token(
|
||||
&config.api.security.token_secret,
|
||||
header_oauth_token,
|
||||
).map_err(|_| create_error!(NotAuthenticated))?;
|
||||
|
||||
let required_scope: v0::OAuth2Scope = parts.extensions.get::<OAuth2Scope>()
|
||||
.copied()
|
||||
.ok_or_else(|| create_error!(NotAuthenticated))?
|
||||
.into();
|
||||
|
||||
if claims.scopes.contains(&v0::OAuth2Scope::Full) || claims.scopes.contains(&required_scope) {
|
||||
db.fetch_user(&claims.sub).await
|
||||
} else {
|
||||
Err(create_error!(MissingScope { scope: required_scope.to_string() }))
|
||||
}
|
||||
} else {
|
||||
Err(create_error!(NotAuthenticated))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use authifier::models::Session;
|
||||
use revolt_config::config;
|
||||
use revolt_models::v0;
|
||||
use rocket::http::Status;
|
||||
use rocket::request::{self, FromRequest, Outcome, Request};
|
||||
|
||||
@@ -36,14 +37,22 @@ impl<'r> FromRequest<'r> for User {
|
||||
} else if let Some(header_oauth_token) = header_oauth_token {
|
||||
let config = config().await;
|
||||
|
||||
let claims = oauth2::decode_token(&config.api.security.token_secret, &header_oauth_token).ok()?;
|
||||
let claims = oauth2::decode_token(
|
||||
&config.api.security.token_secret,
|
||||
&header_oauth_token,
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
if oauth2::scopes_can_access_route(&claims.scopes, request) {
|
||||
let required_scope: v0::OAuth2Scope = request.local_cache(|| None::<crate::OAuth2Scope>)
|
||||
.as_ref()
|
||||
.copied()?
|
||||
.into();
|
||||
|
||||
if claims.scopes.contains(&v0::OAuth2Scope::Full) || claims.scopes.contains(&required_scope) {
|
||||
if let Ok(user) = db.fetch_user(&claims.sub).await {
|
||||
return Some(user)
|
||||
return Some(user);
|
||||
}
|
||||
}
|
||||
|
||||
} else if let Outcome::Success(session) = request.guard::<Session>().await {
|
||||
if let Ok(user) = db.fetch_user(&session.user_id).await {
|
||||
return Some(user);
|
||||
|
||||
@@ -94,6 +94,7 @@ impl From<crate::OAuth2Scope> for OAuth2Scope {
|
||||
match value {
|
||||
crate::OAuth2Scope::ReadIdentify => OAuth2Scope::ReadIdentify,
|
||||
crate::OAuth2Scope::ReadServers => OAuth2Scope::ReadServers,
|
||||
crate::OAuth2Scope::WriteFiles => OAuth2Scope::WriteFiles,
|
||||
crate::OAuth2Scope::Events => OAuth2Scope::Events,
|
||||
crate::OAuth2Scope::Full => OAuth2Scope::Full,
|
||||
}
|
||||
@@ -105,6 +106,7 @@ impl From<OAuth2Scope> for crate::OAuth2Scope {
|
||||
match value {
|
||||
OAuth2Scope::ReadIdentify => crate::OAuth2Scope::ReadIdentify,
|
||||
OAuth2Scope::ReadServers => crate::OAuth2Scope::ReadServers,
|
||||
OAuth2Scope::WriteFiles => crate::OAuth2Scope::WriteFiles,
|
||||
OAuth2Scope::Events => crate::OAuth2Scope::Events,
|
||||
OAuth2Scope::Full => crate::OAuth2Scope::Full,
|
||||
}
|
||||
|
||||
17
crates/core/database/src/util/oauth2/axum.rs
Normal file
17
crates/core/database/src/util/oauth2/axum.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use axum::{extract::FromRequestParts, http::request::Parts};
|
||||
use revolt_result::{Error, Result};
|
||||
|
||||
use super::{OAuth2Scoped, scopes::OAuth2Scope};
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<S: Send + Sync, Scope: OAuth2Scope> FromRequestParts<S> for OAuth2Scoped<Scope> {
|
||||
type Rejection = Error;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self> {
|
||||
parts.extensions.insert(Scope::SCOPE);
|
||||
|
||||
Ok(OAuth2Scoped { _scope: PhantomData })
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use jsonwebtoken::{decode, encode, errors::Error, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use revolt_models::v0;
|
||||
use redis_kiss::AsyncCommands;
|
||||
use revolt_models::v0;
|
||||
use revolt_result::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod scopes;
|
||||
pub use scopes::OAuth2Scoped;
|
||||
|
||||
#[cfg(feature = "rocket")]
|
||||
use rocket::Request;
|
||||
pub mod rocket;
|
||||
|
||||
#[cfg(feature = "axum")]
|
||||
pub mod axum;
|
||||
|
||||
pub use jsonwebtoken::errors::{Error as JWTError, ErrorKind as JWTErrorKind};
|
||||
use ulid::Ulid;
|
||||
@@ -15,7 +21,7 @@ use ulid::Ulid;
|
||||
pub enum TokenType {
|
||||
Auth,
|
||||
Access,
|
||||
Refresh
|
||||
Refresh,
|
||||
}
|
||||
|
||||
impl TokenType {
|
||||
@@ -54,9 +60,7 @@ pub fn encode_token(
|
||||
) -> Result<String, Error> {
|
||||
let now = Utc::now();
|
||||
|
||||
let exp = now
|
||||
.checked_add_signed(token_type.lifetime())
|
||||
.unwrap();
|
||||
let exp = now.checked_add_signed(token_type.lifetime()).unwrap();
|
||||
|
||||
println!("{now:?} {exp:}");
|
||||
|
||||
@@ -69,7 +73,7 @@ pub fn encode_token(
|
||||
client_id,
|
||||
scopes,
|
||||
redirect_uri,
|
||||
code_challange_method
|
||||
code_challange_method,
|
||||
};
|
||||
|
||||
let encoding_key = EncodingKey::from_secret(token_secret.as_bytes());
|
||||
@@ -80,48 +84,15 @@ pub fn encode_token(
|
||||
pub fn decode_token(token_secret: &str, code: &str) -> Result<Claims, Error> {
|
||||
let decoding_key = DecodingKey::from_secret(token_secret.as_bytes());
|
||||
|
||||
let data = decode(code, &decoding_key, &Validation::new(jsonwebtoken::Algorithm::HS256))?;
|
||||
let data = decode(
|
||||
code,
|
||||
&decoding_key,
|
||||
&Validation::new(jsonwebtoken::Algorithm::HS256),
|
||||
)?;
|
||||
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
#[cfg(feature = "rocket")]
|
||||
pub fn scope_can_access_route(scope: v0::OAuth2Scope, request: &Request<'_>) -> bool {
|
||||
println!("{:?}", request.route());
|
||||
|
||||
// TODO: figure out why request.segments(0..) is skipping the first segment
|
||||
let mut segments = request.uri().path().segments();
|
||||
|
||||
if segments.get(0) == Some("0.8") {
|
||||
segments.next(); // skip first segment
|
||||
};
|
||||
|
||||
// Extract the function name of the route
|
||||
let Some(route_name) = request
|
||||
.route()
|
||||
.and_then(|route| route.name.as_ref())
|
||||
.map(|name| name.as_ref())
|
||||
else {
|
||||
return false
|
||||
};
|
||||
|
||||
match scope {
|
||||
v0::OAuth2Scope::ReadIdentify => route_name == "fetch_self",
|
||||
v0::OAuth2Scope::ReadServers => route_name == "fetch_self_servers" || route_name == "fetch_server",
|
||||
// This only grants access to the events websocket and not any routes
|
||||
v0::OAuth2Scope::Events => false,
|
||||
// TODO: maybe disallow revoking other sessions
|
||||
v0::OAuth2Scope::Full => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "rocket")]
|
||||
pub fn scopes_can_access_route(scopes: &[v0::OAuth2Scope], request: &Request<'_>) -> bool {
|
||||
scopes
|
||||
.iter()
|
||||
.any(|&scope| scope_can_access_route(scope, request))
|
||||
}
|
||||
|
||||
pub async fn add_code_challange(token: &str, code_challenge: &str) -> Result<()> {
|
||||
let mut conn = redis_kiss::get_connection()
|
||||
.await
|
||||
@@ -130,7 +101,7 @@ pub async fn add_code_challange(token: &str, code_challenge: &str) -> Result<()>
|
||||
conn.pset_ex::<_, _, ()>(
|
||||
format!("oauth2:{token}:code_challenge"),
|
||||
code_challenge,
|
||||
TokenType::Access.lifetime().num_milliseconds() as usize
|
||||
TokenType::Access.lifetime().num_milliseconds() as usize,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
@@ -146,4 +117,4 @@ pub async fn get_code_challange(token: &str) -> Result<Option<String>> {
|
||||
conn.get(format!("oauth2:{token}:code_challenge"))
|
||||
.await
|
||||
.map_err(|_| create_error!(InternalError))
|
||||
}
|
||||
}
|
||||
53
crates/core/database/src/util/oauth2/rocket.rs
Normal file
53
crates/core/database/src/util/oauth2/rocket.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use std::{convert::Infallible, marker::PhantomData};
|
||||
|
||||
use revolt_okapi::openapi3::{OAuthFlows, SecurityScheme, SecuritySchemeData};
|
||||
use revolt_rocket_okapi::request::{OpenApiFromRequest, RequestHeaderInput};
|
||||
use rocket::{request::{self, FromRequest}, Request};
|
||||
|
||||
use super::{OAuth2Scoped, scopes::OAuth2Scope};
|
||||
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r, Scope: OAuth2Scope> FromRequest<'r> for OAuth2Scoped<Scope> {
|
||||
type Error = Infallible;
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
|
||||
request.local_cache(|| Some(Scope::SCOPE));
|
||||
|
||||
request::Outcome::Success(OAuth2Scoped { _scope: PhantomData })
|
||||
}
|
||||
}
|
||||
|
||||
impl<'r, Scope: OAuth2Scope> OpenApiFromRequest<'r> for OAuth2Scoped<Scope> {
|
||||
fn from_request_input(
|
||||
_gen: &mut revolt_rocket_okapi::r#gen::OpenApiGenerator,
|
||||
_name: String,
|
||||
_required: bool,
|
||||
) -> revolt_rocket_okapi::Result<revolt_rocket_okapi::request::RequestHeaderInput> {
|
||||
Ok(RequestHeaderInput::Security(
|
||||
"OAuth2".to_owned(),
|
||||
SecurityScheme {
|
||||
description: None,
|
||||
extensions: Default::default(),
|
||||
data: SecuritySchemeData::OAuth2 {
|
||||
flows: OAuthFlows::AuthorizationCode {
|
||||
authorization_url: "todo".to_string(),
|
||||
token_url: "todo".to_string(),
|
||||
refresh_url: Some("todo".to_string()),
|
||||
scopes: revolt_okapi::map! {
|
||||
"read:identify".to_string() => "".to_string(),
|
||||
"read:servers".to_string() => "".to_string(),
|
||||
"write:files".to_string() => "".to_string(),
|
||||
"events".to_string() => "".to_string(),
|
||||
"full".to_string() => "".to_string(),
|
||||
},
|
||||
extensions: Default::default()
|
||||
}
|
||||
}
|
||||
},
|
||||
revolt_okapi::map! {
|
||||
"OAuth2".to_owned() => vec![Scope::NAME.to_owned()]
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
27
crates/core/database/src/util/oauth2/scopes.rs
Normal file
27
crates/core/database/src/util/oauth2/scopes.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
pub struct OAuth2Scoped<Scope> {
|
||||
pub(crate) _scope: PhantomData<Scope>
|
||||
}
|
||||
|
||||
pub trait OAuth2Scope {
|
||||
const NAME: &'static str;
|
||||
const SCOPE: crate::OAuth2Scope;
|
||||
}
|
||||
|
||||
macro_rules! define_oauth2_scope {
|
||||
($struct_name:ident, $name:literal) => {
|
||||
pub struct $struct_name;
|
||||
|
||||
impl OAuth2Scope for $struct_name {
|
||||
const NAME: &'static str = $name;
|
||||
const SCOPE: crate::OAuth2Scope = crate::OAuth2Scope::$struct_name;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
define_oauth2_scope!(ReadIdentify, "read:identify");
|
||||
define_oauth2_scope!(ReadServers, "read:servers");
|
||||
define_oauth2_scope!(WriteFiles, "write:files");
|
||||
define_oauth2_scope!(Events, "events");
|
||||
define_oauth2_scope!(Full, "full");
|
||||
Reference in New Issue
Block a user