Skip to content

Commit

Permalink
feat(security): add support for operator users and operator ephemeral…
Browse files Browse the repository at this point in the history
… service accounts
  • Loading branch information
azasypkin committed May 12, 2024
1 parent 6e6ca22 commit 88e4cfc
Show file tree
Hide file tree
Showing 13 changed files with 143 additions and 33 deletions.
6 changes: 3 additions & 3 deletions dev/docker/kratos.local.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ verification = { enabled = true }
[selfservice.flows.registration.after.password]
hooks = [
# `body is base64 version of "function(ctx) { id: ctx.identity.id, email: ctx.identity.traits.email }", see https://www.ory.sh/docs/guides/integrate-with-ory-cloud-through-webhooks
{ hook = "web_hook", config = { method = "POST", url = "http://host.docker.internal:7070/api/users/signup", response.ignore = true, body = "base64://ZnVuY3Rpb24oY3R4KSB7IGlkZW50aXR5OiBjdHguaWRlbnRpdHkgfQ==" } },
{ hook = "web_hook", config = { method = "POST", url = "http://host.docker.internal:7070/api/users/signup", response.ignore = true, body = "base64://ZnVuY3Rpb24oY3R4KSB7IGlkZW50aXR5OiBjdHguaWRlbnRpdHkgfQ==", auth = { type = "api_key", config.name = "Authorization", config.value = "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMzEwNDc4MzksInN1YiI6IkBrcmF0b3MubG9jYWwifQ._tU8TYz7RJfS4yhSrSZ8b7jICm4fhvxKDcVYqA5iP80", config.in = "header" } } },
{ hook = "session" }
]

[selfservice.flows.registration.after.webauthn]
hooks = [
# `body is base64 version of "function(ctx) { id: ctx.identity.id, email: ctx.identity.traits.email }", see https://www.ory.sh/docs/guides/integrate-with-ory-cloud-through-webhooks
{ hook = "web_hook", config = { method = "POST", url = "http://host.docker.internal:7070/api/users/signup", response.ignore = true, body = "base64://ZnVuY3Rpb24oY3R4KSB7IGlkZW50aXR5OiBjdHguaWRlbnRpdHkgfQ==" } },
{ hook = "web_hook", config = { method = "POST", url = "http://host.docker.internal:7070/api/users/signup", response.ignore = true, body = "base64://ZnVuY3Rpb24oY3R4KSB7IGlkZW50aXR5OiBjdHguaWRlbnRpdHkgfQ==", auth = { type = "api_key", config.name = "Authorization", config.value = "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMzEwNDc4MzksInN1YiI6IkBrcmF0b3MubG9jYWwifQ._tU8TYz7RJfS4yhSrSZ8b7jICm4fhvxKDcVYqA5iP80", config.in = "header" } } },
{ hook = "session" }
]

Expand All @@ -53,4 +53,4 @@ cipher = ["32-LONG-SECRET-NOT-SECURE-AT-ALL"]

[courier]
delivery_strategy = "http"
http = { request_config = { url = "http://host.docker.internal:7070/api/users/email", method = "POST", body = "base64://ZnVuY3Rpb24oY3R4KSB7CiAgcmVjaXBpZW50OiBjdHgucmVjaXBpZW50LAogIHRlbXBsYXRlX3R5cGU6IGN0eC50ZW1wbGF0ZV90eXBlLAogIGlkZW50aXR5OiBjdHgudGVtcGxhdGVfZGF0YS5pZGVudGl0eSwKICByZWNvdmVyeV9jb2RlOiBpZiAidGVtcGxhdGVfZGF0YSIgaW4gY3R4ICYmICJyZWNvdmVyeV9jb2RlIiBpbiBjdHgudGVtcGxhdGVfZGF0YSB0aGVuIGN0eC50ZW1wbGF0ZV9kYXRhLnJlY292ZXJ5X2NvZGUgZWxzZSBudWxsLAogIHJlY292ZXJ5X3VybDogaWYgInRlbXBsYXRlX2RhdGEiIGluIGN0eCAmJiAicmVjb3ZlcnlfdXJsIiBpbiBjdHgudGVtcGxhdGVfZGF0YSB0aGVuIGN0eC50ZW1wbGF0ZV9kYXRhLnJlY292ZXJ5X3VybCBlbHNlIG51bGwsCiAgdmVyaWZpY2F0aW9uX3VybDogaWYgInRlbXBsYXRlX2RhdGEiIGluIGN0eCAmJiAidmVyaWZpY2F0aW9uX3VybCIgaW4gY3R4LnRlbXBsYXRlX2RhdGEgdGhlbiBjdHgudGVtcGxhdGVfZGF0YS52ZXJpZmljYXRpb25fdXJsIGVsc2UgbnVsbCwKICB2ZXJpZmljYXRpb25fY29kZTogaWYgInRlbXBsYXRlX2RhdGEiIGluIGN0eCAmJiAidmVyaWZpY2F0aW9uX2NvZGUiIGluIGN0eC50ZW1wbGF0ZV9kYXRhIHRoZW4gY3R4LnRlbXBsYXRlX2RhdGEudmVyaWZpY2F0aW9uX2NvZGUgZWxzZSBudWxsCn0=", headers = { "Content-Type" = "application/json" } } }
http = { request_config = { url = "http://host.docker.internal:7070/api/users/email", method = "POST", body = "base64://ZnVuY3Rpb24oY3R4KSB7CiAgcmVjaXBpZW50OiBjdHgucmVjaXBpZW50LAogIHRlbXBsYXRlX3R5cGU6IGN0eC50ZW1wbGF0ZV90eXBlLAogIGlkZW50aXR5OiBjdHgudGVtcGxhdGVfZGF0YS5pZGVudGl0eSwKICByZWNvdmVyeV9jb2RlOiBpZiAidGVtcGxhdGVfZGF0YSIgaW4gY3R4ICYmICJyZWNvdmVyeV9jb2RlIiBpbiBjdHgudGVtcGxhdGVfZGF0YSB0aGVuIGN0eC50ZW1wbGF0ZV9kYXRhLnJlY292ZXJ5X2NvZGUgZWxzZSBudWxsLAogIHJlY292ZXJ5X3VybDogaWYgInRlbXBsYXRlX2RhdGEiIGluIGN0eCAmJiAicmVjb3ZlcnlfdXJsIiBpbiBjdHgudGVtcGxhdGVfZGF0YSB0aGVuIGN0eC50ZW1wbGF0ZV9kYXRhLnJlY292ZXJ5X3VybCBlbHNlIG51bGwsCiAgdmVyaWZpY2F0aW9uX3VybDogaWYgInRlbXBsYXRlX2RhdGEiIGluIGN0eCAmJiAidmVyaWZpY2F0aW9uX3VybCIgaW4gY3R4LnRlbXBsYXRlX2RhdGEgdGhlbiBjdHgudGVtcGxhdGVfZGF0YS52ZXJpZmljYXRpb25fdXJsIGVsc2UgbnVsbCwKICB2ZXJpZmljYXRpb25fY29kZTogaWYgInRlbXBsYXRlX2RhdGEiIGluIGN0eCAmJiAidmVyaWZpY2F0aW9uX2NvZGUiIGluIGN0eC50ZW1wbGF0ZV9kYXRhIHRoZW4gY3R4LnRlbXBsYXRlX2RhdGEudmVyaWZpY2F0aW9uX2NvZGUgZWxzZSBudWxsCn0=", headers = { "Content-Type" = "application/json" }, auth = { type = "api_key", config.name = "Authorization", config.value = "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjIwMzEwNDc4MzksInN1YiI6IkBrcmF0b3MubG9jYWwifQ._tU8TYz7RJfS4yhSrSZ8b7jICm4fhvxKDcVYqA5iP80", config.in = "header" } } }
1 change: 1 addition & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ mod tests {
security: SecurityConfig {
session_cookie_name: "id",
jwt_secret: None,
operators: None,
preconfigured_users: None,
},
utils: UtilsConfig {
Expand Down
1 change: 1 addition & 0 deletions src/config/raw_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ mod tests {
security: SecurityConfig {
session_cookie_name: "id2",
jwt_secret: None,
operators: None,
preconfigured_users: Some(
{
"[email protected]": PreconfiguredUserConfig {
Expand Down
12 changes: 10 additions & 2 deletions src/config/security_config.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::users::SubscriptionTier;
use serde_derive::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};

/// Describes the preconfigured user configuration.
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
Expand All @@ -19,6 +19,8 @@ pub struct SecurityConfig {
/// Secret key used to sign JWT tokens used for HTTP authentication. If not provided, HTTP
/// authentication will be disabled.
pub jwt_secret: Option<String>,
/// List of user or service account identifiers that should be treated as operators, if specified.
pub operators: Option<HashSet<String>>,
/// List of the preconfigured users, if specified.
pub preconfigured_users: Option<HashMap<String, PreconfiguredUserConfig>>,
}
Expand All @@ -29,6 +31,7 @@ impl Default for SecurityConfig {
session_cookie_name: "id".to_string(),
jwt_secret: None,
preconfigured_users: None,
operators: None,
}
}
}
Expand All @@ -46,7 +49,9 @@ mod tests {
assert_toml_snapshot!(SecurityConfig::default(), @"session_cookie_name = 'id'");

let config = SecurityConfig {
session_cookie_name: "id".to_string(),
jwt_secret: Some("3024bf8975b03b84e405f36a7bacd1c1".to_string()),
operators: Some(["[email protected]".to_string()].into_iter().collect()),
preconfigured_users: Some(
[(
"[email protected]".to_string(),
Expand All @@ -58,12 +63,12 @@ mod tests {
.into_iter()
.collect(),
),
..Default::default()
};

assert_toml_snapshot!(config, @r###"
session_cookie_name = 'id'
jwt_secret = '3024bf8975b03b84e405f36a7bacd1c1'
operators = ['[email protected]']
[preconfigured_users."[email protected]"]
handle = 'test-handle'
tier = 'basic'
Expand All @@ -85,13 +90,15 @@ mod tests {
session_cookie_name: "id".to_string(),
jwt_secret: None,
preconfigured_users: None,
operators: None,
}
);

let config: SecurityConfig = toml::from_str(
r#"
session_cookie_name = 'id'
jwt_secret = '3024bf8975b03b84e405f36a7bacd1c1'
operators = ['[email protected]']
[preconfigured_users."[email protected]"]
handle = 'test-handle'
Expand All @@ -115,6 +122,7 @@ mod tests {
.into_iter()
.collect(),
),
operators: Some(["[email protected]".to_string()].into_iter().collect()),
..Default::default()
}
);
Expand Down
3 changes: 2 additions & 1 deletion src/security.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ mod api_ext;
mod credentials;
mod jwt;
pub mod kratos;
mod operator;

pub use self::credentials::Credentials;
pub use self::{credentials::Credentials, operator::Operator};
51 changes: 40 additions & 11 deletions src/security/api_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::{
credentials::Credentials,
jwt::Claims,
kratos::{Identity, Session},
Operator,
},
users::{User, UserId, UserSignupError, UserSubscription},
};
Expand Down Expand Up @@ -90,6 +91,30 @@ where
}))
}

/// Checks if the user or service account with specified credentials is an operator.
pub async fn get_operator(&self, credentials: Credentials) -> anyhow::Result<Option<Operator>> {
let operator_id = match &credentials {
// If the user is authenticated with a session cookie, user's email is used as an
// operator identifier.
Credentials::SessionCookie(_) => {
self.get_identity(&credentials)
.await?
.ok_or_else(|| anyhow!("Session cookie is invalid"))?
.traits
.email
}
// For JWT, we treat `sub` claim as an operator identifier.
Credentials::Jwt(token) => self.get_jwt_claims(token).await?.sub,
};

let operators = self.api.config.security.operators.as_ref();
if operators.map_or(false, |operators| operators.contains(&operator_id)) {
Ok(Some(Operator::new(operator_id)))
} else {
Ok(None)
}
}

/// Tries to retrieve user identity from Kratos using specified credentials.
async fn get_identity(&self, credentials: &Credentials) -> anyhow::Result<Option<Identity>> {
let client = reqwest::Client::new();
Expand All @@ -107,22 +132,13 @@ where
format!("{}={}", cookie.name(), cookie.value()).as_bytes(),
),
Credentials::Jwt(token) => {
let Some(jwt_secret) = self.api.config.security.jwt_secret.as_ref() else {
return Err(anyhow!("JWT secret is not configured."));
};

let token = decode::<Claims>(
token.as_ref(),
&DecodingKey::from_secret(jwt_secret.as_bytes()),
&Validation::default(),
)?;

let claims = self.get_jwt_claims(token).await?;
client.request(
reqwest::Method::GET,
format!(
"{}admin/identities?credentials_identifier={}",
self.api.config.components.kratos_admin_url.as_str(),
urlencoding::encode(&token.claims.sub)
urlencoding::encode(&claims.sub)
),
)
}
Expand Down Expand Up @@ -175,6 +191,19 @@ where
})
}

/// Tries to parse JWT and extract claims.
async fn get_jwt_claims(&self, token: &str) -> anyhow::Result<Claims> {
let Some(jwt_secret) = self.api.config.security.jwt_secret.as_ref() else {
return Err(anyhow!("JWT secret is not configured."));
};
Ok(decode::<Claims>(
token,
&DecodingKey::from_secret(jwt_secret.as_bytes()),
&Validation::default(),
)?
.claims)
}

/// Updates user's subscription.
pub async fn update_subscription(
&self,
Expand Down
14 changes: 14 additions & 0 deletions src/security/operator.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/// Struct to represent an operator account.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Operator(String);
impl Operator {
/// Creates a new operator account with the provided ID.
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}

/// Returns the ID of the operator account.
pub fn id(&self) -> &str {
&self.0
}
}
2 changes: 2 additions & 0 deletions src/server/extractors.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
mod credentials;
mod operator;
mod user;
mod user_share;
24 changes: 24 additions & 0 deletions src/server/extractors/credentials.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
use crate::{security::Credentials, server::app_state::AppState};
use actix_web::{dev::Payload, error::ErrorUnauthorized, web, Error, FromRequest, HttpRequest};
use actix_web_httpauth::extractors::bearer::BearerAuth;
use anyhow::anyhow;
use std::{future::Future, pin::Pin};

impl FromRequest for Credentials {
type Error = Error;
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;

fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
let req = req.clone();
Box::pin(async move {
let state = web::Data::<AppState>::extract(&req).await?;
Ok(match Option::<BearerAuth>::extract(&req).await? {
Some(bearer_auth) => Credentials::Jwt(bearer_auth.token().to_string()),
None => Credentials::SessionCookie(
req.cookie(&state.config.security.session_cookie_name)
.ok_or_else(|| ErrorUnauthorized(anyhow!("Unauthorized")))?,
),
})
})
}
}
32 changes: 32 additions & 0 deletions src/server/extractors/operator.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
use crate::{
security::{Credentials, Operator},
server::app_state::AppState,
};
use actix_web::{
dev::Payload,
error::{ErrorInternalServerError, ErrorUnauthorized},
web, Error, FromRequest, HttpRequest,
};
use anyhow::anyhow;
use std::{future::Future, pin::Pin};

impl FromRequest for Operator {
type Error = Error;
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;

fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
let req = req.clone();
Box::pin(async move {
let state = web::Data::<AppState>::extract(&req).await?;
let credentials = Credentials::extract(&req).await?;
match state.api.security().get_operator(credentials).await {
Ok(Some(user)) => Ok(user),
Ok(None) => Err(ErrorUnauthorized(anyhow!("Unauthorized"))),
Err(err) => {
log::error!("Failed to extract operator information due to: {err:?}");
Err(ErrorInternalServerError(anyhow!("Internal server error")))
}
}
})
}
}
11 changes: 1 addition & 10 deletions src/server/extractors/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ use actix_web::{
error::{ErrorInternalServerError, ErrorUnauthorized},
web, Error, FromRequest, HttpRequest,
};
use actix_web_httpauth::extractors::bearer::BearerAuth;
use anyhow::anyhow;
use std::{future::Future, pin::Pin};

Expand All @@ -16,15 +15,7 @@ impl FromRequest for User {
let req = req.clone();
Box::pin(async move {
let state = web::Data::<AppState>::extract(&req).await?;

let credentials = match Option::<BearerAuth>::extract(&req).await? {
Some(bearer_auth) => Credentials::Jwt(bearer_auth.token().to_string()),
None => Credentials::SessionCookie(
req.cookie(&state.config.security.session_cookie_name)
.ok_or_else(|| ErrorUnauthorized(anyhow!("Unauthorized")))?,
),
};

let credentials = Credentials::extract(&req).await?;
match state.api.security().authenticate(credentials).await {
Ok(Some(user)) => Ok(user),
Ok(None) => Err(ErrorUnauthorized(anyhow!("Unauthorized"))),
Expand Down
10 changes: 8 additions & 2 deletions src/server/handlers/security_users_email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ use std::{collections::HashMap, str::FromStr};

use crate::{
logging::UserLogContext,
security::kratos::{EmailTemplateType, Identity},
security::{
kratos::{EmailTemplateType, Identity},
Operator,
},
};
use time::OffsetDateTime;
use url::Url;
Expand All @@ -27,6 +30,7 @@ pub struct EmailParams {

pub async fn security_users_email(
state: web::Data<AppState>,
operator: Operator,
body_params: web::Json<EmailParams>,
) -> Result<HttpResponse, SecutilsError> {
let kratos_email = body_params.into_inner();
Expand All @@ -38,6 +42,7 @@ pub async fn security_users_email(
let (destination, content) = match parse_email_params(&kratos_email) {
Ok(content) => {
log::info!(
operator:serde = operator.id(),
user:serde = identity_context;
"Received Kratos {} email request for {}.",
kratos_email.template_type,
Expand All @@ -50,6 +55,7 @@ pub async fn security_users_email(
}
Err(err) => {
log::error!(
operator:serde = operator.id(),
user:serde = identity_context;
"Received unsupported ({}) Kratos email request for {}: {err:?}.",
kratos_email.template_type,
Expand Down Expand Up @@ -78,7 +84,7 @@ pub async fn security_users_email(
.schedule_notification(destination, content, OffsetDateTime::now_utc())
.await
{
log::error!("Failed to schedule Kratos email notification: {err:?}");
log::error!(operator:serde = operator.id(); "Failed to schedule Kratos email notification: {err:?}");
}

Ok(HttpResponse::NoContent().finish())
Expand Down
9 changes: 5 additions & 4 deletions src/server/handlers/security_users_signup.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::{
security::kratos::Identity,
security::{kratos::Identity, Operator},
server::{app_state::AppState, http_errors::generic_internal_server_error},
users::{SubscriptionTier, User, UserId, UserSignupError, UserSubscription},
};
Expand All @@ -16,6 +16,7 @@ pub struct SignupParams {
/// Signups user with the provided identity.
pub async fn security_users_signup(
state: web::Data<AppState>,
operator: Operator,
body_params: web::Json<SignupParams>,
) -> impl Responder {
let body_params = body_params.into_inner();
Expand All @@ -42,7 +43,7 @@ pub async fn security_users_signup(
match security_api.generate_user_handle().await {
Ok(handle) => handle,
Err(err) => {
log::error!("Failed to generate user handle: {err:?}");
log::error!(operator:serde = operator.id(); "Failed to generate user handle: {err:?}");
return generic_internal_server_error();
}
},
Expand All @@ -68,11 +69,11 @@ pub async fn security_users_signup(

match security_api.signup(&user).await {
Ok(_) => {
log::info!(user:serde = user.log_context(); "Successfully signed up a new user.");
log::info!(operator:serde = operator.id(), user:serde = user.log_context(); "Successfully signed up a new user.");
HttpResponse::Ok().finish()
}
Err(err) => {
log::error!(user:serde = user.log_context(); "Failed to signup a user: {err:?}");
log::error!(operator:serde = operator.id(), user:serde = user.log_context(); "Failed to signup a user: {err:?}");
return match err.downcast_ref::<UserSignupError>() {
Some(err) => match err {
UserSignupError::EmailAlreadyRegistered => HttpResponse::BadRequest().json(
Expand Down

0 comments on commit 88e4cfc

Please sign in to comment.