feat: initial oauth2 implementation
This commit is contained in:
@@ -53,6 +53,8 @@ linkify = { optional = true, version = "0.8.1" }
|
||||
url-escape = { optional = true, version = "0.1.1" }
|
||||
validator = { version = "0.16", features = ["derive"] }
|
||||
isahc = { optional = true, version = "1.7", features = ["json"] }
|
||||
jsonwebtoken = "9.3.1"
|
||||
chrono = "0.4"
|
||||
|
||||
# Serialisation
|
||||
serde_json = "1"
|
||||
|
||||
@@ -35,6 +35,10 @@ auto_derived_partial!(
|
||||
#[serde(skip_serializing_if = "String::is_empty", default)]
|
||||
pub privacy_policy_url: String,
|
||||
|
||||
/// Oauth2 bot settings
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub oauth2: Option<BotOauth2>,
|
||||
|
||||
/// Enum of bot flags
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub flags: Option<i32>,
|
||||
@@ -42,11 +46,28 @@ auto_derived_partial!(
|
||||
"PartialBot"
|
||||
);
|
||||
|
||||
auto_derived_partial!(
|
||||
pub struct BotOauth2 {
|
||||
/// Whether the oauth2 client is public and should not receive a secret key
|
||||
#[serde(default)]
|
||||
pub public: bool,
|
||||
/// Secret key used for authorisation, not provided if the client is public
|
||||
#[serde(default)]
|
||||
pub secret: Option<String>,
|
||||
/// Allowed redirects for the authorisation
|
||||
#[serde(default)]
|
||||
pub redirects: Vec<String>
|
||||
},
|
||||
"PartialBotOauth2"
|
||||
);
|
||||
|
||||
auto_derived!(
|
||||
/// Optional fields on bot object
|
||||
pub enum FieldsBot {
|
||||
Token,
|
||||
InteractionsURL,
|
||||
Oauth2,
|
||||
Oauth2Secret,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -63,11 +84,23 @@ impl Default for Bot {
|
||||
interactions_url: Default::default(),
|
||||
terms_of_service_url: Default::default(),
|
||||
privacy_policy_url: Default::default(),
|
||||
oauth2: Default::default(),
|
||||
flags: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::derivable_impls)]
|
||||
impl Default for BotOauth2 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
public: false,
|
||||
secret: Some(nanoid::nanoid!(64)),
|
||||
redirects: Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
impl Bot {
|
||||
/// Create a new bot
|
||||
@@ -124,6 +157,14 @@ impl Bot {
|
||||
FieldsBot::Token => self.token = nanoid::nanoid!(64),
|
||||
FieldsBot::InteractionsURL => {
|
||||
self.interactions_url = String::new();
|
||||
},
|
||||
FieldsBot::Oauth2 => self.oauth2 = None,
|
||||
FieldsBot::Oauth2Secret => {
|
||||
if let Some(oauth2) = &mut self.oauth2 {
|
||||
if !oauth2.public {
|
||||
oauth2.secret = Some(nanoid::nanoid!(64))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@ impl IntoDocumentPath for FieldsBot {
|
||||
match self {
|
||||
FieldsBot::InteractionsURL => Some("interactions_url"),
|
||||
FieldsBot::Token => None,
|
||||
FieldsBot::Oauth2 => Some("oauth2"),
|
||||
FieldsBot::Oauth2Secret => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use authifier::models::Session;
|
||||
use revolt_config::config;
|
||||
use rocket::http::Status;
|
||||
use rocket::request::{self, FromRequest, Outcome, Request};
|
||||
|
||||
use crate::util::oauth2;
|
||||
use crate::{Database, User};
|
||||
|
||||
#[rocket::async_trait]
|
||||
@@ -19,12 +21,29 @@ impl<'r> FromRequest<'r> for User {
|
||||
.next()
|
||||
.map(|x| x.to_string());
|
||||
|
||||
let header_oauth_token = request
|
||||
.headers()
|
||||
.get("x-oauth2-token")
|
||||
.next()
|
||||
.map(|x| x.to_string());
|
||||
|
||||
if let Some(bot_token) = header_bot_token {
|
||||
if let Ok(bot) = db.fetch_bot_by_token(&bot_token).await {
|
||||
if let Ok(user) = db.fetch_user(&bot.id).await {
|
||||
return Some(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()?;
|
||||
|
||||
if oauth2::scopes_can_access_route(&claims.scope, request) {
|
||||
if let Ok(user) = db.fetch_user(&claims.sub).await {
|
||||
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);
|
||||
|
||||
@@ -33,6 +33,7 @@ impl From<crate::Bot> for Bot {
|
||||
interactions_url: value.interactions_url,
|
||||
terms_of_service_url: value.terms_of_service_url,
|
||||
privacy_policy_url: value.privacy_policy_url,
|
||||
oauth2: value.oauth2.map(|oauth2| oauth2.into()),
|
||||
flags: value.flags.unwrap_or_default() as u32,
|
||||
}
|
||||
}
|
||||
@@ -43,6 +44,8 @@ impl From<FieldsBot> for crate::FieldsBot {
|
||||
match value {
|
||||
FieldsBot::InteractionsURL => crate::FieldsBot::InteractionsURL,
|
||||
FieldsBot::Token => crate::FieldsBot::Token,
|
||||
FieldsBot::Oauth2 => crate::FieldsBot::Oauth2,
|
||||
FieldsBot::Oauth2Secret => crate::FieldsBot::Oauth2Secret,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +55,28 @@ impl From<crate::FieldsBot> for FieldsBot {
|
||||
match value {
|
||||
crate::FieldsBot::InteractionsURL => FieldsBot::InteractionsURL,
|
||||
crate::FieldsBot::Token => FieldsBot::Token,
|
||||
crate::FieldsBot::Oauth2 => FieldsBot::Oauth2,
|
||||
crate::FieldsBot::Oauth2Secret => FieldsBot::Oauth2Secret,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BotOauth2> for crate::BotOauth2 {
|
||||
fn from(value: BotOauth2) -> Self {
|
||||
crate::BotOauth2 {
|
||||
public: value.public,
|
||||
secret: value.secret,
|
||||
redirects: value.redirects,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::BotOauth2> for BotOauth2 {
|
||||
fn from(value: crate::BotOauth2) -> Self {
|
||||
BotOauth2 {
|
||||
public: value.public,
|
||||
secret: value.secret,
|
||||
redirects: value.redirects,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ pub mod idempotency;
|
||||
pub mod permissions;
|
||||
pub mod reference;
|
||||
pub mod test_fixtures;
|
||||
pub mod oauth2;
|
||||
151
crates/core/database/src/util/oauth2.rs
Normal file
151
crates/core/database/src/util/oauth2.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use jsonwebtoken::{decode, encode, errors::Error, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use revolt_models::v0;
|
||||
|
||||
#[cfg(feature = "rocket")]
|
||||
use rocket::{http::Method, Request};
|
||||
|
||||
// use redis_kiss::{get_connection, redis::Pipeline, AsyncCommands};
|
||||
// use revolt_result::Result;
|
||||
|
||||
// pub async fn add_access_token(code: &str, redirect_uri: &str, client_id: &str) -> Result<()> {
|
||||
// let conn = get_connection()
|
||||
// .await
|
||||
// .map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
// // 10 mins
|
||||
// Pipeline::new()
|
||||
// .pset_ex(format!("oauth:{code}:client_id"), client_id, 600000)
|
||||
// .pset_ex(format!("oauth:{code}:redirect_uri"), redirect_uri, 600000)
|
||||
// .query_async::<_, ()>(&mut conn.into_inner())
|
||||
// .await
|
||||
// .map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
// Ok(())
|
||||
// }
|
||||
|
||||
// pub struct CodeInfo {
|
||||
// pub redirect_uri: String,
|
||||
// pub client_id: String
|
||||
// }
|
||||
|
||||
// pub async fn get_access_token_info(code: &str) -> Result<CodeInfo> {
|
||||
// let conn = get_connection()
|
||||
// .await
|
||||
// .map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
// let (redirect_uri, client_id) = Pipeline::new()
|
||||
// .get(format!("oauth:{code}:client_id"))
|
||||
// .get(format!("oauth:{code}:redirect_uri"))
|
||||
// .query_async::<_, (String, String)>(&mut conn.into_inner())
|
||||
// .await
|
||||
// .map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
// Ok(CodeInfo { redirect_uri, client_id })
|
||||
// }
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum TokenType {
|
||||
Auth,
|
||||
Access,
|
||||
Refresh
|
||||
}
|
||||
|
||||
impl TokenType {
|
||||
pub fn lifetime(self) -> TimeDelta {
|
||||
match self {
|
||||
TokenType::Access => TimeDelta::weeks(1),
|
||||
TokenType::Auth => TimeDelta::minutes(5),
|
||||
TokenType::Refresh => TimeDelta::weeks(4),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String,
|
||||
pub exp: i64,
|
||||
|
||||
pub r#type: TokenType,
|
||||
pub client_id: String,
|
||||
pub scope: String,
|
||||
pub redirect_uri: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code_challange_method: Option<v0::OAuth2CodeChallangeMethod>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn encode_token(
|
||||
token_secret: &str,
|
||||
token_type: TokenType,
|
||||
user_id: String,
|
||||
client_id: String,
|
||||
redirect_uri: String,
|
||||
scope: String,
|
||||
code_challange_method: Option<v0::OAuth2CodeChallangeMethod>,
|
||||
) -> Result<String, Error> {
|
||||
let exp = Utc::now()
|
||||
.checked_add_signed(token_type.lifetime())
|
||||
.unwrap()
|
||||
.timestamp();
|
||||
|
||||
let claims = Claims {
|
||||
sub: user_id,
|
||||
exp,
|
||||
|
||||
r#type: token_type,
|
||||
client_id,
|
||||
scope,
|
||||
redirect_uri,
|
||||
code_challange_method
|
||||
};
|
||||
|
||||
let encoding_key = EncodingKey::from_secret(token_secret.as_bytes());
|
||||
|
||||
encode(&Header::default(), &claims, &encoding_key)
|
||||
}
|
||||
|
||||
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))?;
|
||||
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
#[cfg(feature = "rocket")]
|
||||
pub fn scope_can_access_route(scope: &str, request: &Request<'_>) -> bool {
|
||||
// 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
|
||||
};
|
||||
|
||||
match scope {
|
||||
"identify" => {
|
||||
println!("{:?}", request.method() == Method::Get);
|
||||
println!("{:?}", segments.get(0));
|
||||
println!("{:?}", segments.get(1));
|
||||
|
||||
request.method() == Method::Get &&
|
||||
segments.get(0) == Some("users") &&
|
||||
segments.get(1) == Some("@me")
|
||||
},
|
||||
"full" => true,
|
||||
_ => false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "rocket")]
|
||||
pub fn scopes_can_access_route(scopes: &str, request: &Request<'_>) -> bool {
|
||||
println!("{scopes}");
|
||||
for scope in scopes.split(' ') {
|
||||
if scope_can_access_route(scope, request) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
Reference in New Issue
Block a user