auth: extract community auth crate
This commit is contained in:
Generated
+20
@@ -7,9 +7,11 @@ name = "admin-api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"base64",
|
||||
"crank-community-auth",
|
||||
"crank-core",
|
||||
"crank-mapping",
|
||||
"crank-registry",
|
||||
@@ -395,6 +397,24 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crank-community-auth"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"axum-extra",
|
||||
"base64",
|
||||
"crank-core",
|
||||
"crank-registry",
|
||||
"rand 0.8.5",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crank-core"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
members = [
|
||||
"apps/admin-api",
|
||||
"apps/mcp-server",
|
||||
"crates/crank-community-auth",
|
||||
"crates/crank-core",
|
||||
"crates/crank-schema",
|
||||
"crates/crank-mapping",
|
||||
|
||||
@@ -158,4 +158,6 @@ Progress:
|
||||
- Phase 0 / task `0.14`: добавлены `AdminServiceBuilder`, seam slots (`identity_provider`, `policy_engine`, `audit_sink`, `token_issuer`, `capability_profile`) и community defaults для них; `apps/admin-api/src/main.rs` переведен на builder
|
||||
- Phase 0 / task `0.15`: route delegation переведен на public seams — `capabilities` route идет через `capability_profile`, write handlers в `routes/access.rs` проверяют `policy_engine` и пишут generic audit events через `audit_sink`, а `machine_auth` route использует `token_issuer` seam и сохраняет текущий Community `403` contract
|
||||
- Phase 0 / task `0.16`: phase verification gate пройден — `just fmt`, `just check`, `just test`, таргетные Community machine-auth tests и MCP initialize smoke (`requires_initialized_notification_before_tool_methods`) зеленые; runtime regression test обновлен под новый `RuntimeError::ProtocolAdapter` contract
|
||||
- Phase 1 / tasks `1.1`–`1.3`: strict-REST Community cleanup уже был закрыт ранее — non-REST adapters и `crank-proto` удалены из workspace, descriptor/premium UI surface убран из Community build, wizard и i18n приведены к REST-only baseline
|
||||
- Phase 1 / task `1.4`: создан crate `crank-community-auth`; в него вынесены password hashing/session cookie primitives и `PasswordIdentityProvider`, `admin-api` переключен на этот crate, а `login()` теперь реально делегирует credential check через `IdentityProvider` seam
|
||||
- backward-compatible alias `CommunityMachineCredentialVerifier` сохранен, поведение Community не изменено
|
||||
|
||||
@@ -14,6 +14,7 @@ argon2.workspace = true
|
||||
axum.workspace = true
|
||||
axum-extra.workspace = true
|
||||
base64.workspace = true
|
||||
crank-community-auth = { path = "../../crates/crank-community-auth" }
|
||||
crank-core = { path = "../../crates/crank-core" }
|
||||
crank-mapping = { path = "../../crates/crank-mapping" }
|
||||
crank-registry = { path = "../../crates/crank-registry" }
|
||||
@@ -33,5 +34,6 @@ tracing-subscriber.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1"
|
||||
reqwest.workspace = true
|
||||
serial_test = "3"
|
||||
|
||||
@@ -194,9 +194,11 @@ mod tests {
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
env, fmt,
|
||||
sync::Arc,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::{Json, Router, routing::post};
|
||||
#[cfg(any())]
|
||||
use crank_adapter_grpc::test_support as grpc_test_support;
|
||||
@@ -211,6 +213,7 @@ mod tests {
|
||||
OperationSecurityLevel, Protocol, ResponseCachePolicy, RestTarget, SecretKind, Target,
|
||||
ToolDescription, WebsocketTarget, WorkspaceId,
|
||||
};
|
||||
use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::PostgresRegistry;
|
||||
#[cfg(any())]
|
||||
@@ -227,7 +230,7 @@ mod tests {
|
||||
use crate::{
|
||||
app::build_app,
|
||||
auth::{AuthSettings, BootstrapAdminConfig, hash_password},
|
||||
service::{AdminService, OperationPayload},
|
||||
service::{AdminService, AdminServiceBuilder, OperationPayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -272,6 +275,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct RejectingIdentityProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl IdentityProvider for RejectingIdentityProvider {
|
||||
fn id(&self) -> &str {
|
||||
"rejecting-test-provider"
|
||||
}
|
||||
|
||||
fn kind(&self) -> IdentityProviderKind {
|
||||
IdentityProviderKind::Password
|
||||
}
|
||||
|
||||
async fn login_password(
|
||||
&self,
|
||||
_payload: crank_core::LoginPayload,
|
||||
) -> Result<LoginOutcome, IdentityError> {
|
||||
Err(IdentityError::BadCredentials)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn creates_publishes_and_tests_rest_operation() {
|
||||
@@ -3344,6 +3367,34 @@ mod tests {
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn login_uses_identity_provider_when_configured() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("identity_provider_login");
|
||||
let service = AdminServiceBuilder::new(
|
||||
registry,
|
||||
storage_root,
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
crank_runtime::community_default().build(),
|
||||
)
|
||||
.with_identity_provider(Arc::new(RejectingIdentityProvider))
|
||||
.build();
|
||||
|
||||
let error = match service
|
||||
.login(crate::service::LoginPayload {
|
||||
email: TEST_AUTH_EMAIL.to_owned(),
|
||||
password: TEST_AUTH_PASSWORD.to_owned(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("login should delegate to identity provider"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.to_string(), "invalid email or password");
|
||||
}
|
||||
|
||||
async fn assert_success_json(response: reqwest::Response) -> Value {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap();
|
||||
|
||||
+14
-99
@@ -1,26 +1,23 @@
|
||||
#[cfg(test)]
|
||||
use argon2::{Algorithm, Params, Version};
|
||||
use argon2::{
|
||||
Argon2,
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
|
||||
};
|
||||
use axum::{
|
||||
extract::{OriginalUri, Request, State},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar};
|
||||
use crank_community_auth::{
|
||||
cleared_session_cookie as build_cleared_session_cookie,
|
||||
create_session_cookie as build_session_cookie, hash_password as community_hash_password,
|
||||
session_cookie as build_session_cookie_header,
|
||||
};
|
||||
use crank_core::{User, UserSessionId, WorkspaceId};
|
||||
use crank_registry::WorkspaceMembershipRecord;
|
||||
use rand::RngCore;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
pub const SESSION_COOKIE_NAME: &str = "crank_session";
|
||||
pub use crank_community_auth::{
|
||||
SESSION_COOKIE_NAME, SessionCookie, extract_session_token, hash_session_secret, verify_password,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BootstrapAdminConfig {
|
||||
@@ -46,104 +43,22 @@ pub struct AuthenticatedSession {
|
||||
pub current_workspace_id: Option<WorkspaceId>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionCookie {
|
||||
pub session_id: UserSessionId,
|
||||
pub value: String,
|
||||
pub expires_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
pub fn hash_password(password: &str, pepper: &str) -> Result<String, ApiError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let password = format!("{password}{pepper}");
|
||||
password_hasher()?
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map(|hash| hash.to_string())
|
||||
community_hash_password(password, pepper)
|
||||
.map_err(|error| ApiError::internal(format!("failed to hash password: {error}")))
|
||||
}
|
||||
|
||||
pub fn verify_password(password: &str, pepper: &str, password_hash: &str) -> bool {
|
||||
let Ok(parsed) = PasswordHash::new(password_hash) else {
|
||||
return false;
|
||||
};
|
||||
let password = format!("{password}{pepper}");
|
||||
match password_hasher() {
|
||||
Ok(argon2) => argon2.verify_password(password.as_bytes(), &parsed).is_ok(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn password_hasher() -> Result<Argon2<'static>, ApiError> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let params = Params::new(8 * 1024, 1, 1, None).map_err(|error| {
|
||||
ApiError::internal(format!("failed to initialize test argon2 params: {error}"))
|
||||
})?;
|
||||
Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
Ok(Argon2::default())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_session_cookie(settings: &AuthSettings) -> Result<SessionCookie, ApiError> {
|
||||
let session_id = UserSessionId::new(format!("sess_{}", uuid::Uuid::now_v7().simple()));
|
||||
let mut secret_bytes = [0_u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut secret_bytes);
|
||||
let secret = URL_SAFE_NO_PAD.encode(secret_bytes);
|
||||
let expires_at = OffsetDateTime::now_utc()
|
||||
.checked_add(Duration::hours(settings.session_ttl_hours))
|
||||
.ok_or_else(|| ApiError::internal("failed to compute session expiration"))?;
|
||||
|
||||
Ok(SessionCookie {
|
||||
session_id,
|
||||
value: format!("{secret}.{}", expires_at.unix_timestamp_nanos()),
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn hash_session_secret(
|
||||
session_id: &UserSessionId,
|
||||
session_value: &str,
|
||||
session_secret: &str,
|
||||
) -> String {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(session_id.as_str().as_bytes());
|
||||
digest.update(b":");
|
||||
digest.update(session_value.as_bytes());
|
||||
digest.update(b":");
|
||||
digest.update(session_secret.as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(digest.finalize())
|
||||
build_session_cookie(settings.session_ttl_hours)
|
||||
.map_err(|error| ApiError::internal(format!("failed to create session cookie: {error}")))
|
||||
}
|
||||
|
||||
pub fn session_cookie(settings: &AuthSettings, token: &str) -> Cookie<'static> {
|
||||
Cookie::build((SESSION_COOKIE_NAME, token.to_owned()))
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Lax)
|
||||
.secure(settings.cookie_secure)
|
||||
.path("/")
|
||||
.max_age(Duration::hours(settings.session_ttl_hours))
|
||||
.build()
|
||||
build_session_cookie_header(token, settings.cookie_secure, settings.session_ttl_hours)
|
||||
}
|
||||
|
||||
pub fn cleared_session_cookie(settings: &AuthSettings) -> Cookie<'static> {
|
||||
Cookie::build((SESSION_COOKIE_NAME, String::new()))
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Lax)
|
||||
.secure(settings.cookie_secure)
|
||||
.path("/")
|
||||
.max_age(Duration::seconds(0))
|
||||
.build()
|
||||
}
|
||||
|
||||
pub fn extract_session_token(jar: &CookieJar) -> Option<(UserSessionId, String)> {
|
||||
let cookie = jar.get(SESSION_COOKIE_NAME)?;
|
||||
let value = cookie.value();
|
||||
let (session_id, session_value) = value.split_once('.')?;
|
||||
|
||||
Some((UserSessionId::new(session_id), session_value.to_owned()))
|
||||
build_cleared_session_cookie(settings.cookie_secure)
|
||||
}
|
||||
|
||||
pub async fn require_session(
|
||||
|
||||
@@ -10,6 +10,7 @@ mod storage;
|
||||
|
||||
use std::{env, net::SocketAddr, path::PathBuf};
|
||||
|
||||
use crank_community_auth::PasswordIdentityProvider;
|
||||
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
|
||||
use sqlx::postgres::PgConnectOptions;
|
||||
use tokio::net::TcpListener;
|
||||
@@ -70,6 +71,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.with_limits(runtime_limits)
|
||||
.with_response_cache(cache_stores.response.clone())
|
||||
.build();
|
||||
let identity_provider =
|
||||
PasswordIdentityProvider::new(registry.clone(), auth_settings.password_pepper.clone());
|
||||
let service = AdminServiceBuilder::new(
|
||||
registry,
|
||||
storage_root,
|
||||
@@ -77,6 +80,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
secret_crypto,
|
||||
runtime,
|
||||
)
|
||||
.with_identity_provider(std::sync::Arc::new(identity_provider))
|
||||
.build();
|
||||
service.bootstrap_admin_user().await?;
|
||||
if env_flag("CRANK_DEMO_SEED") {
|
||||
|
||||
@@ -7,12 +7,11 @@ use crank_core::{
|
||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AggregationMode,
|
||||
AsyncJobHandle, AuditSink, AuthConfig, AuthKind, AuthProfile, AuthProfileId, CapabilityProfile,
|
||||
CommunityCapabilityProfile, ConfigExport, EditionCapabilities, ExecutionMode, ExportMode,
|
||||
GeneratedDraft, GeneratedDraftStatus, IdentityProvider, InvitationId, InvitationStatus,
|
||||
InvitationToken, InvocationLevel, InvocationLog, InvocationLogId, InvocationSource,
|
||||
InvocationStatus, IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest,
|
||||
IssuedAgentTokenResponse, JobStatus, MachineAccessMode, MachineTokenIssuer, MembershipRole,
|
||||
NoMachineTokenIssuer, NoopAuditSink, OperationId, OperationSecurityLevel, OperationStatus,
|
||||
OwnerOnlyPolicyEngine, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
|
||||
GeneratedDraft, GeneratedDraftStatus, IdentityError, IdentityProvider, InvitationId,
|
||||
InvitationStatus, InvitationToken, InvocationLevel, InvocationLog, InvocationLogId,
|
||||
InvocationSource, InvocationStatus, JobStatus, LoginOutcome, MachineTokenIssuer,
|
||||
MembershipRole, NoMachineTokenIssuer, NoopAuditSink, OperationId, OperationSecurityLevel,
|
||||
OperationStatus, OwnerOnlyPolicyEngine, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
|
||||
PlatformApiKeyStatus, PolicyEngine, ProductEdition, Protocol, ResponseCachePolicy, SampleId,
|
||||
Samples, Secret, SecretId, SecretKind, SecretStatus, StreamSession, StreamStatus, Target,
|
||||
TransportBehavior, UsagePeriod, UserId, UserSessionId, Workspace, WorkspaceId, WorkspaceStatus,
|
||||
@@ -855,6 +854,67 @@ impl AdminService {
|
||||
&self,
|
||||
payload: LoginPayload,
|
||||
) -> Result<(SessionCookie, SessionResponse), ApiError> {
|
||||
let authenticated = self.authenticate_login(&payload).await?;
|
||||
|
||||
let session_cookie = create_session_cookie(&self.auth_settings)?;
|
||||
let secret_hash = hash_session_secret(
|
||||
&session_cookie.session_id,
|
||||
&session_cookie.value,
|
||||
&self.auth_settings.session_secret,
|
||||
);
|
||||
let memberships = self
|
||||
.registry
|
||||
.list_workspaces_for_user(&authenticated.user.id)
|
||||
.await?;
|
||||
let current_workspace_id = authenticated
|
||||
.current_workspace_id
|
||||
.as_ref()
|
||||
.map(|workspace_id| workspace_id.as_str().to_owned())
|
||||
.or_else(|| {
|
||||
memberships
|
||||
.first()
|
||||
.map(|membership| membership.workspace.id.as_str().to_owned())
|
||||
});
|
||||
let current_workspace_ref = current_workspace_id
|
||||
.as_ref()
|
||||
.map(|workspace_id| WorkspaceId::new(workspace_id.clone()));
|
||||
self.registry
|
||||
.create_user_session(
|
||||
&session_cookie.session_id,
|
||||
&authenticated.user.id,
|
||||
current_workspace_ref.as_ref(),
|
||||
&secret_hash,
|
||||
&session_cookie.expires_at,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
session_cookie,
|
||||
SessionResponse {
|
||||
user: authenticated.user,
|
||||
memberships,
|
||||
current_workspace_id,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
async fn authenticate_login(
|
||||
&self,
|
||||
payload: &LoginPayload,
|
||||
) -> Result<crank_core::AuthenticatedIdentity, ApiError> {
|
||||
if let Some(identity_provider) = &self.identity_provider {
|
||||
return match identity_provider
|
||||
.login_password(crank_core::LoginPayload {
|
||||
email: payload.email.clone(),
|
||||
password: payload.password.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(LoginOutcome::Authenticated(identity)) => Ok(identity),
|
||||
Err(error) => Err(map_identity_error(error)),
|
||||
};
|
||||
}
|
||||
|
||||
let user = self
|
||||
.registry
|
||||
.get_auth_user_by_email(&payload.email)
|
||||
@@ -869,39 +929,11 @@ impl AdminService {
|
||||
return Err(ApiError::unauthorized("invalid email or password"));
|
||||
}
|
||||
|
||||
let session_cookie = create_session_cookie(&self.auth_settings)?;
|
||||
let secret_hash = hash_session_secret(
|
||||
&session_cookie.session_id,
|
||||
&session_cookie.value,
|
||||
&self.auth_settings.session_secret,
|
||||
);
|
||||
let memberships = self
|
||||
.registry
|
||||
.list_workspaces_for_user(&user.user.id)
|
||||
.await?;
|
||||
let current_workspace_id = memberships
|
||||
.first()
|
||||
.map(|membership| membership.workspace.id.as_str().to_owned());
|
||||
self.registry
|
||||
.create_user_session(
|
||||
&session_cookie.session_id,
|
||||
&user.user.id,
|
||||
memberships
|
||||
.first()
|
||||
.map(|membership| &membership.workspace.id),
|
||||
&secret_hash,
|
||||
&session_cookie.expires_at,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
session_cookie,
|
||||
SessionResponse {
|
||||
user: user.user,
|
||||
memberships,
|
||||
current_workspace_id,
|
||||
},
|
||||
))
|
||||
Ok(crank_core::AuthenticatedIdentity {
|
||||
user: user.user,
|
||||
memberships: vec![],
|
||||
current_workspace_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
@@ -4033,6 +4065,17 @@ fn format_timestamp(timestamp: OffsetDateTime) -> String {
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned())
|
||||
}
|
||||
|
||||
fn map_identity_error(error: IdentityError) -> ApiError {
|
||||
match error {
|
||||
IdentityError::BadCredentials => ApiError::unauthorized("invalid email or password"),
|
||||
IdentityError::AccountDisabled => ApiError::forbidden("account is disabled"),
|
||||
IdentityError::NotSupportedForProvider => ApiError::internal(
|
||||
"password login is not supported by the configured identity provider",
|
||||
),
|
||||
IdentityError::Internal(message) => ApiError::internal(message),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_runtime_auth_for_task(
|
||||
registry: &PostgresRegistry,
|
||||
secret_crypto: &SecretCrypto,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "crank-community-auth"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
argon2.workspace = true
|
||||
async-trait = "0.1"
|
||||
axum-extra.workspace = true
|
||||
base64.workspace = true
|
||||
crank-core = { path = "../crank-core" }
|
||||
crank-registry = { path = "../crank-registry" }
|
||||
rand.workspace = true
|
||||
sha2.workspace = true
|
||||
thiserror.workspace = true
|
||||
time.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
@@ -0,0 +1,61 @@
|
||||
#[cfg(debug_assertions)]
|
||||
use argon2::{Algorithm, Params, Version};
|
||||
use argon2::{
|
||||
Argon2,
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum HashPasswordError {
|
||||
#[error("failed to initialize password hasher: {0}")]
|
||||
Hasher(String),
|
||||
#[error("failed to hash password: {0}")]
|
||||
Hash(String),
|
||||
}
|
||||
|
||||
pub fn hash_password(password: &str, pepper: &str) -> Result<String, HashPasswordError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let password = format!("{password}{pepper}");
|
||||
password_hasher()?
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map(|hash| hash.to_string())
|
||||
.map_err(|error| HashPasswordError::Hash(error.to_string()))
|
||||
}
|
||||
|
||||
pub fn verify_password(password: &str, pepper: &str, password_hash: &str) -> bool {
|
||||
let Ok(parsed) = PasswordHash::new(password_hash) else {
|
||||
return false;
|
||||
};
|
||||
let password = format!("{password}{pepper}");
|
||||
match password_hasher() {
|
||||
Ok(argon2) => argon2.verify_password(password.as_bytes(), &parsed).is_ok(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn password_hasher() -> Result<Argon2<'static>, HashPasswordError> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let params = Params::new(8 * 1024, 1, 1, None)
|
||||
.map_err(|error| HashPasswordError::Hasher(error.to_string()))?;
|
||||
Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
Ok(Argon2::default())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{hash_password, verify_password};
|
||||
|
||||
#[test]
|
||||
fn hashes_and_verifies_password() {
|
||||
let password_hash = hash_password("test-password", "pepper").unwrap();
|
||||
|
||||
assert!(verify_password("test-password", "pepper", &password_hash));
|
||||
assert!(!verify_password("wrong-password", "pepper", &password_hash));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
pub mod hashing;
|
||||
pub mod password_provider;
|
||||
pub mod session_cookie;
|
||||
|
||||
pub use hashing::{HashPasswordError, hash_password, verify_password};
|
||||
pub use password_provider::PasswordIdentityProvider;
|
||||
pub use session_cookie::{
|
||||
SESSION_COOKIE_NAME, SessionCookie, SessionCookieError, cleared_session_cookie,
|
||||
create_session_cookie, extract_session_token, hash_session_secret, session_cookie,
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
use async_trait::async_trait;
|
||||
use crank_core::{
|
||||
AuthenticatedIdentity, IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome,
|
||||
};
|
||||
use crank_registry::PostgresRegistry;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::hashing::verify_password;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PasswordIdentityProvider {
|
||||
registry: PostgresRegistry,
|
||||
password_pepper: String,
|
||||
}
|
||||
|
||||
impl PasswordIdentityProvider {
|
||||
pub fn new(registry: PostgresRegistry, password_pepper: impl Into<String>) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
password_pepper: password_pepper.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IdentityProvider for PasswordIdentityProvider {
|
||||
fn id(&self) -> &str {
|
||||
"community-password"
|
||||
}
|
||||
|
||||
fn kind(&self) -> IdentityProviderKind {
|
||||
IdentityProviderKind::Password
|
||||
}
|
||||
|
||||
async fn login_password(
|
||||
&self,
|
||||
payload: crank_core::LoginPayload,
|
||||
) -> Result<LoginOutcome, IdentityError> {
|
||||
let user = self
|
||||
.registry
|
||||
.get_auth_user_by_email(&payload.email)
|
||||
.await
|
||||
.map_err(|error| IdentityError::Internal(error.to_string()))?
|
||||
.ok_or(IdentityError::BadCredentials)?;
|
||||
|
||||
if user.user.status != crank_core::UserStatus::Active {
|
||||
return Err(IdentityError::AccountDisabled);
|
||||
}
|
||||
|
||||
if !verify_password(
|
||||
&payload.password,
|
||||
&self.password_pepper,
|
||||
&user.password_hash,
|
||||
) {
|
||||
debug!(email = %payload.email, "password identity provider rejected credentials");
|
||||
return Err(IdentityError::BadCredentials);
|
||||
}
|
||||
|
||||
let current_workspace_id = self
|
||||
.registry
|
||||
.list_workspaces_for_user(&user.user.id)
|
||||
.await
|
||||
.map_err(|error| IdentityError::Internal(error.to_string()))?
|
||||
.first()
|
||||
.map(|membership| membership.workspace.id.clone());
|
||||
|
||||
Ok(LoginOutcome::Authenticated(AuthenticatedIdentity {
|
||||
user: user.user,
|
||||
memberships: vec![],
|
||||
current_workspace_id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use crank_core::UserSessionId;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
pub const SESSION_COOKIE_NAME: &str = "crank_session";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionCookie {
|
||||
pub session_id: UserSessionId,
|
||||
pub value: String,
|
||||
pub expires_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SessionCookieError {
|
||||
#[error("failed to compute session expiration")]
|
||||
InvalidExpiration,
|
||||
}
|
||||
|
||||
pub fn create_session_cookie(session_ttl_hours: i64) -> Result<SessionCookie, SessionCookieError> {
|
||||
let session_id = UserSessionId::new(format!("sess_{}", uuid::Uuid::now_v7().simple()));
|
||||
let mut secret_bytes = [0_u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut secret_bytes);
|
||||
let secret = URL_SAFE_NO_PAD.encode(secret_bytes);
|
||||
let expires_at = OffsetDateTime::now_utc()
|
||||
.checked_add(Duration::hours(session_ttl_hours))
|
||||
.ok_or(SessionCookieError::InvalidExpiration)?;
|
||||
|
||||
Ok(SessionCookie {
|
||||
session_id,
|
||||
value: format!("{secret}.{}", expires_at.unix_timestamp_nanos()),
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn hash_session_secret(
|
||||
session_id: &UserSessionId,
|
||||
session_value: &str,
|
||||
session_secret: &str,
|
||||
) -> String {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(session_id.as_str().as_bytes());
|
||||
digest.update(b":");
|
||||
digest.update(session_value.as_bytes());
|
||||
digest.update(b":");
|
||||
digest.update(session_secret.as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(digest.finalize())
|
||||
}
|
||||
|
||||
pub fn session_cookie(token: &str, cookie_secure: bool, session_ttl_hours: i64) -> Cookie<'static> {
|
||||
Cookie::build((SESSION_COOKIE_NAME, token.to_owned()))
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Lax)
|
||||
.secure(cookie_secure)
|
||||
.path("/")
|
||||
.max_age(Duration::hours(session_ttl_hours))
|
||||
.build()
|
||||
}
|
||||
|
||||
pub fn cleared_session_cookie(cookie_secure: bool) -> Cookie<'static> {
|
||||
Cookie::build((SESSION_COOKIE_NAME, String::new()))
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Lax)
|
||||
.secure(cookie_secure)
|
||||
.path("/")
|
||||
.max_age(Duration::seconds(0))
|
||||
.build()
|
||||
}
|
||||
|
||||
pub fn extract_session_token(jar: &CookieJar) -> Option<(UserSessionId, String)> {
|
||||
let cookie = jar.get(SESSION_COOKIE_NAME)?;
|
||||
let value = cookie.value();
|
||||
let (session_id, session_value) = value.split_once('.')?;
|
||||
|
||||
Some((UserSessionId::new(session_id), session_value.to_owned()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
|
||||
use super::{
|
||||
SESSION_COOKIE_NAME, cleared_session_cookie, create_session_cookie, extract_session_token,
|
||||
session_cookie,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn extracts_session_token_from_cookie_jar() {
|
||||
let token = "sess_01.secret-value";
|
||||
let jar = CookieJar::new().add(session_cookie(token, false, 24));
|
||||
|
||||
let (session_id, secret) = extract_session_token(&jar).unwrap();
|
||||
|
||||
assert_eq!(session_id.as_str(), "sess_01");
|
||||
assert_eq!(secret, "secret-value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clears_session_cookie() {
|
||||
let cookie = cleared_session_cookie(false);
|
||||
|
||||
assert_eq!(cookie.name(), SESSION_COOKIE_NAME);
|
||||
assert_eq!(cookie.value(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_session_cookie_with_generated_values() {
|
||||
let session = create_session_cookie(24).unwrap();
|
||||
|
||||
assert!(session.session_id.as_str().starts_with("sess_"));
|
||||
assert!(session.value.contains('.'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user