mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-03 08:46:55 +00:00
fix: better type-safety
This commit is contained in:
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET preferred_comms_channel = $1, updated_at = NOW() WHERE did = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
{
|
||||
"Custom": {
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
"discord",
|
||||
"telegram",
|
||||
"signal"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "03fc2ba947ee547e000b044fafb486e71b9b65a7dd923b5354c5a4dde98332eb"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq = $1",
|
||||
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "event_type",
|
||||
"name": "event_type: RepoEventType",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
@@ -96,5 +96,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "805a344e73f2c19caaffe71de227ddd505599839033e83ae4be5b243d343d651"
|
||||
"hash": "0d32a592a97ad47c65aa37cf0d45417f2966fcbd688be7434626ae5f6971fa1f"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT available_uses, COALESCE(disabled, false) as \"disabled!\" FROM invite_codes WHERE code = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "available_uses",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "disabled!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "200ecf153f1433ae8f6fbe81ab888a04ddd035ec9e88ef5f207e2487a02a1224"
|
||||
}
|
||||
+17
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n email,\n preferred_comms_channel::text as \"preferred_channel!\",\n discord_id,\n discord_verified,\n telegram_username,\n telegram_verified,\n signal_number,\n signal_verified\n FROM users WHERE did = $1",
|
||||
"query": "SELECT\n email,\n preferred_comms_channel as \"preferred_channel!: CommsChannel\",\n discord_id,\n discord_verified,\n telegram_username,\n telegram_verified,\n signal_number,\n signal_verified\n FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -10,8 +10,20 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "preferred_channel!",
|
||||
"type_info": "Text"
|
||||
"name": "preferred_channel!: CommsChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
"discord",
|
||||
"telegram",
|
||||
"signal"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
@@ -51,7 +63,7 @@
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
@@ -60,5 +72,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "426fedba6791c420fe7af6decc296c681d05a5c24a38b8cd7083c8dfa9178ded"
|
||||
"hash": "247470d26a90617e7dc9b5b3a2146ee3f54448e3c24943f7005e3a8e28820d43"
|
||||
}
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n created_at,\n channel as \"channel: String\",\n comms_type as \"comms_type: String\",\n status as \"status: String\",\n subject,\n body\n FROM comms_queue\n WHERE user_id = $1\n ORDER BY created_at DESC\n LIMIT $2\n ",
|
||||
"query": "\n SELECT\n created_at,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\",\n subject,\n body\n FROM comms_queue\n WHERE user_id = $1\n ORDER BY created_at DESC\n LIMIT $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "channel: String",
|
||||
"name": "channel: CommsChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "comms_channel",
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "comms_type: String",
|
||||
"name": "comms_type: CommsType",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "comms_type",
|
||||
@@ -52,7 +52,7 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "status: String",
|
||||
"name": "status: CommsStatus",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "comms_status",
|
||||
@@ -93,5 +93,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9fea6394495b70ef5af2c2f5298e651d1ae78aa9ac6b03f952b6b0416023f671"
|
||||
"hash": "25309f4a08845a49557d694ad9b5b9a137be4dcce28e9293551c8c3fd40fdd86"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT (available_uses > 0 AND NOT COALESCE(disabled, false)) as \"valid!\" FROM invite_codes WHERE code = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "valid!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "36441073d3fb87230f88ddce4e597c248fbf7360e510d703b9eec42efe9e049e"
|
||||
}
|
||||
+15
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type::text as \"account_type!\"\n FROM users\n WHERE handle = $1 OR email = $1\n ",
|
||||
"query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type as \"account_type!: AccountType\"\n FROM users\n WHERE handle = $1 OR email = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -82,8 +82,18 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "account_type!",
|
||||
"type_info": "Text"
|
||||
"name": "account_type!: AccountType",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "account_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"personal",
|
||||
"delegated"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -105,8 +115,8 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "445c2ebb72f3833119f32284b9e721cf34c8ae581e6ae58a392fc93e77a7a015"
|
||||
"hash": "7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC",
|
||||
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "event_type",
|
||||
"name": "event_type: RepoEventType",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
@@ -97,5 +97,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "605dc962cf86004de763aee65757a5a77da150b36aa8470c52fd5835e9b895fc"
|
||||
"hash": "b26bf97a27783eb7fb524a92dda3e68ef8470a9751fcaefe5fd2d7909dead54b"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC",
|
||||
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "event_type",
|
||||
"name": "event_type: RepoEventType",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
@@ -96,5 +96,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e2befe7fa07a1072a8b3f0ed6c1a54a39ffc8769aa65391ea282c78d2cd29f23"
|
||||
"hash": "b8101757a50075d20147014e450cb7deb7e58f84310690c7bde61e1834dc5903"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
|
||||
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "event_type",
|
||||
"name": "event_type: RepoEventType",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
@@ -97,5 +97,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "8f6a1e09351dc716eaadc9e30c5cfea45212901a139e98f0fccfacfbb3371dec"
|
||||
"hash": "d8524ad3f5dc03eb09ed60396a78df5003f804c43ad253d6476523eacdebf811"
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email, handle, preferred_comms_channel as \"preferred_channel!: CommsChannel\", preferred_locale\n FROM users WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "handle",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "preferred_channel!: CommsChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
"discord",
|
||||
"telegram",
|
||||
"signal"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "preferred_locale",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d8fd97c8be3211b2509669dd859245b14e15f81a42d7e0c4c428b65f466af5ee"
|
||||
}
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email, handle, preferred_comms_channel::text as \"preferred_channel!\", preferred_locale\n FROM users WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "handle",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "preferred_channel!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "preferred_locale",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e3aeec9a759b2b68cb11fa48b5d34ffc19430a6b16adb0c49307da0cacdf1ca3"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
|
||||
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "event_type",
|
||||
"name": "event_type: RepoEventType",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
@@ -97,5 +97,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "caffa68d10445a42878b66e6b0224dafb8527c8a4cc9806d6f733edff72bc9db"
|
||||
"hash": "e7aa1080be9eb3a8ddf1f050c93dc8afd10478f41e22307014784b4ee3740b4a"
|
||||
}
|
||||
Generated
+3
-2
@@ -5733,9 +5733,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.17"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -6115,6 +6115,7 @@ dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-layer",
|
||||
|
||||
@@ -87,6 +87,7 @@ sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid"
|
||||
subtle = "2.5"
|
||||
thiserror = "2.0"
|
||||
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "time", "signal", "process"] }
|
||||
tokio-util = "0.7.18"
|
||||
tokio-tungstenite = { version = "0.28", features = ["native-tls"] }
|
||||
totp-rs = { version = "5", features = ["qr"] }
|
||||
tower = "0.5"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
use crate::CommsChannel;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChannelVerificationStatus {
|
||||
pub email: bool,
|
||||
pub discord: bool,
|
||||
pub telegram: bool,
|
||||
pub signal: bool,
|
||||
}
|
||||
|
||||
impl ChannelVerificationStatus {
|
||||
pub fn new(email: bool, discord: bool, telegram: bool, signal: bool) -> Self {
|
||||
Self {
|
||||
email,
|
||||
discord,
|
||||
telegram,
|
||||
signal,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_any_verified(&self) -> bool {
|
||||
self.email || self.discord || self.telegram || self.signal
|
||||
}
|
||||
|
||||
pub fn verified_channels(&self) -> Vec<CommsChannel> {
|
||||
let mut channels = Vec::with_capacity(4);
|
||||
if self.email {
|
||||
channels.push(CommsChannel::Email);
|
||||
}
|
||||
if self.discord {
|
||||
channels.push(CommsChannel::Discord);
|
||||
}
|
||||
if self.telegram {
|
||||
channels.push(CommsChannel::Telegram);
|
||||
}
|
||||
if self.signal {
|
||||
channels.push(CommsChannel::Signal);
|
||||
}
|
||||
channels
|
||||
}
|
||||
|
||||
pub fn is_verified(&self, channel: CommsChannel) -> bool {
|
||||
match channel {
|
||||
CommsChannel::Email => self.email,
|
||||
CommsChannel::Discord => self.discord,
|
||||
CommsChannel::Telegram => self.telegram,
|
||||
CommsChannel::Signal => self.signal,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,14 @@ use tranquil_types::{Did, Handle};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
use crate::scope::DbScope;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DelegationGrant {
|
||||
pub id: Uuid,
|
||||
pub delegated_did: Did,
|
||||
pub controller_did: Did,
|
||||
pub granted_scopes: String,
|
||||
pub granted_scopes: DbScope,
|
||||
pub granted_at: DateTime<Utc>,
|
||||
pub granted_by: Did,
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
@@ -22,7 +23,7 @@ pub struct DelegationGrant {
|
||||
pub struct DelegatedAccountInfo {
|
||||
pub did: Did,
|
||||
pub handle: Handle,
|
||||
pub granted_scopes: String,
|
||||
pub granted_scopes: DbScope,
|
||||
pub granted_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -30,7 +31,7 @@ pub struct DelegatedAccountInfo {
|
||||
pub struct ControllerInfo {
|
||||
pub did: Did,
|
||||
pub handle: Handle,
|
||||
pub granted_scopes: String,
|
||||
pub granted_scopes: DbScope,
|
||||
pub granted_at: DateTime<Utc>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
@@ -67,7 +68,7 @@ pub trait DelegationRepository: Send + Sync {
|
||||
&self,
|
||||
delegated_did: &Did,
|
||||
controller_did: &Did,
|
||||
granted_scopes: &str,
|
||||
granted_scopes: &DbScope,
|
||||
granted_by: &Did,
|
||||
) -> Result<Uuid, DbError>;
|
||||
|
||||
@@ -82,7 +83,7 @@ pub trait DelegationRepository: Send + Sync {
|
||||
&self,
|
||||
delegated_did: &Did,
|
||||
controller_did: &Did,
|
||||
new_scopes: &str,
|
||||
new_scopes: &DbScope,
|
||||
) -> Result<bool, DbError>;
|
||||
|
||||
async fn get_delegation(
|
||||
|
||||
@@ -5,6 +5,7 @@ use tranquil_types::{CidLink, Did, Handle};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
use crate::invite_code::{InviteCodeError, ValidatedInviteCode};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum InviteCodeSortOrder {
|
||||
@@ -13,6 +14,45 @@ pub enum InviteCodeSortOrder {
|
||||
Usage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
pub enum InviteCodeState {
|
||||
#[default]
|
||||
Active,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
impl InviteCodeState {
|
||||
pub fn is_active(self) -> bool {
|
||||
matches!(self, Self::Active)
|
||||
}
|
||||
|
||||
pub fn is_disabled(self) -> bool {
|
||||
matches!(self, Self::Disabled)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bool> for InviteCodeState {
|
||||
fn from(disabled: bool) -> Self {
|
||||
if disabled {
|
||||
Self::Disabled
|
||||
} else {
|
||||
Self::Active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<bool>> for InviteCodeState {
|
||||
fn from(disabled: Option<bool>) -> Self {
|
||||
Self::from(disabled.unwrap_or(false))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<InviteCodeState> for bool {
|
||||
fn from(state: InviteCodeState) -> Self {
|
||||
matches!(state, InviteCodeState::Disabled)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "comms_channel", rename_all = "snake_case")]
|
||||
pub enum CommsChannel {
|
||||
@@ -72,7 +112,7 @@ pub struct QueuedComms {
|
||||
pub struct InviteCodeInfo {
|
||||
pub code: String,
|
||||
pub available_uses: i32,
|
||||
pub disabled: bool,
|
||||
pub state: InviteCodeState,
|
||||
pub for_account: Option<Did>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub created_by: Option<Did>,
|
||||
@@ -95,6 +135,12 @@ pub struct InviteCodeRow {
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl InviteCodeRow {
|
||||
pub fn state(&self) -> InviteCodeState {
|
||||
InviteCodeState::from(self.disabled)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReservedSigningKey {
|
||||
pub id: Uuid,
|
||||
@@ -148,11 +194,21 @@ pub trait InfraRepository: Send + Sync {
|
||||
|
||||
async fn get_invite_code_available_uses(&self, code: &str) -> Result<Option<i32>, DbError>;
|
||||
|
||||
async fn is_invite_code_valid(&self, code: &str) -> Result<bool, DbError>;
|
||||
async fn validate_invite_code<'a>(
|
||||
&self,
|
||||
code: &'a str,
|
||||
) -> Result<ValidatedInviteCode<'a>, InviteCodeError>;
|
||||
|
||||
async fn decrement_invite_code_uses(&self, code: &str) -> Result<(), DbError>;
|
||||
async fn decrement_invite_code_uses(
|
||||
&self,
|
||||
code: &ValidatedInviteCode<'_>,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn record_invite_code_use(&self, code: &str, used_by_user: Uuid) -> Result<(), DbError>;
|
||||
async fn record_invite_code_use(
|
||||
&self,
|
||||
code: &ValidatedInviteCode<'_>,
|
||||
used_by_user: Uuid,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn get_invite_codes_for_account(
|
||||
&self,
|
||||
@@ -317,9 +373,9 @@ pub trait InfraRepository: Send + Sync {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NotificationHistoryRow {
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub channel: String,
|
||||
pub comms_type: String,
|
||||
pub status: String,
|
||||
pub channel: CommsChannel,
|
||||
pub comms_type: CommsType,
|
||||
pub status: CommsStatus,
|
||||
pub subject: Option<String>,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ValidatedInviteCode<'a> {
|
||||
code: &'a str,
|
||||
_marker: PhantomData<&'a ()>,
|
||||
}
|
||||
|
||||
impl<'a> ValidatedInviteCode<'a> {
|
||||
pub fn new_validated(code: &'a str) -> Self {
|
||||
Self {
|
||||
code,
|
||||
_marker: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn code(&self) -> &str {
|
||||
self.code
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum InviteCodeError {
|
||||
NotFound,
|
||||
ExhaustedUses,
|
||||
Disabled,
|
||||
DatabaseError(DbError),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InviteCodeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NotFound => write!(f, "Invite code not found"),
|
||||
Self::ExhaustedUses => write!(f, "Invite code has no remaining uses"),
|
||||
Self::Disabled => write!(f, "Invite code is disabled"),
|
||||
Self::DatabaseError(e) => write!(f, "Database error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for InviteCodeError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::DatabaseError(e) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DbError> for InviteCodeError {
|
||||
fn from(e: DbError) -> Self {
|
||||
Self::DatabaseError(e)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
mod backlink;
|
||||
mod backup;
|
||||
mod blob;
|
||||
mod channel_verification;
|
||||
mod delegation;
|
||||
mod error;
|
||||
mod infra;
|
||||
mod invite_code;
|
||||
mod oauth;
|
||||
mod repo;
|
||||
mod scope;
|
||||
mod sequence;
|
||||
mod session;
|
||||
mod sso;
|
||||
mod user;
|
||||
@@ -16,6 +20,7 @@ pub use backup::{
|
||||
OldBackupInfo, UserBackupInfo,
|
||||
};
|
||||
pub use blob::{BlobForExport, BlobMetadata, BlobRepository, BlobWithTakedown, MissingBlobInfo};
|
||||
pub use channel_verification::ChannelVerificationStatus;
|
||||
pub use delegation::{
|
||||
AuditLogEntry, ControllerInfo, DelegatedAccountInfo, DelegationActionType, DelegationGrant,
|
||||
DelegationRepository,
|
||||
@@ -23,39 +28,45 @@ pub use delegation::{
|
||||
pub use error::DbError;
|
||||
pub use infra::{
|
||||
AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DeletionRequest, InfraRepository,
|
||||
InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeUse, NotificationHistoryRow,
|
||||
QueuedComms, ReservedSigningKey,
|
||||
InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeState, InviteCodeUse,
|
||||
NotificationHistoryRow, QueuedComms, ReservedSigningKey,
|
||||
};
|
||||
pub use invite_code::{InviteCodeError, ValidatedInviteCode};
|
||||
pub use oauth::{
|
||||
DeviceAccountRow, DeviceTrustInfo, OAuthRepository, OAuthSessionListItem, RefreshTokenLookup,
|
||||
ScopePreference, TrustedDeviceRow, TwoFactorChallenge,
|
||||
ScopePreference, TokenFamilyId, TrustedDeviceRow, TwoFactorChallenge,
|
||||
};
|
||||
pub use repo::{
|
||||
ApplyCommitError, ApplyCommitInput, ApplyCommitResult, BrokenGenesisCommit, CommitEventData,
|
||||
EventBlocksCids, FullRecordInfo, ImportBlock, ImportRecord, ImportRepoError, RecordDelete,
|
||||
RecordInfo, RecordUpsert, RecordWithTakedown, RepoAccountInfo, RepoEventNotifier,
|
||||
RepoEventReceiver, RepoInfo, RepoListItem, RepoRepository, RepoSeqEvent, RepoWithoutRev,
|
||||
SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks,
|
||||
AccountStatus, ApplyCommitError, ApplyCommitInput, ApplyCommitResult, BrokenGenesisCommit,
|
||||
CommitEventData, EventBlocksCids, FullRecordInfo, ImportBlock, ImportRecord, ImportRepoError,
|
||||
RecordDelete, RecordInfo, RecordUpsert, RecordWithTakedown, RepoAccountInfo, RepoEventNotifier,
|
||||
RepoEventReceiver, RepoEventType, RepoInfo, RepoListItem, RepoRepository, RepoSeqEvent,
|
||||
RepoWithoutRev, SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks,
|
||||
};
|
||||
pub use scope::{DbScope, InvalidScopeError};
|
||||
pub use sequence::{SequenceNumber, deserialize_optional_sequence};
|
||||
pub use session::{
|
||||
AppPasswordCreate, AppPasswordRecord, RefreshSessionResult, SessionForRefresh, SessionListItem,
|
||||
SessionMfaStatus, SessionRefreshData, SessionRepository, SessionToken, SessionTokenCreate,
|
||||
AppPasswordCreate, AppPasswordPrivilege, AppPasswordRecord, LoginType, RefreshSessionResult,
|
||||
SessionForRefresh, SessionId, SessionListItem, SessionMfaStatus, SessionRefreshData,
|
||||
SessionRepository, SessionToken, SessionTokenCreate,
|
||||
};
|
||||
pub use sso::{
|
||||
ExternalIdentity, SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository,
|
||||
ExternalEmail, ExternalIdentity, ExternalUserId, ExternalUsername, SsoAction, SsoAuthState,
|
||||
SsoPendingRegistration, SsoProviderType, SsoRepository,
|
||||
};
|
||||
pub use user::{
|
||||
AccountSearchResult, CompletePasskeySetupInput, CreateAccountError,
|
||||
AccountSearchResult, AccountType, CompletePasskeySetupInput, CreateAccountError,
|
||||
CreateDelegatedAccountInput, CreatePasskeyAccountInput, CreatePasswordAccountInput,
|
||||
CreatePasswordAccountResult, CreateSsoAccountInput, DidWebOverrides,
|
||||
MigrationReactivationError, MigrationReactivationInput, NotificationPrefs, OAuthTokenWithUser,
|
||||
PasswordResetResult, ReactivatedAccountInfo, RecoverPasskeyAccountInput,
|
||||
RecoverPasskeyAccountResult, ScheduledDeletionAccount, StoredBackupCode, StoredPasskey,
|
||||
TotpRecord, User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo,
|
||||
UserEmailInfo, UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery,
|
||||
UserForPasskeySetup, UserForRecovery, UserForVerification, UserIdAndHandle,
|
||||
UserIdAndPasswordHash, UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId,
|
||||
UserLegacyLoginPref, UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo,
|
||||
UserRepository, UserResendVerification, UserResetCodeInfo, UserRow, UserSessionInfo,
|
||||
UserStatus, UserVerificationInfo, UserWithKey,
|
||||
TotpRecord, TotpRecordState, UnverifiedTotpRecord, User2faStatus, UserAuthInfo, UserCommsPrefs,
|
||||
UserConfirmSignup, UserDidWebInfo, UserEmailInfo, UserForDeletion, UserForDidDoc,
|
||||
UserForDidDocBuild, UserForPasskeyRecovery, UserForPasskeySetup, UserForRecovery,
|
||||
UserForVerification, UserIdAndHandle, UserIdAndPasswordHash, UserIdHandleEmail,
|
||||
UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref, UserLoginCheck,
|
||||
UserLoginFull, UserLoginInfo, UserPasswordInfo, UserRepository, UserResendVerification,
|
||||
UserResetCodeInfo, UserRow, UserSessionInfo, UserStatus, UserVerificationInfo, UserWithKey,
|
||||
VerifiedTotpRecord,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,37 @@ use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct TokenFamilyId(i32);
|
||||
|
||||
impl TokenFamilyId {
|
||||
pub fn new(id: i32) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
pub fn as_i32(self) -> i32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i32> for TokenFamilyId {
|
||||
fn from(id: i32) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TokenFamilyId> for i32 {
|
||||
fn from(id: TokenFamilyId) -> Self {
|
||||
id.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TokenFamilyId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScopePreference {
|
||||
pub scope: String,
|
||||
@@ -53,7 +84,7 @@ pub struct DeviceTrustInfo {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OAuthSessionListItem {
|
||||
pub id: i32,
|
||||
pub id: TokenFamilyId,
|
||||
pub token_id: TokenId,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
@@ -62,19 +93,19 @@ pub struct OAuthSessionListItem {
|
||||
|
||||
pub enum RefreshTokenLookup {
|
||||
Valid {
|
||||
db_id: i32,
|
||||
db_id: TokenFamilyId,
|
||||
token_data: TokenData,
|
||||
},
|
||||
InGracePeriod {
|
||||
db_id: i32,
|
||||
db_id: TokenFamilyId,
|
||||
token_data: TokenData,
|
||||
rotated_at: DateTime<Utc>,
|
||||
},
|
||||
Used {
|
||||
original_token_id: i32,
|
||||
original_token_id: TokenFamilyId,
|
||||
},
|
||||
Expired {
|
||||
db_id: i32,
|
||||
db_id: TokenFamilyId,
|
||||
},
|
||||
NotFound,
|
||||
}
|
||||
@@ -93,28 +124,28 @@ impl RefreshTokenLookup {
|
||||
|
||||
#[async_trait]
|
||||
pub trait OAuthRepository: Send + Sync {
|
||||
async fn create_token(&self, data: &TokenData) -> Result<i32, DbError>;
|
||||
async fn create_token(&self, data: &TokenData) -> Result<TokenFamilyId, DbError>;
|
||||
async fn get_token_by_id(&self, token_id: &TokenId) -> Result<Option<TokenData>, DbError>;
|
||||
async fn get_token_by_refresh_token(
|
||||
&self,
|
||||
refresh_token: &RefreshToken,
|
||||
) -> Result<Option<(i32, TokenData)>, DbError>;
|
||||
) -> Result<Option<(TokenFamilyId, TokenData)>, DbError>;
|
||||
async fn get_token_by_previous_refresh_token(
|
||||
&self,
|
||||
refresh_token: &RefreshToken,
|
||||
) -> Result<Option<(i32, TokenData)>, DbError>;
|
||||
) -> Result<Option<(TokenFamilyId, TokenData)>, DbError>;
|
||||
async fn rotate_token(
|
||||
&self,
|
||||
old_db_id: i32,
|
||||
old_db_id: TokenFamilyId,
|
||||
new_refresh_token: &RefreshToken,
|
||||
new_expires_at: DateTime<Utc>,
|
||||
) -> Result<(), DbError>;
|
||||
async fn check_refresh_token_used(
|
||||
&self,
|
||||
refresh_token: &RefreshToken,
|
||||
) -> Result<Option<i32>, DbError>;
|
||||
) -> Result<Option<TokenFamilyId>, DbError>;
|
||||
async fn delete_token(&self, token_id: &TokenId) -> Result<(), DbError>;
|
||||
async fn delete_token_family(&self, db_id: i32) -> Result<(), DbError>;
|
||||
async fn delete_token_family(&self, db_id: TokenFamilyId) -> Result<(), DbError>;
|
||||
async fn list_tokens_for_user(&self, did: &Did) -> Result<Vec<TokenData>, DbError>;
|
||||
async fn count_tokens_for_user(&self, did: &Did) -> Result<i64, DbError>;
|
||||
async fn delete_oldest_tokens_for_user(
|
||||
@@ -274,7 +305,11 @@ pub trait OAuthRepository: Send + Sync {
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn list_sessions_by_did(&self, did: &Did) -> Result<Vec<OAuthSessionListItem>, DbError>;
|
||||
async fn delete_session_by_id(&self, session_id: i32, did: &Did) -> Result<u64, DbError>;
|
||||
async fn delete_session_by_id(
|
||||
&self,
|
||||
session_id: TokenFamilyId,
|
||||
did: &Did,
|
||||
) -> Result<u64, DbError>;
|
||||
async fn delete_sessions_by_did(&self, did: &Did) -> Result<u64, DbError>;
|
||||
async fn delete_sessions_by_did_except(
|
||||
&self,
|
||||
|
||||
@@ -5,6 +5,116 @@ use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
use crate::sequence::SequenceNumber;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "text", rename_all = "snake_case")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RepoEventType {
|
||||
Commit,
|
||||
Identity,
|
||||
Account,
|
||||
Sync,
|
||||
}
|
||||
|
||||
impl RepoEventType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Commit => "commit",
|
||||
Self::Identity => "identity",
|
||||
Self::Account => "account",
|
||||
Self::Sync => "sync",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "text", rename_all = "lowercase")]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AccountStatus {
|
||||
Active,
|
||||
Takendown,
|
||||
Suspended,
|
||||
Deactivated,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
impl AccountStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Active => "active",
|
||||
Self::Takendown => "takendown",
|
||||
Self::Suspended => "suspended",
|
||||
Self::Deactivated => "deactivated",
|
||||
Self::Deleted => "deleted",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_firehose(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Active => None,
|
||||
other => Some(other.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"active" => Some(Self::Active),
|
||||
"takendown" => Some(Self::Takendown),
|
||||
"suspended" => Some(Self::Suspended),
|
||||
"deactivated" => Some(Self::Deactivated),
|
||||
"deleted" => Some(Self::Deleted),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
matches!(self, Self::Active)
|
||||
}
|
||||
|
||||
pub fn is_takendown(&self) -> bool {
|
||||
matches!(self, Self::Takendown)
|
||||
}
|
||||
|
||||
pub fn is_deactivated(&self) -> bool {
|
||||
matches!(self, Self::Deactivated)
|
||||
}
|
||||
|
||||
pub fn is_suspended(&self) -> bool {
|
||||
matches!(self, Self::Suspended)
|
||||
}
|
||||
|
||||
pub fn is_deleted(&self) -> bool {
|
||||
matches!(self, Self::Deleted)
|
||||
}
|
||||
|
||||
pub fn allows_read(&self) -> bool {
|
||||
matches!(self, Self::Active | Self::Deactivated)
|
||||
}
|
||||
|
||||
pub fn allows_write(&self) -> bool {
|
||||
matches!(self, Self::Active)
|
||||
}
|
||||
|
||||
pub fn from_db_fields(
|
||||
takedown_ref: Option<&str>,
|
||||
deactivated_at: Option<DateTime<Utc>>,
|
||||
) -> Self {
|
||||
if takedown_ref.is_some() {
|
||||
Self::Takendown
|
||||
} else if deactivated_at.is_some() {
|
||||
Self::Deactivated
|
||||
} else {
|
||||
Self::Active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AccountStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RepoAccountInfo {
|
||||
@@ -49,7 +159,7 @@ pub struct RepoWithoutRev {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BrokenGenesisCommit {
|
||||
pub seq: i64,
|
||||
pub seq: SequenceNumber,
|
||||
pub did: Did,
|
||||
pub commit_cid: Option<CidLink>,
|
||||
}
|
||||
@@ -69,15 +179,15 @@ pub struct UserNeedingRecordBlobsBackfill {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RepoSeqEvent {
|
||||
pub seq: i64,
|
||||
pub seq: SequenceNumber,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SequencedEvent {
|
||||
pub seq: i64,
|
||||
pub seq: SequenceNumber,
|
||||
pub did: Did,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub event_type: String,
|
||||
pub event_type: RepoEventType,
|
||||
pub commit_cid: Option<CidLink>,
|
||||
pub prev_cid: Option<CidLink>,
|
||||
pub prev_data_cid: Option<CidLink>,
|
||||
@@ -86,14 +196,14 @@ pub struct SequencedEvent {
|
||||
pub blocks_cids: Option<Vec<String>>,
|
||||
pub handle: Option<Handle>,
|
||||
pub active: Option<bool>,
|
||||
pub status: Option<String>,
|
||||
pub status: Option<AccountStatus>,
|
||||
pub rev: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommitEventData {
|
||||
pub did: Did,
|
||||
pub event_type: String,
|
||||
pub event_type: RepoEventType,
|
||||
pub commit_cid: Option<CidLink>,
|
||||
pub prev_cid: Option<CidLink>,
|
||||
pub ops: Option<serde_json::Value>,
|
||||
@@ -283,27 +393,26 @@ pub trait RepoRepository: Send + Sync {
|
||||
|
||||
async fn count_user_blocks(&self, user_id: Uuid) -> Result<i64, DbError>;
|
||||
|
||||
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<i64, DbError>;
|
||||
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<SequenceNumber, DbError>;
|
||||
|
||||
async fn insert_identity_event(
|
||||
&self,
|
||||
did: &Did,
|
||||
handle: Option<&Handle>,
|
||||
) -> Result<i64, DbError>;
|
||||
) -> Result<SequenceNumber, DbError>;
|
||||
|
||||
async fn insert_account_event(
|
||||
&self,
|
||||
did: &Did,
|
||||
active: bool,
|
||||
status: Option<&str>,
|
||||
) -> Result<i64, DbError>;
|
||||
status: AccountStatus,
|
||||
) -> Result<SequenceNumber, DbError>;
|
||||
|
||||
async fn insert_sync_event(
|
||||
&self,
|
||||
did: &Did,
|
||||
commit_cid: &CidLink,
|
||||
rev: Option<&str>,
|
||||
) -> Result<i64, DbError>;
|
||||
) -> Result<SequenceNumber, DbError>;
|
||||
|
||||
async fn insert_genesis_commit_event(
|
||||
&self,
|
||||
@@ -311,36 +420,49 @@ pub trait RepoRepository: Send + Sync {
|
||||
commit_cid: &CidLink,
|
||||
mst_root_cid: &CidLink,
|
||||
rev: &str,
|
||||
) -> Result<i64, DbError>;
|
||||
) -> Result<SequenceNumber, DbError>;
|
||||
|
||||
async fn update_seq_blocks_cids(&self, seq: i64, blocks_cids: &[String])
|
||||
-> Result<(), DbError>;
|
||||
async fn update_seq_blocks_cids(
|
||||
&self,
|
||||
seq: SequenceNumber,
|
||||
blocks_cids: &[String],
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn delete_sequences_except(&self, did: &Did, keep_seq: i64) -> Result<(), DbError>;
|
||||
async fn delete_sequences_except(
|
||||
&self,
|
||||
did: &Did,
|
||||
keep_seq: SequenceNumber,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn get_max_seq(&self) -> Result<i64, DbError>;
|
||||
async fn get_max_seq(&self) -> Result<SequenceNumber, DbError>;
|
||||
|
||||
async fn get_min_seq_since(&self, since: DateTime<Utc>) -> Result<Option<i64>, DbError>;
|
||||
async fn get_min_seq_since(
|
||||
&self,
|
||||
since: DateTime<Utc>,
|
||||
) -> Result<Option<SequenceNumber>, DbError>;
|
||||
|
||||
async fn get_account_with_repo(&self, did: &Did) -> Result<Option<RepoAccountInfo>, DbError>;
|
||||
|
||||
async fn get_events_since_seq(
|
||||
&self,
|
||||
since_seq: i64,
|
||||
since_seq: SequenceNumber,
|
||||
limit: Option<i64>,
|
||||
) -> Result<Vec<SequencedEvent>, DbError>;
|
||||
|
||||
async fn get_events_in_seq_range(
|
||||
&self,
|
||||
start_seq: i64,
|
||||
end_seq: i64,
|
||||
start_seq: SequenceNumber,
|
||||
end_seq: SequenceNumber,
|
||||
) -> Result<Vec<SequencedEvent>, DbError>;
|
||||
|
||||
async fn get_event_by_seq(&self, seq: i64) -> Result<Option<SequencedEvent>, DbError>;
|
||||
async fn get_event_by_seq(
|
||||
&self,
|
||||
seq: SequenceNumber,
|
||||
) -> Result<Option<SequencedEvent>, DbError>;
|
||||
|
||||
async fn get_events_since_cursor(
|
||||
&self,
|
||||
cursor: i64,
|
||||
cursor: SequenceNumber,
|
||||
limit: i64,
|
||||
) -> Result<Vec<SequencedEvent>, DbError>;
|
||||
|
||||
@@ -359,7 +481,7 @@ pub trait RepoRepository: Send + Sync {
|
||||
async fn get_repo_root_cid_by_user_id(&self, user_id: Uuid)
|
||||
-> Result<Option<CidLink>, DbError>;
|
||||
|
||||
async fn notify_update(&self, seq: i64) -> Result<(), DbError>;
|
||||
async fn notify_update(&self, seq: SequenceNumber) -> Result<(), DbError>;
|
||||
|
||||
async fn import_repo_data(
|
||||
&self,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DbScope(String);
|
||||
|
||||
impl DbScope {
|
||||
pub fn new(scope: impl Into<String>) -> Result<Self, InvalidScopeError> {
|
||||
let scope = scope.into();
|
||||
validate_scope_string(&scope)?;
|
||||
Ok(Self(scope))
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
Self(String::new())
|
||||
}
|
||||
|
||||
pub fn from_db(scope: String) -> Self {
|
||||
match validate_scope_string(&scope) {
|
||||
Ok(()) => Self(scope),
|
||||
Err(e) => panic!("corrupted scope data from database: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DbScope {
|
||||
fn default() -> Self {
|
||||
Self::empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DbScope {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for DbScope {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for DbScope {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
self.0.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DbScope {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Self::new(s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InvalidScopeError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl InvalidScopeError {
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for InvalidScopeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for InvalidScopeError {}
|
||||
|
||||
fn validate_scope_string(scopes: &str) -> Result<(), InvalidScopeError> {
|
||||
if scopes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
scopes.split_whitespace().try_for_each(|scope| {
|
||||
let base = scope.split_once('?').map_or(scope, |(b, _)| b);
|
||||
if is_valid_scope_prefix(base) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(InvalidScopeError::new(format!("Invalid scope: {}", scope)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn is_valid_scope_prefix(base: &str) -> bool {
|
||||
const VALID_PREFIXES: [&str; 8] = [
|
||||
"atproto",
|
||||
"repo:",
|
||||
"blob:",
|
||||
"rpc:",
|
||||
"account:",
|
||||
"identity:",
|
||||
"transition:",
|
||||
"include:",
|
||||
];
|
||||
|
||||
VALID_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| base == prefix.trim_end_matches(':') || base.starts_with(prefix))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_scopes() {
|
||||
assert!(DbScope::new("atproto").is_ok());
|
||||
assert!(DbScope::new("repo:*").is_ok());
|
||||
assert!(DbScope::new("blob:*/*").is_ok());
|
||||
assert!(DbScope::new("repo:* blob:*/*").is_ok());
|
||||
assert!(DbScope::new("").is_ok());
|
||||
assert!(DbScope::new("account:email?action=read").is_ok());
|
||||
assert!(DbScope::new("identity:handle").is_ok());
|
||||
assert!(DbScope::new("transition:generic").is_ok());
|
||||
assert!(DbScope::new("include:app.bsky.authFullApp").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_scopes() {
|
||||
assert!(DbScope::new("invalid:scope").is_err());
|
||||
assert!(DbScope::new("garbage").is_err());
|
||||
assert!(DbScope::new("repo:* invalid:scope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_scope() {
|
||||
let scope = DbScope::empty();
|
||||
assert!(scope.is_empty());
|
||||
assert_eq!(scope.as_str(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display() {
|
||||
let scope = DbScope::new("repo:*").unwrap();
|
||||
assert_eq!(format!("{}", scope), "repo:*");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "corrupted scope data from database")]
|
||||
fn test_from_db_panics_on_corrupted_data() {
|
||||
DbScope::from_db("totally_invalid_garbage_scope".to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_db_accepts_valid_data() {
|
||||
let scope = DbScope::from_db("repo:* blob:*/*".to_string());
|
||||
assert_eq!(scope.as_str(), "repo:* blob:*/*");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct SequenceNumber(i64);
|
||||
|
||||
impl SequenceNumber {
|
||||
pub const ZERO: Self = Self(0);
|
||||
pub const UNSET: Self = Self(-1);
|
||||
|
||||
pub fn new(n: i64) -> Option<Self> {
|
||||
if n >= 0 { Some(Self(n)) } else { None }
|
||||
}
|
||||
|
||||
pub fn from_raw(n: i64) -> Self {
|
||||
Self(n)
|
||||
}
|
||||
|
||||
pub fn as_i64(&self) -> i64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.0 >= 0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SequenceNumber {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for SequenceNumber {
|
||||
fn from(n: i64) -> Self {
|
||||
Self(n)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SequenceNumber> for i64 {
|
||||
fn from(seq: SequenceNumber) -> Self {
|
||||
seq.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for SequenceNumber {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.0.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SequenceNumber {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let n = i64::deserialize(deserializer)?;
|
||||
Ok(Self(n))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize_optional_sequence<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<SequenceNumber>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let opt: Option<i64> = Option::deserialize(deserializer)?;
|
||||
Ok(opt.map(SequenceNumber::from_raw))
|
||||
}
|
||||
@@ -5,15 +5,104 @@ use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub enum LoginType {
|
||||
#[default]
|
||||
Modern,
|
||||
Legacy,
|
||||
}
|
||||
|
||||
impl LoginType {
|
||||
pub fn is_legacy(self) -> bool {
|
||||
matches!(self, Self::Legacy)
|
||||
}
|
||||
|
||||
pub fn is_modern(self) -> bool {
|
||||
matches!(self, Self::Modern)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bool> for LoginType {
|
||||
fn from(legacy: bool) -> Self {
|
||||
if legacy { Self::Legacy } else { Self::Modern }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LoginType> for bool {
|
||||
fn from(lt: LoginType) -> Self {
|
||||
matches!(lt, LoginType::Legacy)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub enum AppPasswordPrivilege {
|
||||
#[default]
|
||||
Standard,
|
||||
Privileged,
|
||||
}
|
||||
|
||||
impl AppPasswordPrivilege {
|
||||
pub fn is_privileged(self) -> bool {
|
||||
matches!(self, Self::Privileged)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bool> for AppPasswordPrivilege {
|
||||
fn from(privileged: bool) -> Self {
|
||||
if privileged {
|
||||
Self::Privileged
|
||||
} else {
|
||||
Self::Standard
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppPasswordPrivilege> for bool {
|
||||
fn from(p: AppPasswordPrivilege) -> Self {
|
||||
matches!(p, AppPasswordPrivilege::Privileged)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct SessionId(i32);
|
||||
|
||||
impl SessionId {
|
||||
pub fn new(id: i32) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
pub fn as_i32(self) -> i32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i32> for SessionId {
|
||||
fn from(id: i32) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SessionId> for i32 {
|
||||
fn from(id: SessionId) -> Self {
|
||||
id.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SessionId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionToken {
|
||||
pub id: i32,
|
||||
pub id: SessionId,
|
||||
pub did: Did,
|
||||
pub access_jti: String,
|
||||
pub refresh_jti: String,
|
||||
pub access_expires_at: DateTime<Utc>,
|
||||
pub refresh_expires_at: DateTime<Utc>,
|
||||
pub legacy_login: bool,
|
||||
pub login_type: LoginType,
|
||||
pub mfa_verified: bool,
|
||||
pub scope: Option<String>,
|
||||
pub controller_did: Option<Did>,
|
||||
@@ -29,7 +118,7 @@ pub struct SessionTokenCreate {
|
||||
pub refresh_jti: String,
|
||||
pub access_expires_at: DateTime<Utc>,
|
||||
pub refresh_expires_at: DateTime<Utc>,
|
||||
pub legacy_login: bool,
|
||||
pub login_type: LoginType,
|
||||
pub mfa_verified: bool,
|
||||
pub scope: Option<String>,
|
||||
pub controller_did: Option<Did>,
|
||||
@@ -38,7 +127,7 @@ pub struct SessionTokenCreate {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionForRefresh {
|
||||
pub id: i32,
|
||||
pub id: SessionId,
|
||||
pub did: Did,
|
||||
pub scope: Option<String>,
|
||||
pub controller_did: Option<Did>,
|
||||
@@ -48,7 +137,7 @@ pub struct SessionForRefresh {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionListItem {
|
||||
pub id: i32,
|
||||
pub id: SessionId,
|
||||
pub access_jti: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub refresh_expires_at: DateTime<Utc>,
|
||||
@@ -61,7 +150,7 @@ pub struct AppPasswordRecord {
|
||||
pub name: String,
|
||||
pub password_hash: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub privileged: bool,
|
||||
pub privilege: AppPasswordPrivilege,
|
||||
pub scopes: Option<String>,
|
||||
pub created_by_controller_did: Option<Did>,
|
||||
}
|
||||
@@ -71,14 +160,14 @@ pub struct AppPasswordCreate {
|
||||
pub user_id: Uuid,
|
||||
pub name: String,
|
||||
pub password_hash: String,
|
||||
pub privileged: bool,
|
||||
pub privilege: AppPasswordPrivilege,
|
||||
pub scopes: Option<String>,
|
||||
pub created_by_controller_did: Option<Did>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionMfaStatus {
|
||||
pub legacy_login: bool,
|
||||
pub login_type: LoginType,
|
||||
pub mfa_verified: bool,
|
||||
pub last_reauth_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
@@ -93,7 +182,7 @@ pub enum RefreshSessionResult {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionRefreshData {
|
||||
pub old_refresh_jti: String,
|
||||
pub session_id: i32,
|
||||
pub session_id: SessionId,
|
||||
pub new_access_jti: String,
|
||||
pub new_refresh_jti: String,
|
||||
pub new_access_expires_at: DateTime<Utc>,
|
||||
@@ -102,7 +191,7 @@ pub struct SessionRefreshData {
|
||||
|
||||
#[async_trait]
|
||||
pub trait SessionRepository: Send + Sync {
|
||||
async fn create_session(&self, data: &SessionTokenCreate) -> Result<i32, DbError>;
|
||||
async fn create_session(&self, data: &SessionTokenCreate) -> Result<SessionId, DbError>;
|
||||
|
||||
async fn get_session_by_access_jti(
|
||||
&self,
|
||||
@@ -116,7 +205,7 @@ pub trait SessionRepository: Send + Sync {
|
||||
|
||||
async fn update_session_tokens(
|
||||
&self,
|
||||
session_id: i32,
|
||||
session_id: SessionId,
|
||||
new_access_jti: &str,
|
||||
new_refresh_jti: &str,
|
||||
new_access_expires_at: DateTime<Utc>,
|
||||
@@ -125,7 +214,7 @@ pub trait SessionRepository: Send + Sync {
|
||||
|
||||
async fn delete_session_by_access_jti(&self, access_jti: &str) -> Result<u64, DbError>;
|
||||
|
||||
async fn delete_session_by_id(&self, session_id: i32) -> Result<u64, DbError>;
|
||||
async fn delete_session_by_id(&self, session_id: SessionId) -> Result<u64, DbError>;
|
||||
|
||||
async fn delete_sessions_by_did(&self, did: &Did) -> Result<u64, DbError>;
|
||||
|
||||
@@ -139,7 +228,7 @@ pub trait SessionRepository: Send + Sync {
|
||||
|
||||
async fn get_session_access_jti_by_id(
|
||||
&self,
|
||||
session_id: i32,
|
||||
session_id: SessionId,
|
||||
did: &Did,
|
||||
) -> Result<Option<String>, DbError>;
|
||||
|
||||
@@ -155,12 +244,15 @@ pub trait SessionRepository: Send + Sync {
|
||||
app_password_name: &str,
|
||||
) -> Result<Vec<String>, DbError>;
|
||||
|
||||
async fn check_refresh_token_used(&self, refresh_jti: &str) -> Result<Option<i32>, DbError>;
|
||||
async fn check_refresh_token_used(
|
||||
&self,
|
||||
refresh_jti: &str,
|
||||
) -> Result<Option<SessionId>, DbError>;
|
||||
|
||||
async fn mark_refresh_token_used(
|
||||
&self,
|
||||
refresh_jti: &str,
|
||||
session_id: i32,
|
||||
session_id: SessionId,
|
||||
) -> Result<bool, DbError>;
|
||||
|
||||
async fn list_app_passwords(&self, user_id: Uuid) -> Result<Vec<AppPasswordRecord>, DbError>;
|
||||
|
||||
@@ -6,6 +6,111 @@ use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ExternalUserId(String);
|
||||
|
||||
impl ExternalUserId {
|
||||
pub fn new(id: impl Into<String>) -> Self {
|
||||
Self(id.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ExternalUserId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ExternalUserId {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ExternalUserId> for String {
|
||||
fn from(id: ExternalUserId) -> Self {
|
||||
id.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ExternalUsername(String);
|
||||
|
||||
impl ExternalUsername {
|
||||
pub fn new(username: impl Into<String>) -> Self {
|
||||
Self(username.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ExternalUsername {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ExternalUsername {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ExternalUsername> for String {
|
||||
fn from(username: ExternalUsername) -> Self {
|
||||
username.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ExternalEmail(String);
|
||||
|
||||
impl ExternalEmail {
|
||||
pub fn new(email: impl Into<String>) -> Self {
|
||||
Self(email.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ExternalEmail {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ExternalEmail {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ExternalEmail> for String {
|
||||
fn from(email: ExternalEmail) -> Self {
|
||||
email.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "sso_provider_type", rename_all = "lowercase")]
|
||||
pub enum SsoProviderType {
|
||||
@@ -17,6 +122,40 @@ pub enum SsoProviderType {
|
||||
Apple,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "text", rename_all = "lowercase")]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SsoAction {
|
||||
Login,
|
||||
Link,
|
||||
Register,
|
||||
}
|
||||
|
||||
impl SsoAction {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Login => "login",
|
||||
Self::Link => "link",
|
||||
Self::Register => "register",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"login" => Some(Self::Login),
|
||||
"link" => Some(Self::Link),
|
||||
"register" => Some(Self::Register),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SsoAction {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl SsoProviderType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -69,9 +208,9 @@ pub struct ExternalIdentity {
|
||||
pub id: Uuid,
|
||||
pub did: Did,
|
||||
pub provider: SsoProviderType,
|
||||
pub provider_user_id: String,
|
||||
pub provider_username: Option<String>,
|
||||
pub provider_email: Option<String>,
|
||||
pub provider_user_id: ExternalUserId,
|
||||
pub provider_username: Option<ExternalUsername>,
|
||||
pub provider_email: Option<ExternalEmail>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
@@ -82,7 +221,7 @@ pub struct SsoAuthState {
|
||||
pub state: String,
|
||||
pub request_uri: String,
|
||||
pub provider: SsoProviderType,
|
||||
pub action: String,
|
||||
pub action: SsoAction,
|
||||
pub nonce: Option<String>,
|
||||
pub code_verifier: Option<String>,
|
||||
pub did: Option<Did>,
|
||||
@@ -95,9 +234,9 @@ pub struct SsoPendingRegistration {
|
||||
pub token: String,
|
||||
pub request_uri: String,
|
||||
pub provider: SsoProviderType,
|
||||
pub provider_user_id: String,
|
||||
pub provider_username: Option<String>,
|
||||
pub provider_email: Option<String>,
|
||||
pub provider_user_id: ExternalUserId,
|
||||
pub provider_username: Option<ExternalUsername>,
|
||||
pub provider_email: Option<ExternalEmail>,
|
||||
pub provider_email_verified: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
@@ -140,7 +279,7 @@ pub trait SsoRepository: Send + Sync {
|
||||
state: &str,
|
||||
request_uri: &str,
|
||||
provider: SsoProviderType,
|
||||
action: &str,
|
||||
action: SsoAction,
|
||||
nonce: Option<&str>,
|
||||
code_verifier: Option<&str>,
|
||||
did: Option<&Did>,
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tranquil_types::{Did, Handle};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{CommsChannel, DbError, SsoProviderType};
|
||||
use crate::{ChannelVerificationStatus, CommsChannel, DbError, SsoProviderType};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "account_type", rename_all = "snake_case")]
|
||||
pub enum AccountType {
|
||||
Personal,
|
||||
Delegated,
|
||||
}
|
||||
|
||||
impl AccountType {
|
||||
pub fn is_delegated(&self) -> bool {
|
||||
matches!(self, Self::Delegated)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserRow {
|
||||
@@ -62,11 +76,8 @@ pub struct UserLoginInfo {
|
||||
pub preferred_comms_channel: CommsChannel,
|
||||
pub deactivated_at: Option<DateTime<Utc>>,
|
||||
pub takedown_ref: Option<String>,
|
||||
pub email_verified: bool,
|
||||
pub discord_verified: bool,
|
||||
pub telegram_verified: bool,
|
||||
pub signal_verified: bool,
|
||||
pub account_type: String,
|
||||
pub channel_verification: ChannelVerificationStatus,
|
||||
pub account_type: AccountType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -74,10 +85,7 @@ pub struct User2faStatus {
|
||||
pub id: Uuid,
|
||||
pub two_factor_enabled: bool,
|
||||
pub preferred_comms_channel: CommsChannel,
|
||||
pub email_verified: bool,
|
||||
pub discord_verified: bool,
|
||||
pub telegram_verified: bool,
|
||||
pub signal_verified: bool,
|
||||
pub channel_verification: ChannelVerificationStatus,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -202,8 +210,11 @@ pub trait UserRepository: Send + Sync {
|
||||
did: &Did,
|
||||
) -> Result<Option<UserIdHandleEmail>, DbError>;
|
||||
|
||||
async fn update_preferred_comms_channel(&self, did: &Did, channel: &str)
|
||||
-> Result<(), DbError>;
|
||||
async fn update_preferred_comms_channel(
|
||||
&self,
|
||||
did: &Did,
|
||||
channel: CommsChannel,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn clear_discord(&self, user_id: Uuid) -> Result<(), DbError>;
|
||||
|
||||
@@ -292,6 +303,8 @@ pub trait UserRepository: Send + Sync {
|
||||
|
||||
async fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, DbError>;
|
||||
|
||||
async fn get_totp_record_state(&self, did: &Did) -> Result<Option<TotpRecordState>, DbError>;
|
||||
|
||||
async fn upsert_totp_secret(
|
||||
&self,
|
||||
did: &Did,
|
||||
@@ -560,7 +573,7 @@ pub struct DidWebOverrides {
|
||||
pub struct UserCommsPrefs {
|
||||
pub email: Option<String>,
|
||||
pub handle: Handle,
|
||||
pub preferred_channel: String,
|
||||
pub preferred_channel: CommsChannel,
|
||||
pub preferred_locale: Option<String>,
|
||||
}
|
||||
|
||||
@@ -611,16 +624,13 @@ pub struct UserAuthInfo {
|
||||
pub password_hash: Option<String>,
|
||||
pub deactivated_at: Option<DateTime<Utc>>,
|
||||
pub takedown_ref: Option<String>,
|
||||
pub email_verified: bool,
|
||||
pub discord_verified: bool,
|
||||
pub telegram_verified: bool,
|
||||
pub signal_verified: bool,
|
||||
pub channel_verification: ChannelVerificationStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NotificationPrefs {
|
||||
pub email: String,
|
||||
pub preferred_channel: String,
|
||||
pub preferred_channel: CommsChannel,
|
||||
pub discord_id: Option<String>,
|
||||
pub discord_verified: bool,
|
||||
pub telegram_username: Option<String>,
|
||||
@@ -641,10 +651,7 @@ pub struct UserVerificationInfo {
|
||||
pub id: Uuid,
|
||||
pub handle: Handle,
|
||||
pub email: Option<String>,
|
||||
pub email_verified: bool,
|
||||
pub discord_verified: bool,
|
||||
pub telegram_verified: bool,
|
||||
pub signal_verified: bool,
|
||||
pub channel_verification: ChannelVerificationStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -675,6 +682,74 @@ pub struct TotpRecord {
|
||||
pub verified: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VerifiedTotpRecord {
|
||||
pub secret_encrypted: Vec<u8>,
|
||||
pub encryption_version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnverifiedTotpRecord {
|
||||
pub secret_encrypted: Vec<u8>,
|
||||
pub encryption_version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TotpRecordState {
|
||||
Verified(VerifiedTotpRecord),
|
||||
Unverified(UnverifiedTotpRecord),
|
||||
}
|
||||
|
||||
impl TotpRecordState {
|
||||
pub fn is_verified(&self) -> bool {
|
||||
matches!(self, Self::Verified(_))
|
||||
}
|
||||
|
||||
pub fn as_verified(&self) -> Option<&VerifiedTotpRecord> {
|
||||
match self {
|
||||
Self::Verified(r) => Some(r),
|
||||
Self::Unverified(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_unverified(&self) -> Option<&UnverifiedTotpRecord> {
|
||||
match self {
|
||||
Self::Unverified(r) => Some(r),
|
||||
Self::Verified(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_verified(self) -> Option<VerifiedTotpRecord> {
|
||||
match self {
|
||||
Self::Verified(r) => Some(r),
|
||||
Self::Unverified(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_unverified(self) -> Option<UnverifiedTotpRecord> {
|
||||
match self {
|
||||
Self::Unverified(r) => Some(r),
|
||||
Self::Verified(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TotpRecord> for TotpRecordState {
|
||||
fn from(record: TotpRecord) -> Self {
|
||||
if record.verified {
|
||||
Self::Verified(VerifiedTotpRecord {
|
||||
secret_encrypted: record.secret_encrypted,
|
||||
encryption_version: record.encryption_version,
|
||||
})
|
||||
} else {
|
||||
Self::Unverified(UnverifiedTotpRecord {
|
||||
secret_encrypted: record.secret_encrypted,
|
||||
encryption_version: record.encryption_version,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredBackupCode {
|
||||
pub id: Uuid,
|
||||
@@ -685,15 +760,12 @@ pub struct StoredBackupCode {
|
||||
pub struct UserSessionInfo {
|
||||
pub handle: Handle,
|
||||
pub email: Option<String>,
|
||||
pub email_verified: bool,
|
||||
pub is_admin: bool,
|
||||
pub deactivated_at: Option<DateTime<Utc>>,
|
||||
pub takedown_ref: Option<String>,
|
||||
pub preferred_locale: Option<String>,
|
||||
pub preferred_comms_channel: CommsChannel,
|
||||
pub discord_verified: bool,
|
||||
pub telegram_verified: bool,
|
||||
pub signal_verified: bool,
|
||||
pub channel_verification: ChannelVerificationStatus,
|
||||
pub migrated_to_pds: Option<String>,
|
||||
pub migrated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
@@ -713,10 +785,7 @@ pub struct UserLoginFull {
|
||||
pub email: Option<String>,
|
||||
pub deactivated_at: Option<DateTime<Utc>>,
|
||||
pub takedown_ref: Option<String>,
|
||||
pub email_verified: bool,
|
||||
pub discord_verified: bool,
|
||||
pub telegram_verified: bool,
|
||||
pub signal_verified: bool,
|
||||
pub channel_verification: ChannelVerificationStatus,
|
||||
pub allow_legacy_login: bool,
|
||||
pub migrated_to_pds: Option<String>,
|
||||
pub preferred_comms_channel: CommsChannel,
|
||||
@@ -748,10 +817,7 @@ pub struct UserResendVerification {
|
||||
pub discord_id: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
pub signal_number: Option<String>,
|
||||
pub email_verified: bool,
|
||||
pub discord_verified: bool,
|
||||
pub telegram_verified: bool,
|
||||
pub signal_verified: bool,
|
||||
pub channel_verification: ChannelVerificationStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
use tranquil_db_traits::{
|
||||
AuditLogEntry, ControllerInfo, DbError, DelegatedAccountInfo, DelegationActionType,
|
||||
AuditLogEntry, ControllerInfo, DbError, DbScope, DelegatedAccountInfo, DelegationActionType,
|
||||
DelegationGrant, DelegationRepository,
|
||||
};
|
||||
use tranquil_types::Did;
|
||||
@@ -80,7 +80,7 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
&self,
|
||||
delegated_did: &Did,
|
||||
controller_did: &Did,
|
||||
granted_scopes: &str,
|
||||
granted_scopes: &DbScope,
|
||||
granted_by: &Did,
|
||||
) -> Result<Uuid, DbError> {
|
||||
let id = sqlx::query_scalar!(
|
||||
@@ -91,7 +91,7 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
"#,
|
||||
delegated_did.as_str(),
|
||||
controller_did.as_str(),
|
||||
granted_scopes,
|
||||
granted_scopes.as_str(),
|
||||
granted_by.as_str()
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
@@ -128,7 +128,7 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
&self,
|
||||
delegated_did: &Did,
|
||||
controller_did: &Did,
|
||||
new_scopes: &str,
|
||||
new_scopes: &DbScope,
|
||||
) -> Result<bool, DbError> {
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
@@ -136,7 +136,7 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
SET granted_scopes = $1
|
||||
WHERE delegated_did = $2 AND controller_did = $3 AND revoked_at IS NULL
|
||||
"#,
|
||||
new_scopes,
|
||||
new_scopes.as_str(),
|
||||
delegated_did.as_str(),
|
||||
controller_did.as_str()
|
||||
)
|
||||
@@ -170,7 +170,7 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
id: r.id,
|
||||
delegated_did: r.delegated_did.into(),
|
||||
controller_did: r.controller_did.into(),
|
||||
granted_scopes: r.granted_scopes,
|
||||
granted_scopes: DbScope::from_db(r.granted_scopes),
|
||||
granted_at: r.granted_at,
|
||||
granted_by: r.granted_by.into(),
|
||||
revoked_at: r.revoked_at,
|
||||
@@ -206,7 +206,7 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
.map(|r| ControllerInfo {
|
||||
did: r.did.into(),
|
||||
handle: r.handle.into(),
|
||||
granted_scopes: r.granted_scopes,
|
||||
granted_scopes: DbScope::from_db(r.granted_scopes),
|
||||
granted_at: r.granted_at,
|
||||
is_active: r.is_active,
|
||||
})
|
||||
@@ -243,7 +243,7 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
.map(|r| DelegatedAccountInfo {
|
||||
did: r.did.into(),
|
||||
handle: r.handle.into(),
|
||||
granted_scopes: r.granted_scopes,
|
||||
granted_scopes: DbScope::from_db(r.granted_scopes),
|
||||
granted_at: r.granted_at,
|
||||
})
|
||||
.collect())
|
||||
@@ -280,7 +280,7 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
.map(|r| ControllerInfo {
|
||||
did: r.did.into(),
|
||||
handle: r.handle.into(),
|
||||
granted_scopes: r.granted_scopes,
|
||||
granted_scopes: DbScope::from_db(r.granted_scopes),
|
||||
granted_at: r.granted_at,
|
||||
is_active: r.is_active,
|
||||
})
|
||||
|
||||
@@ -3,8 +3,9 @@ use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use tranquil_db_traits::{
|
||||
AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DbError, DeletionRequest,
|
||||
InfraRepository, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeUse,
|
||||
NotificationHistoryRow, QueuedComms, ReservedSigningKey,
|
||||
InfraRepository, InviteCodeError, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder,
|
||||
InviteCodeState, InviteCodeUse, NotificationHistoryRow, QueuedComms, ReservedSigningKey,
|
||||
ValidatedInviteCode,
|
||||
};
|
||||
use tranquil_types::{CidLink, Did, Handle};
|
||||
use uuid::Uuid;
|
||||
@@ -182,22 +183,33 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn is_invite_code_valid(&self, code: &str) -> Result<bool, DbError> {
|
||||
let result = sqlx::query_scalar!(
|
||||
r#"SELECT (available_uses > 0 AND NOT COALESCE(disabled, false)) as "valid!" FROM invite_codes WHERE code = $1"#,
|
||||
async fn validate_invite_code<'a>(
|
||||
&self,
|
||||
code: &'a str,
|
||||
) -> Result<ValidatedInviteCode<'a>, InviteCodeError> {
|
||||
let result = sqlx::query!(
|
||||
r#"SELECT available_uses, COALESCE(disabled, false) as "disabled!" FROM invite_codes WHERE code = $1"#,
|
||||
code
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
.map_err(|e| InviteCodeError::DatabaseError(map_sqlx_error(e)))?;
|
||||
|
||||
Ok(result.unwrap_or(false))
|
||||
match result {
|
||||
None => Err(InviteCodeError::NotFound),
|
||||
Some(row) if row.disabled => Err(InviteCodeError::Disabled),
|
||||
Some(row) if row.available_uses <= 0 => Err(InviteCodeError::ExhaustedUses),
|
||||
Some(_) => Ok(ValidatedInviteCode::new_validated(code)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn decrement_invite_code_uses(&self, code: &str) -> Result<(), DbError> {
|
||||
async fn decrement_invite_code_uses(
|
||||
&self,
|
||||
code: &ValidatedInviteCode<'_>,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
|
||||
code
|
||||
code.code()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -206,10 +218,14 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn record_invite_code_use(&self, code: &str, used_by_user: Uuid) -> Result<(), DbError> {
|
||||
async fn record_invite_code_use(
|
||||
&self,
|
||||
code: &ValidatedInviteCode<'_>,
|
||||
used_by_user: Uuid,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
|
||||
code,
|
||||
code.code(),
|
||||
used_by_user
|
||||
)
|
||||
.execute(&self.pool)
|
||||
@@ -245,7 +261,7 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.map(|r| InviteCodeInfo {
|
||||
code: r.code,
|
||||
available_uses: r.available_uses,
|
||||
disabled: r.disabled.unwrap_or(false),
|
||||
state: InviteCodeState::from(r.disabled),
|
||||
for_account: Some(Did::from(r.for_account)),
|
||||
created_at: r.created_at,
|
||||
created_by: None,
|
||||
@@ -422,7 +438,7 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.map(|r| InviteCodeInfo {
|
||||
code: r.code,
|
||||
available_uses: r.available_uses,
|
||||
disabled: r.disabled.unwrap_or(false),
|
||||
state: InviteCodeState::from(r.disabled),
|
||||
for_account: Some(Did::from(r.for_account)),
|
||||
created_at: r.created_at,
|
||||
created_by: Some(Did::from(r.created_by)),
|
||||
@@ -445,7 +461,7 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
Ok(result.map(|r| InviteCodeInfo {
|
||||
code: r.code,
|
||||
available_uses: r.available_uses,
|
||||
disabled: r.disabled.unwrap_or(false),
|
||||
state: InviteCodeState::from(r.disabled),
|
||||
for_account: Some(Did::from(r.for_account)),
|
||||
created_at: r.created_at,
|
||||
created_by: Some(Did::from(r.created_by)),
|
||||
@@ -476,7 +492,7 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
InviteCodeInfo {
|
||||
code: r.code,
|
||||
available_uses: r.available_uses,
|
||||
disabled: r.disabled.unwrap_or(false),
|
||||
state: InviteCodeState::from(r.disabled),
|
||||
for_account: Some(Did::from(r.for_account)),
|
||||
created_at: r.created_at,
|
||||
created_by: Some(Did::from(r.created_by)),
|
||||
@@ -841,9 +857,9 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
r#"
|
||||
SELECT
|
||||
created_at,
|
||||
channel as "channel: String",
|
||||
comms_type as "comms_type: String",
|
||||
status as "status: String",
|
||||
channel as "channel: CommsChannel",
|
||||
comms_type as "comms_type: CommsType",
|
||||
status as "status: CommsStatus",
|
||||
subject,
|
||||
body
|
||||
FROM comms_queue
|
||||
|
||||
@@ -4,11 +4,12 @@ use rand::Rng;
|
||||
use sqlx::PgPool;
|
||||
use tranquil_db_traits::{
|
||||
DbError, DeviceAccountRow, DeviceTrustInfo, OAuthRepository, OAuthSessionListItem,
|
||||
ScopePreference, TrustedDeviceRow, TwoFactorChallenge,
|
||||
ScopePreference, TokenFamilyId, TrustedDeviceRow, TwoFactorChallenge,
|
||||
};
|
||||
use tranquil_oauth::{
|
||||
AuthorizationRequestParameters, AuthorizedClientData, ClientAuth, DeviceData, RequestData,
|
||||
TokenData,
|
||||
AuthorizationRequestParameters, AuthorizedClientData, ClientAuth, Code as OAuthCode,
|
||||
DeviceData, DeviceId as OAuthDeviceId, RefreshToken as OAuthRefreshToken, RequestData,
|
||||
SessionId as OAuthSessionId, TokenData, TokenId as OAuthTokenId,
|
||||
};
|
||||
use tranquil_types::{
|
||||
AuthorizationCode, ClientId, DPoPProofId, DeviceId, Did, Handle, RefreshToken, RequestId,
|
||||
@@ -48,7 +49,7 @@ const REFRESH_GRACE_PERIOD_SECS: i64 = 60;
|
||||
|
||||
#[async_trait]
|
||||
impl OAuthRepository for PostgresOAuthRepository {
|
||||
async fn create_token(&self, data: &TokenData) -> Result<i32, DbError> {
|
||||
async fn create_token(&self, data: &TokenData) -> Result<TokenFamilyId, DbError> {
|
||||
let client_auth_json = to_json(&data.client_auth)?;
|
||||
let parameters_json = to_json(&data.parameters)?;
|
||||
let row = sqlx::query!(
|
||||
@@ -59,25 +60,25 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id
|
||||
"#,
|
||||
data.did,
|
||||
data.token_id,
|
||||
data.did.as_str(),
|
||||
&data.token_id.0,
|
||||
data.created_at,
|
||||
data.updated_at,
|
||||
data.expires_at,
|
||||
data.client_id,
|
||||
client_auth_json,
|
||||
data.device_id,
|
||||
data.device_id.as_ref().map(|d| d.0.as_str()),
|
||||
parameters_json,
|
||||
data.details,
|
||||
data.code,
|
||||
data.current_refresh_token,
|
||||
data.code.as_ref().map(|c| c.0.as_str()),
|
||||
data.current_refresh_token.as_ref().map(|r| r.0.as_str()),
|
||||
data.scope,
|
||||
data.controller_did,
|
||||
data.controller_did.as_ref().map(|d| d.as_str()),
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.id)
|
||||
Ok(TokenFamilyId::new(row.id))
|
||||
}
|
||||
|
||||
async fn get_token_by_id(&self, token_id: &TokenId) -> Result<Option<TokenData>, DbError> {
|
||||
@@ -95,20 +96,27 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.map_err(map_sqlx_error)?;
|
||||
match row {
|
||||
Some(r) => Ok(Some(TokenData {
|
||||
did: r.did,
|
||||
token_id: r.token_id,
|
||||
did: r
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
|
||||
token_id: OAuthTokenId(r.token_id),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
expires_at: r.expires_at,
|
||||
client_id: r.client_id,
|
||||
client_auth: from_json(r.client_auth)?,
|
||||
device_id: r.device_id,
|
||||
device_id: r.device_id.map(OAuthDeviceId),
|
||||
parameters: from_json(r.parameters)?,
|
||||
details: r.details,
|
||||
code: r.code,
|
||||
current_refresh_token: r.current_refresh_token,
|
||||
code: r.code.map(OAuthCode),
|
||||
current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken),
|
||||
scope: r.scope,
|
||||
controller_did: r.controller_did,
|
||||
controller_did: r
|
||||
.controller_did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
|
||||
})),
|
||||
None => Ok(None),
|
||||
}
|
||||
@@ -117,7 +125,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
async fn get_token_by_refresh_token(
|
||||
&self,
|
||||
refresh_token: &RefreshToken,
|
||||
) -> Result<Option<(i32, TokenData)>, DbError> {
|
||||
) -> Result<Option<(TokenFamilyId, TokenData)>, DbError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth,
|
||||
@@ -132,22 +140,29 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.map_err(map_sqlx_error)?;
|
||||
match row {
|
||||
Some(r) => Ok(Some((
|
||||
r.id,
|
||||
TokenFamilyId::new(r.id),
|
||||
TokenData {
|
||||
did: r.did,
|
||||
token_id: r.token_id,
|
||||
did: r
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
|
||||
token_id: OAuthTokenId(r.token_id),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
expires_at: r.expires_at,
|
||||
client_id: r.client_id,
|
||||
client_auth: from_json(r.client_auth)?,
|
||||
device_id: r.device_id,
|
||||
device_id: r.device_id.map(OAuthDeviceId),
|
||||
parameters: from_json(r.parameters)?,
|
||||
details: r.details,
|
||||
code: r.code,
|
||||
current_refresh_token: r.current_refresh_token,
|
||||
code: r.code.map(OAuthCode),
|
||||
current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken),
|
||||
scope: r.scope,
|
||||
controller_did: r.controller_did,
|
||||
controller_did: r
|
||||
.controller_did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
|
||||
},
|
||||
))),
|
||||
None => Ok(None),
|
||||
@@ -157,7 +172,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
async fn get_token_by_previous_refresh_token(
|
||||
&self,
|
||||
refresh_token: &RefreshToken,
|
||||
) -> Result<Option<(i32, TokenData)>, DbError> {
|
||||
) -> Result<Option<(TokenFamilyId, TokenData)>, DbError> {
|
||||
let grace_cutoff = Utc::now() - Duration::seconds(REFRESH_GRACE_PERIOD_SECS);
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
@@ -174,22 +189,29 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.map_err(map_sqlx_error)?;
|
||||
match row {
|
||||
Some(r) => Ok(Some((
|
||||
r.id,
|
||||
TokenFamilyId::new(r.id),
|
||||
TokenData {
|
||||
did: r.did,
|
||||
token_id: r.token_id,
|
||||
did: r
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
|
||||
token_id: OAuthTokenId(r.token_id),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
expires_at: r.expires_at,
|
||||
client_id: r.client_id,
|
||||
client_auth: from_json(r.client_auth)?,
|
||||
device_id: r.device_id,
|
||||
device_id: r.device_id.map(OAuthDeviceId),
|
||||
parameters: from_json(r.parameters)?,
|
||||
details: r.details,
|
||||
code: r.code,
|
||||
current_refresh_token: r.current_refresh_token,
|
||||
code: r.code.map(OAuthCode),
|
||||
current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken),
|
||||
scope: r.scope,
|
||||
controller_did: r.controller_did,
|
||||
controller_did: r
|
||||
.controller_did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
|
||||
},
|
||||
))),
|
||||
None => Ok(None),
|
||||
@@ -198,7 +220,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
|
||||
async fn rotate_token(
|
||||
&self,
|
||||
old_db_id: i32,
|
||||
old_db_id: TokenFamilyId,
|
||||
new_refresh_token: &RefreshToken,
|
||||
new_expires_at: DateTime<Utc>,
|
||||
) -> Result<(), DbError> {
|
||||
@@ -207,7 +229,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
r#"
|
||||
SELECT current_refresh_token FROM oauth_token WHERE id = $1
|
||||
"#,
|
||||
old_db_id
|
||||
old_db_id.as_i32()
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
@@ -219,7 +241,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
VALUES ($1, $2)
|
||||
"#,
|
||||
old_rt,
|
||||
old_db_id
|
||||
old_db_id.as_i32()
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
@@ -232,7 +254,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
previous_refresh_token = $4, rotated_at = NOW()
|
||||
WHERE id = $1
|
||||
"#,
|
||||
old_db_id,
|
||||
old_db_id.as_i32(),
|
||||
new_refresh_token.as_str(),
|
||||
new_expires_at,
|
||||
old_refresh
|
||||
@@ -247,7 +269,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
async fn check_refresh_token_used(
|
||||
&self,
|
||||
refresh_token: &RefreshToken,
|
||||
) -> Result<Option<i32>, DbError> {
|
||||
) -> Result<Option<TokenFamilyId>, DbError> {
|
||||
let row = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT token_id FROM oauth_used_refresh_token WHERE refresh_token = $1
|
||||
@@ -257,7 +279,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row)
|
||||
Ok(row.map(TokenFamilyId::new))
|
||||
}
|
||||
|
||||
async fn delete_token(&self, token_id: &TokenId) -> Result<(), DbError> {
|
||||
@@ -273,12 +295,12 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_token_family(&self, db_id: i32) -> Result<(), DbError> {
|
||||
async fn delete_token_family(&self, db_id: TokenFamilyId) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
DELETE FROM oauth_token WHERE id = $1
|
||||
"#,
|
||||
db_id
|
||||
db_id.as_i32()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -302,20 +324,27 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(TokenData {
|
||||
did: r.did,
|
||||
token_id: r.token_id,
|
||||
did: r
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
|
||||
token_id: OAuthTokenId(r.token_id),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
expires_at: r.expires_at,
|
||||
client_id: r.client_id,
|
||||
client_auth: from_json(r.client_auth)?,
|
||||
device_id: r.device_id,
|
||||
device_id: r.device_id.map(OAuthDeviceId),
|
||||
parameters: from_json(r.parameters)?,
|
||||
details: r.details,
|
||||
code: r.code,
|
||||
current_refresh_token: r.current_refresh_token,
|
||||
code: r.code.map(OAuthCode),
|
||||
current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken),
|
||||
scope: r.scope,
|
||||
controller_did: r.controller_did,
|
||||
controller_did: r
|
||||
.controller_did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -407,13 +436,13 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
"#,
|
||||
request_id.as_str(),
|
||||
data.did,
|
||||
data.device_id,
|
||||
data.did.as_ref().map(|d| d.as_str()),
|
||||
data.device_id.as_ref().map(|d| d.0.as_str()),
|
||||
data.client_id,
|
||||
client_auth_json,
|
||||
parameters_json,
|
||||
data.expires_at,
|
||||
data.code,
|
||||
data.code.as_ref().map(|c| c.0.as_str()),
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -448,10 +477,18 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
client_auth,
|
||||
parameters,
|
||||
expires_at: r.expires_at,
|
||||
did: r.did,
|
||||
device_id: r.device_id,
|
||||
code: r.code,
|
||||
controller_did: r.controller_did,
|
||||
did: r
|
||||
.did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid DID in DB".into()))?,
|
||||
device_id: r.device_id.map(OAuthDeviceId),
|
||||
code: r.code.map(OAuthCode),
|
||||
controller_did: r
|
||||
.controller_did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid controller DID in DB".into()))?,
|
||||
}))
|
||||
}
|
||||
None => Ok(None),
|
||||
@@ -534,10 +571,18 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
client_auth,
|
||||
parameters,
|
||||
expires_at: r.expires_at,
|
||||
did: r.did,
|
||||
device_id: r.device_id,
|
||||
code: r.code,
|
||||
controller_did: r.controller_did,
|
||||
did: r
|
||||
.did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid DID in DB".into()))?,
|
||||
device_id: r.device_id.map(OAuthDeviceId),
|
||||
code: r.code.map(OAuthCode),
|
||||
controller_did: r
|
||||
.controller_did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid controller DID in DB".into()))?,
|
||||
}))
|
||||
}
|
||||
None => Ok(None),
|
||||
@@ -655,7 +700,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
"#,
|
||||
device_id.as_str(),
|
||||
data.session_id,
|
||||
&data.session_id.0,
|
||||
data.user_agent,
|
||||
data.ip_address,
|
||||
data.last_seen_at,
|
||||
@@ -679,7 +724,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| DeviceData {
|
||||
session_id: r.session_id,
|
||||
session_id: OAuthSessionId(r.session_id),
|
||||
user_agent: r.user_agent,
|
||||
ip_address: r.ip_address,
|
||||
last_seen_at: r.last_seen_at,
|
||||
@@ -1207,7 +1252,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| OAuthSessionListItem {
|
||||
id: r.id,
|
||||
id: TokenFamilyId::new(r.id),
|
||||
token_id: TokenId::from(r.token_id),
|
||||
created_at: r.created_at,
|
||||
expires_at: r.expires_at,
|
||||
@@ -1216,10 +1261,14 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn delete_session_by_id(&self, session_id: i32, did: &Did) -> Result<u64, DbError> {
|
||||
async fn delete_session_by_id(
|
||||
&self,
|
||||
session_id: TokenFamilyId,
|
||||
did: &Did,
|
||||
) -> Result<u64, DbError> {
|
||||
let result = sqlx::query!(
|
||||
"DELETE FROM oauth_token WHERE id = $1 AND did = $2",
|
||||
session_id,
|
||||
session_id.as_i32(),
|
||||
did.as_str()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
|
||||
@@ -2,10 +2,10 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use tranquil_db_traits::{
|
||||
BrokenGenesisCommit, CommitEventData, DbError, EventBlocksCids, FullRecordInfo, ImportBlock,
|
||||
ImportRecord, ImportRepoError, RecordInfo, RecordWithTakedown, RepoAccountInfo, RepoInfo,
|
||||
RepoListItem, RepoRepository, RepoWithoutRev, SequencedEvent, UserNeedingRecordBlobsBackfill,
|
||||
UserWithoutBlocks,
|
||||
AccountStatus, BrokenGenesisCommit, CommitEventData, DbError, EventBlocksCids, FullRecordInfo,
|
||||
ImportBlock, ImportRecord, ImportRepoError, RecordInfo, RecordWithTakedown, RepoAccountInfo,
|
||||
RepoEventType, RepoInfo, RepoListItem, RepoRepository, RepoWithoutRev, SequenceNumber,
|
||||
SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks,
|
||||
};
|
||||
use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey};
|
||||
use uuid::Uuid;
|
||||
@@ -21,7 +21,7 @@ struct SequencedEventRow {
|
||||
seq: i64,
|
||||
did: String,
|
||||
created_at: DateTime<Utc>,
|
||||
event_type: String,
|
||||
event_type: RepoEventType,
|
||||
commit_cid: Option<String>,
|
||||
prev_cid: Option<String>,
|
||||
prev_data_cid: Option<String>,
|
||||
@@ -627,7 +627,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
Ok(rows.into_iter().map(|(cid,)| cid).collect())
|
||||
}
|
||||
|
||||
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<i64, DbError> {
|
||||
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<SequenceNumber, DbError> {
|
||||
let seq = sqlx::query_scalar!(
|
||||
r#"
|
||||
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid, rev)
|
||||
@@ -635,7 +635,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
RETURNING seq
|
||||
"#,
|
||||
data.did.as_str(),
|
||||
data.event_type,
|
||||
data.event_type.as_str(),
|
||||
data.commit_cid.as_ref().map(|c| c.as_str()),
|
||||
data.prev_cid.as_ref().map(|c| c.as_str()),
|
||||
data.ops,
|
||||
@@ -648,14 +648,14 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(seq)
|
||||
Ok(seq.into())
|
||||
}
|
||||
|
||||
async fn insert_identity_event(
|
||||
&self,
|
||||
did: &Did,
|
||||
handle: Option<&Handle>,
|
||||
) -> Result<i64, DbError> {
|
||||
) -> Result<SequenceNumber, DbError> {
|
||||
let handle_str = handle.map(|h| h.as_str());
|
||||
let seq = sqlx::query_scalar!(
|
||||
r#"
|
||||
@@ -675,15 +675,16 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(seq)
|
||||
Ok(seq.into())
|
||||
}
|
||||
|
||||
async fn insert_account_event(
|
||||
&self,
|
||||
did: &Did,
|
||||
active: bool,
|
||||
status: Option<&str>,
|
||||
) -> Result<i64, DbError> {
|
||||
status: AccountStatus,
|
||||
) -> Result<SequenceNumber, DbError> {
|
||||
let active = status.is_active();
|
||||
let status_str = status.for_firehose();
|
||||
let seq = sqlx::query_scalar!(
|
||||
r#"
|
||||
INSERT INTO repo_seq (did, event_type, active, status)
|
||||
@@ -692,7 +693,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
"#,
|
||||
did.as_str(),
|
||||
active,
|
||||
status
|
||||
status_str
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
@@ -703,7 +704,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(seq)
|
||||
Ok(seq.into())
|
||||
}
|
||||
|
||||
async fn insert_sync_event(
|
||||
@@ -711,7 +712,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
did: &Did,
|
||||
commit_cid: &CidLink,
|
||||
rev: Option<&str>,
|
||||
) -> Result<i64, DbError> {
|
||||
) -> Result<SequenceNumber, DbError> {
|
||||
let seq = sqlx::query_scalar!(
|
||||
r#"
|
||||
INSERT INTO repo_seq (did, event_type, commit_cid, rev)
|
||||
@@ -731,7 +732,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(seq)
|
||||
Ok(seq.into())
|
||||
}
|
||||
|
||||
async fn insert_genesis_commit_event(
|
||||
@@ -740,7 +741,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
commit_cid: &CidLink,
|
||||
mst_root_cid: &CidLink,
|
||||
rev: &str,
|
||||
) -> Result<i64, DbError> {
|
||||
) -> Result<SequenceNumber, DbError> {
|
||||
let ops = serde_json::json!([]);
|
||||
let blobs: Vec<String> = vec![];
|
||||
let blocks_cids: Vec<String> = vec![mst_root_cid.to_string(), commit_cid.to_string()];
|
||||
@@ -769,18 +770,18 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(seq)
|
||||
Ok(seq.into())
|
||||
}
|
||||
|
||||
async fn update_seq_blocks_cids(
|
||||
&self,
|
||||
seq: i64,
|
||||
seq: SequenceNumber,
|
||||
blocks_cids: &[String],
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"UPDATE repo_seq SET blocks_cids = $1 WHERE seq = $2",
|
||||
blocks_cids,
|
||||
seq
|
||||
seq.as_i64()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -789,11 +790,15 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_sequences_except(&self, did: &Did, keep_seq: i64) -> Result<(), DbError> {
|
||||
async fn delete_sequences_except(
|
||||
&self,
|
||||
did: &Did,
|
||||
keep_seq: SequenceNumber,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"DELETE FROM repo_seq WHERE did = $1 AND seq != $2",
|
||||
did.as_str(),
|
||||
keep_seq
|
||||
keep_seq.as_i64()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -802,16 +807,19 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_max_seq(&self) -> Result<i64, DbError> {
|
||||
async fn get_max_seq(&self) -> Result<SequenceNumber, DbError> {
|
||||
let seq = sqlx::query_scalar!(r#"SELECT COALESCE(MAX(seq), 0) as "max!" FROM repo_seq"#)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(seq)
|
||||
Ok(seq.into())
|
||||
}
|
||||
|
||||
async fn get_min_seq_since(&self, since: DateTime<Utc>) -> Result<Option<i64>, DbError> {
|
||||
async fn get_min_seq_since(
|
||||
&self,
|
||||
since: DateTime<Utc>,
|
||||
) -> Result<Option<SequenceNumber>, DbError> {
|
||||
let seq = sqlx::query_scalar!(
|
||||
"SELECT MIN(seq) FROM repo_seq WHERE created_at >= $1",
|
||||
since
|
||||
@@ -820,7 +828,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(seq)
|
||||
Ok(seq.map(SequenceNumber::from))
|
||||
}
|
||||
|
||||
async fn get_account_with_repo(&self, did: &Did) -> Result<Option<RepoAccountInfo>, DbError> {
|
||||
@@ -846,36 +854,43 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
|
||||
async fn get_events_since_seq(
|
||||
&self,
|
||||
since_seq: i64,
|
||||
since_seq: SequenceNumber,
|
||||
limit: Option<i64>,
|
||||
) -> Result<Vec<SequencedEvent>, DbError> {
|
||||
let map_row = |r: SequencedEventRow| SequencedEvent {
|
||||
seq: r.seq,
|
||||
did: Did::from(r.did),
|
||||
created_at: r.created_at,
|
||||
event_type: r.event_type,
|
||||
commit_cid: r.commit_cid.map(CidLink::from),
|
||||
prev_cid: r.prev_cid.map(CidLink::from),
|
||||
prev_data_cid: r.prev_data_cid.map(CidLink::from),
|
||||
ops: r.ops,
|
||||
blobs: r.blobs,
|
||||
blocks_cids: r.blocks_cids,
|
||||
handle: r.handle.map(Handle::from),
|
||||
active: r.active,
|
||||
status: r.status,
|
||||
rev: r.rev,
|
||||
let map_row = |r: SequencedEventRow| {
|
||||
let status = r
|
||||
.status
|
||||
.as_deref()
|
||||
.and_then(AccountStatus::parse)
|
||||
.or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active));
|
||||
SequencedEvent {
|
||||
seq: r.seq.into(),
|
||||
did: Did::from(r.did),
|
||||
created_at: r.created_at,
|
||||
event_type: r.event_type,
|
||||
commit_cid: r.commit_cid.map(CidLink::from),
|
||||
prev_cid: r.prev_cid.map(CidLink::from),
|
||||
prev_data_cid: r.prev_data_cid.map(CidLink::from),
|
||||
ops: r.ops,
|
||||
blobs: r.blobs,
|
||||
blocks_cids: r.blocks_cids,
|
||||
handle: r.handle.map(Handle::from),
|
||||
active: r.active,
|
||||
status,
|
||||
rev: r.rev,
|
||||
}
|
||||
};
|
||||
match limit {
|
||||
Some(lim) => {
|
||||
let rows = sqlx::query_as!(
|
||||
SequencedEventRow,
|
||||
r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,
|
||||
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
||||
ops, blobs, blocks_cids, handle, active, status, rev
|
||||
FROM repo_seq
|
||||
WHERE seq > $1
|
||||
ORDER BY seq ASC
|
||||
LIMIT $2"#,
|
||||
since_seq,
|
||||
since_seq.as_i64(),
|
||||
lim
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
@@ -886,12 +901,12 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
None => {
|
||||
let rows = sqlx::query_as!(
|
||||
SequencedEventRow,
|
||||
r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,
|
||||
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
||||
ops, blobs, blocks_cids, handle, active, status, rev
|
||||
FROM repo_seq
|
||||
WHERE seq > $1
|
||||
ORDER BY seq ASC"#,
|
||||
since_seq
|
||||
since_seq.as_i64()
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
@@ -903,25 +918,71 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
|
||||
async fn get_events_in_seq_range(
|
||||
&self,
|
||||
start_seq: i64,
|
||||
end_seq: i64,
|
||||
start_seq: SequenceNumber,
|
||||
end_seq: SequenceNumber,
|
||||
) -> Result<Vec<SequencedEvent>, DbError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,
|
||||
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
||||
ops, blobs, blocks_cids, handle, active, status, rev
|
||||
FROM repo_seq
|
||||
WHERE seq > $1 AND seq < $2
|
||||
ORDER BY seq ASC"#,
|
||||
start_seq,
|
||||
end_seq
|
||||
start_seq.as_i64(),
|
||||
end_seq.as_i64()
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| SequencedEvent {
|
||||
seq: r.seq,
|
||||
.map(|r| {
|
||||
let status = r
|
||||
.status
|
||||
.as_deref()
|
||||
.and_then(AccountStatus::parse)
|
||||
.or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active));
|
||||
SequencedEvent {
|
||||
seq: r.seq.into(),
|
||||
did: Did::from(r.did),
|
||||
created_at: r.created_at,
|
||||
event_type: r.event_type,
|
||||
commit_cid: r.commit_cid.map(CidLink::from),
|
||||
prev_cid: r.prev_cid.map(CidLink::from),
|
||||
prev_data_cid: r.prev_data_cid.map(CidLink::from),
|
||||
ops: r.ops,
|
||||
blobs: r.blobs,
|
||||
blocks_cids: r.blocks_cids,
|
||||
handle: r.handle.map(Handle::from),
|
||||
active: r.active,
|
||||
status,
|
||||
rev: r.rev,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_event_by_seq(
|
||||
&self,
|
||||
seq: SequenceNumber,
|
||||
) -> Result<Option<SequencedEvent>, DbError> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
||||
ops, blobs, blocks_cids, handle, active, status, rev
|
||||
FROM repo_seq
|
||||
WHERE seq = $1"#,
|
||||
seq.as_i64()
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| {
|
||||
let status = r
|
||||
.status
|
||||
.as_deref()
|
||||
.and_then(AccountStatus::parse)
|
||||
.or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active));
|
||||
SequencedEvent {
|
||||
seq: r.seq.into(),
|
||||
did: Did::from(r.did),
|
||||
created_at: r.created_at,
|
||||
event_type: r.event_type,
|
||||
@@ -933,54 +994,25 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
blocks_cids: r.blocks_cids,
|
||||
handle: r.handle.map(Handle::from),
|
||||
active: r.active,
|
||||
status: r.status,
|
||||
status,
|
||||
rev: r.rev,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_event_by_seq(&self, seq: i64) -> Result<Option<SequencedEvent>, DbError> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,
|
||||
ops, blobs, blocks_cids, handle, active, status, rev
|
||||
FROM repo_seq
|
||||
WHERE seq = $1"#,
|
||||
seq
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| SequencedEvent {
|
||||
seq: r.seq,
|
||||
did: Did::from(r.did),
|
||||
created_at: r.created_at,
|
||||
event_type: r.event_type,
|
||||
commit_cid: r.commit_cid.map(CidLink::from),
|
||||
prev_cid: r.prev_cid.map(CidLink::from),
|
||||
prev_data_cid: r.prev_data_cid.map(CidLink::from),
|
||||
ops: r.ops,
|
||||
blobs: r.blobs,
|
||||
blocks_cids: r.blocks_cids,
|
||||
handle: r.handle.map(Handle::from),
|
||||
active: r.active,
|
||||
status: r.status,
|
||||
rev: r.rev,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_events_since_cursor(
|
||||
&self,
|
||||
cursor: i64,
|
||||
cursor: SequenceNumber,
|
||||
limit: i64,
|
||||
) -> Result<Vec<SequencedEvent>, DbError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,
|
||||
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
||||
ops, blobs, blocks_cids, handle, active, status, rev
|
||||
FROM repo_seq
|
||||
WHERE seq > $1
|
||||
ORDER BY seq ASC
|
||||
LIMIT $2"#,
|
||||
cursor,
|
||||
cursor.as_i64(),
|
||||
limit
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
@@ -988,21 +1020,28 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| SequencedEvent {
|
||||
seq: r.seq,
|
||||
did: Did::from(r.did),
|
||||
created_at: r.created_at,
|
||||
event_type: r.event_type,
|
||||
commit_cid: r.commit_cid.map(CidLink::from),
|
||||
prev_cid: r.prev_cid.map(CidLink::from),
|
||||
prev_data_cid: r.prev_data_cid.map(CidLink::from),
|
||||
ops: r.ops,
|
||||
blobs: r.blobs,
|
||||
blocks_cids: r.blocks_cids,
|
||||
handle: r.handle.map(Handle::from),
|
||||
active: r.active,
|
||||
status: r.status,
|
||||
rev: r.rev,
|
||||
.map(|r| {
|
||||
let status = r
|
||||
.status
|
||||
.as_deref()
|
||||
.and_then(AccountStatus::parse)
|
||||
.or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active));
|
||||
SequencedEvent {
|
||||
seq: r.seq.into(),
|
||||
did: Did::from(r.did),
|
||||
created_at: r.created_at,
|
||||
event_type: r.event_type,
|
||||
commit_cid: r.commit_cid.map(CidLink::from),
|
||||
prev_cid: r.prev_cid.map(CidLink::from),
|
||||
prev_data_cid: r.prev_data_cid.map(CidLink::from),
|
||||
ops: r.ops,
|
||||
blobs: r.blobs,
|
||||
blocks_cids: r.blocks_cids,
|
||||
handle: r.handle.map(Handle::from),
|
||||
active: r.active,
|
||||
status,
|
||||
rev: r.rev,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -1079,8 +1118,8 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
Ok(cid.map(CidLink::from))
|
||||
}
|
||||
|
||||
async fn notify_update(&self, seq: i64) -> Result<(), DbError> {
|
||||
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq))
|
||||
async fn notify_update(&self, seq: SequenceNumber) -> Result<(), DbError> {
|
||||
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq.as_i64()))
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
@@ -1329,7 +1368,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
"#,
|
||||
)
|
||||
.bind(&event.did)
|
||||
.bind(&event.event_type)
|
||||
.bind(event.event_type.as_str())
|
||||
.bind(&event.commit_cid)
|
||||
.bind(&event.prev_cid)
|
||||
.bind(&event.ops)
|
||||
@@ -1375,7 +1414,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| BrokenGenesisCommit {
|
||||
seq: r.seq,
|
||||
seq: r.seq.into(),
|
||||
did: Did::from(r.did),
|
||||
commit_cid: r.commit_cid.map(CidLink::from),
|
||||
})
|
||||
|
||||
@@ -2,9 +2,9 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use tranquil_db_traits::{
|
||||
AppPasswordCreate, AppPasswordRecord, DbError, RefreshSessionResult, SessionForRefresh,
|
||||
SessionListItem, SessionMfaStatus, SessionRefreshData, SessionRepository, SessionToken,
|
||||
SessionTokenCreate,
|
||||
AppPasswordCreate, AppPasswordPrivilege, AppPasswordRecord, DbError, LoginType,
|
||||
RefreshSessionResult, SessionForRefresh, SessionId, SessionListItem, SessionMfaStatus,
|
||||
SessionRefreshData, SessionRepository, SessionToken, SessionTokenCreate,
|
||||
};
|
||||
use tranquil_types::Did;
|
||||
use uuid::Uuid;
|
||||
@@ -23,7 +23,7 @@ impl PostgresSessionRepository {
|
||||
|
||||
#[async_trait]
|
||||
impl SessionRepository for PostgresSessionRepository {
|
||||
async fn create_session(&self, data: &SessionTokenCreate) -> Result<i32, DbError> {
|
||||
async fn create_session(&self, data: &SessionTokenCreate) -> Result<SessionId, DbError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO session_tokens
|
||||
@@ -37,7 +37,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
data.refresh_jti,
|
||||
data.access_expires_at,
|
||||
data.refresh_expires_at,
|
||||
data.legacy_login,
|
||||
bool::from(data.login_type),
|
||||
data.mfa_verified,
|
||||
data.scope,
|
||||
data.controller_did.as_ref().map(|d| d.as_str()),
|
||||
@@ -47,7 +47,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.id)
|
||||
Ok(SessionId::new(row.id))
|
||||
}
|
||||
|
||||
async fn get_session_by_access_jti(
|
||||
@@ -69,13 +69,13 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| SessionToken {
|
||||
id: r.id,
|
||||
id: SessionId::new(r.id),
|
||||
did: Did::from(r.did),
|
||||
access_jti: r.access_jti,
|
||||
refresh_jti: r.refresh_jti,
|
||||
access_expires_at: r.access_expires_at,
|
||||
refresh_expires_at: r.refresh_expires_at,
|
||||
legacy_login: r.legacy_login,
|
||||
login_type: LoginType::from(r.legacy_login),
|
||||
mfa_verified: r.mfa_verified,
|
||||
scope: r.scope,
|
||||
controller_did: r.controller_did.map(Did::from),
|
||||
@@ -104,7 +104,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| SessionForRefresh {
|
||||
id: r.id,
|
||||
id: SessionId::new(r.id),
|
||||
did: Did::from(r.did),
|
||||
scope: r.scope,
|
||||
controller_did: r.controller_did.map(Did::from),
|
||||
@@ -115,7 +115,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
|
||||
async fn update_session_tokens(
|
||||
&self,
|
||||
session_id: i32,
|
||||
session_id: SessionId,
|
||||
new_access_jti: &str,
|
||||
new_refresh_jti: &str,
|
||||
new_access_expires_at: DateTime<Utc>,
|
||||
@@ -132,7 +132,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
new_refresh_jti,
|
||||
new_access_expires_at,
|
||||
new_refresh_expires_at,
|
||||
session_id
|
||||
session_id.as_i32()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -153,11 +153,14 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn delete_session_by_id(&self, session_id: i32) -> Result<u64, DbError> {
|
||||
let result = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
async fn delete_session_by_id(&self, session_id: SessionId) -> Result<u64, DbError> {
|
||||
let result = sqlx::query!(
|
||||
"DELETE FROM session_tokens WHERE id = $1",
|
||||
session_id.as_i32()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
@@ -205,7 +208,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| SessionListItem {
|
||||
id: r.id,
|
||||
id: SessionId::new(r.id),
|
||||
access_jti: r.access_jti,
|
||||
created_at: r.created_at,
|
||||
refresh_expires_at: r.refresh_expires_at,
|
||||
@@ -215,12 +218,12 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
|
||||
async fn get_session_access_jti_by_id(
|
||||
&self,
|
||||
session_id: i32,
|
||||
session_id: SessionId,
|
||||
did: &Did,
|
||||
) -> Result<Option<String>, DbError> {
|
||||
let row = sqlx::query_scalar!(
|
||||
"SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2",
|
||||
session_id,
|
||||
session_id.as_i32(),
|
||||
did.as_str()
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -264,7 +267,10 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
async fn check_refresh_token_used(&self, refresh_jti: &str) -> Result<Option<i32>, DbError> {
|
||||
async fn check_refresh_token_used(
|
||||
&self,
|
||||
refresh_jti: &str,
|
||||
) -> Result<Option<SessionId>, DbError> {
|
||||
let row = sqlx::query_scalar!(
|
||||
"SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1",
|
||||
refresh_jti
|
||||
@@ -273,13 +279,13 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row)
|
||||
Ok(row.map(SessionId::new))
|
||||
}
|
||||
|
||||
async fn mark_refresh_token_used(
|
||||
&self,
|
||||
refresh_jti: &str,
|
||||
session_id: i32,
|
||||
session_id: SessionId,
|
||||
) -> Result<bool, DbError> {
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
@@ -288,7 +294,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
ON CONFLICT (refresh_jti) DO NOTHING
|
||||
"#,
|
||||
refresh_jti,
|
||||
session_id
|
||||
session_id.as_i32()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
@@ -319,7 +325,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
name: r.name,
|
||||
password_hash: r.password_hash,
|
||||
created_at: r.created_at,
|
||||
privileged: r.privileged,
|
||||
privilege: AppPasswordPrivilege::from(r.privileged),
|
||||
scopes: r.scopes,
|
||||
created_by_controller_did: r.created_by_controller_did.map(Did::from),
|
||||
})
|
||||
@@ -352,7 +358,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
name: r.name,
|
||||
password_hash: r.password_hash,
|
||||
created_at: r.created_at,
|
||||
privileged: r.privileged,
|
||||
privilege: AppPasswordPrivilege::from(r.privileged),
|
||||
scopes: r.scopes,
|
||||
created_by_controller_did: r.created_by_controller_did.map(Did::from),
|
||||
})
|
||||
@@ -383,7 +389,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
name: r.name,
|
||||
password_hash: r.password_hash,
|
||||
created_at: r.created_at,
|
||||
privileged: r.privileged,
|
||||
privilege: AppPasswordPrivilege::from(r.privileged),
|
||||
scopes: r.scopes,
|
||||
created_by_controller_did: r.created_by_controller_did.map(Did::from),
|
||||
}))
|
||||
@@ -399,7 +405,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
data.user_id,
|
||||
data.name,
|
||||
data.password_hash,
|
||||
data.privileged,
|
||||
bool::from(data.privilege),
|
||||
data.scopes,
|
||||
data.created_by_controller_did.as_ref().map(|d| d.as_str())
|
||||
)
|
||||
@@ -480,7 +486,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| SessionMfaStatus {
|
||||
legacy_login: r.legacy_login,
|
||||
login_type: LoginType::from(r.legacy_login),
|
||||
mfa_verified: r.mfa_verified,
|
||||
last_reauth_at: r.last_reauth_at,
|
||||
}))
|
||||
@@ -535,16 +541,19 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
let result = sqlx::query!(
|
||||
"INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2) ON CONFLICT (refresh_jti) DO NOTHING",
|
||||
data.old_refresh_jti,
|
||||
data.session_id
|
||||
data.session_id.as_i32()
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", data.session_id)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
let _ = sqlx::query!(
|
||||
"DELETE FROM session_tokens WHERE id = $1",
|
||||
data.session_id.as_i32()
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
tx.commit().await.map_err(map_sqlx_error)?;
|
||||
return Ok(RefreshSessionResult::ConcurrentRefresh);
|
||||
}
|
||||
@@ -555,7 +564,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
data.new_refresh_jti,
|
||||
data.new_access_expires_at,
|
||||
data.new_refresh_expires_at,
|
||||
data.session_id
|
||||
data.session_id.as_i32()
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
|
||||
@@ -2,7 +2,8 @@ use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
use tranquil_db_traits::{
|
||||
DbError, ExternalIdentity, SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository,
|
||||
DbError, ExternalEmail, ExternalIdentity, ExternalUserId, ExternalUsername, SsoAction,
|
||||
SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository,
|
||||
};
|
||||
use tranquil_types::Did;
|
||||
use uuid::Uuid;
|
||||
@@ -69,11 +70,11 @@ impl SsoRepository for PostgresSsoRepository {
|
||||
|
||||
Ok(row.map(|r| ExternalIdentity {
|
||||
id: r.id,
|
||||
did: Did::new_unchecked(&r.did),
|
||||
did: unsafe { Did::new_unchecked(&r.did) },
|
||||
provider: r.provider,
|
||||
provider_user_id: r.provider_user_id,
|
||||
provider_username: r.provider_username,
|
||||
provider_email: r.provider_email,
|
||||
provider_user_id: ExternalUserId::from(r.provider_user_id),
|
||||
provider_username: r.provider_username.map(ExternalUsername::from),
|
||||
provider_email: r.provider_email.map(ExternalEmail::from),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
last_login_at: r.last_login_at,
|
||||
@@ -102,11 +103,11 @@ impl SsoRepository for PostgresSsoRepository {
|
||||
.into_iter()
|
||||
.map(|r| ExternalIdentity {
|
||||
id: r.id,
|
||||
did: Did::new_unchecked(&r.did),
|
||||
did: unsafe { Did::new_unchecked(&r.did) },
|
||||
provider: r.provider,
|
||||
provider_user_id: r.provider_user_id,
|
||||
provider_username: r.provider_username,
|
||||
provider_email: r.provider_email,
|
||||
provider_user_id: ExternalUserId::from(r.provider_user_id),
|
||||
provider_username: r.provider_username.map(ExternalUsername::from),
|
||||
provider_email: r.provider_email.map(ExternalEmail::from),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
last_login_at: r.last_login_at,
|
||||
@@ -161,7 +162,7 @@ impl SsoRepository for PostgresSsoRepository {
|
||||
state: &str,
|
||||
request_uri: &str,
|
||||
provider: SsoProviderType,
|
||||
action: &str,
|
||||
action: SsoAction,
|
||||
nonce: Option<&str>,
|
||||
code_verifier: Option<&str>,
|
||||
did: Option<&Did>,
|
||||
@@ -174,7 +175,7 @@ impl SsoRepository for PostgresSsoRepository {
|
||||
state,
|
||||
request_uri,
|
||||
provider as SsoProviderType,
|
||||
action,
|
||||
action.as_str(),
|
||||
nonce,
|
||||
code_verifier,
|
||||
did.map(|d| d.as_str()),
|
||||
@@ -200,17 +201,21 @@ impl SsoRepository for PostgresSsoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| SsoAuthState {
|
||||
state: r.state,
|
||||
request_uri: r.request_uri,
|
||||
provider: r.provider,
|
||||
action: r.action,
|
||||
nonce: r.nonce,
|
||||
code_verifier: r.code_verifier,
|
||||
did: r.did.map(|d| Did::new_unchecked(&d)),
|
||||
created_at: r.created_at,
|
||||
expires_at: r.expires_at,
|
||||
}))
|
||||
row.map(|r| {
|
||||
let action = SsoAction::parse(&r.action).ok_or(DbError::NotFound)?;
|
||||
Ok(SsoAuthState {
|
||||
state: r.state,
|
||||
request_uri: r.request_uri,
|
||||
provider: r.provider,
|
||||
action,
|
||||
nonce: r.nonce,
|
||||
code_verifier: r.code_verifier,
|
||||
did: r.did.map(|d| unsafe { Did::new_unchecked(&d) }),
|
||||
created_at: r.created_at,
|
||||
expires_at: r.expires_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn cleanup_expired_sso_auth_states(&self) -> Result<u64, DbError> {
|
||||
@@ -280,9 +285,9 @@ impl SsoRepository for PostgresSsoRepository {
|
||||
token: r.token,
|
||||
request_uri: r.request_uri,
|
||||
provider: r.provider,
|
||||
provider_user_id: r.provider_user_id,
|
||||
provider_username: r.provider_username,
|
||||
provider_email: r.provider_email,
|
||||
provider_user_id: ExternalUserId::from(r.provider_user_id),
|
||||
provider_username: r.provider_username.map(ExternalUsername::from),
|
||||
provider_email: r.provider_email.map(ExternalEmail::from),
|
||||
provider_email_verified: r.provider_email_verified,
|
||||
created_at: r.created_at,
|
||||
expires_at: r.expires_at,
|
||||
@@ -311,9 +316,9 @@ impl SsoRepository for PostgresSsoRepository {
|
||||
token: r.token,
|
||||
request_uri: r.request_uri,
|
||||
provider: r.provider,
|
||||
provider_user_id: r.provider_user_id,
|
||||
provider_username: r.provider_username,
|
||||
provider_email: r.provider_email,
|
||||
provider_user_id: ExternalUserId::from(r.provider_user_id),
|
||||
provider_username: r.provider_username.map(ExternalUsername::from),
|
||||
provider_email: r.provider_email.map(ExternalEmail::from),
|
||||
provider_email_verified: r.provider_email_verified,
|
||||
created_at: r.created_at,
|
||||
expires_at: r.expires_at,
|
||||
|
||||
@@ -5,15 +5,16 @@ use tranquil_types::{Did, Handle};
|
||||
use uuid::Uuid;
|
||||
|
||||
use tranquil_db_traits::{
|
||||
AccountSearchResult, CommsChannel, DbError, DidWebOverrides, NotificationPrefs,
|
||||
OAuthTokenWithUser, PasswordResetResult, SsoProviderType, StoredBackupCode, StoredPasskey,
|
||||
TotpRecord, User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo,
|
||||
UserEmailInfo, UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery,
|
||||
UserForPasskeySetup, UserForRecovery, UserForVerification, UserIdAndHandle,
|
||||
UserIdAndPasswordHash, UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId,
|
||||
UserLegacyLoginPref, UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo,
|
||||
UserRepository, UserResendVerification, UserResetCodeInfo, UserRow, UserSessionInfo,
|
||||
UserStatus, UserVerificationInfo, UserWithKey,
|
||||
AccountSearchResult, AccountType, ChannelVerificationStatus, CommsChannel, DbError,
|
||||
DidWebOverrides, NotificationPrefs, OAuthTokenWithUser, PasswordResetResult, SsoProviderType,
|
||||
StoredBackupCode, StoredPasskey, TotpRecord, TotpRecordState, User2faStatus, UserAuthInfo,
|
||||
UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, UserEmailInfo, UserForDeletion,
|
||||
UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, UserForPasskeySetup,
|
||||
UserForRecovery, UserForVerification, UserIdAndHandle, UserIdAndPasswordHash,
|
||||
UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref,
|
||||
UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo, UserRepository,
|
||||
UserResendVerification, UserResetCodeInfo, UserRow, UserSessionInfo, UserStatus,
|
||||
UserVerificationInfo, UserWithKey,
|
||||
};
|
||||
|
||||
pub struct PostgresUserRepository {
|
||||
@@ -280,10 +281,12 @@ impl UserRepository for PostgresUserRepository {
|
||||
password_hash: r.password_hash,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
email_verified: r.email_verified,
|
||||
discord_verified: r.discord_verified,
|
||||
telegram_verified: r.telegram_verified,
|
||||
signal_verified: r.signal_verified,
|
||||
channel_verification: ChannelVerificationStatus::new(
|
||||
r.email_verified,
|
||||
r.discord_verified,
|
||||
r.telegram_verified,
|
||||
r.signal_verified,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -308,7 +311,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
|
||||
async fn get_comms_prefs(&self, user_id: Uuid) -> Result<Option<UserCommsPrefs>, DbError> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT email, handle, preferred_comms_channel::text as "preferred_channel!", preferred_locale
|
||||
r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale
|
||||
FROM users WHERE id = $1"#,
|
||||
user_id
|
||||
)
|
||||
@@ -601,7 +604,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT
|
||||
email,
|
||||
preferred_comms_channel::text as "preferred_channel!",
|
||||
preferred_comms_channel as "preferred_channel!: CommsChannel",
|
||||
discord_id,
|
||||
discord_verified,
|
||||
telegram_username,
|
||||
@@ -647,13 +650,13 @@ impl UserRepository for PostgresUserRepository {
|
||||
async fn update_preferred_comms_channel(
|
||||
&self,
|
||||
did: &Did,
|
||||
channel: &str,
|
||||
channel: CommsChannel,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query(
|
||||
"UPDATE users SET preferred_comms_channel = $1::comms_channel, updated_at = NOW() WHERE did = $2",
|
||||
sqlx::query!(
|
||||
"UPDATE users SET preferred_comms_channel = $1, updated_at = NOW() WHERE did = $2",
|
||||
channel as CommsChannel,
|
||||
did.as_str()
|
||||
)
|
||||
.bind(channel)
|
||||
.bind(did.as_str())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
@@ -709,10 +712,12 @@ impl UserRepository for PostgresUserRepository {
|
||||
id: r.id,
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
email_verified: r.email_verified,
|
||||
discord_verified: r.discord_verified,
|
||||
telegram_verified: r.telegram_verified,
|
||||
signal_verified: r.signal_verified,
|
||||
channel_verification: ChannelVerificationStatus::new(
|
||||
r.email_verified,
|
||||
r.discord_verified,
|
||||
r.telegram_verified,
|
||||
r.signal_verified,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1065,6 +1070,12 @@ impl UserRepository for PostgresUserRepository {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_totp_record_state(&self, did: &Did) -> Result<Option<TotpRecordState>, DbError> {
|
||||
self.get_totp_record(did)
|
||||
.await
|
||||
.map(|opt| opt.map(TotpRecordState::from))
|
||||
}
|
||||
|
||||
async fn upsert_totp_secret(
|
||||
&self,
|
||||
did: &Did,
|
||||
@@ -1300,7 +1311,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
preferred_comms_channel as "preferred_comms_channel!: CommsChannel",
|
||||
deactivated_at, takedown_ref,
|
||||
email_verified, discord_verified, telegram_verified, signal_verified,
|
||||
account_type::text as "account_type!"
|
||||
account_type as "account_type!: AccountType"
|
||||
FROM users
|
||||
WHERE handle = $1 OR email = $1
|
||||
"#,
|
||||
@@ -1320,10 +1331,12 @@ impl UserRepository for PostgresUserRepository {
|
||||
preferred_comms_channel: row.preferred_comms_channel,
|
||||
deactivated_at: row.deactivated_at,
|
||||
takedown_ref: row.takedown_ref,
|
||||
email_verified: row.email_verified,
|
||||
discord_verified: row.discord_verified,
|
||||
telegram_verified: row.telegram_verified,
|
||||
signal_verified: row.signal_verified,
|
||||
channel_verification: ChannelVerificationStatus::new(
|
||||
row.email_verified,
|
||||
row.discord_verified,
|
||||
row.telegram_verified,
|
||||
row.signal_verified,
|
||||
),
|
||||
account_type: row.account_type,
|
||||
})
|
||||
})
|
||||
@@ -1348,10 +1361,12 @@ impl UserRepository for PostgresUserRepository {
|
||||
id: row.id,
|
||||
two_factor_enabled: row.two_factor_enabled,
|
||||
preferred_comms_channel: row.preferred_comms_channel,
|
||||
email_verified: row.email_verified,
|
||||
discord_verified: row.discord_verified,
|
||||
telegram_verified: row.telegram_verified,
|
||||
signal_verified: row.signal_verified,
|
||||
channel_verification: ChannelVerificationStatus::new(
|
||||
row.email_verified,
|
||||
row.discord_verified,
|
||||
row.telegram_verified,
|
||||
row.signal_verified,
|
||||
),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1376,15 +1391,17 @@ impl UserRepository for PostgresUserRepository {
|
||||
opt.map(|row| UserSessionInfo {
|
||||
handle: Handle::from(row.handle),
|
||||
email: row.email,
|
||||
email_verified: row.email_verified,
|
||||
is_admin: row.is_admin,
|
||||
deactivated_at: row.deactivated_at,
|
||||
takedown_ref: row.takedown_ref,
|
||||
preferred_locale: row.preferred_locale,
|
||||
preferred_comms_channel: row.preferred_comms_channel,
|
||||
discord_verified: row.discord_verified,
|
||||
telegram_verified: row.telegram_verified,
|
||||
signal_verified: row.signal_verified,
|
||||
channel_verification: ChannelVerificationStatus::new(
|
||||
row.email_verified,
|
||||
row.discord_verified,
|
||||
row.telegram_verified,
|
||||
row.signal_verified,
|
||||
),
|
||||
migrated_to_pds: row.migrated_to_pds,
|
||||
migrated_at: row.migrated_at,
|
||||
})
|
||||
@@ -1469,10 +1486,12 @@ impl UserRepository for PostgresUserRepository {
|
||||
email: row.email,
|
||||
deactivated_at: row.deactivated_at,
|
||||
takedown_ref: row.takedown_ref,
|
||||
email_verified: row.email_verified,
|
||||
discord_verified: row.discord_verified,
|
||||
telegram_verified: row.telegram_verified,
|
||||
signal_verified: row.signal_verified,
|
||||
channel_verification: ChannelVerificationStatus::new(
|
||||
row.email_verified,
|
||||
row.discord_verified,
|
||||
row.telegram_verified,
|
||||
row.signal_verified,
|
||||
),
|
||||
allow_legacy_login: row.allow_legacy_login,
|
||||
migrated_to_pds: row.migrated_to_pds,
|
||||
preferred_comms_channel: row.preferred_comms_channel,
|
||||
@@ -1543,10 +1562,12 @@ impl UserRepository for PostgresUserRepository {
|
||||
discord_id: row.discord_id,
|
||||
telegram_username: row.telegram_username,
|
||||
signal_number: row.signal_number,
|
||||
email_verified: row.email_verified,
|
||||
discord_verified: row.discord_verified,
|
||||
telegram_verified: row.telegram_verified,
|
||||
signal_verified: row.signal_verified,
|
||||
channel_verification: ChannelVerificationStatus::new(
|
||||
row.email_verified,
|
||||
row.discord_verified,
|
||||
row.telegram_verified,
|
||||
row.signal_verified,
|
||||
),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ pub use dpop::{
|
||||
};
|
||||
pub use error::OAuthError;
|
||||
pub use types::{
|
||||
AuthFlowState, AuthorizationRequestParameters, AuthorizationServerMetadata,
|
||||
AuthorizedClientData, ClientAuth, Code, DPoPClaims, DeviceData, DeviceId, JwkPublicKey, Jwks,
|
||||
OAuthClientMetadata, ParResponse, ProtectedResourceMetadata, RefreshToken, RefreshTokenState,
|
||||
RequestData, RequestId, SessionId, TokenData, TokenId, TokenRequest, TokenResponse,
|
||||
AuthFlow, AuthFlowWithUser, AuthorizationRequestParameters, AuthorizationServerMetadata,
|
||||
AuthorizedClientData, ClientAuth, Code, CodeChallengeMethod, DPoPClaims, DeviceData, DeviceId,
|
||||
FlowAuthenticated, FlowAuthorized, FlowExpired, FlowNotAuthenticated, FlowNotAuthorized,
|
||||
FlowPending, JwkPublicKey, Jwks, OAuthClientMetadata, ParResponse, Prompt,
|
||||
ProtectedResourceMetadata, RefreshToken, RefreshTokenState, RequestData, RequestId,
|
||||
ResponseMode, ResponseType, SessionId, TokenData, TokenId, TokenRequest, TokenResponse,
|
||||
};
|
||||
|
||||
+254
-151
@@ -1,23 +1,36 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use tranquil_types::Did;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(transparent)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct RequestId(pub String);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(transparent)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct TokenId(pub String);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(transparent)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct DeviceId(pub String);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(transparent)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct SessionId(pub String);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(transparent)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct Code(pub String);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(transparent)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct RefreshToken(pub String);
|
||||
|
||||
impl RequestId {
|
||||
@@ -82,19 +95,76 @@ pub enum ClientAuth {
|
||||
PrivateKeyJwt { client_assertion: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ResponseType {
|
||||
#[default]
|
||||
Code,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CodeChallengeMethod {
|
||||
#[default]
|
||||
#[serde(rename = "S256")]
|
||||
S256,
|
||||
#[serde(rename = "plain")]
|
||||
Plain,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ResponseMode {
|
||||
#[default]
|
||||
Query,
|
||||
Fragment,
|
||||
FormPost,
|
||||
}
|
||||
|
||||
impl ResponseMode {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Query => "query",
|
||||
Self::Fragment => "fragment",
|
||||
Self::FormPost => "form_post",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Prompt {
|
||||
None,
|
||||
Login,
|
||||
Consent,
|
||||
SelectAccount,
|
||||
Create,
|
||||
}
|
||||
|
||||
impl Prompt {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "none",
|
||||
Self::Login => "login",
|
||||
Self::Consent => "consent",
|
||||
Self::SelectAccount => "select_account",
|
||||
Self::Create => "create",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthorizationRequestParameters {
|
||||
pub response_type: String,
|
||||
pub response_type: ResponseType,
|
||||
pub client_id: String,
|
||||
pub redirect_uri: String,
|
||||
pub scope: Option<String>,
|
||||
pub state: Option<String>,
|
||||
pub code_challenge: String,
|
||||
pub code_challenge_method: String,
|
||||
pub response_mode: Option<String>,
|
||||
pub code_challenge_method: CodeChallengeMethod,
|
||||
pub response_mode: Option<ResponseMode>,
|
||||
pub login_hint: Option<String>,
|
||||
pub dpop_jkt: Option<String>,
|
||||
pub prompt: Option<String>,
|
||||
pub prompt: Option<Prompt>,
|
||||
#[serde(flatten)]
|
||||
pub extra: Option<JsonValue>,
|
||||
}
|
||||
@@ -105,15 +175,15 @@ pub struct RequestData {
|
||||
pub client_auth: Option<ClientAuth>,
|
||||
pub parameters: AuthorizationRequestParameters,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub did: Option<String>,
|
||||
pub device_id: Option<String>,
|
||||
pub code: Option<String>,
|
||||
pub controller_did: Option<String>,
|
||||
pub did: Option<Did>,
|
||||
pub device_id: Option<DeviceId>,
|
||||
pub code: Option<Code>,
|
||||
pub controller_did: Option<Did>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeviceData {
|
||||
pub session_id: String,
|
||||
pub session_id: SessionId,
|
||||
pub user_agent: Option<String>,
|
||||
pub ip_address: String,
|
||||
pub last_seen_at: DateTime<Utc>,
|
||||
@@ -121,20 +191,20 @@ pub struct DeviceData {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TokenData {
|
||||
pub did: String,
|
||||
pub token_id: String,
|
||||
pub did: Did,
|
||||
pub token_id: TokenId,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub client_id: String,
|
||||
pub client_auth: ClientAuth,
|
||||
pub device_id: Option<String>,
|
||||
pub device_id: Option<DeviceId>,
|
||||
pub parameters: AuthorizationRequestParameters,
|
||||
pub details: Option<JsonValue>,
|
||||
pub code: Option<String>,
|
||||
pub current_refresh_token: Option<String>,
|
||||
pub code: Option<Code>,
|
||||
pub current_refresh_token: Option<RefreshToken>,
|
||||
pub scope: Option<String>,
|
||||
pub controller_did: Option<String>,
|
||||
pub controller_did: Option<Did>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -247,99 +317,144 @@ pub struct Jwks {
|
||||
pub keys: Vec<JwkPublicKey>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthFlowState {
|
||||
Pending,
|
||||
Authenticated {
|
||||
did: String,
|
||||
device_id: Option<String>,
|
||||
},
|
||||
Authorized {
|
||||
did: String,
|
||||
device_id: Option<String>,
|
||||
code: String,
|
||||
},
|
||||
Expired,
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlowPending {
|
||||
pub parameters: AuthorizationRequestParameters,
|
||||
pub client_id: String,
|
||||
pub client_auth: Option<ClientAuth>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub controller_did: Option<Did>,
|
||||
}
|
||||
|
||||
impl AuthFlowState {
|
||||
pub fn from_request_data(data: &RequestData) -> Self {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlowAuthenticated {
|
||||
pub parameters: AuthorizationRequestParameters,
|
||||
pub client_id: String,
|
||||
pub client_auth: Option<ClientAuth>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub did: Did,
|
||||
pub device_id: Option<DeviceId>,
|
||||
pub controller_did: Option<Did>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlowAuthorized {
|
||||
pub parameters: AuthorizationRequestParameters,
|
||||
pub client_id: String,
|
||||
pub client_auth: Option<ClientAuth>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub did: Did,
|
||||
pub device_id: Option<DeviceId>,
|
||||
pub code: Code,
|
||||
pub controller_did: Option<Did>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FlowExpired;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FlowNotAuthenticated;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FlowNotAuthorized;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AuthFlow {
|
||||
Pending(FlowPending),
|
||||
Authenticated(FlowAuthenticated),
|
||||
Authorized(FlowAuthorized),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AuthFlowWithUser {
|
||||
Authenticated(FlowAuthenticated),
|
||||
Authorized(FlowAuthorized),
|
||||
}
|
||||
|
||||
impl AuthFlow {
|
||||
pub fn from_request_data(data: RequestData) -> Result<Self, FlowExpired> {
|
||||
if data.expires_at < chrono::Utc::now() {
|
||||
return AuthFlowState::Expired;
|
||||
return Err(FlowExpired);
|
||||
}
|
||||
match (&data.did, &data.code) {
|
||||
(Some(did), Some(code)) => AuthFlowState::Authorized {
|
||||
did: did.clone(),
|
||||
device_id: data.device_id.clone(),
|
||||
code: code.clone(),
|
||||
},
|
||||
(Some(did), None) => AuthFlowState::Authenticated {
|
||||
did: did.clone(),
|
||||
device_id: data.device_id.clone(),
|
||||
},
|
||||
(None, _) => AuthFlowState::Pending,
|
||||
match (data.did, data.code) {
|
||||
(None, _) => Ok(AuthFlow::Pending(FlowPending {
|
||||
parameters: data.parameters,
|
||||
client_id: data.client_id,
|
||||
client_auth: data.client_auth,
|
||||
expires_at: data.expires_at,
|
||||
controller_did: data.controller_did,
|
||||
})),
|
||||
(Some(did), None) => Ok(AuthFlow::Authenticated(FlowAuthenticated {
|
||||
parameters: data.parameters,
|
||||
client_id: data.client_id,
|
||||
client_auth: data.client_auth,
|
||||
expires_at: data.expires_at,
|
||||
did,
|
||||
device_id: data.device_id,
|
||||
controller_did: data.controller_did,
|
||||
})),
|
||||
(Some(did), Some(code)) => Ok(AuthFlow::Authorized(FlowAuthorized {
|
||||
parameters: data.parameters,
|
||||
client_id: data.client_id,
|
||||
client_auth: data.client_auth,
|
||||
expires_at: data.expires_at,
|
||||
did,
|
||||
device_id: data.device_id,
|
||||
code,
|
||||
controller_did: data.controller_did,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_pending(&self) -> bool {
|
||||
matches!(self, AuthFlowState::Pending)
|
||||
}
|
||||
|
||||
pub fn is_authenticated(&self) -> bool {
|
||||
matches!(self, AuthFlowState::Authenticated { .. })
|
||||
}
|
||||
|
||||
pub fn is_authorized(&self) -> bool {
|
||||
matches!(self, AuthFlowState::Authorized { .. })
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
matches!(self, AuthFlowState::Expired)
|
||||
}
|
||||
|
||||
pub fn can_authenticate(&self) -> bool {
|
||||
matches!(self, AuthFlowState::Pending)
|
||||
}
|
||||
|
||||
pub fn can_authorize(&self) -> bool {
|
||||
matches!(self, AuthFlowState::Authenticated { .. })
|
||||
}
|
||||
|
||||
pub fn can_exchange(&self) -> bool {
|
||||
matches!(self, AuthFlowState::Authorized { .. })
|
||||
}
|
||||
|
||||
pub fn did(&self) -> Option<&str> {
|
||||
pub fn require_user(self) -> Result<AuthFlowWithUser, FlowNotAuthenticated> {
|
||||
match self {
|
||||
AuthFlowState::Authenticated { did, .. } | AuthFlowState::Authorized { did, .. } => {
|
||||
Some(did)
|
||||
}
|
||||
_ => None,
|
||||
AuthFlow::Pending(_) => Err(FlowNotAuthenticated),
|
||||
AuthFlow::Authenticated(a) => Ok(AuthFlowWithUser::Authenticated(a)),
|
||||
AuthFlow::Authorized(a) => Ok(AuthFlowWithUser::Authorized(a)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn code(&self) -> Option<&str> {
|
||||
pub fn require_authorized(self) -> Result<FlowAuthorized, FlowNotAuthorized> {
|
||||
match self {
|
||||
AuthFlowState::Authorized { code, .. } => Some(code),
|
||||
_ => None,
|
||||
AuthFlow::Authorized(a) => Ok(a),
|
||||
_ => Err(FlowNotAuthorized),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AuthFlowState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
impl AuthFlowWithUser {
|
||||
pub fn did(&self) -> &Did {
|
||||
match self {
|
||||
AuthFlowState::Pending => write!(f, "pending"),
|
||||
AuthFlowState::Authenticated { did, .. } => write!(f, "authenticated ({})", did),
|
||||
AuthFlowState::Authorized { did, code, .. } => {
|
||||
write!(
|
||||
f,
|
||||
"authorized ({}, code={}...)",
|
||||
did,
|
||||
&code[..8.min(code.len())]
|
||||
)
|
||||
}
|
||||
AuthFlowState::Expired => write!(f, "expired"),
|
||||
AuthFlowWithUser::Authenticated(a) => &a.did,
|
||||
AuthFlowWithUser::Authorized(a) => &a.did,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn device_id(&self) -> Option<&DeviceId> {
|
||||
match self {
|
||||
AuthFlowWithUser::Authenticated(a) => a.device_id.as_ref(),
|
||||
AuthFlowWithUser::Authorized(a) => a.device_id.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parameters(&self) -> &AuthorizationRequestParameters {
|
||||
match self {
|
||||
AuthFlowWithUser::Authenticated(a) => &a.parameters,
|
||||
AuthFlowWithUser::Authorized(a) => &a.parameters,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn client_id(&self) -> &str {
|
||||
match self {
|
||||
AuthFlowWithUser::Authenticated(a) => &a.client_id,
|
||||
AuthFlowWithUser::Authorized(a) => &a.client_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn controller_did(&self) -> Option<&Did> {
|
||||
match self {
|
||||
AuthFlowWithUser::Authenticated(a) => a.controller_did.as_ref(),
|
||||
AuthFlowWithUser::Authorized(a) => a.controller_did.as_ref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -406,21 +521,21 @@ mod tests {
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
fn make_request_data(
|
||||
did: Option<String>,
|
||||
code: Option<String>,
|
||||
did: Option<Did>,
|
||||
code: Option<Code>,
|
||||
expires_in: Duration,
|
||||
) -> RequestData {
|
||||
RequestData {
|
||||
client_id: "test-client".into(),
|
||||
client_auth: None,
|
||||
parameters: AuthorizationRequestParameters {
|
||||
response_type: "code".into(),
|
||||
response_type: ResponseType::Code,
|
||||
client_id: "test-client".into(),
|
||||
redirect_uri: "https://example.com/callback".into(),
|
||||
scope: Some("atproto".into()),
|
||||
state: None,
|
||||
code_challenge: "test".into(),
|
||||
code_challenge_method: "S256".into(),
|
||||
code_challenge_method: CodeChallengeMethod::S256,
|
||||
response_mode: None,
|
||||
login_hint: None,
|
||||
dpop_jkt: None,
|
||||
@@ -435,67 +550,55 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_did(s: &str) -> Did {
|
||||
s.parse().expect("valid test DID")
|
||||
}
|
||||
|
||||
fn test_code(s: &str) -> Code {
|
||||
Code(s.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_flow_state_pending() {
|
||||
fn test_auth_flow_pending() {
|
||||
let data = make_request_data(None, None, Duration::minutes(5));
|
||||
let state = AuthFlowState::from_request_data(&data);
|
||||
assert!(state.is_pending());
|
||||
assert!(!state.is_authenticated());
|
||||
assert!(!state.is_authorized());
|
||||
assert!(!state.is_expired());
|
||||
assert!(state.can_authenticate());
|
||||
assert!(!state.can_authorize());
|
||||
assert!(!state.can_exchange());
|
||||
assert!(state.did().is_none());
|
||||
assert!(state.code().is_none());
|
||||
let flow = AuthFlow::from_request_data(data).expect("should not be expired");
|
||||
assert!(matches!(flow, AuthFlow::Pending(_)));
|
||||
assert!(flow.clone().require_user().is_err());
|
||||
assert!(flow.require_authorized().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_flow_state_authenticated() {
|
||||
let data = make_request_data(Some("did:plc:test".into()), None, Duration::minutes(5));
|
||||
let state = AuthFlowState::from_request_data(&data);
|
||||
assert!(!state.is_pending());
|
||||
assert!(state.is_authenticated());
|
||||
assert!(!state.is_authorized());
|
||||
assert!(!state.is_expired());
|
||||
assert!(!state.can_authenticate());
|
||||
assert!(state.can_authorize());
|
||||
assert!(!state.can_exchange());
|
||||
assert_eq!(state.did(), Some("did:plc:test"));
|
||||
assert!(state.code().is_none());
|
||||
fn test_auth_flow_authenticated() {
|
||||
let did = test_did("did:plc:test");
|
||||
let data = make_request_data(Some(did.clone()), None, Duration::minutes(5));
|
||||
let flow = AuthFlow::from_request_data(data).expect("should not be expired");
|
||||
assert!(matches!(flow, AuthFlow::Authenticated(_)));
|
||||
let with_user = flow.clone().require_user().expect("should have user");
|
||||
assert_eq!(with_user.did(), &did);
|
||||
assert!(flow.require_authorized().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_flow_state_authorized() {
|
||||
let data = make_request_data(
|
||||
Some("did:plc:test".into()),
|
||||
Some("auth-code-123".into()),
|
||||
Duration::minutes(5),
|
||||
);
|
||||
let state = AuthFlowState::from_request_data(&data);
|
||||
assert!(!state.is_pending());
|
||||
assert!(!state.is_authenticated());
|
||||
assert!(state.is_authorized());
|
||||
assert!(!state.is_expired());
|
||||
assert!(!state.can_authenticate());
|
||||
assert!(!state.can_authorize());
|
||||
assert!(state.can_exchange());
|
||||
assert_eq!(state.did(), Some("did:plc:test"));
|
||||
assert_eq!(state.code(), Some("auth-code-123"));
|
||||
fn test_auth_flow_authorized() {
|
||||
let did = test_did("did:plc:test");
|
||||
let code = test_code("auth-code-123");
|
||||
let data = make_request_data(Some(did.clone()), Some(code.clone()), Duration::minutes(5));
|
||||
let flow = AuthFlow::from_request_data(data).expect("should not be expired");
|
||||
assert!(matches!(flow, AuthFlow::Authorized(_)));
|
||||
let with_user = flow.clone().require_user().expect("should have user");
|
||||
assert_eq!(with_user.did(), &did);
|
||||
let authorized = flow.require_authorized().expect("should be authorized");
|
||||
assert_eq!(authorized.did, did);
|
||||
assert_eq!(authorized.code, code);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_flow_state_expired() {
|
||||
let data = make_request_data(
|
||||
Some("did:plc:test".into()),
|
||||
Some("code".into()),
|
||||
Duration::minutes(-1),
|
||||
);
|
||||
let state = AuthFlowState::from_request_data(&data);
|
||||
assert!(state.is_expired());
|
||||
assert!(!state.can_authenticate());
|
||||
assert!(!state.can_authorize());
|
||||
assert!(!state.can_exchange());
|
||||
fn test_auth_flow_expired() {
|
||||
let did = test_did("did:plc:test");
|
||||
let code = test_code("code");
|
||||
let data = make_request_data(Some(did), Some(code), Duration::minutes(-1));
|
||||
let result = AuthFlow::from_request_data(data);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -67,6 +67,7 @@ subtle = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
tower = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
tower-layer = { workspace = true }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::api::EmptyResponse;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use crate::auth::{Admin, Auth};
|
||||
use crate::state::AppState;
|
||||
use crate::types::Did;
|
||||
@@ -9,7 +9,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tracing::{error, warn};
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteAccountInput {
|
||||
@@ -26,10 +26,7 @@ pub async fn delete_account(
|
||||
.user_repo
|
||||
.get_id_and_handle_by_did(did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error in delete_account: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("in delete_account")?
|
||||
.ok_or(ApiError::AccountNotFound)
|
||||
.map(|row| (row.id, row.handle))?;
|
||||
|
||||
@@ -37,13 +34,14 @@ pub async fn delete_account(
|
||||
.user_repo
|
||||
.admin_delete_account_complete(user_id, did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to delete account {}: {:?}", did, e);
|
||||
ApiError::InternalError(Some("Failed to delete account".into()))
|
||||
})?;
|
||||
.log_db_err("deleting account")?;
|
||||
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(
|
||||
&state,
|
||||
did,
|
||||
tranquil_db_traits::AccountStatus::Deleted,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"Failed to sequence account deletion event for {}: {}",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::api::error::{ApiError, AtpJson};
|
||||
use crate::api::error::{ApiError, AtpJson, DbResultExt};
|
||||
use crate::auth::{Admin, Auth};
|
||||
use crate::state::AppState;
|
||||
use crate::types::Did;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -9,7 +10,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, warn};
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -39,15 +40,12 @@ pub async fn send_email(
|
||||
.user_repo
|
||||
.get_by_did(&input.recipient_did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error in send_email: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("in send_email")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let email = user.email.ok_or(ApiError::NoEmail)?;
|
||||
let (user_id, handle) = (user.id, user.handle);
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname = pds_hostname();
|
||||
let subject = input
|
||||
.subject
|
||||
.clone()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use crate::auth::{Admin, Auth};
|
||||
use crate::state::AppState;
|
||||
use crate::types::{Did, Handle};
|
||||
@@ -10,7 +10,6 @@ use axum::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetAccountInfoParams {
|
||||
@@ -74,10 +73,7 @@ pub async fn get_account_info(
|
||||
.infra_repo
|
||||
.get_admin_account_info_by_did(¶ms.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error in get_account_info: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("in get_account_info")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let invited_by = get_invited_by(&state, account.id).await;
|
||||
@@ -153,7 +149,7 @@ async fn get_invites_for_user(
|
||||
.map(|ic| InviteCodeInfo {
|
||||
code: ic.code.clone(),
|
||||
available: ic.available_uses,
|
||||
disabled: ic.disabled,
|
||||
disabled: ic.state.is_disabled(),
|
||||
for_account: ic.for_account,
|
||||
created_by: ic.created_by,
|
||||
created_at: ic.created_at.to_rfc3339(),
|
||||
@@ -181,7 +177,7 @@ async fn get_invite_code_info(state: &AppState, code: &str) -> Option<InviteCode
|
||||
Some(InviteCodeInfo {
|
||||
code: info.code,
|
||||
available: info.available_uses,
|
||||
disabled: info.disabled,
|
||||
disabled: info.state.is_disabled(),
|
||||
for_account: info.for_account,
|
||||
created_by: info.created_by,
|
||||
created_at: info.created_at.to_rfc3339(),
|
||||
@@ -214,10 +210,7 @@ pub async fn get_account_infos(
|
||||
.infra_repo
|
||||
.get_admin_account_infos_by_dids(&dids_typed)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to fetch account infos: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("fetching account infos")?;
|
||||
|
||||
let user_ids: Vec<uuid::Uuid> = accounts.iter().map(|u| u.id).collect();
|
||||
|
||||
@@ -272,7 +265,7 @@ pub async fn get_account_infos(
|
||||
let info = InviteCodeInfo {
|
||||
code: ic.code.clone(),
|
||||
available: ic.available_uses,
|
||||
disabled: ic.disabled,
|
||||
disabled: ic.state.is_disabled(),
|
||||
for_account: ic.for_account,
|
||||
created_by: ic.created_by,
|
||||
created_at: ic.created_at.to_rfc3339(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use crate::auth::{Admin, Auth};
|
||||
use crate::state::AppState;
|
||||
use crate::types::{Did, Handle};
|
||||
@@ -9,7 +9,6 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SearchAccountsParams {
|
||||
@@ -66,10 +65,7 @@ pub async fn search_accounts(
|
||||
limit + 1,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error in search_accounts: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("in search_accounts")?;
|
||||
|
||||
let has_more = rows.len() > limit as usize;
|
||||
let accounts: Vec<AccountView> = rows
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::api::error::ApiError;
|
||||
use crate::auth::{Admin, Auth};
|
||||
use crate::state::AppState;
|
||||
use crate::types::{Did, Handle, PlainPassword};
|
||||
use crate::util::pds_hostname_without_port;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -69,8 +70,7 @@ pub async fn update_account_handle(
|
||||
{
|
||||
return Err(ApiError::InvalidHandle(None));
|
||||
}
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let handle = if !input_handle.contains('.') {
|
||||
format!("{}.{}", input_handle, hostname_for_handles)
|
||||
} else {
|
||||
@@ -84,7 +84,7 @@ pub async fn update_account_handle(
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
let handle_for_check = Handle::new_unchecked(&handle);
|
||||
let handle_for_check = unsafe { Handle::new_unchecked(&handle) };
|
||||
if let Ok(true) = state
|
||||
.user_repo
|
||||
.check_handle_exists(&handle_for_check, user_id)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use crate::auth::{Admin, Auth};
|
||||
use crate::state::AppState;
|
||||
use axum::{Json, extract::State};
|
||||
@@ -56,10 +56,7 @@ pub async fn get_server_config(
|
||||
.infra_repo
|
||||
.get_server_configs(keys)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching server config: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("fetching server config")?;
|
||||
|
||||
let config_map: std::collections::HashMap<String, String> = rows.into_iter().collect();
|
||||
|
||||
@@ -92,10 +89,7 @@ pub async fn update_server_config(
|
||||
.infra_repo
|
||||
.upsert_server_config("server_name", trimmed)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error upserting server_name: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("upserting server_name")?;
|
||||
}
|
||||
|
||||
if let Some(ref color) = req.primary_color {
|
||||
@@ -104,19 +98,13 @@ pub async fn update_server_config(
|
||||
.infra_repo
|
||||
.delete_server_config("primary_color")
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error deleting primary_color: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("deleting primary_color")?;
|
||||
} else if is_valid_hex_color(color) {
|
||||
state
|
||||
.infra_repo
|
||||
.upsert_server_config("primary_color", color)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error upserting primary_color: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("upserting primary_color")?;
|
||||
} else {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Invalid primary color format (expected #RRGGBB)".into(),
|
||||
@@ -130,19 +118,13 @@ pub async fn update_server_config(
|
||||
.infra_repo
|
||||
.delete_server_config("primary_color_dark")
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error deleting primary_color_dark: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("deleting primary_color_dark")?;
|
||||
} else if is_valid_hex_color(color) {
|
||||
state
|
||||
.infra_repo
|
||||
.upsert_server_config("primary_color_dark", color)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error upserting primary_color_dark: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("upserting primary_color_dark")?;
|
||||
} else {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Invalid primary dark color format (expected #RRGGBB)".into(),
|
||||
@@ -156,19 +138,13 @@ pub async fn update_server_config(
|
||||
.infra_repo
|
||||
.delete_server_config("secondary_color")
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error deleting secondary_color: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("deleting secondary_color")?;
|
||||
} else if is_valid_hex_color(color) {
|
||||
state
|
||||
.infra_repo
|
||||
.upsert_server_config("secondary_color", color)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error upserting secondary_color: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("upserting secondary_color")?;
|
||||
} else {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Invalid secondary color format (expected #RRGGBB)".into(),
|
||||
@@ -182,19 +158,13 @@ pub async fn update_server_config(
|
||||
.infra_repo
|
||||
.delete_server_config("secondary_color_dark")
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error deleting secondary_color_dark: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("deleting secondary_color_dark")?;
|
||||
} else if is_valid_hex_color(color) {
|
||||
state
|
||||
.infra_repo
|
||||
.upsert_server_config("secondary_color_dark", color)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error upserting secondary_color_dark: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("upserting secondary_color_dark")?;
|
||||
} else {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Invalid secondary dark color format (expected #RRGGBB)".into(),
|
||||
@@ -217,7 +187,7 @@ pub async fn update_server_config(
|
||||
};
|
||||
|
||||
if let Some(old_cid_str) = should_delete_old {
|
||||
let old_cid = CidLink::new_unchecked(old_cid_str);
|
||||
let old_cid = unsafe { CidLink::new_unchecked(old_cid_str) };
|
||||
if let Ok(Some(storage_key)) =
|
||||
state.infra_repo.get_blob_storage_key_by_cid(&old_cid).await
|
||||
{
|
||||
@@ -235,19 +205,13 @@ pub async fn update_server_config(
|
||||
.infra_repo
|
||||
.delete_server_config("logo_cid")
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error deleting logo_cid: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("deleting logo_cid")?;
|
||||
} else {
|
||||
state
|
||||
.infra_repo
|
||||
.upsert_server_config("logo_cid", logo_cid)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error upserting logo_cid: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("upserting logo_cid")?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::api::EmptyResponse;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use crate::auth::{Admin, Auth};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
@@ -91,10 +91,7 @@ pub async fn get_invite_codes(
|
||||
.infra_repo
|
||||
.list_invite_codes(params.cursor.as_deref(), limit, sort_order)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching invite codes: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("fetching invite codes")?;
|
||||
|
||||
let user_ids: Vec<uuid::Uuid> = codes_rows.iter().map(|r| r.created_by_user).collect();
|
||||
let code_strings: Vec<String> = codes_rows.iter().map(|r| r.code.clone()).collect();
|
||||
@@ -138,7 +135,7 @@ pub async fn get_invite_codes(
|
||||
InviteCodeInfo {
|
||||
code: r.code.clone(),
|
||||
available: r.available_uses,
|
||||
disabled: r.disabled.unwrap_or(false),
|
||||
disabled: r.state().is_disabled(),
|
||||
for_account: creator_did.clone(),
|
||||
created_by: creator_did,
|
||||
created_at: r.created_at.to_rfc3339(),
|
||||
|
||||
@@ -175,7 +175,7 @@ pub async fn update_subject_status(
|
||||
Some("com.atproto.admin.defs#repoRef") => {
|
||||
let did_str = input.subject.get("did").and_then(|d| d.as_str());
|
||||
if let Some(did_str) = did_str {
|
||||
let did = Did::new_unchecked(did_str);
|
||||
let did = unsafe { Did::new_unchecked(did_str) };
|
||||
if let Some(takedown) = &input.takedown {
|
||||
let takedown_ref = if takedown.applied {
|
||||
takedown.r#ref.as_deref()
|
||||
@@ -207,34 +207,24 @@ pub async fn update_subject_status(
|
||||
}
|
||||
if let Some(takedown) = &input.takedown {
|
||||
let status = if takedown.applied {
|
||||
Some("takendown")
|
||||
tranquil_db_traits::AccountStatus::Takendown
|
||||
} else {
|
||||
None
|
||||
tranquil_db_traits::AccountStatus::Active
|
||||
};
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(
|
||||
&state,
|
||||
&did,
|
||||
!takedown.applied,
|
||||
status,
|
||||
)
|
||||
.await
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_account_event(&state, &did, status).await
|
||||
{
|
||||
warn!("Failed to sequence account event for takedown: {}", e);
|
||||
}
|
||||
}
|
||||
if let Some(deactivated) = &input.deactivated {
|
||||
let status = if deactivated.applied {
|
||||
Some("deactivated")
|
||||
tranquil_db_traits::AccountStatus::Deactivated
|
||||
} else {
|
||||
None
|
||||
tranquil_db_traits::AccountStatus::Active
|
||||
};
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(
|
||||
&state,
|
||||
&did,
|
||||
!deactivated.applied,
|
||||
status,
|
||||
)
|
||||
.await
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_account_event(&state, &did, status).await
|
||||
{
|
||||
warn!("Failed to sequence account event for deactivation: {}", e);
|
||||
}
|
||||
|
||||
@@ -33,13 +33,13 @@ pub async fn get_age_assurance_state() -> Response {
|
||||
}
|
||||
|
||||
async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option<String> {
|
||||
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
|
||||
let auth_header = crate::util::get_header_str(headers, "Authorization");
|
||||
tracing::debug!(?auth_header, "age assurance: extracting token");
|
||||
|
||||
let extracted = extract_auth_token_from_header(auth_header)?;
|
||||
tracing::debug!("age assurance: got token, validating");
|
||||
|
||||
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
|
||||
let dpop_proof = crate::util::get_header_str(headers, "DPoP");
|
||||
let http_uri = "/";
|
||||
|
||||
let auth_user = match validate_token_with_dpop(
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::repo::record::utils::create_signed_commit;
|
||||
use crate::auth::{Active, Auth};
|
||||
use crate::delegation::{DelegationActionType, SCOPE_PRESETS, scopes};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::delegation::{
|
||||
DelegationActionType, SCOPE_PRESETS, ValidatedDelegationScope, verify_can_add_controllers,
|
||||
verify_can_be_controller, verify_can_control_accounts,
|
||||
};
|
||||
use crate::rate_limit::{AccountCreationLimit, RateLimited};
|
||||
use crate::state::AppState;
|
||||
use crate::types::{Did, Handle, Nsid, Rkey};
|
||||
use crate::util::extract_client_ip;
|
||||
use crate::util::{pds_hostname, pds_hostname_without_port};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use jacquard_common::types::{integer::LimitedU32, string::Tid};
|
||||
@@ -57,7 +61,7 @@ pub async fn list_controllers(
|
||||
.map(|c| ControllerInfo {
|
||||
did: c.did,
|
||||
handle: c.handle,
|
||||
granted_scopes: c.granted_scopes,
|
||||
granted_scopes: c.granted_scopes.into_string(),
|
||||
granted_at: c.granted_at,
|
||||
is_active: c.is_active,
|
||||
})
|
||||
@@ -69,7 +73,7 @@ pub async fn list_controllers(
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddControllerInput {
|
||||
pub controller_did: Did,
|
||||
pub granted_scopes: String,
|
||||
pub granted_scopes: ValidatedDelegationScope,
|
||||
}
|
||||
|
||||
pub async fn add_controller(
|
||||
@@ -77,10 +81,6 @@ pub async fn add_controller(
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<AddControllerInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if let Err(e) = scopes::validate_delegation_scopes(&input.granted_scopes) {
|
||||
return Ok(ApiError::InvalidScopes(e).into_response());
|
||||
}
|
||||
|
||||
let controller_exists = state
|
||||
.user_repo
|
||||
.get_by_did(&input.controller_did)
|
||||
@@ -93,51 +93,23 @@ pub async fn add_controller(
|
||||
return Ok(ApiError::ControllerNotFound.into_response());
|
||||
}
|
||||
|
||||
match state.delegation_repo.controls_any_accounts(&auth.did).await {
|
||||
Ok(true) => {
|
||||
return Ok(ApiError::InvalidDelegation(
|
||||
"Cannot add controllers to an account that controls other accounts".into(),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to check delegation status: {:?}", e);
|
||||
return Ok(
|
||||
ApiError::InternalError(Some("Failed to verify delegation status".into()))
|
||||
.into_response(),
|
||||
);
|
||||
}
|
||||
Ok(false) => {}
|
||||
}
|
||||
let can_add = match verify_can_add_controllers(&state, &auth).await {
|
||||
Ok(proof) => proof,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
match state
|
||||
.delegation_repo
|
||||
.has_any_controllers(&input.controller_did)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
return Ok(ApiError::InvalidDelegation(
|
||||
"Cannot add a controlled account as a controller".into(),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to check controller status: {:?}", e);
|
||||
return Ok(
|
||||
ApiError::InternalError(Some("Failed to verify controller status".into()))
|
||||
.into_response(),
|
||||
);
|
||||
}
|
||||
Ok(false) => {}
|
||||
}
|
||||
let can_be_controller = match verify_can_be_controller(&state, &input.controller_did).await {
|
||||
Ok(proof) => proof,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
match state
|
||||
.delegation_repo
|
||||
.create_delegation(
|
||||
&auth.did,
|
||||
&input.controller_did,
|
||||
can_add.did(),
|
||||
can_be_controller.did(),
|
||||
&input.granted_scopes,
|
||||
&auth.did,
|
||||
can_add.did(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -145,12 +117,12 @@ pub async fn add_controller(
|
||||
let _ = state
|
||||
.delegation_repo
|
||||
.log_delegation_action(
|
||||
&auth.did,
|
||||
&auth.did,
|
||||
Some(&input.controller_did),
|
||||
can_add.did(),
|
||||
can_add.did(),
|
||||
Some(can_be_controller.did()),
|
||||
DelegationActionType::GrantCreated,
|
||||
Some(serde_json::json!({
|
||||
"granted_scopes": input.granted_scopes
|
||||
"granted_scopes": input.granted_scopes.as_str()
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
@@ -235,7 +207,7 @@ pub async fn remove_controller(
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateControllerScopesInput {
|
||||
pub controller_did: Did,
|
||||
pub granted_scopes: String,
|
||||
pub granted_scopes: ValidatedDelegationScope,
|
||||
}
|
||||
|
||||
pub async fn update_controller_scopes(
|
||||
@@ -243,10 +215,6 @@ pub async fn update_controller_scopes(
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<UpdateControllerScopesInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if let Err(e) = scopes::validate_delegation_scopes(&input.granted_scopes) {
|
||||
return Ok(ApiError::InvalidScopes(e).into_response());
|
||||
}
|
||||
|
||||
match state
|
||||
.delegation_repo
|
||||
.update_delegation_scopes(&auth.did, &input.controller_did, &input.granted_scopes)
|
||||
@@ -261,7 +229,7 @@ pub async fn update_controller_scopes(
|
||||
Some(&input.controller_did),
|
||||
DelegationActionType::ScopesModified,
|
||||
Some(serde_json::json!({
|
||||
"new_scopes": input.granted_scopes
|
||||
"new_scopes": input.granted_scopes.as_str()
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
@@ -326,7 +294,7 @@ pub async fn list_controlled_accounts(
|
||||
.map(|a| DelegatedAccountInfo {
|
||||
did: a.did,
|
||||
handle: a.handle,
|
||||
granted_scopes: a.granted_scopes,
|
||||
granted_scopes: a.granted_scopes.into_string(),
|
||||
granted_at: a.granted_at,
|
||||
})
|
||||
.collect(),
|
||||
@@ -443,7 +411,7 @@ pub async fn get_scope_presets() -> Response {
|
||||
pub struct CreateDelegatedAccountInput {
|
||||
pub handle: String,
|
||||
pub email: Option<String>,
|
||||
pub controller_scopes: String,
|
||||
pub controller_scopes: ValidatedDelegationScope,
|
||||
pub invite_code: Option<String>,
|
||||
}
|
||||
|
||||
@@ -456,45 +424,17 @@ pub struct CreateDelegatedAccountResponse {
|
||||
|
||||
pub async fn create_delegated_account(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
_rate_limit: RateLimited<AccountCreationLimit>,
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<CreateDelegatedAccountInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::AccountCreation, &client_ip)
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Delegated account creation rate limit exceeded");
|
||||
return Ok(ApiError::RateLimitExceeded(Some(
|
||||
"Too many account creation attempts. Please try again later.".into(),
|
||||
))
|
||||
.into_response());
|
||||
}
|
||||
let can_control = match verify_can_control_accounts(&state, &auth).await {
|
||||
Ok(proof) => proof,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
if let Err(e) = scopes::validate_delegation_scopes(&input.controller_scopes) {
|
||||
return Ok(ApiError::InvalidScopes(e).into_response());
|
||||
}
|
||||
|
||||
match state.delegation_repo.has_any_controllers(&auth.did).await {
|
||||
Ok(true) => {
|
||||
return Ok(ApiError::InvalidDelegation(
|
||||
"Cannot create delegated accounts from a controlled account".into(),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to check controller status: {:?}", e);
|
||||
return Ok(
|
||||
ApiError::InternalError(Some("Failed to verify controller status".into()))
|
||||
.into_response(),
|
||||
);
|
||||
}
|
||||
Ok(false) => {}
|
||||
}
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
|
||||
let hostname = pds_hostname();
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let pds_suffix = format!(".{}", hostname_for_handles);
|
||||
|
||||
let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) {
|
||||
@@ -527,15 +467,10 @@ pub async fn create_delegated_account(
|
||||
return Ok(ApiError::InvalidEmail.into_response());
|
||||
}
|
||||
|
||||
if let Some(ref code) = input.invite_code {
|
||||
let valid = state
|
||||
.infra_repo
|
||||
.is_invite_code_valid(code)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if !valid {
|
||||
return Ok(ApiError::InvalidInviteCode.into_response());
|
||||
let validated_invite_code = if let Some(ref code) = input.invite_code {
|
||||
match state.infra_repo.validate_invite_code(code).await {
|
||||
Ok(validated) => Some(validated),
|
||||
Err(_) => return Ok(ApiError::InvalidInviteCode.into_response()),
|
||||
}
|
||||
} else {
|
||||
let invite_required = std::env::var("INVITE_CODE_REQUIRED")
|
||||
@@ -544,7 +479,8 @@ pub async fn create_delegated_account(
|
||||
if invite_required {
|
||||
return Ok(ApiError::InviteCodeRequired.into_response());
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
use k256::ecdsa::SigningKey;
|
||||
use rand::rngs::OsRng;
|
||||
@@ -593,9 +529,9 @@ pub async fn create_delegated_account(
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let did = Did::new_unchecked(&genesis_result.did);
|
||||
let handle = Handle::new_unchecked(&handle);
|
||||
info!(did = %did, handle = %handle, controller = %&auth.did, "Created DID for delegated account");
|
||||
let did = unsafe { Did::new_unchecked(&genesis_result.did) };
|
||||
let handle = unsafe { Handle::new_unchecked(&handle) };
|
||||
info!(did = %did, handle = %handle, controller = %can_control.did(), "Created DID for delegated account");
|
||||
|
||||
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
|
||||
Ok(bytes) => bytes,
|
||||
@@ -635,8 +571,8 @@ pub async fn create_delegated_account(
|
||||
handle: handle.clone(),
|
||||
email: email.clone(),
|
||||
did: did.clone(),
|
||||
controller_did: auth.did.clone(),
|
||||
controller_scopes: input.controller_scopes.clone(),
|
||||
controller_did: can_control.did().clone(),
|
||||
controller_scopes: input.controller_scopes.as_str().to_string(),
|
||||
encrypted_key_bytes,
|
||||
encryption_version: crate::config::ENCRYPTION_VERSION,
|
||||
commit_cid: commit_cid.to_string(),
|
||||
@@ -645,7 +581,7 @@ pub async fn create_delegated_account(
|
||||
invite_code: input.invite_code.clone(),
|
||||
};
|
||||
|
||||
let _user_id = match state
|
||||
let user_id = match state
|
||||
.user_repo
|
||||
.create_delegated_account(&create_input)
|
||||
.await
|
||||
@@ -663,12 +599,26 @@ pub async fn create_delegated_account(
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(validated) = validated_invite_code
|
||||
&& let Err(e) = state
|
||||
.infra_repo
|
||||
.record_invite_code_use(&validated, user_id)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to record invite code use for {}: {:?}", did, e);
|
||||
}
|
||||
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
|
||||
{
|
||||
warn!("Failed to sequence identity event for {}: {}", did, e);
|
||||
}
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(
|
||||
&state,
|
||||
&did,
|
||||
tranquil_db_traits::AccountStatus::Active,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to sequence account event for {}: {}", did, e);
|
||||
}
|
||||
@@ -677,8 +627,8 @@ pub async fn create_delegated_account(
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": handle
|
||||
});
|
||||
let profile_collection = Nsid::new_unchecked("app.bsky.actor.profile");
|
||||
let profile_rkey = Rkey::new_unchecked("self");
|
||||
let profile_collection = unsafe { Nsid::new_unchecked("app.bsky.actor.profile") };
|
||||
let profile_rkey = unsafe { Rkey::new_unchecked("self") };
|
||||
if let Err(e) = crate::api::repo::record::create_record_internal(
|
||||
&state,
|
||||
&did,
|
||||
@@ -700,7 +650,7 @@ pub async fn create_delegated_account(
|
||||
DelegationActionType::GrantCreated,
|
||||
Some(json!({
|
||||
"account_created": true,
|
||||
"granted_scopes": input.controller_scopes
|
||||
"granted_scopes": input.controller_scopes.as_str()
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
|
||||
@@ -694,6 +694,12 @@ impl From<crate::storage::StorageError> for ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::rate_limit::UserRateLimitError> for ApiError {
|
||||
fn from(e: crate::rate_limit::UserRateLimitError) -> Self {
|
||||
Self::RateLimitExceeded(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn parse_did(s: &str) -> Result<tranquil_types::Did, Response> {
|
||||
s.parse()
|
||||
@@ -756,3 +762,16 @@ fn extract_json_error_message(rejection: &JsonRejection) -> String {
|
||||
_ => "Invalid request body".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub trait DbResultExt<T> {
|
||||
fn log_db_err(self, ctx: &str) -> Result<T, ApiError>;
|
||||
}
|
||||
|
||||
impl<T, E: std::fmt::Debug> DbResultExt<T> for Result<T, E> {
|
||||
fn log_db_err(self, ctx: &str) -> Result<T, ApiError> {
|
||||
self.map_err(|e| {
|
||||
tracing::error!("DB error {}: {:?}", ctx, e);
|
||||
ApiError::DatabaseError
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ use crate::api::error::ApiError;
|
||||
use crate::api::repo::record::utils::create_signed_commit;
|
||||
use crate::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_service_token};
|
||||
use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::rate_limit::{AccountCreationLimit, RateLimited};
|
||||
use crate::state::AppState;
|
||||
use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey};
|
||||
use crate::util::{pds_hostname, pds_hostname_without_port};
|
||||
use crate::validation::validate_password;
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -22,21 +24,6 @@ use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for")
|
||||
&& let Ok(value) = forwarded.to_str()
|
||||
&& let Some(first_ip) = value.split(',').next()
|
||||
{
|
||||
return first_ip.trim().to_string();
|
||||
}
|
||||
if let Some(real_ip) = headers.get("x-real-ip")
|
||||
&& let Ok(value) = real_ip.to_str()
|
||||
{
|
||||
return value.trim().to_string();
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateAccountInput {
|
||||
@@ -68,6 +55,7 @@ pub struct CreateAccountOutput {
|
||||
|
||||
pub async fn create_account(
|
||||
State(state): State<AppState>,
|
||||
_rate_limit: RateLimited<AccountCreationLimit>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<CreateAccountInput>,
|
||||
) -> Response {
|
||||
@@ -84,20 +72,9 @@ pub async fn create_account(
|
||||
} else {
|
||||
info!("create_account called");
|
||||
}
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::AccountCreation, &client_ip)
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Account creation rate limit exceeded");
|
||||
return ApiError::RateLimitExceeded(Some(
|
||||
"Too many account creation attempts. Please try again later.".into(),
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let migration_auth = if let Some(extracted) =
|
||||
extract_auth_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok()))
|
||||
extract_auth_token_from_header(crate::util::get_header_str(&headers, "Authorization"))
|
||||
{
|
||||
let token = extracted.token;
|
||||
if is_service_token(&token) {
|
||||
@@ -143,7 +120,7 @@ pub async fn create_account(
|
||||
if (is_migration || is_did_web_byod)
|
||||
&& let (Some(provided_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref())
|
||||
{
|
||||
if provided_did != auth_did {
|
||||
if provided_did != auth_did.as_str() {
|
||||
info!(
|
||||
"[MIGRATION] createAccount: Service token mismatch - token_did={} provided_did={}",
|
||||
auth_did, provided_did
|
||||
@@ -164,8 +141,7 @@ pub async fn create_account(
|
||||
}
|
||||
}
|
||||
|
||||
let hostname_for_validation =
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname_for_validation = pds_hostname_without_port();
|
||||
let pds_suffix = format!(".{}", hostname_for_validation);
|
||||
|
||||
let validated_short_handle = if !input.handle.contains('.')
|
||||
@@ -242,8 +218,8 @@ pub async fn create_account(
|
||||
_ => return ApiError::InvalidVerificationChannel.into_response(),
|
||||
})
|
||||
};
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
|
||||
let hostname = pds_hostname();
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let pds_endpoint = format!("https://{}", hostname);
|
||||
let suffix = format!(".{}", hostname_for_handles);
|
||||
let handle = if input.handle.ends_with(&suffix) {
|
||||
@@ -308,7 +284,7 @@ pub async fn create_account(
|
||||
}
|
||||
if !is_did_web_byod
|
||||
&& let Err(e) =
|
||||
verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await
|
||||
verify_did_web(d, hostname, &input.handle, input.signing_key.as_deref()).await
|
||||
{
|
||||
return ApiError::InvalidDid(e).into_response();
|
||||
}
|
||||
@@ -322,13 +298,9 @@ pub async fn create_account(
|
||||
d.clone()
|
||||
} else if d.starts_with("did:web:") {
|
||||
if !is_did_web_byod
|
||||
&& let Err(e) = verify_did_web(
|
||||
d,
|
||||
&hostname,
|
||||
&input.handle,
|
||||
input.signing_key.as_deref(),
|
||||
)
|
||||
.await
|
||||
&& let Err(e) =
|
||||
verify_did_web(d, hostname, &input.handle, input.signing_key.as_deref())
|
||||
.await
|
||||
{
|
||||
return ApiError::InvalidDid(e).into_response();
|
||||
}
|
||||
@@ -408,8 +380,8 @@ pub async fn create_account(
|
||||
};
|
||||
if is_migration {
|
||||
let reactivate_input = tranquil_db_traits::MigrationReactivationInput {
|
||||
did: Did::new_unchecked(&did),
|
||||
new_handle: Handle::new_unchecked(&handle),
|
||||
did: unsafe { Did::new_unchecked(&did) },
|
||||
new_handle: unsafe { Handle::new_unchecked(&handle) },
|
||||
new_email: email.clone(),
|
||||
};
|
||||
match state
|
||||
@@ -463,12 +435,12 @@ pub async fn create_account(
|
||||
}
|
||||
};
|
||||
let session_data = tranquil_db_traits::SessionTokenCreate {
|
||||
did: Did::new_unchecked(&did),
|
||||
did: unsafe { Did::new_unchecked(&did) },
|
||||
access_jti: access_meta.jti.clone(),
|
||||
refresh_jti: refresh_meta.jti.clone(),
|
||||
access_expires_at: access_meta.expires_at,
|
||||
refresh_expires_at: refresh_meta.expires_at,
|
||||
legacy_login: false,
|
||||
login_type: tranquil_db_traits::LoginType::Modern,
|
||||
mfa_verified: false,
|
||||
scope: None,
|
||||
controller_did: None,
|
||||
@@ -478,8 +450,7 @@ pub async fn create_account(
|
||||
error!("Error creating session: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let hostname =
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname = pds_hostname();
|
||||
let verification_required = if let Some(ref user_email) = email {
|
||||
let token =
|
||||
crate::auth::verification_token::generate_migration_token(&did, user_email);
|
||||
@@ -491,7 +462,7 @@ pub async fn create_account(
|
||||
reactivated.user_id,
|
||||
user_email,
|
||||
&formatted_token,
|
||||
&hostname,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -505,7 +476,7 @@ pub async fn create_account(
|
||||
axum::http::StatusCode::OK,
|
||||
Json(CreateAccountOutput {
|
||||
handle: handle.clone().into(),
|
||||
did: Did::new_unchecked(&did),
|
||||
did: unsafe { Did::new_unchecked(&did) },
|
||||
did_doc: state.did_resolver.resolve_did_document(&did).await,
|
||||
access_jwt: access_meta.token,
|
||||
refresh_jwt: refresh_meta.token,
|
||||
@@ -529,7 +500,7 @@ pub async fn create_account(
|
||||
}
|
||||
}
|
||||
|
||||
let handle_typed = Handle::new_unchecked(&handle);
|
||||
let handle_typed = unsafe { Handle::new_unchecked(&handle) };
|
||||
let handle_available = match state
|
||||
.user_repo
|
||||
.check_handle_available_for_new_account(&handle_typed)
|
||||
@@ -613,7 +584,7 @@ pub async fn create_account(
|
||||
}
|
||||
};
|
||||
let rev = Tid::now(LimitedU32::MIN);
|
||||
let did_for_commit = Did::new_unchecked(&did);
|
||||
let did_for_commit = unsafe { Did::new_unchecked(&did) };
|
||||
let (commit_bytes, _sig) =
|
||||
match create_signed_commit(&did_for_commit, mst_root, rev.as_ref(), None, &signing_key) {
|
||||
Ok(result) => result,
|
||||
@@ -649,9 +620,9 @@ pub async fn create_account(
|
||||
};
|
||||
|
||||
let create_input = tranquil_db_traits::CreatePasswordAccountInput {
|
||||
handle: Handle::new_unchecked(&handle),
|
||||
handle: unsafe { Handle::new_unchecked(&handle) },
|
||||
email: email.clone(),
|
||||
did: Did::new_unchecked(&did),
|
||||
did: unsafe { Did::new_unchecked(&did) },
|
||||
password_hash,
|
||||
preferred_comms_channel,
|
||||
discord_id: input
|
||||
@@ -701,8 +672,8 @@ pub async fn create_account(
|
||||
};
|
||||
let user_id = create_result.user_id;
|
||||
if !is_migration && !is_did_web_byod {
|
||||
let did_typed = Did::new_unchecked(&did);
|
||||
let handle_typed = Handle::new_unchecked(&handle);
|
||||
let did_typed = unsafe { Did::new_unchecked(&did) };
|
||||
let handle_typed = unsafe { Handle::new_unchecked(&handle) };
|
||||
if let Err(e) = crate::api::repo::record::sequence_identity_event(
|
||||
&state,
|
||||
&did_typed,
|
||||
@@ -712,8 +683,12 @@ pub async fn create_account(
|
||||
{
|
||||
warn!("Failed to sequence identity event for {}: {}", did, e);
|
||||
}
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_account_event(&state, &did_typed, true, None).await
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(
|
||||
&state,
|
||||
&did_typed,
|
||||
tranquil_db_traits::AccountStatus::Active,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to sequence account event for {}: {}", did, e);
|
||||
}
|
||||
@@ -742,8 +717,8 @@ pub async fn create_account(
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": input.handle
|
||||
});
|
||||
let profile_collection = Nsid::new_unchecked("app.bsky.actor.profile");
|
||||
let profile_rkey = Rkey::new_unchecked("self");
|
||||
let profile_collection = unsafe { Nsid::new_unchecked("app.bsky.actor.profile") };
|
||||
let profile_rkey = unsafe { Rkey::new_unchecked("self") };
|
||||
if let Err(e) = crate::api::repo::record::create_record_internal(
|
||||
&state,
|
||||
&did_typed,
|
||||
@@ -756,7 +731,7 @@ pub async fn create_account(
|
||||
warn!("Failed to create default profile for {}: {}", did, e);
|
||||
}
|
||||
}
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname = pds_hostname();
|
||||
if !is_migration {
|
||||
if let Some(ref recipient) = verification_recipient {
|
||||
let verification_token = crate::auth::verification_token::generate_signup_token(
|
||||
@@ -772,7 +747,7 @@ pub async fn create_account(
|
||||
verification_channel,
|
||||
recipient,
|
||||
&formatted_token,
|
||||
&hostname,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -791,7 +766,7 @@ pub async fn create_account(
|
||||
user_id,
|
||||
user_email,
|
||||
&formatted_token,
|
||||
&hostname,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -816,12 +791,12 @@ pub async fn create_account(
|
||||
}
|
||||
};
|
||||
let session_data = tranquil_db_traits::SessionTokenCreate {
|
||||
did: Did::new_unchecked(&did),
|
||||
did: unsafe { Did::new_unchecked(&did) },
|
||||
access_jti: access_meta.jti.clone(),
|
||||
refresh_jti: refresh_meta.jti.clone(),
|
||||
access_expires_at: access_meta.expires_at,
|
||||
refresh_expires_at: refresh_meta.expires_at,
|
||||
legacy_login: false,
|
||||
login_type: tranquil_db_traits::LoginType::Modern,
|
||||
mfa_verified: false,
|
||||
scope: None,
|
||||
controller_did: None,
|
||||
@@ -845,7 +820,7 @@ pub async fn create_account(
|
||||
StatusCode::OK,
|
||||
Json(CreateAccountOutput {
|
||||
handle: handle.clone().into(),
|
||||
did: Did::new_unchecked(&did),
|
||||
did: unsafe { Did::new_unchecked(&did) },
|
||||
did_doc,
|
||||
access_jwt: access_meta.token,
|
||||
refresh_jwt: refresh_meta.token,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
use crate::api::{ApiError, DidResponse, EmptyResponse};
|
||||
use crate::auth::{Auth, NotTakendown};
|
||||
use crate::plc::signing_key_to_did_key;
|
||||
use crate::rate_limit::{
|
||||
HandleUpdateDailyLimit, HandleUpdateLimit, check_user_rate_limit_with_message,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::types::Handle;
|
||||
use crate::util::{get_header_str, pds_hostname, pds_hostname_without_port};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
@@ -101,20 +105,17 @@ pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result<String, &'static str
|
||||
}
|
||||
|
||||
pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let host_header = headers
|
||||
.get("host")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or(&hostname);
|
||||
let hostname = pds_hostname();
|
||||
let hostname_without_port = pds_hostname_without_port();
|
||||
let host_header = get_header_str(&headers, "host").unwrap_or(hostname);
|
||||
let host_without_port = host_header.split(':').next().unwrap_or(host_header);
|
||||
let hostname_without_port = hostname.split(':').next().unwrap_or(&hostname);
|
||||
if host_without_port != hostname_without_port
|
||||
&& host_without_port.ends_with(&format!(".{}", hostname_without_port))
|
||||
{
|
||||
let handle = host_without_port
|
||||
.strip_suffix(&format!(".{}", hostname_without_port))
|
||||
.unwrap_or(host_without_port);
|
||||
return serve_subdomain_did_doc(&state, handle, &hostname).await;
|
||||
return serve_subdomain_did_doc(&state, handle, hostname).await;
|
||||
}
|
||||
let did = if hostname.contains(':') {
|
||||
format!("did:web:{}", hostname.replace(':', "%3A"))
|
||||
@@ -257,8 +258,8 @@ async fn serve_subdomain_did_doc(state: &AppState, subdomain: &str, hostname: &s
|
||||
}
|
||||
|
||||
pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
|
||||
let hostname = pds_hostname();
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let current_handle = format!("{}.{}", handle, hostname_for_handles);
|
||||
let current_handle_typed: Handle = match current_handle.parse() {
|
||||
Ok(h) => h,
|
||||
@@ -531,7 +532,7 @@ pub async fn get_recommended_did_credentials(
|
||||
ApiError::AuthenticationFailed(Some("OAuth tokens cannot get DID credentials".into()))
|
||||
})?;
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname = pds_hostname();
|
||||
let pds_endpoint = format!("https://{}", hostname);
|
||||
let signing_key = k256::ecdsa::SigningKey::from_slice(&key_bytes)
|
||||
.map_err(|_| ApiError::InternalError(None))?;
|
||||
@@ -585,22 +586,18 @@ pub async fn update_handle(
|
||||
return Ok(e);
|
||||
}
|
||||
let did = auth.did.clone();
|
||||
if !state
|
||||
.check_rate_limit(crate::state::RateLimitKind::HandleUpdate, &did)
|
||||
.await
|
||||
{
|
||||
return Err(ApiError::RateLimitExceeded(Some(
|
||||
"Too many handle updates. Try again later.".into(),
|
||||
)));
|
||||
}
|
||||
if !state
|
||||
.check_rate_limit(crate::state::RateLimitKind::HandleUpdateDaily, &did)
|
||||
.await
|
||||
{
|
||||
return Err(ApiError::RateLimitExceeded(Some(
|
||||
"Daily handle update limit exceeded.".into(),
|
||||
)));
|
||||
}
|
||||
let _rate_limit = check_user_rate_limit_with_message::<HandleUpdateLimit>(
|
||||
&state,
|
||||
&did,
|
||||
"Too many handle updates. Try again later.",
|
||||
)
|
||||
.await?;
|
||||
let _daily_rate_limit = check_user_rate_limit_with_message::<HandleUpdateDailyLimit>(
|
||||
&state,
|
||||
&did,
|
||||
"Daily handle update limit exceeded.",
|
||||
)
|
||||
.await?;
|
||||
let user_row = state
|
||||
.user_repo
|
||||
.get_id_and_handle_by_did(&did)
|
||||
@@ -639,8 +636,7 @@ pub async fn update_handle(
|
||||
"Inappropriate language in handle".into(),
|
||||
)));
|
||||
}
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let suffix = format!(".{}", hostname_for_handles);
|
||||
let is_service_domain =
|
||||
crate::handle::is_service_domain_handle(&new_handle, hostname_for_handles);
|
||||
@@ -656,7 +652,7 @@ pub async fn update_handle(
|
||||
format!("{}.{}", new_handle, hostname_for_handles)
|
||||
};
|
||||
if full_handle == current_handle {
|
||||
let handle_typed = Handle::new_unchecked(&full_handle);
|
||||
let handle_typed = unsafe { Handle::new_unchecked(&full_handle) };
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle_typed))
|
||||
.await
|
||||
@@ -679,7 +675,7 @@ pub async fn update_handle(
|
||||
full_handle
|
||||
} else {
|
||||
if new_handle == current_handle {
|
||||
let handle_typed = Handle::new_unchecked(&new_handle);
|
||||
let handle_typed = unsafe { Handle::new_unchecked(&new_handle) };
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle_typed))
|
||||
.await
|
||||
@@ -772,7 +768,7 @@ pub async fn update_plc_handle(
|
||||
}
|
||||
|
||||
pub async fn well_known_atproto_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
let host = match headers.get("host").and_then(|h| h.to_str().ok()) {
|
||||
let host = match crate::util::get_header_str(&headers, "host") {
|
||||
Some(h) => h,
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing host header").into_response(),
|
||||
};
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use crate::api::EmptyResponse;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use crate::auth::{Auth, Permissive};
|
||||
use crate::state::AppState;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{
|
||||
extract::State,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use tracing::{error, info, warn};
|
||||
use tracing::{info, warn};
|
||||
|
||||
fn generate_plc_token() -> String {
|
||||
crate::util::generate_token_code()
|
||||
@@ -28,10 +29,7 @@ pub async fn request_plc_operation_signature(
|
||||
.user_repo
|
||||
.get_id_by_did(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("fetching user id")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let _ = state.infra_repo.delete_plc_tokens_for_user(user_id).await;
|
||||
@@ -41,18 +39,15 @@ pub async fn request_plc_operation_signature(
|
||||
.infra_repo
|
||||
.insert_plc_token(user_id, &plc_token, expires_at)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to create PLC token: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("creating PLC token")?;
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname = pds_hostname();
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_plc_operation(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
user_id,
|
||||
&plc_token,
|
||||
&hostname,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::api::error::DbResultExt;
|
||||
use crate::auth::{Auth, Permissive};
|
||||
use crate::circuit_breaker::with_circuit_breaker;
|
||||
use crate::plc::{PlcClient, PlcError, PlcService, create_update_op, sign_operation};
|
||||
@@ -64,20 +65,14 @@ pub async fn sign_plc_operation(
|
||||
.user_repo
|
||||
.get_id_by_did(did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("fetching user id")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let token_expiry = state
|
||||
.infra_repo
|
||||
.get_plc_token_expiry(user_id, token)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("fetching PLC token expiry")?
|
||||
.ok_or_else(|| ApiError::InvalidToken(Some("Invalid or expired token".into())))?;
|
||||
|
||||
if Utc::now() > token_expiry {
|
||||
@@ -88,10 +83,7 @@ pub async fn sign_plc_operation(
|
||||
.user_repo
|
||||
.get_user_key_by_id(user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("fetching user key")?
|
||||
.ok_or_else(|| ApiError::InternalError(Some("User signing key not found".into())))?;
|
||||
|
||||
let key_bytes = crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use crate::api::error::DbResultExt;
|
||||
use crate::api::{ApiError, EmptyResponse};
|
||||
use crate::auth::{Auth, Permissive};
|
||||
use crate::circuit_breaker::with_circuit_breaker;
|
||||
use crate::plc::{PlcClient, signing_key_to_did_key, validate_plc_operation};
|
||||
use crate::state::AppState;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -40,26 +42,20 @@ pub async fn submit_plc_operation(
|
||||
.map_err(|e| ApiError::InvalidRequest(format!("Invalid operation: {}", e)))?;
|
||||
|
||||
let op = &input.operation;
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname = pds_hostname();
|
||||
let public_url = format!("https://{}", hostname);
|
||||
let user = state
|
||||
.user_repo
|
||||
.get_id_and_handle_by_did(did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("fetching user")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let key_row = state
|
||||
.user_repo
|
||||
.get_user_key_by_id(user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("fetching user key")?
|
||||
.ok_or_else(|| ApiError::InternalError(Some("User signing key not found".into())))?;
|
||||
|
||||
let key_bytes = crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::auth::{Active, Auth};
|
||||
use crate::state::AppState;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -9,11 +10,12 @@ use axum::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::info;
|
||||
use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotificationPrefsResponse {
|
||||
pub preferred_channel: String,
|
||||
pub preferred_channel: CommsChannel,
|
||||
pub email: String,
|
||||
pub discord_id: Option<String>,
|
||||
pub discord_verified: bool,
|
||||
@@ -50,9 +52,9 @@ pub async fn get_notification_prefs(
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotificationHistoryEntry {
|
||||
pub created_at: String,
|
||||
pub channel: String,
|
||||
pub comms_type: String,
|
||||
pub status: String,
|
||||
pub channel: CommsChannel,
|
||||
pub comms_type: CommsType,
|
||||
pub status: CommsStatus,
|
||||
pub subject: Option<String>,
|
||||
pub body: String,
|
||||
}
|
||||
@@ -81,30 +83,29 @@ pub async fn get_notification_history(
|
||||
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
|
||||
|
||||
let sensitive_types = [
|
||||
"email_verification",
|
||||
"password_reset",
|
||||
"email_update",
|
||||
"two_factor_code",
|
||||
"passkey_recovery",
|
||||
"migration_verification",
|
||||
"plc_operation",
|
||||
"channel_verification",
|
||||
"signup_verification",
|
||||
CommsType::EmailVerification,
|
||||
CommsType::PasswordReset,
|
||||
CommsType::EmailUpdate,
|
||||
CommsType::TwoFactorCode,
|
||||
CommsType::PasskeyRecovery,
|
||||
CommsType::MigrationVerification,
|
||||
CommsType::PlcOperation,
|
||||
CommsType::ChannelVerification,
|
||||
];
|
||||
|
||||
let notifications = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let body = if sensitive_types.contains(&row.comms_type.as_str()) {
|
||||
let body = if sensitive_types.contains(&row.comms_type) {
|
||||
"[Code redacted for security]".to_string()
|
||||
} else {
|
||||
row.body.clone()
|
||||
};
|
||||
NotificationHistoryEntry {
|
||||
created_at: row.created_at.to_rfc3339(),
|
||||
channel: row.channel.clone(),
|
||||
comms_type: row.comms_type.clone(),
|
||||
status: row.status.clone(),
|
||||
channel: row.channel,
|
||||
comms_type: row.comms_type,
|
||||
status: row.status,
|
||||
subject: row.subject.clone(),
|
||||
body,
|
||||
}
|
||||
@@ -145,7 +146,7 @@ pub async fn request_channel_verification(
|
||||
let formatted_token = crate::auth::verification_token::format_token_for_display(&token);
|
||||
|
||||
if channel == "email" {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname = pds_hostname();
|
||||
let handle_str = handle.unwrap_or("user");
|
||||
crate::comms::comms_repo::enqueue_email_update(
|
||||
state.infra_repo.as_ref(),
|
||||
@@ -153,7 +154,7 @@ pub async fn request_channel_verification(
|
||||
identifier,
|
||||
handle_str,
|
||||
&formatted_token,
|
||||
&hostname,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to enqueue email notification: {}", e))?;
|
||||
@@ -200,19 +201,24 @@ pub async fn update_notification_prefs(
|
||||
|
||||
let mut verification_required: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(ref channel) = input.preferred_channel {
|
||||
let valid_channels = ["email", "discord", "telegram", "signal"];
|
||||
if !valid_channels.contains(&channel.as_str()) {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Invalid channel. Must be one of: email, discord, telegram, signal".into(),
|
||||
));
|
||||
}
|
||||
if let Some(ref channel_str) = input.preferred_channel {
|
||||
let channel = match channel_str.as_str() {
|
||||
"email" => CommsChannel::Email,
|
||||
"discord" => CommsChannel::Discord,
|
||||
"telegram" => CommsChannel::Telegram,
|
||||
"signal" => CommsChannel::Signal,
|
||||
_ => {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Invalid channel. Must be one of: email, discord, telegram, signal".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
state
|
||||
.user_repo
|
||||
.update_preferred_comms_channel(&auth.did, channel)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
|
||||
info!(did = %auth.did, channel = %channel, "Updated preferred notification channel");
|
||||
info!(did = %auth.did, channel = ?channel, "Updated preferred notification channel");
|
||||
}
|
||||
|
||||
if let Some(ref new_email) = input.email {
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::convert::Infallible;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::proxy_client::proxy_client;
|
||||
use crate::state::AppState;
|
||||
use crate::util::get_header_str;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{RawQuery, Request, State},
|
||||
@@ -191,11 +192,7 @@ async fn proxy_handler(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let Some(proxy_header) = headers
|
||||
.get("atproto-proxy")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(String::from)
|
||||
else {
|
||||
let Some(proxy_header) = get_header_str(&headers, "atproto-proxy").map(String::from) else {
|
||||
return ApiError::InvalidRequest("Missing required atproto-proxy header".into())
|
||||
.into_response();
|
||||
};
|
||||
@@ -217,10 +214,10 @@ async fn proxy_handler(
|
||||
|
||||
let mut auth_header_val = headers.get("Authorization").cloned();
|
||||
if let Some(extracted) = crate::auth::extract_auth_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
crate::util::get_header_str(&headers, "Authorization"),
|
||||
) {
|
||||
let token = extracted.token;
|
||||
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
|
||||
let dpop_proof = crate::util::get_header_str(&headers, "DPoP");
|
||||
let http_uri = crate::util::build_full_url(&uri.to_string());
|
||||
|
||||
match crate::auth::validate_token_with_dpop(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::auth::{Auth, AuthAny, NotTakendown, Permissive};
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use crate::auth::{Auth, AuthAny, NotTakendown, Permissive, VerifyScope};
|
||||
use crate::delegation::DelegationActionType;
|
||||
use crate::state::AppState;
|
||||
use crate::types::{CidLink, Did};
|
||||
use crate::util::get_max_blob_size;
|
||||
use crate::util::{get_header_str, get_max_blob_size};
|
||||
use axum::body::Body;
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -56,18 +56,16 @@ pub async fn upload_blob(
|
||||
if user.status.is_takendown() {
|
||||
return Err(ApiError::AccountTakedown);
|
||||
}
|
||||
let mime_type_for_check = headers
|
||||
.get("content-type")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("application/octet-stream");
|
||||
if let Err(e) = crate::auth::scope_check::check_blob_scope(
|
||||
user.is_oauth(),
|
||||
user.scope.as_deref(),
|
||||
mime_type_for_check,
|
||||
) {
|
||||
return Ok(e);
|
||||
}
|
||||
(user.did.clone(), user.controller_did.clone())
|
||||
let mime_type_for_check =
|
||||
get_header_str(&headers, "content-type").unwrap_or("application/octet-stream");
|
||||
let scope_proof = match user.verify_blob_upload(mime_type_for_check) {
|
||||
Ok(proof) => proof,
|
||||
Err(e) => return Ok(e.into_response()),
|
||||
};
|
||||
(
|
||||
scope_proof.principal_did().into_did(),
|
||||
scope_proof.controller_did().map(|c| c.into_did()),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,10 +78,8 @@ pub async fn upload_blob(
|
||||
return Err(ApiError::Forbidden);
|
||||
}
|
||||
|
||||
let client_mime_hint = headers
|
||||
.get("content-type")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("application/octet-stream");
|
||||
let client_mime_hint =
|
||||
get_header_str(&headers, "content-type").unwrap_or("application/octet-stream");
|
||||
|
||||
let user_id = state
|
||||
.user_repo
|
||||
@@ -140,7 +136,7 @@ pub async fn upload_blob(
|
||||
};
|
||||
let cid = Cid::new_v1(0x55, multihash);
|
||||
let cid_str = cid.to_string();
|
||||
let cid_link: CidLink = CidLink::new_unchecked(&cid_str);
|
||||
let cid_link: CidLink = unsafe { CidLink::new_unchecked(&cid_str) };
|
||||
let storage_key = cid_str.clone();
|
||||
|
||||
info!(
|
||||
@@ -232,10 +228,7 @@ pub async fn list_missing_blobs(
|
||||
.user_repo
|
||||
.get_by_did(did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching user: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("fetching user")?
|
||||
.ok_or(ApiError::InternalError(None))?;
|
||||
|
||||
let limit = params.limit.unwrap_or(500).clamp(1, 1000);
|
||||
@@ -244,10 +237,7 @@ pub async fn list_missing_blobs(
|
||||
.blob_repo
|
||||
.list_missing_blobs(user.id, cursor, limit + 1)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching missing blobs: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("fetching missing blobs")?;
|
||||
|
||||
let has_more = missing.len() > limit as usize;
|
||||
let blobs: Vec<RecordBlob> = missing
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::api::EmptyResponse;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use crate::api::repo::record::create_signed_commit;
|
||||
use crate::auth::{Auth, NotTakendown};
|
||||
use crate::state::AppState;
|
||||
@@ -49,10 +49,7 @@ pub async fn import_repo(
|
||||
.user_repo
|
||||
.get_by_did(did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching user: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("fetching user")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
if user.takedown_ref.is_some() {
|
||||
return Err(ApiError::AccountTakedown);
|
||||
@@ -207,10 +204,9 @@ pub async fn import_repo(
|
||||
let record_uri =
|
||||
AtUri::from_parts(did.as_str(), &record.collection, &record.rkey);
|
||||
record.blob_refs.iter().map(move |blob_ref| {
|
||||
(
|
||||
record_uri.clone(),
|
||||
CidLink::new_unchecked(blob_ref.cid.clone()),
|
||||
)
|
||||
(record_uri.clone(), unsafe {
|
||||
CidLink::new_unchecked(blob_ref.cid.clone())
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -275,7 +271,7 @@ pub async fn import_repo(
|
||||
error!("Failed to store new commit block: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let new_root_cid_link = CidLink::new_unchecked(new_root_cid.to_string());
|
||||
let new_root_cid_link = unsafe { CidLink::new_unchecked(new_root_cid.to_string()) };
|
||||
state
|
||||
.repo_repo
|
||||
.update_repo_root(user_id, &new_root_cid_link, &new_rev_str)
|
||||
@@ -368,8 +364,8 @@ async fn sequence_import_event(
|
||||
) -> Result<(), tranquil_db::DbError> {
|
||||
let data = tranquil_db::CommitEventData {
|
||||
did: did.clone(),
|
||||
event_type: "commit".to_string(),
|
||||
commit_cid: Some(CidLink::new_unchecked(commit_cid)),
|
||||
event_type: tranquil_db::RepoEventType::Commit,
|
||||
commit_cid: Some(unsafe { CidLink::new_unchecked(commit_cid) }),
|
||||
prev_cid: None,
|
||||
ops: Some(serde_json::json!([])),
|
||||
blobs: Some(vec![]),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::types::AtIdentifier;
|
||||
use crate::util::pds_hostname_without_port;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
@@ -18,8 +19,7 @@ pub async fn describe_repo(
|
||||
State(state): State<AppState>,
|
||||
Query(input): Query<DescribeRepoInput>,
|
||||
) -> Response {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let user_row = if input.repo.is_did() {
|
||||
let did: crate::types::Did = match input.repo.as_str().parse() {
|
||||
Ok(d) => d,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
use super::validation::validate_record_with_status;
|
||||
use super::validation_mode::{ValidationMode, deserialize_validation_mode};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
|
||||
use crate::auth::{Active, Auth};
|
||||
use crate::auth::{
|
||||
Active, Auth, WriteOpKind, require_not_migrated, require_verified_or_delegated,
|
||||
verify_batch_write_scopes,
|
||||
};
|
||||
use crate::cid_types::CommitCid;
|
||||
use crate::delegation::DelegationActionType;
|
||||
use crate::repo::tracking::TrackingBlockStore;
|
||||
use crate::state::AppState;
|
||||
@@ -34,7 +39,7 @@ async fn process_single_write(
|
||||
write: &WriteOp,
|
||||
acc: WriteAccumulator,
|
||||
did: &Did,
|
||||
validate: Option<bool>,
|
||||
validate: ValidationMode,
|
||||
tracking_store: &TrackingBlockStore,
|
||||
) -> Result<WriteAccumulator, Response> {
|
||||
let WriteAccumulator {
|
||||
@@ -51,19 +56,17 @@ async fn process_single_write(
|
||||
rkey,
|
||||
value,
|
||||
} => {
|
||||
let validation_status = match validate {
|
||||
Some(false) => None,
|
||||
_ => {
|
||||
let require_lexicon = validate == Some(true);
|
||||
match validate_record_with_status(
|
||||
value,
|
||||
collection,
|
||||
rkey.as_ref(),
|
||||
require_lexicon,
|
||||
) {
|
||||
Ok(status) => Some(status),
|
||||
Err(err_response) => return Err(*err_response),
|
||||
}
|
||||
let validation_status = if validate.should_skip() {
|
||||
None
|
||||
} else {
|
||||
match validate_record_with_status(
|
||||
value,
|
||||
collection,
|
||||
rkey.as_ref(),
|
||||
validate.requires_lexicon(),
|
||||
) {
|
||||
Ok(status) => Some(status),
|
||||
Err(err_response) => return Err(*err_response),
|
||||
}
|
||||
};
|
||||
all_blob_cids.extend(extract_blob_cids(value));
|
||||
@@ -104,19 +107,17 @@ async fn process_single_write(
|
||||
rkey,
|
||||
value,
|
||||
} => {
|
||||
let validation_status = match validate {
|
||||
Some(false) => None,
|
||||
_ => {
|
||||
let require_lexicon = validate == Some(true);
|
||||
match validate_record_with_status(
|
||||
value,
|
||||
collection,
|
||||
Some(rkey),
|
||||
require_lexicon,
|
||||
) {
|
||||
Ok(status) => Some(status),
|
||||
Err(err_response) => return Err(*err_response),
|
||||
}
|
||||
let validation_status = if validate.should_skip() {
|
||||
None
|
||||
} else {
|
||||
match validate_record_with_status(
|
||||
value,
|
||||
collection,
|
||||
Some(rkey),
|
||||
validate.requires_lexicon(),
|
||||
) {
|
||||
Ok(status) => Some(status),
|
||||
Err(err_response) => return Err(*err_response),
|
||||
}
|
||||
};
|
||||
all_blob_cids.extend(extract_blob_cids(value));
|
||||
@@ -181,7 +182,7 @@ async fn process_writes(
|
||||
writes: &[WriteOp],
|
||||
initial_mst: Mst<TrackingBlockStore>,
|
||||
did: &Did,
|
||||
validate: Option<bool>,
|
||||
validate: ValidationMode,
|
||||
tracking_store: &TrackingBlockStore,
|
||||
) -> Result<WriteAccumulator, Response> {
|
||||
use futures::stream::{self, TryStreamExt};
|
||||
@@ -222,7 +223,8 @@ pub enum WriteOp {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApplyWritesInput {
|
||||
pub repo: AtIdentifier,
|
||||
pub validate: Option<bool>,
|
||||
#[serde(default, deserialize_with = "deserialize_validation_mode")]
|
||||
pub validate: ValidationMode,
|
||||
pub writes: Vec<WriteOp>,
|
||||
pub swap_commit: Option<String>,
|
||||
}
|
||||
@@ -270,36 +272,7 @@ pub async fn apply_writes(
|
||||
input.repo,
|
||||
input.writes.len()
|
||||
);
|
||||
let did = auth.did.clone();
|
||||
let is_oauth = auth.is_oauth();
|
||||
let scope = auth.scope.clone();
|
||||
let controller_did = auth.controller_did.clone();
|
||||
if input.repo.as_str() != did {
|
||||
return Err(ApiError::InvalidRepo(
|
||||
"Repo does not match authenticated user".into(),
|
||||
));
|
||||
}
|
||||
if state
|
||||
.user_repo
|
||||
.is_account_migrated(&did)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(ApiError::AccountMigrated);
|
||||
}
|
||||
let is_verified = state
|
||||
.user_repo
|
||||
.has_verified_comms_channel(&did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let is_delegated = state
|
||||
.delegation_repo
|
||||
.is_delegated_account(&did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !is_verified && !is_delegated {
|
||||
return Err(ApiError::AccountNotVerified);
|
||||
}
|
||||
|
||||
if input.writes.is_empty() {
|
||||
return Err(ApiError::InvalidRequest("writes array is empty".into()));
|
||||
}
|
||||
@@ -310,74 +283,40 @@ pub async fn apply_writes(
|
||||
)));
|
||||
}
|
||||
|
||||
let has_custom_scope = scope
|
||||
.as_ref()
|
||||
.map(|s| s != "com.atproto.access")
|
||||
.unwrap_or(false);
|
||||
if is_oauth || has_custom_scope {
|
||||
use std::collections::HashSet;
|
||||
let create_collections: HashSet<&Nsid> = input
|
||||
.writes
|
||||
.iter()
|
||||
.filter_map(|w| {
|
||||
if let WriteOp::Create { collection, .. } = w {
|
||||
Some(collection)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let update_collections: HashSet<&Nsid> = input
|
||||
.writes
|
||||
.iter()
|
||||
.filter_map(|w| {
|
||||
if let WriteOp::Update { collection, .. } = w {
|
||||
Some(collection)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let delete_collections: HashSet<&Nsid> = input
|
||||
.writes
|
||||
.iter()
|
||||
.filter_map(|w| {
|
||||
if let WriteOp::Delete { collection, .. } = w {
|
||||
Some(collection)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let batch_proof = match verify_batch_write_scopes(
|
||||
&auth,
|
||||
&auth,
|
||||
&input.writes,
|
||||
|w| match w {
|
||||
WriteOp::Create { collection, .. } => collection.as_str(),
|
||||
WriteOp::Update { collection, .. } => collection.as_str(),
|
||||
WriteOp::Delete { collection, .. } => collection.as_str(),
|
||||
},
|
||||
|w| match w {
|
||||
WriteOp::Create { .. } => WriteOpKind::Create,
|
||||
WriteOp::Update { .. } => WriteOpKind::Update,
|
||||
WriteOp::Delete { .. } => WriteOpKind::Delete,
|
||||
},
|
||||
) {
|
||||
Ok(proof) => proof,
|
||||
Err(e) => return Ok(e.into_response()),
|
||||
};
|
||||
|
||||
let scope_checks = create_collections
|
||||
.iter()
|
||||
.map(|c| (crate::oauth::RepoAction::Create, c))
|
||||
.chain(
|
||||
update_collections
|
||||
.iter()
|
||||
.map(|c| (crate::oauth::RepoAction::Update, c)),
|
||||
)
|
||||
.chain(
|
||||
delete_collections
|
||||
.iter()
|
||||
.map(|c| (crate::oauth::RepoAction::Delete, c)),
|
||||
);
|
||||
let principal_did = batch_proof.principal_did();
|
||||
let controller_did = batch_proof.controller_did().map(|c| c.into_did());
|
||||
|
||||
if let Some(err) = scope_checks
|
||||
.filter_map(|(action, collection)| {
|
||||
crate::auth::scope_check::check_repo_scope(
|
||||
is_oauth,
|
||||
scope.as_deref(),
|
||||
action,
|
||||
collection,
|
||||
)
|
||||
.err()
|
||||
})
|
||||
.next()
|
||||
{
|
||||
return Ok(err);
|
||||
}
|
||||
if input.repo.as_str() != principal_did.as_str() {
|
||||
return Err(ApiError::InvalidRepo(
|
||||
"Repo does not match authenticated user".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let did = principal_did.into_did();
|
||||
if let Err(e) = require_not_migrated(&state, &did).await {
|
||||
return Ok(e);
|
||||
}
|
||||
if let Err(e) = require_verified_or_delegated(&state, batch_proof.user()).await {
|
||||
return Ok(e);
|
||||
}
|
||||
|
||||
let user_id: uuid::Uuid = state
|
||||
@@ -394,16 +333,16 @@ pub async fn apply_writes(
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| ApiError::InternalError(Some("Repo root not found".into())))?;
|
||||
let current_root_cid = Cid::from_str(&root_cid_str)
|
||||
let current_root_cid = CommitCid::from_str(&root_cid_str)
|
||||
.map_err(|_| ApiError::InternalError(Some("Invalid repo root CID".into())))?;
|
||||
if let Some(swap_commit) = &input.swap_commit
|
||||
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
|
||||
&& CommitCid::from_str(swap_commit).ok().as_ref() != Some(¤t_root_cid)
|
||||
{
|
||||
return Err(ApiError::InvalidSwap(Some("Repo has been modified".into())));
|
||||
}
|
||||
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
|
||||
let commit_bytes = tracking_store
|
||||
.get(¤t_root_cid)
|
||||
.get(current_root_cid.as_cid())
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
@@ -471,7 +410,7 @@ pub async fn apply_writes(
|
||||
} => Some(*cid),
|
||||
_ => None,
|
||||
});
|
||||
let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid)
|
||||
let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid.into_cid())
|
||||
.chain(
|
||||
old_mst_blocks
|
||||
.keys()
|
||||
@@ -487,7 +426,7 @@ pub async fn apply_writes(
|
||||
CommitParams {
|
||||
did: &did,
|
||||
user_id,
|
||||
current_root_cid: Some(current_root_cid),
|
||||
current_root_cid: Some(current_root_cid.into_cid()),
|
||||
prev_data_cid: Some(commit.data),
|
||||
new_mst_root,
|
||||
ops,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
|
||||
use crate::api::repo::record::write::{CommitInfo, prepare_repo_write};
|
||||
use crate::auth::{Active, Auth};
|
||||
use crate::auth::{Active, Auth, VerifyScope};
|
||||
use crate::cid_types::CommitCid;
|
||||
use crate::delegation::DelegationActionType;
|
||||
use crate::repo::tracking::TrackingBlockStore;
|
||||
use crate::state::AppState;
|
||||
@@ -43,32 +44,28 @@ pub async fn delete_record(
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<DeleteRecordInput>,
|
||||
) -> Result<Response, crate::api::error::ApiError> {
|
||||
let repo_auth = match prepare_repo_write(&state, &auth, &input.repo).await {
|
||||
let scope_proof = match auth.verify_repo_delete(&input.collection) {
|
||||
Ok(proof) => proof,
|
||||
Err(e) => return Ok(e.into_response()),
|
||||
};
|
||||
|
||||
let repo_auth = match prepare_repo_write(&state, &scope_proof, &input.repo).await {
|
||||
Ok(res) => res,
|
||||
Err(err_res) => return Ok(err_res),
|
||||
};
|
||||
|
||||
if let Err(e) = crate::auth::scope_check::check_repo_scope(
|
||||
repo_auth.is_oauth,
|
||||
repo_auth.scope.as_deref(),
|
||||
crate::oauth::RepoAction::Delete,
|
||||
&input.collection,
|
||||
) {
|
||||
return Ok(e);
|
||||
}
|
||||
|
||||
let did = repo_auth.did;
|
||||
let user_id = repo_auth.user_id;
|
||||
let current_root_cid = repo_auth.current_root_cid;
|
||||
let controller_did = repo_auth.controller_did;
|
||||
|
||||
if let Some(swap_commit) = &input.swap_commit
|
||||
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
|
||||
&& CommitCid::from_str(swap_commit).ok().as_ref() != Some(¤t_root_cid)
|
||||
{
|
||||
return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response());
|
||||
}
|
||||
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
|
||||
let commit_bytes = match tracking_store.get(¤t_root_cid).await {
|
||||
let commit_bytes = match tracking_store.get(current_root_cid.as_cid()).await {
|
||||
Ok(Some(b)) => b,
|
||||
_ => {
|
||||
return Ok(
|
||||
@@ -159,7 +156,7 @@ pub async fn delete_record(
|
||||
.into_iter()
|
||||
.collect();
|
||||
let written_cids_str: Vec<String> = written_cids.iter().map(|c| c.to_string()).collect();
|
||||
let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid)
|
||||
let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid.into_cid())
|
||||
.chain(
|
||||
old_mst_blocks
|
||||
.keys()
|
||||
@@ -173,7 +170,7 @@ pub async fn delete_record(
|
||||
CommitParams {
|
||||
did: &did,
|
||||
user_id,
|
||||
current_root_cid: Some(current_root_cid),
|
||||
current_root_cid: Some(current_root_cid.into_cid()),
|
||||
prev_data_cid: Some(commit.data),
|
||||
new_mst_root,
|
||||
ops: vec![op],
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
pub mod batch;
|
||||
pub mod delete;
|
||||
pub mod pagination;
|
||||
pub mod read;
|
||||
pub mod utils;
|
||||
pub mod validation;
|
||||
pub mod validation_mode;
|
||||
pub mod write;
|
||||
|
||||
pub use pagination::PaginationDirection;
|
||||
pub use validation_mode::ValidationMode;
|
||||
|
||||
pub use batch::apply_writes;
|
||||
pub use delete::{DeleteRecordInput, delete_record, delete_record_internal};
|
||||
pub use read::{GetRecordInput, ListRecordsInput, ListRecordsOutput, get_record, list_records};
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
use serde::{Deserialize, Deserializer};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum PaginationDirection {
|
||||
#[default]
|
||||
Forward,
|
||||
Backward,
|
||||
}
|
||||
|
||||
impl PaginationDirection {
|
||||
pub fn from_optional_bool(value: Option<bool>) -> Self {
|
||||
match value {
|
||||
Some(true) => Self::Backward,
|
||||
Some(false) | None => Self::Forward,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_reverse(&self) -> bool {
|
||||
matches!(self, Self::Backward)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize_pagination_direction<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<PaginationDirection, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let opt: Option<bool> = Option::deserialize(deserializer)?;
|
||||
Ok(PaginationDirection::from_optional_bool(opt))
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::pagination::{PaginationDirection, deserialize_pagination_direction};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::types::{AtIdentifier, Nsid, Rkey};
|
||||
use crate::util::pds_hostname_without_port;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
@@ -58,8 +60,7 @@ pub async fn get_record(
|
||||
_headers: HeaderMap,
|
||||
Query(input): Query<GetRecordInput>,
|
||||
) -> Response {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let user_id_opt = if input.repo.is_did() {
|
||||
let did: crate::types::Did = match input.repo.as_str().parse() {
|
||||
Ok(d) => d,
|
||||
@@ -144,7 +145,8 @@ pub struct ListRecordsInput {
|
||||
pub rkey_start: Option<Rkey>,
|
||||
#[serde(rename = "rkeyEnd")]
|
||||
pub rkey_end: Option<Rkey>,
|
||||
pub reverse: Option<bool>,
|
||||
#[serde(default, deserialize_with = "deserialize_pagination_direction")]
|
||||
pub reverse: PaginationDirection,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
pub struct ListRecordsOutput {
|
||||
@@ -157,8 +159,7 @@ pub async fn list_records(
|
||||
State(state): State<AppState>,
|
||||
Query(input): Query<ListRecordsInput>,
|
||||
) -> Response {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let user_id_opt = if input.repo.is_did() {
|
||||
let did: crate::types::Did = match input.repo.as_str().parse() {
|
||||
Ok(d) => d,
|
||||
@@ -194,7 +195,6 @@ pub async fn list_records(
|
||||
}
|
||||
};
|
||||
let limit = input.limit.unwrap_or(50).clamp(1, 100);
|
||||
let reverse = input.reverse.unwrap_or(false);
|
||||
let limit_i64 = limit as i64;
|
||||
let cursor_rkey = input
|
||||
.cursor
|
||||
@@ -207,7 +207,7 @@ pub async fn list_records(
|
||||
&input.collection,
|
||||
cursor_rkey.as_ref(),
|
||||
limit_i64,
|
||||
reverse,
|
||||
input.reverse.is_reverse(),
|
||||
input.rkey_start.as_ref(),
|
||||
input.rkey_end.as_ref(),
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ use jacquard_repo::storage::BlockStore;
|
||||
use k256::ecdsa::SigningKey;
|
||||
use serde_json::{Value, json};
|
||||
use std::str::FromStr;
|
||||
use tranquil_db_traits::SequenceNumber;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn extract_blob_cids(record: &Value) -> Vec<String> {
|
||||
@@ -139,6 +140,7 @@ pub async fn commit_and_log(
|
||||
) -> Result<CommitResult, String> {
|
||||
use tranquil_db_traits::{
|
||||
ApplyCommitError, ApplyCommitInput, CommitEventData, RecordDelete, RecordUpsert,
|
||||
RepoEventType,
|
||||
};
|
||||
|
||||
let CommitParams {
|
||||
@@ -199,7 +201,7 @@ pub async fn commit_and_log(
|
||||
upserts.push(RecordUpsert {
|
||||
collection: collection.clone(),
|
||||
rkey: rkey.clone(),
|
||||
cid: crate::types::CidLink::new_unchecked(cid.to_string()),
|
||||
cid: unsafe { crate::types::CidLink::new_unchecked(cid.to_string()) },
|
||||
});
|
||||
}
|
||||
RecordOp::Delete {
|
||||
@@ -263,15 +265,15 @@ pub async fn commit_and_log(
|
||||
|
||||
let commit_event = CommitEventData {
|
||||
did: did.clone(),
|
||||
event_type: "commit".to_string(),
|
||||
commit_cid: Some(crate::types::CidLink::new_unchecked(
|
||||
new_root_cid.to_string(),
|
||||
)),
|
||||
prev_cid: current_root_cid.map(|c| crate::types::CidLink::new_unchecked(c.to_string())),
|
||||
event_type: RepoEventType::Commit,
|
||||
commit_cid: Some(unsafe { crate::types::CidLink::new_unchecked(new_root_cid.to_string()) }),
|
||||
prev_cid: current_root_cid
|
||||
.map(|c| unsafe { crate::types::CidLink::new_unchecked(c.to_string()) }),
|
||||
ops: Some(json!(ops_json)),
|
||||
blobs: Some(blobs.to_vec()),
|
||||
blocks_cids: Some(blocks_cids.to_vec()),
|
||||
prev_data_cid: prev_data_cid.map(|c| crate::types::CidLink::new_unchecked(c.to_string())),
|
||||
prev_data_cid: prev_data_cid
|
||||
.map(|c| unsafe { crate::types::CidLink::new_unchecked(c.to_string()) }),
|
||||
rev: Some(rev_str.clone()),
|
||||
};
|
||||
|
||||
@@ -279,8 +281,8 @@ pub async fn commit_and_log(
|
||||
user_id,
|
||||
did: did.clone(),
|
||||
expected_root_cid: current_root_cid
|
||||
.map(|c| crate::types::CidLink::new_unchecked(c.to_string())),
|
||||
new_root_cid: crate::types::CidLink::new_unchecked(new_root_cid.to_string()),
|
||||
.map(|c| unsafe { crate::types::CidLink::new_unchecked(c.to_string()) }),
|
||||
new_root_cid: unsafe { crate::types::CidLink::new_unchecked(new_root_cid.to_string()) },
|
||||
new_rev: rev_str.clone(),
|
||||
new_block_cids: all_block_cids,
|
||||
obsolete_block_cids: obsolete_bytes,
|
||||
@@ -417,7 +419,7 @@ pub async fn sequence_identity_event(
|
||||
state: &AppState,
|
||||
did: &Did,
|
||||
handle: Option<&Handle>,
|
||||
) -> Result<i64, String> {
|
||||
) -> Result<SequenceNumber, String> {
|
||||
state
|
||||
.repo_repo
|
||||
.insert_identity_event(did, handle)
|
||||
@@ -427,12 +429,11 @@ pub async fn sequence_identity_event(
|
||||
pub async fn sequence_account_event(
|
||||
state: &AppState,
|
||||
did: &Did,
|
||||
active: bool,
|
||||
status: Option<&str>,
|
||||
) -> Result<i64, String> {
|
||||
status: tranquil_db_traits::AccountStatus,
|
||||
) -> Result<SequenceNumber, String> {
|
||||
state
|
||||
.repo_repo
|
||||
.insert_account_event(did, active, status)
|
||||
.insert_account_event(did, status)
|
||||
.await
|
||||
.map_err(|e| format!("DB Error (account event): {}", e))
|
||||
}
|
||||
@@ -441,8 +442,8 @@ pub async fn sequence_sync_event(
|
||||
did: &Did,
|
||||
commit_cid: &str,
|
||||
rev: Option<&str>,
|
||||
) -> Result<i64, String> {
|
||||
let cid_link = crate::types::CidLink::new_unchecked(commit_cid);
|
||||
) -> Result<SequenceNumber, String> {
|
||||
let cid_link = unsafe { crate::types::CidLink::new_unchecked(commit_cid) };
|
||||
state
|
||||
.repo_repo
|
||||
.insert_sync_event(did, &cid_link, rev)
|
||||
@@ -456,9 +457,10 @@ pub async fn sequence_genesis_commit(
|
||||
commit_cid: &Cid,
|
||||
mst_root_cid: &Cid,
|
||||
rev: &str,
|
||||
) -> Result<i64, String> {
|
||||
let commit_cid_link = crate::types::CidLink::new_unchecked(commit_cid.to_string());
|
||||
let mst_root_cid_link = crate::types::CidLink::new_unchecked(mst_root_cid.to_string());
|
||||
) -> Result<SequenceNumber, String> {
|
||||
let commit_cid_link = unsafe { crate::types::CidLink::new_unchecked(commit_cid.to_string()) };
|
||||
let mst_root_cid_link =
|
||||
unsafe { crate::types::CidLink::new_unchecked(mst_root_cid.to_string()) };
|
||||
state
|
||||
.repo_repo
|
||||
.insert_genesis_commit_event(did, &commit_cid_link, &mst_root_cid_link, rev)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
use serde::{Deserialize, Deserializer};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum ValidationMode {
|
||||
Skip,
|
||||
#[default]
|
||||
Infer,
|
||||
Strict,
|
||||
}
|
||||
|
||||
impl ValidationMode {
|
||||
pub fn from_optional_bool(value: Option<bool>) -> Self {
|
||||
match value {
|
||||
Some(false) => Self::Skip,
|
||||
Some(true) => Self::Strict,
|
||||
None => Self::Infer,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_skip(&self) -> bool {
|
||||
matches!(self, Self::Skip)
|
||||
}
|
||||
|
||||
pub fn requires_lexicon(&self) -> bool {
|
||||
matches!(self, Self::Strict)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize_validation_mode<'de, D>(deserializer: D) -> Result<ValidationMode, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let opt: Option<bool> = Option::deserialize(deserializer)?;
|
||||
Ok(ValidationMode::from_optional_bool(opt))
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
use super::validation::validate_record_with_status;
|
||||
use super::validation_mode::{ValidationMode, deserialize_validation_mode};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::repo::record::utils::{
|
||||
CommitParams, RecordOp, commit_and_log, extract_backlinks, extract_blob_cids,
|
||||
};
|
||||
use crate::auth::{Active, Auth};
|
||||
use crate::auth::{
|
||||
Active, Auth, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated,
|
||||
require_verified_or_delegated,
|
||||
};
|
||||
use crate::cid_types::CommitCid;
|
||||
use crate::delegation::DelegationActionType;
|
||||
use crate::repo::tracking::TrackingBlockStore;
|
||||
use crate::state::AppState;
|
||||
@@ -26,46 +31,31 @@ use uuid::Uuid;
|
||||
pub struct RepoWriteAuth {
|
||||
pub did: Did,
|
||||
pub user_id: Uuid,
|
||||
pub current_root_cid: Cid,
|
||||
pub current_root_cid: CommitCid,
|
||||
pub is_oauth: bool,
|
||||
pub scope: Option<String>,
|
||||
pub controller_did: Option<Did>,
|
||||
}
|
||||
|
||||
pub async fn prepare_repo_write(
|
||||
pub async fn prepare_repo_write<A: RepoScopeAction>(
|
||||
state: &AppState,
|
||||
auth_user: &crate::auth::AuthenticatedUser,
|
||||
scope_proof: &ScopeVerified<'_, A>,
|
||||
repo: &AtIdentifier,
|
||||
) -> Result<RepoWriteAuth, Response> {
|
||||
if repo.as_str() != auth_user.did.as_str() {
|
||||
let user = scope_proof.user();
|
||||
let principal_did = scope_proof.principal_did();
|
||||
if repo.as_str() != principal_did.as_str() {
|
||||
return Err(
|
||||
ApiError::InvalidRepo("Repo does not match authenticated user".into()).into_response(),
|
||||
);
|
||||
}
|
||||
if state
|
||||
.user_repo
|
||||
.is_account_migrated(&auth_user.did)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(ApiError::AccountMigrated.into_response());
|
||||
}
|
||||
let is_verified = state
|
||||
.user_repo
|
||||
.has_verified_comms_channel(&auth_user.did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let is_delegated = state
|
||||
.delegation_repo
|
||||
.is_delegated_account(&auth_user.did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !is_verified && !is_delegated {
|
||||
return Err(ApiError::AccountNotVerified.into_response());
|
||||
}
|
||||
|
||||
require_not_migrated(state, principal_did.as_did()).await?;
|
||||
let _account_verified = require_verified_or_delegated(state, user).await?;
|
||||
|
||||
let user_id = state
|
||||
.user_repo
|
||||
.get_id_by_did(&auth_user.did)
|
||||
.get_id_by_did(principal_did.as_did())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching user: {}", e);
|
||||
@@ -83,16 +73,16 @@ pub async fn prepare_repo_write(
|
||||
.ok_or_else(|| {
|
||||
ApiError::InternalError(Some("Repo root not found".into())).into_response()
|
||||
})?;
|
||||
let current_root_cid = Cid::from_str(&root_cid_str).map_err(|_| {
|
||||
let current_root_cid = CommitCid::from_str(&root_cid_str).map_err(|_| {
|
||||
ApiError::InternalError(Some("Invalid repo root CID".into())).into_response()
|
||||
})?;
|
||||
Ok(RepoWriteAuth {
|
||||
did: auth_user.did.clone(),
|
||||
did: principal_did.into_did(),
|
||||
user_id,
|
||||
current_root_cid,
|
||||
is_oauth: auth_user.is_oauth(),
|
||||
scope: auth_user.scope.clone(),
|
||||
controller_did: auth_user.controller_did.clone(),
|
||||
is_oauth: user.is_oauth(),
|
||||
scope: user.scope.clone(),
|
||||
controller_did: scope_proof.controller_did().map(|c| c.into_did()),
|
||||
})
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
@@ -101,7 +91,8 @@ pub struct CreateRecordInput {
|
||||
pub repo: AtIdentifier,
|
||||
pub collection: Nsid,
|
||||
pub rkey: Option<Rkey>,
|
||||
pub validate: Option<bool>,
|
||||
#[serde(default, deserialize_with = "deserialize_validation_mode")]
|
||||
pub validate: ValidationMode,
|
||||
pub record: serde_json::Value,
|
||||
#[serde(rename = "swapCommit")]
|
||||
pub swap_commit: Option<String>,
|
||||
@@ -127,40 +118,35 @@ pub async fn create_record(
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<CreateRecordInput>,
|
||||
) -> Result<Response, crate::api::error::ApiError> {
|
||||
let repo_auth = match prepare_repo_write(&state, &auth, &input.repo).await {
|
||||
let scope_proof = match auth.verify_repo_create(&input.collection) {
|
||||
Ok(proof) => proof,
|
||||
Err(e) => return Ok(e.into_response()),
|
||||
};
|
||||
|
||||
let repo_auth = match prepare_repo_write(&state, &scope_proof, &input.repo).await {
|
||||
Ok(res) => res,
|
||||
Err(err_res) => return Ok(err_res),
|
||||
};
|
||||
|
||||
if let Err(e) = crate::auth::scope_check::check_repo_scope(
|
||||
repo_auth.is_oauth,
|
||||
repo_auth.scope.as_deref(),
|
||||
crate::oauth::RepoAction::Create,
|
||||
&input.collection,
|
||||
) {
|
||||
return Ok(e);
|
||||
}
|
||||
|
||||
let did = repo_auth.did;
|
||||
let user_id = repo_auth.user_id;
|
||||
let current_root_cid = repo_auth.current_root_cid;
|
||||
let controller_did = repo_auth.controller_did;
|
||||
|
||||
if let Some(swap_commit) = &input.swap_commit
|
||||
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
|
||||
&& CommitCid::from_str(swap_commit).ok().as_ref() != Some(¤t_root_cid)
|
||||
{
|
||||
return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response());
|
||||
}
|
||||
|
||||
let validation_status = if input.validate == Some(false) {
|
||||
let validation_status = if input.validate.should_skip() {
|
||||
None
|
||||
} else {
|
||||
let require_lexicon = input.validate == Some(true);
|
||||
match validate_record_with_status(
|
||||
&input.record,
|
||||
&input.collection,
|
||||
input.rkey.as_ref(),
|
||||
require_lexicon,
|
||||
input.validate.requires_lexicon(),
|
||||
) {
|
||||
Ok(status) => Some(status),
|
||||
Err(err_response) => return Ok(*err_response),
|
||||
@@ -169,7 +155,7 @@ pub async fn create_record(
|
||||
let rkey = input.rkey.unwrap_or_else(Rkey::generate);
|
||||
|
||||
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
|
||||
let commit_bytes = match tracking_store.get(¤t_root_cid).await {
|
||||
let commit_bytes = match tracking_store.get(current_root_cid.as_cid()).await {
|
||||
Ok(Some(b)) => b,
|
||||
_ => {
|
||||
return Ok(
|
||||
@@ -192,7 +178,7 @@ pub async fn create_record(
|
||||
let mut conflict_uris_to_cleanup: Vec<AtUri> = Vec::new();
|
||||
let mut all_old_mst_blocks = std::collections::BTreeMap::new();
|
||||
|
||||
if input.validate != Some(false) {
|
||||
if !input.validate.should_skip() {
|
||||
let record_uri = AtUri::from_parts(&did, &input.collection, &rkey);
|
||||
let backlinks = extract_backlinks(&record_uri, &input.record);
|
||||
|
||||
@@ -323,7 +309,7 @@ pub async fn create_record(
|
||||
.collect();
|
||||
let written_cids_str: Vec<String> = written_cids.iter().map(|c| c.to_string()).collect();
|
||||
let blob_cids = extract_blob_cids(&input.record);
|
||||
let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid)
|
||||
let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid.into_cid())
|
||||
.chain(
|
||||
all_old_mst_blocks
|
||||
.keys()
|
||||
@@ -337,7 +323,7 @@ pub async fn create_record(
|
||||
CommitParams {
|
||||
did: &did,
|
||||
user_id,
|
||||
current_root_cid: Some(current_root_cid),
|
||||
current_root_cid: Some(current_root_cid.into_cid()),
|
||||
prev_data_cid: Some(initial_mst_root),
|
||||
new_mst_root,
|
||||
ops,
|
||||
@@ -412,7 +398,8 @@ pub struct PutRecordInput {
|
||||
pub repo: AtIdentifier,
|
||||
pub collection: Nsid,
|
||||
pub rkey: Rkey,
|
||||
pub validate: Option<bool>,
|
||||
#[serde(default, deserialize_with = "deserialize_validation_mode")]
|
||||
pub validate: ValidationMode,
|
||||
pub record: serde_json::Value,
|
||||
#[serde(rename = "swapCommit")]
|
||||
pub swap_commit: Option<String>,
|
||||
@@ -434,40 +421,28 @@ pub async fn put_record(
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<PutRecordInput>,
|
||||
) -> Result<Response, crate::api::error::ApiError> {
|
||||
let repo_auth = match prepare_repo_write(&state, &auth, &input.repo).await {
|
||||
let upsert_proof = match auth.verify_repo_upsert(&input.collection) {
|
||||
Ok(proof) => proof,
|
||||
Err(e) => return Ok(e.into_response()),
|
||||
};
|
||||
|
||||
let repo_auth = match prepare_repo_write(&state, &upsert_proof, &input.repo).await {
|
||||
Ok(res) => res,
|
||||
Err(err_res) => return Ok(err_res),
|
||||
};
|
||||
|
||||
if let Err(e) = crate::auth::scope_check::check_repo_scope(
|
||||
repo_auth.is_oauth,
|
||||
repo_auth.scope.as_deref(),
|
||||
crate::oauth::RepoAction::Create,
|
||||
&input.collection,
|
||||
) {
|
||||
return Ok(e);
|
||||
}
|
||||
if let Err(e) = crate::auth::scope_check::check_repo_scope(
|
||||
repo_auth.is_oauth,
|
||||
repo_auth.scope.as_deref(),
|
||||
crate::oauth::RepoAction::Update,
|
||||
&input.collection,
|
||||
) {
|
||||
return Ok(e);
|
||||
}
|
||||
|
||||
let did = repo_auth.did;
|
||||
let user_id = repo_auth.user_id;
|
||||
let current_root_cid = repo_auth.current_root_cid;
|
||||
let controller_did = repo_auth.controller_did;
|
||||
|
||||
if let Some(swap_commit) = &input.swap_commit
|
||||
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
|
||||
&& CommitCid::from_str(swap_commit).ok().as_ref() != Some(¤t_root_cid)
|
||||
{
|
||||
return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response());
|
||||
}
|
||||
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
|
||||
let commit_bytes = match tracking_store.get(¤t_root_cid).await {
|
||||
let commit_bytes = match tracking_store.get(current_root_cid.as_cid()).await {
|
||||
Ok(Some(b)) => b,
|
||||
_ => {
|
||||
return Ok(
|
||||
@@ -485,15 +460,14 @@ pub async fn put_record(
|
||||
};
|
||||
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
|
||||
let key = format!("{}/{}", input.collection, input.rkey);
|
||||
let validation_status = if input.validate == Some(false) {
|
||||
let validation_status = if input.validate.should_skip() {
|
||||
None
|
||||
} else {
|
||||
let require_lexicon = input.validate == Some(true);
|
||||
match validate_record_with_status(
|
||||
&input.record,
|
||||
&input.collection,
|
||||
Some(&input.rkey),
|
||||
require_lexicon,
|
||||
input.validate.requires_lexicon(),
|
||||
) {
|
||||
Ok(status) => Some(status),
|
||||
Err(err_response) => return Ok(*err_response),
|
||||
@@ -610,7 +584,7 @@ pub async fn put_record(
|
||||
let written_cids_str: Vec<String> = written_cids.iter().map(|c| c.to_string()).collect();
|
||||
let is_update = existing_cid.is_some();
|
||||
let blob_cids = extract_blob_cids(&input.record);
|
||||
let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid)
|
||||
let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid.into_cid())
|
||||
.chain(
|
||||
old_mst_blocks
|
||||
.keys()
|
||||
@@ -624,7 +598,7 @@ pub async fn put_record(
|
||||
CommitParams {
|
||||
did: &did,
|
||||
user_id,
|
||||
current_root_cid: Some(current_root_cid),
|
||||
current_root_cid: Some(current_root_cid.into_cid()),
|
||||
prev_data_cid: Some(commit.data),
|
||||
new_mst_root,
|
||||
ops: vec![op],
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use crate::api::EmptyResponse;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::auth::{Auth, NotTakendown, Permissive};
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use crate::auth::{Auth, NotTakendown, Permissive, require_legacy_session_mfa};
|
||||
use crate::cache::Cache;
|
||||
use crate::plc::PlcClient;
|
||||
use crate::state::AppState;
|
||||
use crate::types::PlainPassword;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -130,7 +131,7 @@ async fn assert_valid_did_document_for_service(
|
||||
did: &crate::types::Did,
|
||||
with_retry: bool,
|
||||
) -> Result<(), ApiError> {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname = pds_hostname();
|
||||
let expected_endpoint = format!("https://{}", hostname);
|
||||
|
||||
if did.as_str().starts_with("did:plc:") {
|
||||
@@ -219,10 +220,10 @@ async fn assert_valid_did_document_for_service(
|
||||
.and_then(|v| v.get("atproto"))
|
||||
.and_then(|k| k.as_str());
|
||||
|
||||
let user_key = user_repo.get_user_key_by_did(did).await.map_err(|e| {
|
||||
error!("Failed to fetch user key: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let user_key = user_repo
|
||||
.get_user_key_by_did(did)
|
||||
.await
|
||||
.log_db_err("fetching user key")?;
|
||||
|
||||
if let Some(key_info) = user_key {
|
||||
let key_bytes =
|
||||
@@ -379,8 +380,12 @@ pub async fn activate_account(
|
||||
"[MIGRATION] activateAccount: Sequencing account event (active=true) for did={}",
|
||||
did
|
||||
);
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(
|
||||
&state,
|
||||
&did,
|
||||
tranquil_db_traits::AccountStatus::Active,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"[MIGRATION] activateAccount: Failed to sequence account activation event: {}",
|
||||
@@ -502,8 +507,7 @@ pub async fn deactivate_account(
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(
|
||||
&state,
|
||||
&did,
|
||||
false,
|
||||
Some("deactivated"),
|
||||
tranquil_db_traits::AccountStatus::Deactivated,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -523,20 +527,14 @@ pub async fn request_account_delete(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<NotTakendown>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let did = &auth.did;
|
||||
|
||||
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, did).await {
|
||||
return Ok(crate::api::server::reauth::legacy_mfa_required_response(
|
||||
&*state.user_repo,
|
||||
&*state.session_repo,
|
||||
did,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
let session_mfa = match require_legacy_session_mfa(&state, &auth).await {
|
||||
Ok(proof) => proof,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
let user_id = state
|
||||
.user_repo
|
||||
.get_id_by_did(did)
|
||||
.get_id_by_did(session_mfa.did())
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
@@ -545,25 +543,22 @@ pub async fn request_account_delete(
|
||||
let expires_at = Utc::now() + Duration::minutes(15);
|
||||
state
|
||||
.infra_repo
|
||||
.create_deletion_request(&confirmation_token, did, expires_at)
|
||||
.create_deletion_request(&confirmation_token, session_mfa.did(), expires_at)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error creating deletion token: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
.log_db_err("creating deletion token")?;
|
||||
let hostname = pds_hostname();
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_account_deletion(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
user_id,
|
||||
&confirmation_token,
|
||||
&hostname,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to enqueue account deletion notification: {:?}", e);
|
||||
}
|
||||
info!("Account deletion requested for user {}", did);
|
||||
info!("Account deletion requested for user {}", session_mfa.did());
|
||||
Ok(EmptyResponse::ok().into_response())
|
||||
}
|
||||
|
||||
@@ -642,8 +637,12 @@ pub async fn delete_account(
|
||||
error!("DB error deleting account: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let account_seq =
|
||||
crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await;
|
||||
let account_seq = crate::api::repo::record::sequence_account_event(
|
||||
&state,
|
||||
did,
|
||||
tranquil_db_traits::AccountStatus::Deleted,
|
||||
)
|
||||
.await;
|
||||
match account_seq {
|
||||
Ok(seq) => {
|
||||
if let Err(e) = state.repo_repo.delete_sequences_except(did, seq).await {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
use crate::api::EmptyResponse;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use crate::auth::{Auth, NotTakendown, Permissive, generate_app_password};
|
||||
use crate::delegation::{DelegationActionType, intersect_scopes};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::rate_limit::{AppPasswordLimit, RateLimited};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, warn};
|
||||
use tracing::error;
|
||||
use tranquil_db_traits::AppPasswordCreate;
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -39,26 +39,20 @@ pub async fn list_app_passwords(
|
||||
.user_repo
|
||||
.get_by_did(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error getting user: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("getting user")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let rows = state
|
||||
.session_repo
|
||||
.list_app_passwords(user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error listing app passwords: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("listing app passwords")?;
|
||||
let passwords: Vec<AppPassword> = rows
|
||||
.iter()
|
||||
.map(|row| AppPassword {
|
||||
name: row.name.clone(),
|
||||
created_at: row.created_at.to_rfc3339(),
|
||||
privileged: row.privileged,
|
||||
privileged: row.privilege.is_privileged(),
|
||||
scopes: row.scopes.clone(),
|
||||
created_by_controller: row
|
||||
.created_by_controller_did
|
||||
@@ -89,27 +83,15 @@ pub struct CreateAppPasswordOutput {
|
||||
|
||||
pub async fn create_app_password(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
_rate_limit: RateLimited<AppPasswordLimit>,
|
||||
auth: Auth<NotTakendown>,
|
||||
Json(input): Json<CreateAppPasswordInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::AppPassword, &client_ip)
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "App password creation rate limit exceeded");
|
||||
return Err(ApiError::RateLimitExceeded(None));
|
||||
}
|
||||
|
||||
let user = state
|
||||
.user_repo
|
||||
.get_by_did(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error getting user: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("getting user")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let name = input.name.trim();
|
||||
@@ -121,10 +103,7 @@ pub async fn create_app_password(
|
||||
.session_repo
|
||||
.get_app_password_by_name(user.id, name)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error checking app password: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("checking app password")?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ApiError::DuplicateAppPassword);
|
||||
@@ -140,7 +119,7 @@ pub async fn create_app_password(
|
||||
let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default();
|
||||
|
||||
let requested = input.scopes.as_deref().unwrap_or("atproto");
|
||||
let intersected = intersect_scopes(requested, &granted_scopes);
|
||||
let intersected = intersect_scopes(requested, granted_scopes.as_str());
|
||||
|
||||
if intersected.is_empty() && !granted_scopes.is_empty() {
|
||||
return Err(ApiError::InsufficientScope(None));
|
||||
@@ -171,14 +150,15 @@ pub async fn create_app_password(
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
let privileged = input.privileged.unwrap_or(false);
|
||||
let privilege =
|
||||
tranquil_db_traits::AppPasswordPrivilege::from(input.privileged.unwrap_or(false));
|
||||
let created_at = chrono::Utc::now();
|
||||
|
||||
let create_data = AppPasswordCreate {
|
||||
user_id: user.id,
|
||||
name: name.to_string(),
|
||||
password_hash,
|
||||
privileged,
|
||||
privilege,
|
||||
scopes: final_scopes.clone(),
|
||||
created_by_controller_did: controller_did.clone(),
|
||||
};
|
||||
@@ -187,10 +167,7 @@ pub async fn create_app_password(
|
||||
.session_repo
|
||||
.create_app_password(&create_data)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error creating app password: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("creating app password")?;
|
||||
|
||||
if let Some(ref controller) = controller_did {
|
||||
let _ = state
|
||||
@@ -214,7 +191,7 @@ pub async fn create_app_password(
|
||||
name: name.to_string(),
|
||||
password,
|
||||
created_at: created_at.to_rfc3339(),
|
||||
privileged,
|
||||
privileged: privilege.is_privileged(),
|
||||
scopes: final_scopes,
|
||||
})
|
||||
.into_response())
|
||||
@@ -234,10 +211,7 @@ pub async fn revoke_app_password(
|
||||
.user_repo
|
||||
.get_by_did(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error getting user: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("getting user")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let name = input.name.trim();
|
||||
@@ -255,10 +229,7 @@ pub async fn revoke_app_password(
|
||||
.session_repo
|
||||
.delete_sessions_by_app_password(&auth.did, name)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error revoking sessions for app password: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("revoking sessions for app password")?;
|
||||
|
||||
futures::future::join_all(sessions_to_invalidate.iter().map(|jti| {
|
||||
let cache_key = format!("auth:session:{}:{}", &auth.did, jti);
|
||||
@@ -273,10 +244,7 @@ pub async fn revoke_app_password(
|
||||
.session_repo
|
||||
.delete_app_password(user.id, name)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error revoking app password: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("revoking app password")?;
|
||||
|
||||
Ok(EmptyResponse::ok().into_response())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use crate::api::{EmptyResponse, TokenRequiredResponse, VerifiedResponse};
|
||||
use crate::auth::{Auth, NotTakendown};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::rate_limit::{EmailUpdateLimit, RateLimited, VerificationCheckLimit};
|
||||
use crate::state::AppState;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -44,19 +46,10 @@ pub struct RequestEmailUpdateInput {
|
||||
|
||||
pub async fn request_email_update(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
_rate_limit: RateLimited<EmailUpdateLimit>,
|
||||
auth: Auth<NotTakendown>,
|
||||
input: Option<Json<RequestEmailUpdateInput>>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::EmailUpdate, &client_ip)
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Email update rate limit exceeded");
|
||||
return Err(ApiError::RateLimitExceeded(None));
|
||||
}
|
||||
|
||||
if let Err(e) = crate::auth::scope_check::check_account_scope(
|
||||
auth.is_oauth(),
|
||||
auth.scope.as_deref(),
|
||||
@@ -70,10 +63,7 @@ pub async fn request_email_update(
|
||||
.user_repo
|
||||
.get_email_info_by_did(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("getting email info")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let Some(current_email) = user.email else {
|
||||
@@ -111,14 +101,14 @@ pub async fn request_email_update(
|
||||
}
|
||||
}
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname = pds_hostname();
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_email_update_token(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
user.id,
|
||||
&code,
|
||||
&formatted_code,
|
||||
&hostname,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -139,19 +129,10 @@ pub struct ConfirmEmailInput {
|
||||
|
||||
pub async fn confirm_email(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
_rate_limit: RateLimited<EmailUpdateLimit>,
|
||||
auth: Auth<NotTakendown>,
|
||||
Json(input): Json<ConfirmEmailInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::EmailUpdate, &client_ip)
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Confirm email rate limit exceeded");
|
||||
return Err(ApiError::RateLimitExceeded(None));
|
||||
}
|
||||
|
||||
if let Err(e) = crate::auth::scope_check::check_account_scope(
|
||||
auth.is_oauth(),
|
||||
auth.scope.as_deref(),
|
||||
@@ -166,10 +147,7 @@ pub async fn confirm_email(
|
||||
.user_repo
|
||||
.get_email_info_by_did(did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("getting email info")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let Some(ref email) = user.email else {
|
||||
@@ -213,10 +191,7 @@ pub async fn confirm_email(
|
||||
.user_repo
|
||||
.set_email_verified(user.id, true)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error confirming email: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("confirming email")?;
|
||||
|
||||
info!("Email confirmed for user {}", user.id);
|
||||
Ok(EmptyResponse::ok().into_response())
|
||||
@@ -250,10 +225,7 @@ pub async fn update_email(
|
||||
.user_repo
|
||||
.get_email_info_by_did(did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("getting email info")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let user_id = user.id;
|
||||
@@ -325,23 +297,20 @@ pub async fn update_email(
|
||||
.user_repo
|
||||
.update_email(user_id, &new_email)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error updating email: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("updating email")?;
|
||||
|
||||
let verification_token =
|
||||
crate::auth::verification_token::generate_signup_token(did, "email", &new_email);
|
||||
let formatted_token =
|
||||
crate::auth::verification_token::format_token_for_display(&verification_token);
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname = pds_hostname();
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
|
||||
state.infra_repo.as_ref(),
|
||||
user_id,
|
||||
"email",
|
||||
&new_email,
|
||||
&formatted_token,
|
||||
&hostname,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -371,17 +340,9 @@ pub struct CheckEmailVerifiedInput {
|
||||
|
||||
pub async fn check_email_verified(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
_rate_limit: RateLimited<VerificationCheckLimit>,
|
||||
Json(input): Json<CheckEmailVerifiedInput>,
|
||||
) -> Response {
|
||||
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::VerificationCheck, &client_ip)
|
||||
.await
|
||||
{
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
|
||||
match state
|
||||
.user_repo
|
||||
.check_email_verified_by_identifier(&input.identifier)
|
||||
@@ -403,17 +364,9 @@ pub struct AuthorizeEmailUpdateQuery {
|
||||
|
||||
pub async fn authorize_email_update(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
_rate_limit: RateLimited<VerificationCheckLimit>,
|
||||
axum::extract::Query(query): axum::extract::Query<AuthorizeEmailUpdateQuery>,
|
||||
) -> Response {
|
||||
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::VerificationCheck, &client_ip)
|
||||
.await
|
||||
{
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
|
||||
let verified = crate::auth::verification_token::verify_token_signature(&query.token);
|
||||
|
||||
let token_data = match verified {
|
||||
@@ -488,7 +441,7 @@ pub async fn authorize_email_update(
|
||||
|
||||
info!(did = %did, "Email update authorized via link click");
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname = pds_hostname();
|
||||
let redirect_url = format!(
|
||||
"https://{}/app/verify?type=email-authorize-success",
|
||||
hostname
|
||||
@@ -499,17 +452,9 @@ pub async fn authorize_email_update(
|
||||
|
||||
pub async fn check_email_update_status(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
_rate_limit: RateLimited<VerificationCheckLimit>,
|
||||
auth: Auth<NotTakendown>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::VerificationCheck, &client_ip)
|
||||
.await
|
||||
{
|
||||
return Err(ApiError::RateLimitExceeded(None));
|
||||
}
|
||||
|
||||
if let Err(e) = crate::auth::scope_check::check_account_scope(
|
||||
auth.is_oauth(),
|
||||
auth.scope.as_deref(),
|
||||
@@ -549,17 +494,9 @@ pub struct CheckEmailInUseInput {
|
||||
|
||||
pub async fn check_email_in_use(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
_rate_limit: RateLimited<VerificationCheckLimit>,
|
||||
Json(input): Json<CheckEmailInUseInput>,
|
||||
) -> Response {
|
||||
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::VerificationCheck, &client_ip)
|
||||
.await
|
||||
{
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
|
||||
let email = input.email.trim().to_lowercase();
|
||||
if email.is_empty() {
|
||||
return ApiError::InvalidRequest("email is required".into()).into_response();
|
||||
@@ -587,17 +524,9 @@ pub struct CheckCommsChannelInUseInput {
|
||||
|
||||
pub async fn check_comms_channel_in_use(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
_rate_limit: RateLimited<VerificationCheckLimit>,
|
||||
Json(input): Json<CheckCommsChannelInUseInput>,
|
||||
) -> Response {
|
||||
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::VerificationCheck, &client_ip)
|
||||
.await
|
||||
{
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
|
||||
let channel = match input.channel.to_lowercase().as_str() {
|
||||
"email" => CommsChannel::Email,
|
||||
"discord" => CommsChannel::Discord,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::api::error::DbResultExt;
|
||||
use crate::auth::{Admin, Auth, NotTakendown};
|
||||
use crate::state::AppState;
|
||||
use crate::types::Did;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -24,7 +26,7 @@ fn gen_random_token() -> String {
|
||||
}
|
||||
|
||||
fn gen_invite_code() -> String {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname = pds_hostname();
|
||||
let hostname_prefix = hostname.replace('.', "-");
|
||||
format!("{}-{}", hostname_prefix, gen_random_token())
|
||||
}
|
||||
@@ -121,10 +123,7 @@ pub async fn create_invite_codes(
|
||||
.user_repo
|
||||
.get_any_admin_user_id()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error looking up admin user: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("looking up admin user")?
|
||||
.ok_or_else(|| {
|
||||
error!("No admin user found to create invite codes");
|
||||
ApiError::InternalError(None)
|
||||
@@ -202,14 +201,11 @@ pub async fn get_account_invite_codes(
|
||||
.infra_repo
|
||||
.get_invite_codes_for_account(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching invite codes: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("fetching invite codes")?;
|
||||
|
||||
let filtered_codes: Vec<_> = codes_info
|
||||
.into_iter()
|
||||
.filter(|info| !info.disabled)
|
||||
.filter(|info| info.state.is_active())
|
||||
.collect();
|
||||
|
||||
let codes = futures::future::join_all(filtered_codes.into_iter().map(|info| {
|
||||
|
||||
@@ -21,7 +21,7 @@ pub async fn get_logo(State(state): State<AppState>) -> Response {
|
||||
Some(c) if !c.is_empty() => c,
|
||||
_ => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
let cid = crate::types::CidLink::new_unchecked(&cid_str);
|
||||
let cid = unsafe { crate::types::CidLink::new_unchecked(&cid_str) };
|
||||
|
||||
let metadata = match state.blob_repo.get_blob_metadata(&cid).await {
|
||||
Ok(Some(m)) => m,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::state::AppState;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -30,9 +31,9 @@ pub fn is_self_hosted_did_web_enabled() -> bool {
|
||||
}
|
||||
|
||||
pub async fn describe_server() -> impl IntoResponse {
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let pds_hostname = pds_hostname();
|
||||
let domains_str =
|
||||
std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| pds_hostname.clone());
|
||||
std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| pds_hostname.to_string());
|
||||
let domains: Vec<&str> = domains_str.split(',').map(|s| s.trim()).collect();
|
||||
let invite_code_required = std::env::var("INVITE_CODE_REQUIRED")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::api::error::DbResultExt;
|
||||
use crate::auth::{Active, Auth};
|
||||
use crate::state::AppState;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -49,10 +51,7 @@ pub async fn update_did_document(
|
||||
.user_repo
|
||||
.get_user_for_did_doc(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("DB error getting user: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("getting user")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
if let Some(ref methods) = input.verification_methods {
|
||||
@@ -107,10 +106,7 @@ pub async fn update_did_document(
|
||||
.user_repo
|
||||
.upsert_did_web_overrides(user.id, verification_methods_json, also_known_as)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("DB error upserting did_web_overrides: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("upserting did_web_overrides")?;
|
||||
|
||||
if let Some(ref endpoint) = input.service_endpoint {
|
||||
let endpoint_clean = endpoint.trim().trim_end_matches('/');
|
||||
@@ -118,10 +114,7 @@ pub async fn update_did_document(
|
||||
.user_repo
|
||||
.update_migrated_to_pds(&auth.did, endpoint_clean)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("DB error updating service endpoint: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("updating service endpoint")?;
|
||||
}
|
||||
|
||||
let did_doc = build_did_document(&state, &auth.did).await;
|
||||
@@ -154,7 +147,7 @@ pub async fn get_did_document(
|
||||
}
|
||||
|
||||
async fn build_did_document(state: &AppState, did: &crate::types::Did) -> serde_json::Value {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname = pds_hostname();
|
||||
|
||||
let user = match state.user_repo.get_user_for_did_doc_build(did).await {
|
||||
Ok(Some(row)) => row,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::api::SuccessResponse;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::auth::NormalizedLoginIdentifier;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -19,25 +20,12 @@ use uuid::Uuid;
|
||||
|
||||
use crate::api::repo::record::utils::create_signed_commit;
|
||||
use crate::auth::{ServiceTokenVerifier, generate_app_password, is_service_token};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::rate_limit::{AccountCreationLimit, PasswordResetLimit, RateLimited};
|
||||
use crate::state::AppState;
|
||||
use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey};
|
||||
use crate::util::{pds_hostname, pds_hostname_without_port};
|
||||
use crate::validation::validate_password;
|
||||
|
||||
fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for")
|
||||
&& let Ok(value) = forwarded.to_str()
|
||||
&& let Some(first_ip) = value.split(',').next()
|
||||
{
|
||||
return first_ip.trim().to_string();
|
||||
}
|
||||
if let Some(real_ip) = headers.get("x-real-ip")
|
||||
&& let Ok(value) = real_ip.to_str()
|
||||
{
|
||||
return value.trim().to_string();
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
fn generate_setup_token() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..32)
|
||||
@@ -80,23 +68,12 @@ pub struct CreatePasskeyAccountResponse {
|
||||
|
||||
pub async fn create_passkey_account(
|
||||
State(state): State<AppState>,
|
||||
_rate_limit: RateLimited<AccountCreationLimit>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<CreatePasskeyAccountInput>,
|
||||
) -> Response {
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::AccountCreation, &client_ip)
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Account creation rate limit exceeded");
|
||||
return ApiError::RateLimitExceeded(Some(
|
||||
"Too many account creation attempts. Please try again later.".into(),
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let byod_auth = if let Some(extracted) = crate::auth::extract_auth_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
crate::util::get_header_str(&headers, "Authorization"),
|
||||
) {
|
||||
let token = extracted.token;
|
||||
if is_service_token(&token) {
|
||||
@@ -135,8 +112,8 @@ pub async fn create_passkey_account(
|
||||
.map(|d| d.starts_with("did:web:"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
|
||||
let hostname = pds_hostname();
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let pds_suffix = format!(".{}", hostname_for_handles);
|
||||
|
||||
let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) {
|
||||
@@ -169,15 +146,10 @@ pub async fn create_passkey_account(
|
||||
return ApiError::InvalidEmail.into_response();
|
||||
}
|
||||
|
||||
if let Some(ref code) = input.invite_code {
|
||||
let valid = state
|
||||
.infra_repo
|
||||
.is_invite_code_valid(code)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if !valid {
|
||||
return ApiError::InvalidInviteCode.into_response();
|
||||
let _validated_invite_code = if let Some(ref code) = input.invite_code {
|
||||
match state.infra_repo.validate_invite_code(code).await {
|
||||
Ok(validated) => Some(validated),
|
||||
Err(_) => return ApiError::InvalidInviteCode.into_response(),
|
||||
}
|
||||
} else {
|
||||
let invite_required = std::env::var("INVITE_CODE_REQUIRED")
|
||||
@@ -186,7 +158,8 @@ pub async fn create_passkey_account(
|
||||
if invite_required {
|
||||
return ApiError::InviteCodeRequired.into_response();
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let verification_channel = input.verification_channel.as_deref().unwrap_or("email");
|
||||
let verification_recipient = match verification_channel {
|
||||
@@ -268,7 +241,7 @@ pub async fn create_passkey_account(
|
||||
}
|
||||
if is_byod_did_web {
|
||||
if let Some(ref auth_did) = byod_auth
|
||||
&& d != auth_did
|
||||
&& d != auth_did.as_str()
|
||||
{
|
||||
return ApiError::AuthorizationError(format!(
|
||||
"Service token issuer {} does not match DID {}",
|
||||
@@ -280,7 +253,7 @@ pub async fn create_passkey_account(
|
||||
} else {
|
||||
if let Err(e) = crate::api::identity::did::verify_did_web(
|
||||
d,
|
||||
&hostname,
|
||||
hostname,
|
||||
&input.handle,
|
||||
input.signing_key.as_deref(),
|
||||
)
|
||||
@@ -296,7 +269,7 @@ pub async fn create_passkey_account(
|
||||
if let Some(ref auth_did) = byod_auth {
|
||||
if let Some(ref provided_did) = input.did {
|
||||
if provided_did.starts_with("did:plc:") {
|
||||
if provided_did != auth_did {
|
||||
if provided_did != auth_did.as_str() {
|
||||
return ApiError::AuthorizationError(format!(
|
||||
"Service token issuer {} does not match DID {}",
|
||||
auth_did, provided_did
|
||||
@@ -389,7 +362,7 @@ pub async fn create_passkey_account(
|
||||
}
|
||||
};
|
||||
let rev = Tid::now(LimitedU32::MIN);
|
||||
let did_typed = Did::new_unchecked(&did);
|
||||
let did_typed = unsafe { Did::new_unchecked(&did) };
|
||||
let (commit_bytes, _sig) =
|
||||
match create_signed_commit(&did_typed, mst_root, rev.as_ref(), None, &secret_key) {
|
||||
Ok(result) => result,
|
||||
@@ -422,7 +395,7 @@ pub async fn create_passkey_account(
|
||||
_ => tranquil_db_traits::CommsChannel::Email,
|
||||
};
|
||||
|
||||
let handle_typed = Handle::new_unchecked(&handle);
|
||||
let handle_typed = unsafe { Handle::new_unchecked(&handle) };
|
||||
let create_input = tranquil_db_traits::CreatePasskeyAccountInput {
|
||||
handle: handle_typed.clone(),
|
||||
email: email.clone().unwrap_or_default(),
|
||||
@@ -484,8 +457,12 @@ pub async fn create_passkey_account(
|
||||
{
|
||||
warn!("Failed to sequence identity event for {}: {}", did, e);
|
||||
}
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_account_event(&state, &did_typed, true, None).await
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(
|
||||
&state,
|
||||
&did_typed,
|
||||
tranquil_db_traits::AccountStatus::Active,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to sequence account event for {}: {}", did, e);
|
||||
}
|
||||
@@ -493,8 +470,8 @@ pub async fn create_passkey_account(
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": handle
|
||||
});
|
||||
let profile_collection = Nsid::new_unchecked("app.bsky.actor.profile");
|
||||
let profile_rkey = Rkey::new_unchecked("self");
|
||||
let profile_collection = unsafe { Nsid::new_unchecked("app.bsky.actor.profile") };
|
||||
let profile_rkey = unsafe { Rkey::new_unchecked("self") };
|
||||
if let Err(e) = crate::api::repo::record::create_record_internal(
|
||||
&state,
|
||||
&did_typed,
|
||||
@@ -521,7 +498,7 @@ pub async fn create_passkey_account(
|
||||
verification_channel,
|
||||
&verification_recipient,
|
||||
&formatted_token,
|
||||
&hostname,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -541,7 +518,7 @@ pub async fn create_passkey_account(
|
||||
refresh_jti,
|
||||
access_expires_at: token_meta.expires_at,
|
||||
refresh_expires_at: refresh_expires,
|
||||
legacy_login: false,
|
||||
login_type: tranquil_db::LoginType::Modern,
|
||||
mfa_verified: false,
|
||||
scope: None,
|
||||
controller_did: None,
|
||||
@@ -626,14 +603,7 @@ pub async fn complete_passkey_setup(
|
||||
return ApiError::InvalidToken(None).into_response();
|
||||
}
|
||||
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
error!("Failed to create WebAuthn config: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let webauthn = &state.webauthn_config;
|
||||
|
||||
let reg_state = match state
|
||||
.user_repo
|
||||
@@ -768,14 +738,7 @@ pub async fn start_passkey_registration_for_setup(
|
||||
return ApiError::InvalidToken(None).into_response();
|
||||
}
|
||||
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
error!("Failed to create WebAuthn config: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let webauthn = &state.webauthn_config;
|
||||
|
||||
let existing_passkeys = state
|
||||
.user_repo
|
||||
@@ -840,30 +803,18 @@ pub struct RequestPasskeyRecoveryInput {
|
||||
|
||||
pub async fn request_passkey_recovery(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
_rate_limit: RateLimited<PasswordResetLimit>,
|
||||
Json(input): Json<RequestPasskeyRecoveryInput>,
|
||||
) -> Response {
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::PasswordReset, &client_ip)
|
||||
.await
|
||||
{
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname_for_handles = pds_hostname.split(':').next().unwrap_or(&pds_hostname);
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let identifier = input.email.trim().to_lowercase();
|
||||
let identifier = identifier.strip_prefix('@').unwrap_or(&identifier);
|
||||
let normalized_handle = if identifier.contains('@') || identifier.contains('.') {
|
||||
identifier.to_string()
|
||||
} else {
|
||||
format!("{}.{}", identifier, hostname_for_handles)
|
||||
};
|
||||
let normalized_handle =
|
||||
NormalizedLoginIdentifier::normalize(&input.email, hostname_for_handles);
|
||||
|
||||
let user = match state
|
||||
.user_repo
|
||||
.get_user_for_passkey_recovery(identifier, &normalized_handle)
|
||||
.get_user_for_passkey_recovery(identifier, normalized_handle.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) if !u.password_required => u,
|
||||
@@ -890,7 +841,7 @@ pub async fn request_passkey_recovery(
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname = pds_hostname();
|
||||
let recovery_url = format!(
|
||||
"https://{}/app/recover-passkey?did={}&token={}",
|
||||
hostname,
|
||||
@@ -903,7 +854,7 @@ pub async fn request_passkey_recovery(
|
||||
state.infra_repo.as_ref(),
|
||||
user.id,
|
||||
&recovery_url,
|
||||
&hostname,
|
||||
hostname,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::api::EmptyResponse;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::auth::webauthn::WebAuthnConfig;
|
||||
use crate::auth::{Active, Auth};
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use crate::auth::{Active, Auth, require_legacy_session_mfa, require_reauth_window};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -12,14 +11,6 @@ use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, info, warn};
|
||||
use webauthn_rs::prelude::*;
|
||||
|
||||
fn get_webauthn() -> Result<WebAuthnConfig, ApiError> {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
WebAuthnConfig::new(&hostname).map_err(|e| {
|
||||
error!("Failed to create WebAuthn config: {}", e);
|
||||
ApiError::InternalError(Some("WebAuthn configuration failed".into()))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StartRegistrationInput {
|
||||
@@ -37,26 +28,20 @@ pub async fn start_passkey_registration(
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<StartRegistrationInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let webauthn = get_webauthn()?;
|
||||
let webauthn = &state.webauthn_config;
|
||||
|
||||
let handle = state
|
||||
.user_repo
|
||||
.get_handle_by_did(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching user: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("fetching user")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let existing_passkeys = state
|
||||
.user_repo
|
||||
.get_passkeys_for_user(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching existing passkeys: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("fetching existing passkeys")?;
|
||||
|
||||
let exclude_credentials: Vec<CredentialID> = existing_passkeys
|
||||
.iter()
|
||||
@@ -81,10 +66,7 @@ pub async fn start_passkey_registration(
|
||||
.user_repo
|
||||
.save_webauthn_challenge(&auth.did, "registration", &state_json)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to save registration state: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("saving registration state")?;
|
||||
|
||||
let options = serde_json::to_value(&ccr).unwrap_or(serde_json::json!({}));
|
||||
|
||||
@@ -112,16 +94,13 @@ pub async fn finish_passkey_registration(
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<FinishRegistrationInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let webauthn = get_webauthn()?;
|
||||
let webauthn = &state.webauthn_config;
|
||||
|
||||
let reg_state_json = state
|
||||
.user_repo
|
||||
.load_webauthn_challenge(&auth.did, "registration")
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error loading registration state: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("loading registration state")?
|
||||
.ok_or(ApiError::NoRegistrationInProgress)?;
|
||||
|
||||
let reg_state: SecurityKeyRegistration =
|
||||
@@ -157,10 +136,7 @@ pub async fn finish_passkey_registration(
|
||||
input.friendly_name.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to save passkey: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("saving passkey")?;
|
||||
|
||||
if let Err(e) = state
|
||||
.user_repo
|
||||
@@ -208,10 +184,7 @@ pub async fn list_passkeys(
|
||||
.user_repo
|
||||
.get_passkeys_for_user(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching passkeys: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("fetching passkeys")?;
|
||||
|
||||
let passkey_infos: Vec<PasskeyInfo> = passkeys
|
||||
.into_iter()
|
||||
@@ -241,30 +214,21 @@ pub async fn delete_passkey(
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<DeletePasskeyInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.did).await
|
||||
{
|
||||
return Ok(crate::api::server::reauth::legacy_mfa_required_response(
|
||||
&*state.user_repo,
|
||||
&*state.session_repo,
|
||||
&auth.did,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
let session_mfa = match require_legacy_session_mfa(&state, &auth).await {
|
||||
Ok(proof) => proof,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
if crate::api::server::reauth::check_reauth_required(&*state.session_repo, &auth.did).await {
|
||||
return Ok(crate::api::server::reauth::reauth_required_response(
|
||||
&*state.user_repo,
|
||||
&*state.session_repo,
|
||||
&auth.did,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
let reauth_mfa = match require_reauth_window(&state, &auth).await {
|
||||
Ok(proof) => proof,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
let id: uuid::Uuid = input.id.parse().map_err(|_| ApiError::InvalidId)?;
|
||||
|
||||
match state.user_repo.delete_passkey(id, &auth.did).await {
|
||||
match state.user_repo.delete_passkey(id, reauth_mfa.did()).await {
|
||||
Ok(true) => {
|
||||
info!(did = %auth.did, passkey_id = %id, "Passkey deleted");
|
||||
info!(did = %session_mfa.did(), passkey_id = %id, "Passkey deleted");
|
||||
Ok(EmptyResponse::ok().into_response())
|
||||
}
|
||||
Ok(false) => Err(ApiError::PasskeyNotFound),
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use crate::api::{EmptyResponse, HasPasswordResponse, SuccessResponse};
|
||||
use crate::auth::{Active, Auth};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::auth::{
|
||||
Active, Auth, NormalizedLoginIdentifier, require_legacy_session_mfa, require_reauth_window,
|
||||
require_reauth_window_if_available,
|
||||
};
|
||||
use crate::rate_limit::{PasswordResetLimit, RateLimited, ResetPasswordLimit};
|
||||
use crate::state::AppState;
|
||||
use crate::types::PlainPassword;
|
||||
use crate::util::{pds_hostname, pds_hostname_without_port};
|
||||
use crate::validation::validate_password;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use bcrypt::{DEFAULT_COST, hash, verify};
|
||||
use bcrypt::{DEFAULT_COST, hash};
|
||||
use chrono::{Duration, Utc};
|
||||
use serde::Deserialize;
|
||||
use tracing::{error, info, warn};
|
||||
@@ -18,20 +22,6 @@ use tracing::{error, info, warn};
|
||||
fn generate_reset_code() -> String {
|
||||
crate::util::generate_token_code()
|
||||
}
|
||||
fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for")
|
||||
&& let Ok(value) = forwarded.to_str()
|
||||
&& let Some(first_ip) = value.split(',').next()
|
||||
{
|
||||
return first_ip.trim().to_string();
|
||||
}
|
||||
if let Some(real_ip) = headers.get("x-real-ip")
|
||||
&& let Ok(value) = real_ip.to_str()
|
||||
{
|
||||
return value.trim().to_string();
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RequestPasswordResetInput {
|
||||
@@ -41,31 +31,18 @@ pub struct RequestPasswordResetInput {
|
||||
|
||||
pub async fn request_password_reset(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
_rate_limit: RateLimited<PasswordResetLimit>,
|
||||
Json(input): Json<RequestPasswordResetInput>,
|
||||
) -> Response {
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::PasswordReset, &client_ip)
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Password reset rate limit exceeded");
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
let identifier = input.email.trim();
|
||||
if identifier.is_empty() {
|
||||
return ApiError::InvalidRequest("email or handle is required".into()).into_response();
|
||||
}
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname_for_handles = pds_hostname.split(':').next().unwrap_or(&pds_hostname);
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let normalized = identifier.to_lowercase();
|
||||
let normalized = normalized.strip_prefix('@').unwrap_or(&normalized);
|
||||
let is_email_lookup = normalized.contains('@');
|
||||
let normalized_handle = if normalized.contains('@') || normalized.contains('.') {
|
||||
normalized.to_string()
|
||||
} else {
|
||||
format!("{}.{}", normalized, hostname_for_handles)
|
||||
};
|
||||
let normalized_handle = NormalizedLoginIdentifier::normalize(identifier, hostname_for_handles);
|
||||
|
||||
let multiple_accounts_warning = if is_email_lookup {
|
||||
match state.user_repo.count_accounts_by_email(normalized).await {
|
||||
@@ -78,7 +55,7 @@ pub async fn request_password_reset(
|
||||
|
||||
let user_id = match state
|
||||
.user_repo
|
||||
.get_id_by_email_or_handle(normalized, &normalized_handle)
|
||||
.get_id_by_email_or_handle(normalized, normalized_handle.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(Some(id)) => id,
|
||||
@@ -101,13 +78,13 @@ pub async fn request_password_reset(
|
||||
error!("DB error setting reset code: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname = pds_hostname();
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_password_reset(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
user_id,
|
||||
&code,
|
||||
&hostname,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -135,17 +112,9 @@ pub struct ResetPasswordInput {
|
||||
|
||||
pub async fn reset_password(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
_rate_limit: RateLimited<ResetPasswordLimit>,
|
||||
Json(input): Json<ResetPasswordInput>,
|
||||
) -> Response {
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::ResetPassword, &client_ip)
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Reset password rate limit exceeded");
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
let token = input.token.trim();
|
||||
let password = &input.password;
|
||||
if token.is_empty() {
|
||||
@@ -230,50 +199,35 @@ pub async fn change_password(
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<ChangePasswordInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.did).await
|
||||
{
|
||||
return Ok(crate::api::server::reauth::legacy_mfa_required_response(
|
||||
&*state.user_repo,
|
||||
&*state.session_repo,
|
||||
&auth.did,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
use crate::auth::verify_password_mfa;
|
||||
|
||||
let current_password = &input.current_password;
|
||||
let new_password = &input.new_password;
|
||||
if current_password.is_empty() {
|
||||
let session_mfa = match require_legacy_session_mfa(&state, &auth).await {
|
||||
Ok(proof) => proof,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
if input.current_password.is_empty() {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"currentPassword is required".into(),
|
||||
));
|
||||
}
|
||||
if new_password.is_empty() {
|
||||
if input.new_password.is_empty() {
|
||||
return Err(ApiError::InvalidRequest("newPassword is required".into()));
|
||||
}
|
||||
if let Err(e) = validate_password(new_password) {
|
||||
if let Err(e) = validate_password(&input.new_password) {
|
||||
return Err(ApiError::InvalidRequest(e.to_string()));
|
||||
}
|
||||
|
||||
let password_mfa = verify_password_mfa(&state, &auth, &input.current_password).await?;
|
||||
|
||||
let user = state
|
||||
.user_repo
|
||||
.get_id_and_password_hash_by_did(&auth.did)
|
||||
.get_id_and_password_hash_by_did(password_mfa.did())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error in change_password: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("in change_password")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let (user_id, password_hash) = (user.id, user.password_hash);
|
||||
let valid = verify(current_password, &password_hash).map_err(|e| {
|
||||
error!("Password verification error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
if !valid {
|
||||
return Err(ApiError::InvalidPassword(
|
||||
"Current password is incorrect".into(),
|
||||
));
|
||||
}
|
||||
let new_password_clone = new_password.to_string();
|
||||
let new_password_clone = input.new_password.to_string();
|
||||
let new_hash = tokio::task::spawn_blocking(move || hash(new_password_clone, DEFAULT_COST))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -287,14 +241,11 @@ pub async fn change_password(
|
||||
|
||||
state
|
||||
.user_repo
|
||||
.update_password_hash(user_id, &new_hash)
|
||||
.update_password_hash(user.id, &new_hash)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error updating password: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("updating password")?;
|
||||
|
||||
info!(did = %&auth.did, "Password changed successfully");
|
||||
info!(did = %session_mfa.did(), "Password changed successfully");
|
||||
Ok(EmptyResponse::ok().into_response())
|
||||
}
|
||||
|
||||
@@ -302,48 +253,32 @@ pub async fn get_password_status(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Active>,
|
||||
) -> Result<Response, ApiError> {
|
||||
match state.user_repo.has_password_by_did(&auth.did).await {
|
||||
Ok(Some(has)) => Ok(HasPasswordResponse::response(has).into_response()),
|
||||
Ok(None) => Err(ApiError::AccountNotFound),
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
Err(ApiError::InternalError(None))
|
||||
}
|
||||
}
|
||||
let has = state
|
||||
.user_repo
|
||||
.has_password_by_did(&auth.did)
|
||||
.await
|
||||
.log_db_err("checking password status")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
Ok(HasPasswordResponse::response(has).into_response())
|
||||
}
|
||||
|
||||
pub async fn remove_password(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Active>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.did).await
|
||||
{
|
||||
return Ok(crate::api::server::reauth::legacy_mfa_required_response(
|
||||
&*state.user_repo,
|
||||
&*state.session_repo,
|
||||
&auth.did,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
let session_mfa = match require_legacy_session_mfa(&state, &auth).await {
|
||||
Ok(proof) => proof,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
if crate::api::server::reauth::check_reauth_required_cached(
|
||||
&*state.session_repo,
|
||||
&state.cache,
|
||||
&auth.did,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(crate::api::server::reauth::reauth_required_response(
|
||||
&*state.user_repo,
|
||||
&*state.session_repo,
|
||||
&auth.did,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
let reauth_mfa = match require_reauth_window(&state, &auth).await {
|
||||
Ok(proof) => proof,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
let has_passkeys = state
|
||||
.user_repo
|
||||
.has_passkeys(&auth.did)
|
||||
.has_passkeys(reauth_mfa.did())
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !has_passkeys {
|
||||
@@ -354,12 +289,9 @@ pub async fn remove_password(
|
||||
|
||||
let user = state
|
||||
.user_repo
|
||||
.get_password_info_by_did(&auth.did)
|
||||
.get_password_info_by_did(reauth_mfa.did())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("getting password info")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
if user.password_hash.is_none() {
|
||||
@@ -372,12 +304,9 @@ pub async fn remove_password(
|
||||
.user_repo
|
||||
.remove_user_password(user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error removing password: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("removing password")?;
|
||||
|
||||
info!(did = %&auth.did, "Password removed - account is now passkey-only");
|
||||
info!(did = %session_mfa.did(), "Password removed - account is now passkey-only");
|
||||
Ok(SuccessResponse::ok().into_response())
|
||||
}
|
||||
|
||||
@@ -392,41 +321,10 @@ pub async fn set_password(
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<SetPasswordInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let has_password = state
|
||||
.user_repo
|
||||
.has_password_by_did(&auth.did)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(false);
|
||||
let has_passkeys = state
|
||||
.user_repo
|
||||
.has_passkeys(&auth.did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let has_totp = state
|
||||
.user_repo
|
||||
.has_totp_enabled(&auth.did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
let has_any_reauth_method = has_password || has_passkeys || has_totp;
|
||||
|
||||
if has_any_reauth_method
|
||||
&& crate::api::server::reauth::check_reauth_required_cached(
|
||||
&*state.session_repo,
|
||||
&state.cache,
|
||||
&auth.did,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(crate::api::server::reauth::reauth_required_response(
|
||||
&*state.user_repo,
|
||||
&*state.session_repo,
|
||||
&auth.did,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
let reauth_mfa = match require_reauth_window_if_available(&state, &auth).await {
|
||||
Ok(proof) => proof,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
let new_password = &input.new_password;
|
||||
if new_password.is_empty() {
|
||||
@@ -436,14 +334,13 @@ pub async fn set_password(
|
||||
return Err(ApiError::InvalidRequest(e.to_string()));
|
||||
}
|
||||
|
||||
let did = reauth_mfa.as_ref().map(|m| m.did()).unwrap_or(&auth.did);
|
||||
|
||||
let user = state
|
||||
.user_repo
|
||||
.get_password_info_by_did(&auth.did)
|
||||
.get_password_info_by_did(did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("getting password info")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
if user.password_hash.is_some() {
|
||||
@@ -468,11 +365,8 @@ pub async fn set_password(
|
||||
.user_repo
|
||||
.set_new_user_password(user.id, &new_hash)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error setting password: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("setting password")?;
|
||||
|
||||
info!(did = %&auth.did, "Password set for passkey-only account");
|
||||
info!(did = %did, "Password set for passkey-only account");
|
||||
Ok(SuccessResponse::ok().into_response())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -11,10 +11,11 @@ use tracing::{error, info, warn};
|
||||
use tranquil_db_traits::{SessionRepository, UserRepository};
|
||||
|
||||
use crate::auth::{Active, Auth};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message};
|
||||
use crate::state::AppState;
|
||||
use crate::types::PlainPassword;
|
||||
|
||||
const REAUTH_WINDOW_SECONDS: i64 = 300;
|
||||
pub const REAUTH_WINDOW_SECONDS: i64 = 300;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -32,10 +33,7 @@ pub async fn get_reauth_status(
|
||||
.session_repo
|
||||
.get_last_reauth_at(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("getting last reauth")?;
|
||||
|
||||
let reauth_required = is_reauth_required(last_reauth_at);
|
||||
let available_methods =
|
||||
@@ -70,10 +68,7 @@ pub async fn reauth_password(
|
||||
.user_repo
|
||||
.get_password_hash_by_did(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("fetching password hash")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
|
||||
@@ -97,10 +92,7 @@ pub async fn reauth_password(
|
||||
|
||||
let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error updating reauth: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("updating reauth")?;
|
||||
|
||||
info!(did = %&auth.did, "Re-auth successful via password");
|
||||
Ok(Json(ReauthResponse { reauthed_at }).into_response())
|
||||
@@ -117,15 +109,12 @@ pub async fn reauth_totp(
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<TotpReauthInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::TotpVerify, &auth.did)
|
||||
.await
|
||||
{
|
||||
warn!(did = %&auth.did, "TOTP verification rate limit exceeded");
|
||||
return Err(ApiError::RateLimitExceeded(Some(
|
||||
"Too many verification attempts. Please try again in a few minutes.".into(),
|
||||
)));
|
||||
}
|
||||
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
|
||||
&state,
|
||||
&auth.did,
|
||||
"Too many verification attempts. Please try again in a few minutes.",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let valid =
|
||||
crate::api::server::totp::verify_totp_or_backup_for_user(&state, &auth.did, &input.code)
|
||||
@@ -140,10 +129,7 @@ pub async fn reauth_totp(
|
||||
|
||||
let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error updating reauth: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("updating reauth")?;
|
||||
|
||||
info!(did = %&auth.did, "Re-auth successful via TOTP");
|
||||
Ok(Json(ReauthResponse { reauthed_at }).into_response())
|
||||
@@ -159,16 +145,11 @@ pub async fn reauth_passkey_start(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Active>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
|
||||
let stored_passkeys = state
|
||||
.user_repo
|
||||
.get_passkeys_for_user(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to get passkeys: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("getting passkeys")?;
|
||||
|
||||
if stored_passkeys.is_empty() {
|
||||
return Err(ApiError::NoPasskeys);
|
||||
@@ -185,10 +166,7 @@ pub async fn reauth_passkey_start(
|
||||
)));
|
||||
}
|
||||
|
||||
let webauthn = crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname).map_err(|e| {
|
||||
error!("Failed to create WebAuthn config: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let webauthn = &state.webauthn_config;
|
||||
|
||||
let (rcr, auth_state) = webauthn.start_authentication(passkeys).map_err(|e| {
|
||||
error!("Failed to start passkey authentication: {:?}", e);
|
||||
@@ -204,10 +182,7 @@ pub async fn reauth_passkey_start(
|
||||
.user_repo
|
||||
.save_webauthn_challenge(&auth.did, "authentication", &state_json)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to save authentication state: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("saving authentication state")?;
|
||||
|
||||
let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({}));
|
||||
Ok(Json(PasskeyReauthStartResponse { options }).into_response())
|
||||
@@ -224,16 +199,11 @@ pub async fn reauth_passkey_finish(
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<PasskeyReauthFinishInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
|
||||
let auth_state_json = state
|
||||
.user_repo
|
||||
.load_webauthn_challenge(&auth.did, "authentication")
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to load authentication state: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("loading authentication state")?
|
||||
.ok_or(ApiError::NoChallengeInProgress)?;
|
||||
|
||||
let auth_state: webauthn_rs::prelude::SecurityKeyAuthentication =
|
||||
@@ -248,12 +218,8 @@ pub async fn reauth_passkey_finish(
|
||||
ApiError::InvalidCredential
|
||||
})?;
|
||||
|
||||
let webauthn = crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname).map_err(|e| {
|
||||
error!("Failed to create WebAuthn config: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
let auth_result = webauthn
|
||||
let auth_result = state
|
||||
.webauthn_config
|
||||
.finish_authentication(&credential, &auth_state)
|
||||
.map_err(|e| {
|
||||
warn!(did = %&auth.did, "Passkey re-auth failed: {:?}", e);
|
||||
@@ -287,10 +253,7 @@ pub async fn reauth_passkey_finish(
|
||||
|
||||
let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error updating reauth: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("updating reauth")?;
|
||||
|
||||
info!(did = %&auth.did, "Re-auth successful via passkey");
|
||||
Ok(Json(ReauthResponse { reauthed_at }).into_response())
|
||||
@@ -418,7 +381,7 @@ pub async fn check_legacy_session_mfa(
|
||||
) -> bool {
|
||||
match session_repo.get_session_mfa_status(did).await {
|
||||
Ok(Some(status)) => {
|
||||
if !status.legacy_login {
|
||||
if status.login_type.is_modern() {
|
||||
return true;
|
||||
}
|
||||
if status.mfa_verified {
|
||||
|
||||
@@ -51,8 +51,8 @@ pub async fn get_service_auth(
|
||||
headers: axum::http::HeaderMap,
|
||||
Query(params): Query<GetServiceAuthParams>,
|
||||
) -> Response {
|
||||
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
|
||||
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
|
||||
let auth_header = crate::util::get_header_str(&headers, "Authorization");
|
||||
let dpop_proof = crate::util::get_header_str(&headers, "DPoP");
|
||||
info!(
|
||||
has_auth_header = auth_header.is_some(),
|
||||
has_dpop_proof = dpop_proof.is_some(),
|
||||
@@ -94,7 +94,7 @@ pub async fn get_service_auth(
|
||||
.await
|
||||
{
|
||||
Ok(result) => crate::auth::AuthenticatedUser {
|
||||
did: Did::new_unchecked(result.did),
|
||||
did: unsafe { Did::new_unchecked(result.did) },
|
||||
is_admin: false,
|
||||
status: AccountStatus::Active,
|
||||
scope: result.scope,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use crate::api::{EmptyResponse, SuccessResponse};
|
||||
use crate::auth::{Active, Auth, Permissive};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::auth::{
|
||||
Active, Auth, NormalizedLoginIdentifier, Permissive, require_legacy_session_mfa,
|
||||
require_reauth_window,
|
||||
};
|
||||
use crate::rate_limit::{LoginLimit, RateLimited, RefreshSessionLimit};
|
||||
use crate::state::AppState;
|
||||
use crate::types::{AccountState, Did, Handle, PlainPassword};
|
||||
use crate::util::{pds_hostname, pds_hostname_without_port};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -13,34 +18,9 @@ use bcrypt::verify;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
use tranquil_db_traits::{SessionId, TokenFamilyId};
|
||||
use tranquil_types::TokenId;
|
||||
|
||||
fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for")
|
||||
&& let Ok(value) = forwarded.to_str()
|
||||
&& let Some(first_ip) = value.split(',').next()
|
||||
{
|
||||
return first_ip.trim().to_string();
|
||||
}
|
||||
if let Some(real_ip) = headers.get("x-real-ip")
|
||||
&& let Ok(value) = real_ip.to_str()
|
||||
{
|
||||
return value.trim().to_string();
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
fn normalize_handle(identifier: &str, pds_hostname: &str) -> String {
|
||||
let identifier = identifier.trim();
|
||||
if identifier.contains('@') || identifier.starts_with("did:") {
|
||||
identifier.to_string()
|
||||
} else if !identifier.contains('.') {
|
||||
format!("{}.{}", identifier.to_lowercase(), pds_hostname)
|
||||
} else {
|
||||
identifier.to_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
fn full_handle(stored_handle: &str, _pds_hostname: &str) -> String {
|
||||
stored_handle.to_string()
|
||||
}
|
||||
@@ -75,31 +55,25 @@ pub struct CreateSessionOutput {
|
||||
|
||||
pub async fn create_session(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
rate_limit: RateLimited<LoginLimit>,
|
||||
Json(input): Json<CreateSessionInput>,
|
||||
) -> Response {
|
||||
let client_ip = rate_limit.client_ip();
|
||||
info!(
|
||||
"create_session called with identifier: {}",
|
||||
input.identifier
|
||||
);
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::Login, &client_ip)
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Login rate limit exceeded");
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname_for_handles = pds_hostname.split(':').next().unwrap_or(&pds_hostname);
|
||||
let normalized_identifier = normalize_handle(&input.identifier, hostname_for_handles);
|
||||
let pds_host = pds_hostname();
|
||||
let hostname_for_handles = pds_hostname_without_port();
|
||||
let normalized_identifier =
|
||||
NormalizedLoginIdentifier::normalize(&input.identifier, hostname_for_handles);
|
||||
info!(
|
||||
"Normalized identifier: {} -> {}",
|
||||
input.identifier, normalized_identifier
|
||||
);
|
||||
let row = match state
|
||||
.user_repo
|
||||
.get_login_full_by_identifier(&normalized_identifier)
|
||||
.get_login_full_by_identifier(normalized_identifier.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
@@ -165,8 +139,7 @@ pub async fn create_session(
|
||||
warn!("Login attempt for takendown account: {}", row.did);
|
||||
return ApiError::AccountTakedown.into_response();
|
||||
}
|
||||
let is_verified =
|
||||
row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified;
|
||||
let is_verified = row.channel_verification.has_any_verified();
|
||||
let is_delegated = state
|
||||
.delegation_repo
|
||||
.is_delegated_account(&row.did)
|
||||
@@ -226,7 +199,7 @@ pub async fn create_session(
|
||||
refresh_jti: refresh_meta.jti.clone(),
|
||||
access_expires_at: access_meta.expires_at,
|
||||
refresh_expires_at: refresh_meta.expires_at,
|
||||
legacy_login: is_legacy_login,
|
||||
login_type: tranquil_db_traits::LoginType::from(is_legacy_login),
|
||||
mfa_verified: false,
|
||||
scope: app_password_scopes.clone(),
|
||||
controller_did: app_password_controller.clone(),
|
||||
@@ -246,13 +219,13 @@ pub async fn create_session(
|
||||
ip = %client_ip,
|
||||
"Legacy login on TOTP-enabled account - sending notification"
|
||||
);
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname = pds_hostname();
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_legacy_login(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
row.id,
|
||||
&hostname,
|
||||
&client_ip,
|
||||
hostname,
|
||||
client_ip,
|
||||
row.preferred_comms_channel,
|
||||
)
|
||||
.await
|
||||
@@ -260,7 +233,7 @@ pub async fn create_session(
|
||||
error!("Failed to queue legacy login notification: {:?}", e);
|
||||
}
|
||||
}
|
||||
let handle = full_handle(&row.handle, &pds_hostname);
|
||||
let handle = full_handle(&row.handle, pds_host);
|
||||
let is_active = account_state.is_active();
|
||||
let status = account_state.status_for_session().map(String::from);
|
||||
Json(CreateSessionOutput {
|
||||
@@ -270,7 +243,7 @@ pub async fn create_session(
|
||||
did: row.did,
|
||||
did_doc,
|
||||
email: row.email,
|
||||
email_confirmed: Some(row.email_verified),
|
||||
email_confirmed: Some(row.channel_verification.email),
|
||||
active: Some(is_active),
|
||||
status,
|
||||
})
|
||||
@@ -292,16 +265,17 @@ pub async fn get_session(
|
||||
);
|
||||
match db_result {
|
||||
Ok(Some(row)) => {
|
||||
let (preferred_channel, preferred_channel_verified) = match row.preferred_comms_channel
|
||||
{
|
||||
tranquil_db_traits::CommsChannel::Email => ("email", row.email_verified),
|
||||
tranquil_db_traits::CommsChannel::Discord => ("discord", row.discord_verified),
|
||||
tranquil_db_traits::CommsChannel::Telegram => ("telegram", row.telegram_verified),
|
||||
tranquil_db_traits::CommsChannel::Signal => ("signal", row.signal_verified),
|
||||
let preferred_channel = match row.preferred_comms_channel {
|
||||
tranquil_db_traits::CommsChannel::Email => "email",
|
||||
tranquil_db_traits::CommsChannel::Discord => "discord",
|
||||
tranquil_db_traits::CommsChannel::Telegram => "telegram",
|
||||
tranquil_db_traits::CommsChannel::Signal => "signal",
|
||||
};
|
||||
let pds_hostname =
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let handle = full_handle(&row.handle, &pds_hostname);
|
||||
let preferred_channel_verified = row
|
||||
.channel_verification
|
||||
.is_verified(row.preferred_comms_channel);
|
||||
let pds_hostname = pds_hostname();
|
||||
let handle = full_handle(&row.handle, pds_hostname);
|
||||
let account_state = AccountState::from_db_fields(
|
||||
row.deactivated_at,
|
||||
row.takedown_ref.clone(),
|
||||
@@ -313,7 +287,7 @@ pub async fn get_session(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let email_confirmed_value = can_read_email && row.email_verified;
|
||||
let email_confirmed_value = can_read_email && row.channel_verification.email;
|
||||
let mut response = json!({
|
||||
"handle": handle,
|
||||
"did": &auth.did,
|
||||
@@ -352,9 +326,10 @@ pub async fn delete_session(
|
||||
headers: axum::http::HeaderMap,
|
||||
_auth: Auth<Active>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let extracted = crate::auth::extract_auth_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
)
|
||||
let extracted = crate::auth::extract_auth_token_from_header(crate::util::get_header_str(
|
||||
&headers,
|
||||
"Authorization",
|
||||
))
|
||||
.ok_or(ApiError::AuthenticationRequired)?;
|
||||
let jti = crate::auth::get_jti_from_token(&extracted.token)
|
||||
.map_err(|_| ApiError::AuthenticationFailed(None))?;
|
||||
@@ -374,19 +349,13 @@ pub async fn delete_session(
|
||||
|
||||
pub async fn refresh_session(
|
||||
State(state): State<AppState>,
|
||||
_rate_limit: RateLimited<RefreshSessionLimit>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Response {
|
||||
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::RefreshSession, &client_ip)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(ip = %client_ip, "Refresh session rate limit exceeded");
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
let extracted = match crate::auth::extract_auth_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
let extracted = match crate::auth::extract_auth_token_from_header(crate::util::get_header_str(
|
||||
&headers,
|
||||
"Authorization",
|
||||
)) {
|
||||
Some(t) => t,
|
||||
None => return ApiError::AuthenticationRequired.into_response(),
|
||||
};
|
||||
@@ -503,15 +472,17 @@ pub async fn refresh_session(
|
||||
);
|
||||
match db_result {
|
||||
Ok(Some(u)) => {
|
||||
let (preferred_channel, preferred_channel_verified) = match u.preferred_comms_channel {
|
||||
tranquil_db_traits::CommsChannel::Email => ("email", u.email_verified),
|
||||
tranquil_db_traits::CommsChannel::Discord => ("discord", u.discord_verified),
|
||||
tranquil_db_traits::CommsChannel::Telegram => ("telegram", u.telegram_verified),
|
||||
tranquil_db_traits::CommsChannel::Signal => ("signal", u.signal_verified),
|
||||
let preferred_channel = match u.preferred_comms_channel {
|
||||
tranquil_db_traits::CommsChannel::Email => "email",
|
||||
tranquil_db_traits::CommsChannel::Discord => "discord",
|
||||
tranquil_db_traits::CommsChannel::Telegram => "telegram",
|
||||
tranquil_db_traits::CommsChannel::Signal => "signal",
|
||||
};
|
||||
let pds_hostname =
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let handle = full_handle(&u.handle, &pds_hostname);
|
||||
let preferred_channel_verified = u
|
||||
.channel_verification
|
||||
.is_verified(u.preferred_comms_channel);
|
||||
let pds_hostname = pds_hostname();
|
||||
let handle = full_handle(&u.handle, pds_hostname);
|
||||
let account_state =
|
||||
AccountState::from_db_fields(u.deactivated_at, u.takedown_ref.clone(), None, None);
|
||||
let mut response = json!({
|
||||
@@ -520,7 +491,7 @@ pub async fn refresh_session(
|
||||
"handle": handle,
|
||||
"did": session_row.did,
|
||||
"email": u.email,
|
||||
"emailConfirmed": u.email_verified,
|
||||
"emailConfirmed": u.channel_verification.email,
|
||||
"preferredChannel": preferred_channel,
|
||||
"preferredChannelVerified": preferred_channel_verified,
|
||||
"preferredLocale": u.preferred_locale,
|
||||
@@ -664,7 +635,7 @@ pub async fn confirm_signup(
|
||||
refresh_jti: refresh_meta.jti.clone(),
|
||||
access_expires_at: access_meta.expires_at,
|
||||
refresh_expires_at: refresh_meta.expires_at,
|
||||
legacy_login: false,
|
||||
login_type: tranquil_db_traits::LoginType::Modern,
|
||||
mfa_verified: false,
|
||||
scope: None,
|
||||
controller_did: None,
|
||||
@@ -675,12 +646,12 @@ pub async fn confirm_signup(
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname = pds_hostname();
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_welcome(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
row.id,
|
||||
&hostname,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -731,8 +702,7 @@ pub async fn resend_verification(
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let is_verified =
|
||||
row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified;
|
||||
let is_verified = row.channel_verification.has_any_verified();
|
||||
if is_verified {
|
||||
return ApiError::InvalidRequest("Account is already verified".into()).into_response();
|
||||
}
|
||||
@@ -756,14 +726,14 @@ pub async fn resend_verification(
|
||||
let formatted_token =
|
||||
crate::auth::verification_token::format_token_for_display(&verification_token);
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname = pds_hostname();
|
||||
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
|
||||
state.infra_repo.as_ref(),
|
||||
row.id,
|
||||
channel_str,
|
||||
&recipient,
|
||||
&formatted_token,
|
||||
&hostname,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -804,19 +774,13 @@ pub async fn list_sessions(
|
||||
.session_repo
|
||||
.list_sessions_by_did(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching JWT sessions: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("fetching JWT sessions")?;
|
||||
|
||||
let oauth_rows = state
|
||||
.oauth_repo
|
||||
.list_sessions_by_did(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching OAuth sessions: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("fetching OAuth sessions")?;
|
||||
|
||||
let jwt_sessions = jwt_rows.into_iter().map(|row| SessionInfo {
|
||||
id: format!("jwt:{}", row.id),
|
||||
@@ -869,43 +833,36 @@ pub async fn revoke_session(
|
||||
Json(input): Json<RevokeSessionInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if let Some(jwt_id) = input.session_id.strip_prefix("jwt:") {
|
||||
let session_id: i32 = jwt_id
|
||||
.parse()
|
||||
let session_id = jwt_id
|
||||
.parse::<i32>()
|
||||
.map(SessionId::new)
|
||||
.map_err(|_| ApiError::InvalidRequest("Invalid session ID".into()))?;
|
||||
let access_jti = state
|
||||
.session_repo
|
||||
.get_session_access_jti_by_id(session_id, &auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error in revoke_session: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("in revoke_session")?
|
||||
.ok_or(ApiError::SessionNotFound)?;
|
||||
state
|
||||
.session_repo
|
||||
.delete_session_by_id(session_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error deleting session: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("deleting session")?;
|
||||
let cache_key = format!("auth:session:{}:{}", &auth.did, access_jti);
|
||||
if let Err(e) = state.cache.delete(&cache_key).await {
|
||||
warn!("Failed to invalidate session cache: {:?}", e);
|
||||
}
|
||||
info!(did = %&auth.did, session_id = %session_id, "JWT session revoked");
|
||||
} else if let Some(oauth_id) = input.session_id.strip_prefix("oauth:") {
|
||||
let session_id: i32 = oauth_id
|
||||
.parse()
|
||||
let session_id = oauth_id
|
||||
.parse::<i32>()
|
||||
.map(TokenFamilyId::new)
|
||||
.map_err(|_| ApiError::InvalidRequest("Invalid session ID".into()))?;
|
||||
let deleted = state
|
||||
.oauth_repo
|
||||
.delete_session_by_id(session_id, &auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error deleting OAuth session: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("deleting OAuth session")?;
|
||||
if deleted == 0 {
|
||||
return Err(ApiError::SessionNotFound);
|
||||
}
|
||||
@@ -932,36 +889,24 @@ pub async fn revoke_all_sessions(
|
||||
.session_repo
|
||||
.delete_sessions_by_did(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error revoking JWT sessions: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("revoking JWT sessions")?;
|
||||
let jti_typed = TokenId::from(jti.clone());
|
||||
state
|
||||
.oauth_repo
|
||||
.delete_sessions_by_did_except(&auth.did, &jti_typed)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error revoking OAuth sessions: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("revoking OAuth sessions")?;
|
||||
} else {
|
||||
state
|
||||
.session_repo
|
||||
.delete_sessions_by_did_except_jti(&auth.did, &jti)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error revoking JWT sessions: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("revoking JWT sessions")?;
|
||||
state
|
||||
.oauth_repo
|
||||
.delete_sessions_by_did(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error revoking OAuth sessions: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("revoking OAuth sessions")?;
|
||||
}
|
||||
|
||||
info!(did = %&auth.did, "All other sessions revoked");
|
||||
@@ -983,10 +928,7 @@ pub async fn get_legacy_login_preference(
|
||||
.user_repo
|
||||
.get_legacy_login_pref(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("getting legacy login pref")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
Ok(Json(LegacyLoginPreferenceOutput {
|
||||
allow_legacy_login: pref.allow_legacy_login,
|
||||
@@ -1006,38 +948,26 @@ pub async fn update_legacy_login_preference(
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<UpdateLegacyLoginInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.did).await
|
||||
{
|
||||
return Ok(crate::api::server::reauth::legacy_mfa_required_response(
|
||||
&*state.user_repo,
|
||||
&*state.session_repo,
|
||||
&auth.did,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
let session_mfa = match require_legacy_session_mfa(&state, &auth).await {
|
||||
Ok(proof) => proof,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
if crate::api::server::reauth::check_reauth_required(&*state.session_repo, &auth.did).await {
|
||||
return Ok(crate::api::server::reauth::reauth_required_response(
|
||||
&*state.user_repo,
|
||||
&*state.session_repo,
|
||||
&auth.did,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
let reauth_mfa = match require_reauth_window(&state, &auth).await {
|
||||
Ok(proof) => proof,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
let updated = state
|
||||
.user_repo
|
||||
.update_legacy_login(&auth.did, input.allow_legacy_login)
|
||||
.update_legacy_login(reauth_mfa.did(), input.allow_legacy_login)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("updating legacy login")?;
|
||||
if !updated {
|
||||
return Err(ApiError::AccountNotFound);
|
||||
}
|
||||
info!(
|
||||
did = %&auth.did,
|
||||
did = %session_mfa.did(),
|
||||
allow_legacy_login = input.allow_legacy_login,
|
||||
"Legacy login preference updated"
|
||||
);
|
||||
@@ -1071,10 +1001,7 @@ pub async fn update_locale(
|
||||
.user_repo
|
||||
.update_locale(&auth.did, &input.preferred_locale)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error updating locale: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("updating locale")?;
|
||||
if !updated {
|
||||
return Err(ApiError::AccountNotFound);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
use crate::api::EmptyResponse;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::auth::{Active, Auth};
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use crate::auth::{
|
||||
decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, generate_qr_png_base64,
|
||||
generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format,
|
||||
verify_backup_code, verify_totp_code,
|
||||
Active, Auth, decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes,
|
||||
generate_qr_png_base64, generate_totp_secret, generate_totp_uri, hash_backup_code,
|
||||
is_backup_code_format, require_legacy_session_mfa, verify_backup_code, verify_password_mfa,
|
||||
verify_totp_code, verify_totp_mfa,
|
||||
};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message};
|
||||
use crate::state::AppState;
|
||||
use crate::types::PlainPassword;
|
||||
use crate::util::pds_hostname;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -30,9 +32,11 @@ pub async fn create_totp_secret(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Active>,
|
||||
) -> Result<Response, ApiError> {
|
||||
match state.user_repo.get_totp_record(&auth.did).await {
|
||||
Ok(Some(record)) if record.verified => return Err(ApiError::TotpAlreadyEnabled),
|
||||
Ok(_) => {}
|
||||
use tranquil_db_traits::TotpRecordState;
|
||||
|
||||
match state.user_repo.get_totp_record_state(&auth.did).await {
|
||||
Ok(Some(TotpRecordState::Verified(_))) => return Err(ApiError::TotpAlreadyEnabled),
|
||||
Ok(Some(TotpRecordState::Unverified(_))) | Ok(None) => {}
|
||||
Err(e) => {
|
||||
error!("DB error checking TOTP: {:?}", e);
|
||||
return Err(ApiError::InternalError(None));
|
||||
@@ -45,16 +49,13 @@ pub async fn create_totp_secret(
|
||||
.user_repo
|
||||
.get_handle_by_did(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching handle: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("fetching handle")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let uri = generate_totp_uri(&secret, &handle, &hostname);
|
||||
let hostname = pds_hostname();
|
||||
let uri = generate_totp_uri(&secret, &handle, hostname);
|
||||
|
||||
let qr_code = generate_qr_png_base64(&secret, &handle, &hostname).map_err(|e| {
|
||||
let qr_code = generate_qr_png_base64(&secret, &handle, hostname).map_err(|e| {
|
||||
error!("Failed to generate QR code: {:?}", e);
|
||||
ApiError::InternalError(Some("Failed to generate QR code".into()))
|
||||
})?;
|
||||
@@ -68,10 +69,7 @@ pub async fn create_totp_secret(
|
||||
.user_repo
|
||||
.upsert_totp_secret(&auth.did, &encrypted_secret, ENCRYPTION_VERSION)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to store TOTP secret: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("storing TOTP secret")?;
|
||||
|
||||
let secret_base32 = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &secret);
|
||||
|
||||
@@ -101,16 +99,18 @@ pub async fn enable_totp(
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<EnableTotpInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::TotpVerify, &auth.did)
|
||||
.await
|
||||
{
|
||||
warn!(did = %&auth.did, "TOTP verification rate limit exceeded");
|
||||
return Err(ApiError::RateLimitExceeded(None));
|
||||
}
|
||||
use tranquil_db_traits::TotpRecordState;
|
||||
|
||||
let totp_record = match state.user_repo.get_totp_record(&auth.did).await {
|
||||
Ok(Some(row)) => row,
|
||||
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
|
||||
&state,
|
||||
&auth.did,
|
||||
"Too many verification attempts. Please try again in a few minutes.",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let unverified_record = match state.user_repo.get_totp_record_state(&auth.did).await {
|
||||
Ok(Some(TotpRecordState::Unverified(record))) => record,
|
||||
Ok(Some(TotpRecordState::Verified(_))) => return Err(ApiError::TotpAlreadyEnabled),
|
||||
Ok(None) => return Err(ApiError::TotpNotEnabled),
|
||||
Err(e) => {
|
||||
error!("DB error fetching TOTP: {:?}", e);
|
||||
@@ -118,13 +118,9 @@ pub async fn enable_totp(
|
||||
}
|
||||
};
|
||||
|
||||
if totp_record.verified {
|
||||
return Err(ApiError::TotpAlreadyEnabled);
|
||||
}
|
||||
|
||||
let secret = decrypt_totp_secret(
|
||||
&totp_record.secret_encrypted,
|
||||
totp_record.encryption_version,
|
||||
&unverified_record.secret_encrypted,
|
||||
unverified_record.encryption_version,
|
||||
)
|
||||
.map_err(|e| {
|
||||
error!("Failed to decrypt TOTP secret: {:?}", e);
|
||||
@@ -152,10 +148,7 @@ pub async fn enable_totp(
|
||||
.user_repo
|
||||
.enable_totp_with_backup_codes(&auth.did, &backup_hashes)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to enable TOTP: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("enabling TOTP")?;
|
||||
|
||||
info!(did = %&auth.did, "TOTP enabled with {} backup codes", backup_codes.len());
|
||||
|
||||
@@ -173,79 +166,28 @@ pub async fn disable_totp(
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<DisableTotpInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.did).await
|
||||
{
|
||||
return Ok(crate::api::server::reauth::legacy_mfa_required_response(
|
||||
&*state.user_repo,
|
||||
&*state.session_repo,
|
||||
&auth.did,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::TotpVerify, &auth.did)
|
||||
.await
|
||||
{
|
||||
warn!(did = %&auth.did, "TOTP verification rate limit exceeded");
|
||||
return Err(ApiError::RateLimitExceeded(None));
|
||||
}
|
||||
|
||||
let password_hash = state
|
||||
.user_repo
|
||||
.get_password_hash_by_did(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching user: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
|
||||
if !password_valid {
|
||||
return Err(ApiError::InvalidPassword("Password is incorrect".into()));
|
||||
}
|
||||
|
||||
let totp_record = match state.user_repo.get_totp_record(&auth.did).await {
|
||||
Ok(Some(row)) if row.verified => row,
|
||||
Ok(Some(_)) | Ok(None) => return Err(ApiError::TotpNotEnabled),
|
||||
Err(e) => {
|
||||
error!("DB error fetching TOTP: {:?}", e);
|
||||
return Err(ApiError::InternalError(None));
|
||||
}
|
||||
let session_mfa = match require_legacy_session_mfa(&state, &auth).await {
|
||||
Ok(proof) => proof,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
let code = input.code.trim();
|
||||
let code_valid = if is_backup_code_format(code) {
|
||||
verify_backup_code_for_user(&state, &auth.did, code).await
|
||||
} else {
|
||||
let secret = decrypt_totp_secret(
|
||||
&totp_record.secret_encrypted,
|
||||
totp_record.encryption_version,
|
||||
)
|
||||
.map_err(|e| {
|
||||
error!("Failed to decrypt TOTP secret: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
verify_totp_code(&secret, code)
|
||||
};
|
||||
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
|
||||
&state,
|
||||
session_mfa.did(),
|
||||
"Too many verification attempts. Please try again in a few minutes.",
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !code_valid {
|
||||
return Err(ApiError::InvalidCode(Some(
|
||||
"Invalid verification code".into(),
|
||||
)));
|
||||
}
|
||||
let password_mfa = verify_password_mfa(&state, &auth, &input.password).await?;
|
||||
let totp_mfa = verify_totp_mfa(&state, &auth, &input.code).await?;
|
||||
|
||||
state
|
||||
.user_repo
|
||||
.delete_totp_and_backup_codes(&auth.did)
|
||||
.delete_totp_and_backup_codes(totp_mfa.did())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to delete TOTP: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("deleting TOTP")?;
|
||||
|
||||
info!(did = %&auth.did, "TOTP disabled");
|
||||
info!(did = %session_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method());
|
||||
|
||||
Ok(EmptyResponse::ok().into_response())
|
||||
}
|
||||
@@ -262,9 +204,11 @@ pub async fn get_totp_status(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Active>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let enabled = match state.user_repo.get_totp_record(&auth.did).await {
|
||||
Ok(Some(row)) => row.verified,
|
||||
Ok(None) => false,
|
||||
use tranquil_db_traits::TotpRecordState;
|
||||
|
||||
let enabled = match state.user_repo.get_totp_record_state(&auth.did).await {
|
||||
Ok(Some(TotpRecordState::Verified(_))) => true,
|
||||
Ok(Some(TotpRecordState::Unverified(_))) | Ok(None) => false,
|
||||
Err(e) => {
|
||||
error!("DB error fetching TOTP status: {:?}", e);
|
||||
return Err(ApiError::InternalError(None));
|
||||
@@ -275,10 +219,7 @@ pub async fn get_totp_status(
|
||||
.user_repo
|
||||
.count_unused_backup_codes(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error counting backup codes: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("counting backup codes")?;
|
||||
|
||||
Ok(Json(GetTotpStatusResponse {
|
||||
enabled,
|
||||
@@ -305,53 +246,15 @@ pub async fn regenerate_backup_codes(
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<RegenerateBackupCodesInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::TotpVerify, &auth.did)
|
||||
.await
|
||||
{
|
||||
warn!(did = %&auth.did, "TOTP verification rate limit exceeded");
|
||||
return Err(ApiError::RateLimitExceeded(None));
|
||||
}
|
||||
|
||||
let password_hash = state
|
||||
.user_repo
|
||||
.get_password_hash_by_did(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching user: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
|
||||
if !password_valid {
|
||||
return Err(ApiError::InvalidPassword("Password is incorrect".into()));
|
||||
}
|
||||
|
||||
let totp_record = match state.user_repo.get_totp_record(&auth.did).await {
|
||||
Ok(Some(row)) if row.verified => row,
|
||||
Ok(Some(_)) | Ok(None) => return Err(ApiError::TotpNotEnabled),
|
||||
Err(e) => {
|
||||
error!("DB error fetching TOTP: {:?}", e);
|
||||
return Err(ApiError::InternalError(None));
|
||||
}
|
||||
};
|
||||
|
||||
let secret = decrypt_totp_secret(
|
||||
&totp_record.secret_encrypted,
|
||||
totp_record.encryption_version,
|
||||
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
|
||||
&state,
|
||||
&auth.did,
|
||||
"Too many verification attempts. Please try again in a few minutes.",
|
||||
)
|
||||
.map_err(|e| {
|
||||
error!("Failed to decrypt TOTP secret: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.await?;
|
||||
|
||||
let code = input.code.trim();
|
||||
if !verify_totp_code(&secret, code) {
|
||||
return Err(ApiError::InvalidCode(Some(
|
||||
"Invalid verification code".into(),
|
||||
)));
|
||||
}
|
||||
let password_mfa = verify_password_mfa(&state, &auth, &input.password).await?;
|
||||
let totp_mfa = verify_totp_mfa(&state, &auth, &input.code).await?;
|
||||
|
||||
let backup_codes = generate_backup_codes();
|
||||
let backup_hashes: Vec<_> = backup_codes
|
||||
@@ -365,14 +268,11 @@ pub async fn regenerate_backup_codes(
|
||||
|
||||
state
|
||||
.user_repo
|
||||
.replace_backup_codes(&auth.did, &backup_hashes)
|
||||
.replace_backup_codes(totp_mfa.did(), &backup_hashes)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to regenerate backup codes: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("replacing backup codes")?;
|
||||
|
||||
info!(did = %&auth.did, "Backup codes regenerated");
|
||||
info!(did = %password_mfa.did(), "Backup codes regenerated (verified via {} and {})", password_mfa.method(), totp_mfa.method());
|
||||
|
||||
Ok(Json(RegenerateBackupCodesResponse { backup_codes }).into_response())
|
||||
}
|
||||
@@ -410,20 +310,22 @@ pub async fn verify_totp_or_backup_for_user(
|
||||
did: &crate::types::Did,
|
||||
code: &str,
|
||||
) -> bool {
|
||||
use tranquil_db_traits::TotpRecordState;
|
||||
|
||||
let code = code.trim();
|
||||
|
||||
if is_backup_code_format(code) {
|
||||
return verify_backup_code_for_user(state, did, code).await;
|
||||
}
|
||||
|
||||
let totp_record = match state.user_repo.get_totp_record(did).await {
|
||||
Ok(Some(row)) if row.verified => row,
|
||||
let verified_record = match state.user_repo.get_totp_record_state(did).await {
|
||||
Ok(Some(TotpRecordState::Verified(record))) => record,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
let secret = match decrypt_totp_secret(
|
||||
&totp_record.secret_encrypted,
|
||||
totp_record.encryption_version,
|
||||
&verified_record.secret_encrypted,
|
||||
verified_record.encryption_version,
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::api::SuccessResponse;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -79,10 +79,7 @@ pub async fn list_trusted_devices(
|
||||
.oauth_repo
|
||||
.list_trusted_devices(&auth.did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("listing trusted devices")?;
|
||||
|
||||
let devices = rows
|
||||
.into_iter()
|
||||
@@ -134,10 +131,7 @@ pub async fn revoke_trusted_device(
|
||||
.oauth_repo
|
||||
.revoke_device_trust(&device_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("revoking device trust")?;
|
||||
|
||||
info!(did = %&auth.did, device_id = %input.device_id, "Trusted device revoked");
|
||||
Ok(SuccessResponse::ok().into_response())
|
||||
@@ -175,10 +169,7 @@ pub async fn update_trusted_device(
|
||||
.oauth_repo
|
||||
.update_device_friendly_name(&device_id, input.friendly_name.as_deref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("updating device friendly name")?;
|
||||
|
||||
info!(did = %auth.did, device_id = %input.device_id, "Trusted device updated");
|
||||
Ok(SuccessResponse::ok().into_response())
|
||||
|
||||
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::util::pds_hostname;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -70,7 +71,7 @@ pub async fn resend_migration_verification(
|
||||
return Ok(Json(ResendMigrationVerificationOutput { sent: true }));
|
||||
}
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname = pds_hostname();
|
||||
let token = crate::auth::verification_token::generate_migration_token(&user.did, &email);
|
||||
let formatted_token = crate::auth::verification_token::format_token_for_display(&token);
|
||||
|
||||
@@ -80,7 +81,7 @@ pub async fn resend_migration_verification(
|
||||
user.id,
|
||||
&email,
|
||||
&formatted_token,
|
||||
&hostname,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::error::{ApiError, DbResultExt};
|
||||
use crate::types::Did;
|
||||
use axum::{Json, extract::State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, info, warn};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::auth::verification_token::{
|
||||
VerificationPurpose, normalize_token_input, verify_token_signature,
|
||||
@@ -81,25 +81,19 @@ async fn handle_migration_verification(
|
||||
.user_repo
|
||||
.get_verification_info(&did_typed)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(error = ?e, "Database error during migration verification");
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("during migration verification")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
if user.email.as_ref().map(|e| e.to_lowercase()) != Some(identifier.to_string()) {
|
||||
return Err(ApiError::IdentifierMismatch);
|
||||
}
|
||||
|
||||
if !user.email_verified {
|
||||
if !user.channel_verification.email {
|
||||
state
|
||||
.user_repo
|
||||
.set_email_verified_flag(user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(error = ?e, "Failed to update email_verified status");
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("updating email_verified status")?;
|
||||
}
|
||||
|
||||
info!(did = %did, "Migration email verified successfully");
|
||||
@@ -125,7 +119,7 @@ async fn handle_channel_update(
|
||||
.user_repo
|
||||
.get_id_by_did(&did_typed)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(None))?
|
||||
.log_db_err("fetching user id")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
match channel {
|
||||
@@ -134,10 +128,7 @@ async fn handle_channel_update(
|
||||
.user_repo
|
||||
.verify_email_channel(user_id, identifier)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to update email channel: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("updating email channel")?;
|
||||
if !success {
|
||||
return Err(ApiError::EmailTaken);
|
||||
}
|
||||
@@ -147,30 +138,21 @@ async fn handle_channel_update(
|
||||
.user_repo
|
||||
.verify_discord_channel(user_id, identifier)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to update discord channel: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("updating discord channel")?;
|
||||
}
|
||||
"telegram" => {
|
||||
state
|
||||
.user_repo
|
||||
.verify_telegram_channel(user_id, identifier)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to update telegram channel: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("updating telegram channel")?;
|
||||
}
|
||||
"signal" => {
|
||||
state
|
||||
.user_repo
|
||||
.verify_signal_channel(user_id, identifier)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to update signal channel: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("updating signal channel")?;
|
||||
}
|
||||
_ => {
|
||||
return Err(ApiError::InvalidChannel);
|
||||
@@ -200,16 +182,10 @@ async fn handle_signup_verification(
|
||||
.user_repo
|
||||
.get_verification_info(&did_typed)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(error = ?e, "Database error during signup verification");
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.log_db_err("during signup verification")?
|
||||
.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let is_verified = user.email_verified
|
||||
|| user.discord_verified
|
||||
|| user.telegram_verified
|
||||
|| user.signal_verified;
|
||||
let is_verified = user.channel_verification.has_any_verified();
|
||||
if is_verified {
|
||||
info!(did = %did, "Account already verified");
|
||||
return Ok(Json(VerifyTokenOutput {
|
||||
@@ -226,40 +202,28 @@ async fn handle_signup_verification(
|
||||
.user_repo
|
||||
.set_email_verified_flag(user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(error = ?e, "Failed to update email verified status");
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("updating email verified status")?;
|
||||
}
|
||||
"discord" => {
|
||||
state
|
||||
.user_repo
|
||||
.set_discord_verified_flag(user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(error = ?e, "Failed to update discord verified status");
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("updating discord verified status")?;
|
||||
}
|
||||
"telegram" => {
|
||||
state
|
||||
.user_repo
|
||||
.set_telegram_verified_flag(user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(error = ?e, "Failed to update telegram verified status");
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("updating telegram verified status")?;
|
||||
}
|
||||
"signal" => {
|
||||
state
|
||||
.user_repo
|
||||
.set_signal_verified_flag(user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(error = ?e, "Failed to update signal verified status");
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
.log_db_err("updating signal verified status")?;
|
||||
}
|
||||
_ => {
|
||||
return Err(ApiError::InvalidChannel);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
use super::AuthenticatedUser;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::types::Did;
|
||||
|
||||
pub struct AccountVerified<'a> {
|
||||
user: &'a AuthenticatedUser,
|
||||
}
|
||||
|
||||
impl<'a> AccountVerified<'a> {
|
||||
pub fn did(&self) -> &Did {
|
||||
&self.user.did
|
||||
}
|
||||
|
||||
pub fn user(&self) -> &AuthenticatedUser {
|
||||
self.user
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn require_verified_or_delegated<'a>(
|
||||
state: &AppState,
|
||||
user: &'a AuthenticatedUser,
|
||||
) -> Result<AccountVerified<'a>, Response> {
|
||||
let is_verified = state
|
||||
.user_repo
|
||||
.has_verified_comms_channel(&user.did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_verified {
|
||||
return Ok(AccountVerified { user });
|
||||
}
|
||||
|
||||
let is_delegated = state
|
||||
.delegation_repo
|
||||
.is_delegated_account(&user.did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_delegated {
|
||||
return Ok(AccountVerified { user });
|
||||
}
|
||||
|
||||
Err(ApiError::AccountNotVerified.into_response())
|
||||
}
|
||||
|
||||
pub async fn require_not_migrated(state: &AppState, did: &Did) -> Result<(), Response> {
|
||||
match state.user_repo.is_account_migrated(did).await {
|
||||
Ok(true) => Err(ApiError::AccountMigrated.into_response()),
|
||||
Ok(false) => Ok(()),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to check migration status: {:?}", e);
|
||||
Err(
|
||||
ApiError::InternalError(Some("Failed to verify migration status".into()))
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,547 +0,0 @@
|
||||
mod common;
|
||||
mod helpers;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::Utc;
|
||||
use common::{base_url, client, create_account_and_login, pds_endpoint};
|
||||
use helpers::verify_new_account;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn generate_pkce() -> (String, String) {
|
||||
let verifier_bytes: [u8; 32] = rand::random();
|
||||
let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(code_verifier.as_bytes());
|
||||
let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
|
||||
(code_verifier, code_challenge)
|
||||
}
|
||||
|
||||
async fn setup_mock_client_metadata(redirect_uri: &str, dpop_bound: bool) -> MockServer {
|
||||
let mock_server = MockServer::start().await;
|
||||
let metadata = json!({
|
||||
"client_id": mock_server.uri(),
|
||||
"client_name": "Auth Extractor Test Client",
|
||||
"redirect_uris": [redirect_uri],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
"dpop_bound_access_tokens": dpop_bound
|
||||
});
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(metadata))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
mock_server
|
||||
}
|
||||
|
||||
async fn get_oauth_session(
|
||||
http_client: &reqwest::Client,
|
||||
url: &str,
|
||||
dpop_bound: bool,
|
||||
) -> (String, String, String, String) {
|
||||
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
|
||||
let handle = format!("ae{}", suffix);
|
||||
let password = "AuthExtract123!";
|
||||
let create_res = http_client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": password
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(create_res.status(), StatusCode::OK);
|
||||
let account: Value = create_res.json().await.unwrap();
|
||||
let did = account["did"].as_str().unwrap().to_string();
|
||||
verify_new_account(http_client, &did).await;
|
||||
|
||||
let redirect_uri = "https://example.com/auth-callback";
|
||||
let mock_client = setup_mock_client_metadata(redirect_uri, dpop_bound).await;
|
||||
let client_id = mock_client.uri();
|
||||
let (code_verifier, code_challenge) = generate_pkce();
|
||||
|
||||
let par_body: Value = http_client
|
||||
.post(format!("{}/oauth/par", url))
|
||||
.form(&[
|
||||
("response_type", "code"),
|
||||
("client_id", &client_id),
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_challenge", &code_challenge),
|
||||
("code_challenge_method", "S256"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let request_uri = par_body["request_uri"].as_str().unwrap();
|
||||
|
||||
let auth_res = http_client
|
||||
.post(format!("{}/oauth/authorize", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.json(&json!({
|
||||
"request_uri": request_uri,
|
||||
"username": &handle,
|
||||
"password": password,
|
||||
"remember_device": false
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let auth_body: Value = auth_res.json().await.unwrap();
|
||||
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
|
||||
|
||||
if location.contains("/oauth/consent") {
|
||||
let consent_res = http_client
|
||||
.post(format!("{}/oauth/authorize/consent", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({
|
||||
"request_uri": request_uri,
|
||||
"approved_scopes": ["atproto"],
|
||||
"remember": false
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let consent_body: Value = consent_res.json().await.unwrap();
|
||||
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
|
||||
}
|
||||
|
||||
let code = location
|
||||
.split("code=")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split('&')
|
||||
.next()
|
||||
.unwrap();
|
||||
|
||||
let token_body: Value = http_client
|
||||
.post(format!("{}/oauth/token", url))
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("code", code),
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_verifier", &code_verifier),
|
||||
("client_id", &client_id),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
(
|
||||
token_body["access_token"].as_str().unwrap().to_string(),
|
||||
token_body["refresh_token"].as_str().unwrap().to_string(),
|
||||
client_id,
|
||||
did,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oauth_token_works_with_bearer_auth() {
|
||||
let url = base_url().await;
|
||||
let http_client = client();
|
||||
let (access_token, _, _, did) = get_oauth_session(&http_client, url, false).await;
|
||||
|
||||
let res = http_client
|
||||
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
||||
.bearer_auth(&access_token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK, "OAuth token should work with RequiredAuth extractor");
|
||||
let body: Value = res.json().await.unwrap();
|
||||
assert_eq!(body["did"].as_str().unwrap(), did);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_token_still_works() {
|
||||
let url = base_url().await;
|
||||
let http_client = client();
|
||||
let (jwt, did) = create_account_and_login(&http_client).await;
|
||||
|
||||
let res = http_client
|
||||
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
||||
.bearer_auth(&jwt)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK, "Session token should still work");
|
||||
let body: Value = res.json().await.unwrap();
|
||||
assert_eq!(body["did"].as_str().unwrap(), did);
|
||||
}
|
||||
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oauth_admin_extractor_allows_oauth_tokens() {
|
||||
let url = base_url().await;
|
||||
let http_client = client();
|
||||
|
||||
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
|
||||
let handle = format!("adm{}", suffix);
|
||||
let password = "AdminOAuth123!";
|
||||
let create_res = http_client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": password
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(create_res.status(), StatusCode::OK);
|
||||
let account: Value = create_res.json().await.unwrap();
|
||||
let did = account["did"].as_str().unwrap().to_string();
|
||||
verify_new_account(&http_client, &did).await;
|
||||
|
||||
let pool = common::get_test_db_pool().await;
|
||||
sqlx::query!("UPDATE users SET is_admin = TRUE WHERE did = $1", &did)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("Failed to mark user as admin");
|
||||
|
||||
let redirect_uri = "https://example.com/admin-callback";
|
||||
let mock_client = setup_mock_client_metadata(redirect_uri, false).await;
|
||||
let client_id = mock_client.uri();
|
||||
let (code_verifier, code_challenge) = generate_pkce();
|
||||
|
||||
let par_body: Value = http_client
|
||||
.post(format!("{}/oauth/par", url))
|
||||
.form(&[
|
||||
("response_type", "code"),
|
||||
("client_id", &client_id),
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_challenge", &code_challenge),
|
||||
("code_challenge_method", "S256"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let request_uri = par_body["request_uri"].as_str().unwrap();
|
||||
|
||||
let auth_res = http_client
|
||||
.post(format!("{}/oauth/authorize", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.json(&json!({
|
||||
"request_uri": request_uri,
|
||||
"username": &handle,
|
||||
"password": password,
|
||||
"remember_device": false
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let auth_body: Value = auth_res.json().await.unwrap();
|
||||
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
|
||||
if location.contains("/oauth/consent") {
|
||||
let consent_res = http_client
|
||||
.post(format!("{}/oauth/authorize/consent", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({
|
||||
"request_uri": request_uri,
|
||||
"approved_scopes": ["atproto"],
|
||||
"remember": false
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let consent_body: Value = consent_res.json().await.unwrap();
|
||||
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
|
||||
}
|
||||
|
||||
let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
|
||||
let token_body: Value = http_client
|
||||
.post(format!("{}/oauth/token", url))
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("code", code),
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_verifier", &code_verifier),
|
||||
("client_id", &client_id),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let access_token = token_body["access_token"].as_str().unwrap();
|
||||
|
||||
let res = http_client
|
||||
.get(format!("{}/xrpc/com.atproto.admin.getAccountInfos?dids={}", url, did))
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
res.status(),
|
||||
StatusCode::OK,
|
||||
"OAuth token for admin user should work with admin endpoint"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_expired_oauth_token_returns_proper_error() {
|
||||
let url = base_url().await;
|
||||
let http_client = client();
|
||||
|
||||
let now = Utc::now().timestamp();
|
||||
let header = json!({"alg": "HS256", "typ": "at+jwt"});
|
||||
let payload = json!({
|
||||
"iss": url,
|
||||
"sub": "did:plc:test123",
|
||||
"aud": url,
|
||||
"iat": now - 7200,
|
||||
"exp": now - 3600,
|
||||
"jti": "expired-token",
|
||||
"sid": "expired-session",
|
||||
"scope": "atproto",
|
||||
"client_id": "https://example.com"
|
||||
});
|
||||
let fake_token = format!(
|
||||
"{}.{}.{}",
|
||||
URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()),
|
||||
URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()),
|
||||
URL_SAFE_NO_PAD.encode([1u8; 32])
|
||||
);
|
||||
|
||||
let res = http_client
|
||||
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
||||
.bearer_auth(&fake_token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
res.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Expired token should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dpop_nonce_error_has_proper_headers() {
|
||||
let url = base_url().await;
|
||||
let pds_url = pds_endpoint();
|
||||
let http_client = client();
|
||||
|
||||
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
|
||||
let handle = format!("dpop{}", suffix);
|
||||
let create_res = http_client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@test.com", handle),
|
||||
"password": "DpopTest123!"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(create_res.status(), StatusCode::OK);
|
||||
let account: Value = create_res.json().await.unwrap();
|
||||
let did = account["did"].as_str().unwrap();
|
||||
verify_new_account(&http_client, did).await;
|
||||
|
||||
let redirect_uri = "https://example.com/dpop-callback";
|
||||
let mock_server = MockServer::start().await;
|
||||
let client_id = mock_server.uri();
|
||||
let metadata = json!({
|
||||
"client_id": &client_id,
|
||||
"client_name": "DPoP Test Client",
|
||||
"redirect_uris": [redirect_uri],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
"dpop_bound_access_tokens": true
|
||||
});
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(metadata))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let (code_verifier, code_challenge) = generate_pkce();
|
||||
let par_body: Value = http_client
|
||||
.post(format!("{}/oauth/par", url))
|
||||
.form(&[
|
||||
("response_type", "code"),
|
||||
("client_id", &client_id),
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_challenge", &code_challenge),
|
||||
("code_challenge_method", "S256"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let request_uri = par_body["request_uri"].as_str().unwrap();
|
||||
let auth_res = http_client
|
||||
.post(format!("{}/oauth/authorize", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.json(&json!({
|
||||
"request_uri": request_uri,
|
||||
"username": &handle,
|
||||
"password": "DpopTest123!",
|
||||
"remember_device": false
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let auth_body: Value = auth_res.json().await.unwrap();
|
||||
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
|
||||
if location.contains("/oauth/consent") {
|
||||
let consent_res = http_client
|
||||
.post(format!("{}/oauth/authorize/consent", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({
|
||||
"request_uri": request_uri,
|
||||
"approved_scopes": ["atproto"],
|
||||
"remember": false
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let consent_body: Value = consent_res.json().await.unwrap();
|
||||
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
|
||||
}
|
||||
|
||||
let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
|
||||
|
||||
let token_endpoint = format!("{}/oauth/token", pds_url);
|
||||
let (_, dpop_proof) = generate_dpop_proof("POST", &token_endpoint, None);
|
||||
|
||||
let token_res = http_client
|
||||
.post(format!("{}/oauth/token", url))
|
||||
.header("DPoP", &dpop_proof)
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("code", code),
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_verifier", &code_verifier),
|
||||
("client_id", &client_id),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let token_status = token_res.status();
|
||||
let token_nonce = token_res.headers().get("dpop-nonce").map(|h| h.to_str().unwrap().to_string());
|
||||
let token_body: Value = token_res.json().await.unwrap();
|
||||
|
||||
let access_token = if token_status == StatusCode::OK {
|
||||
token_body["access_token"].as_str().unwrap().to_string()
|
||||
} else if token_body.get("error").and_then(|e| e.as_str()) == Some("use_dpop_nonce") {
|
||||
let nonce = token_nonce.expect("Token endpoint should return DPoP-Nonce on use_dpop_nonce error");
|
||||
let (_, dpop_proof_with_nonce) = generate_dpop_proof("POST", &token_endpoint, Some(&nonce));
|
||||
|
||||
let retry_res = http_client
|
||||
.post(format!("{}/oauth/token", url))
|
||||
.header("DPoP", &dpop_proof_with_nonce)
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("code", code),
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_verifier", &code_verifier),
|
||||
("client_id", &client_id),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let retry_body: Value = retry_res.json().await.unwrap();
|
||||
retry_body["access_token"].as_str().expect("Should get access_token after nonce retry").to_string()
|
||||
} else {
|
||||
panic!("Token exchange failed unexpectedly: {:?}", token_body);
|
||||
};
|
||||
|
||||
let res = http_client
|
||||
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
||||
.header("Authorization", format!("DPoP {}", access_token))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "DPoP token without proof should fail");
|
||||
|
||||
let www_auth = res.headers().get("www-authenticate").map(|h| h.to_str().unwrap());
|
||||
assert!(www_auth.is_some(), "Should have WWW-Authenticate header");
|
||||
assert!(
|
||||
www_auth.unwrap().contains("use_dpop_nonce"),
|
||||
"WWW-Authenticate should indicate dpop nonce required"
|
||||
);
|
||||
|
||||
let nonce = res.headers().get("dpop-nonce").map(|h| h.to_str().unwrap());
|
||||
assert!(nonce.is_some(), "Should return DPoP-Nonce header");
|
||||
|
||||
let body: Value = res.json().await.unwrap();
|
||||
assert_eq!(body["error"].as_str().unwrap(), "use_dpop_nonce");
|
||||
}
|
||||
|
||||
fn generate_dpop_proof(method: &str, uri: &str, nonce: Option<&str>) -> (Value, String) {
|
||||
use p256::ecdsa::{SigningKey, signature::Signer};
|
||||
use p256::elliptic_curve::rand_core::OsRng;
|
||||
|
||||
let signing_key = SigningKey::random(&mut OsRng);
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
let point = verifying_key.to_encoded_point(false);
|
||||
let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
|
||||
let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
|
||||
|
||||
let jwk = json!({
|
||||
"kty": "EC",
|
||||
"crv": "P-256",
|
||||
"x": x,
|
||||
"y": y
|
||||
});
|
||||
|
||||
let header = {
|
||||
let h = json!({
|
||||
"typ": "dpop+jwt",
|
||||
"alg": "ES256",
|
||||
"jwk": jwk.clone()
|
||||
});
|
||||
h
|
||||
};
|
||||
|
||||
let mut payload = json!({
|
||||
"jti": uuid::Uuid::new_v4().to_string(),
|
||||
"htm": method,
|
||||
"htu": uri,
|
||||
"iat": Utc::now().timestamp()
|
||||
});
|
||||
if let Some(n) = nonce {
|
||||
payload["nonce"] = json!(n);
|
||||
}
|
||||
|
||||
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
|
||||
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
|
||||
let signing_input = format!("{}.{}", header_b64, payload_b64);
|
||||
|
||||
let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes());
|
||||
let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
|
||||
|
||||
let proof = format!("{}.{}", signing_input, sig_b64);
|
||||
(jwk, proof)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use tracing::{debug, error, info};
|
||||
|
||||
use super::{
|
||||
AccountStatus, AuthSource, AuthenticatedUser, ServiceTokenClaims, ServiceTokenVerifier,
|
||||
is_service_token, validate_bearer_token_for_service_auth,
|
||||
is_service_token, scope_verified::VerifyScope, validate_bearer_token_for_service_auth,
|
||||
};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::oauth::scopes::{RepoAction, ScopePermissions};
|
||||
@@ -293,7 +293,7 @@ async fn extract_auth_internal(
|
||||
return Ok(ExtractedAuth::Service(claims));
|
||||
}
|
||||
|
||||
let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok());
|
||||
let dpop_proof = crate::util::get_header_str(&parts.headers, "DPoP");
|
||||
let method = parts.method.as_str();
|
||||
let uri = build_full_url(&parts.uri.to_string());
|
||||
|
||||
@@ -358,6 +358,22 @@ impl<P: AuthPolicy> std::ops::Deref for Auth<P> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: AuthPolicy> AsRef<AuthenticatedUser> for Auth<P> {
|
||||
fn as_ref(&self) -> &AuthenticatedUser {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: AuthPolicy> VerifyScope for Auth<P> {
|
||||
fn needs_scope_check(&self) -> bool {
|
||||
self.0.is_oauth()
|
||||
}
|
||||
|
||||
fn permissions(&self) -> ScopePermissions {
|
||||
self.0.permissions()
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: AuthPolicy> FromRequestParts<AppState> for Auth<P> {
|
||||
type Rejection = AuthError;
|
||||
|
||||
@@ -418,10 +434,7 @@ impl FromRequestParts<AppState> for ServiceAuth {
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
match extract_auth_internal(parts, state).await? {
|
||||
ExtractedAuth::Service(claims) => {
|
||||
let did: Did = claims
|
||||
.iss
|
||||
.parse()
|
||||
.map_err(|_| AuthError::AuthenticationFailed)?;
|
||||
let did = claims.iss.clone();
|
||||
Ok(ServiceAuth { did, claims })
|
||||
}
|
||||
ExtractedAuth::User(_) => Err(AuthError::AuthenticationFailed),
|
||||
@@ -438,10 +451,7 @@ impl OptionalFromRequestParts<AppState> for ServiceAuth {
|
||||
) -> Result<Option<Self>, Self::Rejection> {
|
||||
match extract_auth_internal(parts, state).await {
|
||||
Ok(ExtractedAuth::Service(claims)) => {
|
||||
let did: Did = claims
|
||||
.iss
|
||||
.parse()
|
||||
.map_err(|_| AuthError::AuthenticationFailed)?;
|
||||
let did = claims.iss.clone();
|
||||
Ok(Some(ServiceAuth { did, claims }))
|
||||
}
|
||||
Ok(ExtractedAuth::User(_)) => Err(AuthError::AuthenticationFailed),
|
||||
@@ -503,10 +513,7 @@ impl<P: AuthPolicy> FromRequestParts<AppState> for AuthAny<P> {
|
||||
Ok(AuthAny::User(Auth(user, PhantomData)))
|
||||
}
|
||||
ExtractedAuth::Service(claims) => {
|
||||
let did: Did = claims
|
||||
.iss
|
||||
.parse()
|
||||
.map_err(|_| AuthError::AuthenticationFailed)?;
|
||||
let did = claims.iss.clone();
|
||||
Ok(AuthAny::Service(ServiceAuth { did, claims }))
|
||||
}
|
||||
}
|
||||
@@ -526,10 +533,7 @@ impl<P: AuthPolicy> OptionalFromRequestParts<AppState> for AuthAny<P> {
|
||||
Ok(Some(AuthAny::User(Auth(user, PhantomData))))
|
||||
}
|
||||
Ok(ExtractedAuth::Service(claims)) => {
|
||||
let did: Did = claims
|
||||
.iss
|
||||
.parse()
|
||||
.map_err(|_| AuthError::AuthenticationFailed)?;
|
||||
let did = claims.iss.clone();
|
||||
Ok(Some(AuthAny::Service(ServiceAuth { did, claims })))
|
||||
}
|
||||
Err(AuthError::MissingToken) => Ok(None),
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NormalizedLoginIdentifier(String);
|
||||
|
||||
impl NormalizedLoginIdentifier {
|
||||
pub fn normalize(identifier: &str, pds_hostname: &str) -> Self {
|
||||
let trimmed = identifier.trim();
|
||||
let stripped = trimmed.strip_prefix('@').unwrap_or(trimmed);
|
||||
|
||||
let normalized = match () {
|
||||
_ if stripped.starts_with("did:") => stripped.to_string(),
|
||||
_ if stripped.contains('@') => stripped.to_string(),
|
||||
_ if !stripped.contains('.') => {
|
||||
format!("{}.{}", stripped.to_lowercase(), pds_hostname)
|
||||
}
|
||||
_ => stripped.to_lowercase(),
|
||||
};
|
||||
|
||||
Self(normalized)
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for NormalizedLoginIdentifier {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for NormalizedLoginIdentifier {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BareLoginIdentifier(String);
|
||||
|
||||
impl BareLoginIdentifier {
|
||||
pub fn from_identifier(identifier: &str, pds_hostname: &str) -> Self {
|
||||
let trimmed = identifier.trim();
|
||||
let stripped = trimmed.strip_prefix('@').unwrap_or(trimmed);
|
||||
let suffix = format!(".{}", pds_hostname);
|
||||
let bare = stripped.strip_suffix(&suffix).unwrap_or(stripped);
|
||||
Self(bare.to_string())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for BareLoginIdentifier {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for BareLoginIdentifier {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalized_identifier_handles_did() {
|
||||
let id = NormalizedLoginIdentifier::normalize("did:plc:abc123", "example.com");
|
||||
assert_eq!(id.as_str(), "did:plc:abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_identifier_handles_email() {
|
||||
let id = NormalizedLoginIdentifier::normalize("user@example.org", "pds.example.com");
|
||||
assert_eq!(id.as_str(), "user@example.org");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_identifier_handles_bare_handle() {
|
||||
let id = NormalizedLoginIdentifier::normalize("alice", "pds.example.com");
|
||||
assert_eq!(id.as_str(), "alice.pds.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_identifier_handles_bare_handle_with_at_prefix() {
|
||||
let id = NormalizedLoginIdentifier::normalize("@alice", "pds.example.com");
|
||||
assert_eq!(id.as_str(), "alice.pds.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_identifier_handles_full_handle() {
|
||||
let id = NormalizedLoginIdentifier::normalize("alice.bsky.social", "pds.example.com");
|
||||
assert_eq!(id.as_str(), "alice.bsky.social");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_identifier_handles_uppercase() {
|
||||
let id = NormalizedLoginIdentifier::normalize("ALICE", "pds.example.com");
|
||||
assert_eq!(id.as_str(), "alice.pds.example.com");
|
||||
|
||||
let id2 = NormalizedLoginIdentifier::normalize("ALICE.BSKY.SOCIAL", "pds.example.com");
|
||||
assert_eq!(id2.as_str(), "alice.bsky.social");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_identifier_trims_whitespace() {
|
||||
let id = NormalizedLoginIdentifier::normalize(" alice ", "pds.example.com");
|
||||
assert_eq!(id.as_str(), "alice.pds.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_identifier_strips_hostname_suffix() {
|
||||
let id = BareLoginIdentifier::from_identifier("alice.pds.example.com", "pds.example.com");
|
||||
assert_eq!(id.as_str(), "alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_identifier_preserves_non_matching() {
|
||||
let id = BareLoginIdentifier::from_identifier("alice.bsky.social", "pds.example.com");
|
||||
assert_eq!(id.as_str(), "alice.bsky.social");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_identifier_strips_at_prefix() {
|
||||
let id = BareLoginIdentifier::from_identifier("@alice.pds.example.com", "pds.example.com");
|
||||
assert_eq!(id.as_str(), "alice");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
use axum::response::Response;
|
||||
|
||||
use super::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
use crate::types::Did;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MfaMethod {
|
||||
Totp,
|
||||
Passkey,
|
||||
Password,
|
||||
RecoveryCode,
|
||||
SessionReauth,
|
||||
}
|
||||
|
||||
impl MfaMethod {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Totp => "totp",
|
||||
Self::Passkey => "passkey",
|
||||
Self::Password => "password",
|
||||
Self::RecoveryCode => "recovery_code",
|
||||
Self::SessionReauth => "session_reauth",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MfaMethod {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MfaVerified<'a> {
|
||||
user: &'a AuthenticatedUser,
|
||||
method: MfaMethod,
|
||||
}
|
||||
|
||||
impl<'a> MfaVerified<'a> {
|
||||
fn new(user: &'a AuthenticatedUser, method: MfaMethod) -> Self {
|
||||
Self { user, method }
|
||||
}
|
||||
|
||||
pub(crate) fn from_totp(user: &'a AuthenticatedUser) -> Self {
|
||||
Self::new(user, MfaMethod::Totp)
|
||||
}
|
||||
|
||||
pub(crate) fn from_password(user: &'a AuthenticatedUser) -> Self {
|
||||
Self::new(user, MfaMethod::Password)
|
||||
}
|
||||
|
||||
pub(crate) fn from_recovery_code(user: &'a AuthenticatedUser) -> Self {
|
||||
Self::new(user, MfaMethod::RecoveryCode)
|
||||
}
|
||||
|
||||
pub(crate) fn from_session_reauth(user: &'a AuthenticatedUser) -> Self {
|
||||
Self::new(user, MfaMethod::SessionReauth)
|
||||
}
|
||||
|
||||
pub fn user(&self) -> &AuthenticatedUser {
|
||||
self.user
|
||||
}
|
||||
|
||||
pub fn did(&self) -> &Did {
|
||||
&self.user.did
|
||||
}
|
||||
|
||||
pub fn method(&self) -> MfaMethod {
|
||||
self.method
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn require_legacy_session_mfa<'a>(
|
||||
state: &AppState,
|
||||
user: &'a AuthenticatedUser,
|
||||
) -> Result<MfaVerified<'a>, Response> {
|
||||
use crate::api::server::reauth::{check_legacy_session_mfa, legacy_mfa_required_response};
|
||||
|
||||
if check_legacy_session_mfa(&*state.session_repo, &user.did).await {
|
||||
Ok(MfaVerified::from_session_reauth(user))
|
||||
} else {
|
||||
Err(legacy_mfa_required_response(&*state.user_repo, &*state.session_repo, &user.did).await)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn require_reauth_window<'a>(
|
||||
state: &AppState,
|
||||
user: &'a AuthenticatedUser,
|
||||
) -> Result<MfaVerified<'a>, Response> {
|
||||
use crate::api::server::reauth::{REAUTH_WINDOW_SECONDS, reauth_required_response};
|
||||
use chrono::Utc;
|
||||
|
||||
let status = state
|
||||
.session_repo
|
||||
.get_session_mfa_status(&user.did)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
match status {
|
||||
Some(s) => {
|
||||
if let Some(last_reauth) = s.last_reauth_at {
|
||||
let elapsed = Utc::now().signed_duration_since(last_reauth);
|
||||
if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS {
|
||||
return Ok(MfaVerified::from_session_reauth(user));
|
||||
}
|
||||
}
|
||||
Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await)
|
||||
}
|
||||
None => {
|
||||
Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn require_reauth_window_if_available<'a>(
|
||||
state: &AppState,
|
||||
user: &'a AuthenticatedUser,
|
||||
) -> Result<Option<MfaVerified<'a>>, Response> {
|
||||
use crate::api::server::reauth::{check_reauth_required_cached, reauth_required_response};
|
||||
|
||||
let has_password = state
|
||||
.user_repo
|
||||
.has_password_by_did(&user.did)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(false);
|
||||
let has_passkeys = state
|
||||
.user_repo
|
||||
.has_passkeys(&user.did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let has_totp = state
|
||||
.user_repo
|
||||
.has_totp_enabled(&user.did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
let has_any_reauth_method = has_password || has_passkeys || has_totp;
|
||||
|
||||
if !has_any_reauth_method {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if check_reauth_required_cached(&*state.session_repo, &state.cache, &user.did).await {
|
||||
Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await)
|
||||
} else {
|
||||
Ok(Some(MfaVerified::from_session_reauth(user)))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn verify_password_mfa<'a>(
|
||||
state: &AppState,
|
||||
user: &'a AuthenticatedUser,
|
||||
password: &str,
|
||||
) -> Result<MfaVerified<'a>, crate::api::error::ApiError> {
|
||||
let hash = state
|
||||
.user_repo
|
||||
.get_password_hash_by_did(&user.did)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
match hash {
|
||||
Some(h) => {
|
||||
if bcrypt::verify(password, &h).unwrap_or(false) {
|
||||
Ok(MfaVerified::from_password(user))
|
||||
} else {
|
||||
Err(crate::api::error::ApiError::InvalidPassword(
|
||||
"Password is incorrect".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
None => Err(crate::api::error::ApiError::AccountNotFound),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn verify_totp_mfa<'a>(
|
||||
state: &AppState,
|
||||
user: &'a AuthenticatedUser,
|
||||
code: &str,
|
||||
) -> Result<MfaVerified<'a>, crate::api::error::ApiError> {
|
||||
use crate::auth::{decrypt_totp_secret, is_backup_code_format, verify_totp_code};
|
||||
use tranquil_db_traits::TotpRecordState;
|
||||
|
||||
let code = code.trim();
|
||||
|
||||
if is_backup_code_format(code) {
|
||||
let backup_codes = state
|
||||
.user_repo
|
||||
.get_unused_backup_codes(&user.did)
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default();
|
||||
let code_upper = code.to_uppercase();
|
||||
|
||||
let matched = backup_codes
|
||||
.iter()
|
||||
.find(|row| crate::auth::verify_backup_code(&code_upper, &row.code_hash));
|
||||
|
||||
return match matched {
|
||||
Some(row) => {
|
||||
let _ = state.user_repo.mark_backup_code_used(row.id).await;
|
||||
Ok(MfaVerified::from_recovery_code(user))
|
||||
}
|
||||
None => Err(crate::api::error::ApiError::InvalidCode(Some(
|
||||
"Invalid backup code".into(),
|
||||
))),
|
||||
};
|
||||
}
|
||||
|
||||
let verified_record = match state.user_repo.get_totp_record_state(&user.did).await {
|
||||
Ok(Some(TotpRecordState::Verified(record))) => record,
|
||||
_ => {
|
||||
return Err(crate::api::error::ApiError::TotpNotEnabled);
|
||||
}
|
||||
};
|
||||
|
||||
let secret = decrypt_totp_secret(
|
||||
&verified_record.secret_encrypted,
|
||||
verified_record.encryption_version,
|
||||
)
|
||||
.map_err(|_| crate::api::error::ApiError::InternalError(None))?;
|
||||
|
||||
if verify_totp_code(&secret, code) {
|
||||
let _ = state.user_repo.update_totp_last_used(&user.did).await;
|
||||
Ok(MfaVerified::from_totp(user))
|
||||
} else {
|
||||
Err(crate::api::error::ApiError::InvalidCode(Some(
|
||||
"Invalid verification code".into(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -10,16 +10,33 @@ use crate::types::Did;
|
||||
use tranquil_db::UserRepository;
|
||||
use tranquil_db_traits::OAuthRepository;
|
||||
|
||||
pub mod account_verified;
|
||||
pub mod extractor;
|
||||
pub mod login_identifier;
|
||||
pub mod mfa_verified;
|
||||
pub mod scope_check;
|
||||
pub mod scope_verified;
|
||||
pub mod service;
|
||||
pub mod verification_token;
|
||||
pub mod webauthn;
|
||||
|
||||
pub use login_identifier::{BareLoginIdentifier, NormalizedLoginIdentifier};
|
||||
|
||||
pub use account_verified::{AccountVerified, require_not_migrated, require_verified_or_delegated};
|
||||
pub use extractor::{
|
||||
Active, Admin, AnyUser, Auth, AuthAny, AuthError, AuthPolicy, ExtractedToken, NotTakendown,
|
||||
Permissive, ServiceAuth, extract_auth_token_from_header, extract_bearer_token_from_header,
|
||||
};
|
||||
pub use mfa_verified::{
|
||||
MfaMethod, MfaVerified, require_legacy_session_mfa, require_reauth_window,
|
||||
require_reauth_window_if_available, verify_password_mfa, verify_totp_mfa,
|
||||
};
|
||||
pub use scope_verified::{
|
||||
AccountManage, AccountRead, BatchWriteScopes, BlobScopeAction, BlobUpload, ControllerDid,
|
||||
IdentityAccess, PrincipalDid, RepoCreate, RepoDelete, RepoScopeAction, RepoUpdate, RepoUpsert,
|
||||
RpcCall, ScopeAction, ScopeVerificationError, ScopeVerified, VerifyScope, WriteOpKind,
|
||||
verify_batch_write_scopes,
|
||||
};
|
||||
pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token};
|
||||
|
||||
pub use tranquil_auth::{
|
||||
@@ -409,7 +426,7 @@ async fn validate_bearer_token_with_options_internal(
|
||||
.claims
|
||||
.act
|
||||
.as_ref()
|
||||
.map(|a| Did::new_unchecked(a.sub.clone()));
|
||||
.map(|a| unsafe { Did::new_unchecked(a.sub.clone()) });
|
||||
let status =
|
||||
AccountStatus::from_db_fields(takedown_ref.as_deref(), deactivated_at);
|
||||
return Ok(AuthenticatedUser {
|
||||
@@ -461,12 +478,14 @@ async fn validate_bearer_token_with_options_internal(
|
||||
None
|
||||
};
|
||||
return Ok(AuthenticatedUser {
|
||||
did: Did::new_unchecked(oauth_token.did),
|
||||
did: unsafe { Did::new_unchecked(oauth_token.did) },
|
||||
key_bytes,
|
||||
is_admin: oauth_token.is_admin,
|
||||
status,
|
||||
scope: oauth_info.scope,
|
||||
controller_did: oauth_info.controller_did.map(Did::new_unchecked),
|
||||
controller_did: oauth_info
|
||||
.controller_did
|
||||
.map(|d| unsafe { Did::new_unchecked(d) }),
|
||||
auth_source: AuthSource::OAuth,
|
||||
});
|
||||
} else {
|
||||
@@ -545,7 +564,7 @@ pub async fn validate_token_with_dpop(
|
||||
None
|
||||
};
|
||||
Ok(AuthenticatedUser {
|
||||
did: Did::new_unchecked(result.did),
|
||||
did: unsafe { Did::new_unchecked(result.did) },
|
||||
key_bytes,
|
||||
is_admin: user_info.is_admin,
|
||||
status,
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::ops::Deref;
|
||||
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::oauth::scopes::{
|
||||
AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions,
|
||||
};
|
||||
use crate::types::Did;
|
||||
|
||||
use super::AuthenticatedUser;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PrincipalDid(Did);
|
||||
|
||||
impl PrincipalDid {
|
||||
pub fn as_did(&self) -> &Did {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_did(self) -> Did {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for PrincipalDid {
|
||||
type Target = Did;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<Did> for PrincipalDid {
|
||||
fn as_ref(&self) -> &Did {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PrincipalDid {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ControllerDid(Did);
|
||||
|
||||
impl ControllerDid {
|
||||
pub fn as_did(&self) -> &Did {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_did(self) -> Did {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for ControllerDid {
|
||||
type Target = Did;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<Did> for ControllerDid {
|
||||
fn as_ref(&self) -> &Did {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ControllerDid {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ScopeVerificationError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ScopeVerificationError {
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ScopeVerificationError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ScopeVerificationError {}
|
||||
|
||||
impl IntoResponse for ScopeVerificationError {
|
||||
fn into_response(self) -> Response {
|
||||
ApiError::InsufficientScope(Some(self.message)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
mod private {
|
||||
pub trait Sealed {}
|
||||
pub trait RepoScopeSealed {}
|
||||
pub trait BlobScopeSealed {}
|
||||
}
|
||||
|
||||
pub trait ScopeAction: private::Sealed {}
|
||||
|
||||
pub trait RepoScopeAction: ScopeAction + private::RepoScopeSealed {}
|
||||
|
||||
pub trait BlobScopeAction: ScopeAction + private::BlobScopeSealed {}
|
||||
|
||||
pub struct RepoCreate;
|
||||
pub struct RepoUpdate;
|
||||
pub struct RepoDelete;
|
||||
pub struct RepoUpsert;
|
||||
pub struct BlobUpload;
|
||||
pub struct RpcCall;
|
||||
pub struct AccountRead;
|
||||
pub struct AccountManage;
|
||||
pub struct IdentityAccess;
|
||||
|
||||
impl private::Sealed for RepoCreate {}
|
||||
impl private::Sealed for RepoUpdate {}
|
||||
impl private::Sealed for RepoDelete {}
|
||||
impl private::Sealed for RepoUpsert {}
|
||||
impl private::Sealed for BlobUpload {}
|
||||
impl private::Sealed for RpcCall {}
|
||||
impl private::Sealed for AccountRead {}
|
||||
impl private::Sealed for AccountManage {}
|
||||
impl private::Sealed for IdentityAccess {}
|
||||
|
||||
impl private::RepoScopeSealed for RepoCreate {}
|
||||
impl private::RepoScopeSealed for RepoUpdate {}
|
||||
impl private::RepoScopeSealed for RepoDelete {}
|
||||
impl private::RepoScopeSealed for RepoUpsert {}
|
||||
|
||||
impl private::BlobScopeSealed for BlobUpload {}
|
||||
|
||||
impl ScopeAction for RepoCreate {}
|
||||
impl ScopeAction for RepoUpdate {}
|
||||
impl ScopeAction for RepoDelete {}
|
||||
impl ScopeAction for RepoUpsert {}
|
||||
impl ScopeAction for BlobUpload {}
|
||||
impl ScopeAction for RpcCall {}
|
||||
impl ScopeAction for AccountRead {}
|
||||
impl ScopeAction for AccountManage {}
|
||||
impl ScopeAction for IdentityAccess {}
|
||||
|
||||
impl RepoScopeAction for RepoCreate {}
|
||||
impl RepoScopeAction for RepoUpdate {}
|
||||
impl RepoScopeAction for RepoDelete {}
|
||||
impl RepoScopeAction for RepoUpsert {}
|
||||
|
||||
impl BlobScopeAction for BlobUpload {}
|
||||
|
||||
pub struct ScopeVerified<'a, A: ScopeAction> {
|
||||
user: &'a AuthenticatedUser,
|
||||
_action: PhantomData<A>,
|
||||
}
|
||||
|
||||
impl<'a, A: ScopeAction> ScopeVerified<'a, A> {
|
||||
pub fn user(&self) -> &AuthenticatedUser {
|
||||
self.user
|
||||
}
|
||||
|
||||
pub fn principal_did(&self) -> PrincipalDid {
|
||||
PrincipalDid(self.user.did.clone())
|
||||
}
|
||||
|
||||
pub fn controller_did(&self) -> Option<ControllerDid> {
|
||||
self.user.controller_did.clone().map(ControllerDid)
|
||||
}
|
||||
|
||||
pub fn is_admin(&self) -> bool {
|
||||
self.user.is_admin
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BatchWriteScopes<'a> {
|
||||
user: &'a AuthenticatedUser,
|
||||
has_creates: bool,
|
||||
has_updates: bool,
|
||||
has_deletes: bool,
|
||||
}
|
||||
|
||||
impl<'a> BatchWriteScopes<'a> {
|
||||
pub fn principal_did(&self) -> PrincipalDid {
|
||||
PrincipalDid(self.user.did.clone())
|
||||
}
|
||||
|
||||
pub fn controller_did(&self) -> Option<ControllerDid> {
|
||||
self.user.controller_did.clone().map(ControllerDid)
|
||||
}
|
||||
|
||||
pub fn user(&self) -> &AuthenticatedUser {
|
||||
self.user
|
||||
}
|
||||
|
||||
pub fn has_creates(&self) -> bool {
|
||||
self.has_creates
|
||||
}
|
||||
|
||||
pub fn has_updates(&self) -> bool {
|
||||
self.has_updates
|
||||
}
|
||||
|
||||
pub fn has_deletes(&self) -> bool {
|
||||
self.has_deletes
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verify_batch_write_scopes<'a, T, C, F>(
|
||||
auth: &'a impl VerifyScope,
|
||||
user: &'a AuthenticatedUser,
|
||||
writes: &[T],
|
||||
get_collection: F,
|
||||
classify: C,
|
||||
) -> Result<BatchWriteScopes<'a>, ScopeVerificationError>
|
||||
where
|
||||
F: Fn(&T) -> &str,
|
||||
C: Fn(&T) -> WriteOpKind,
|
||||
{
|
||||
use std::collections::HashSet;
|
||||
|
||||
let create_collections: HashSet<&str> = writes
|
||||
.iter()
|
||||
.filter(|w| matches!(classify(w), WriteOpKind::Create))
|
||||
.map(&get_collection)
|
||||
.collect();
|
||||
|
||||
let update_collections: HashSet<&str> = writes
|
||||
.iter()
|
||||
.filter(|w| matches!(classify(w), WriteOpKind::Update))
|
||||
.map(&get_collection)
|
||||
.collect();
|
||||
|
||||
let delete_collections: HashSet<&str> = writes
|
||||
.iter()
|
||||
.filter(|w| matches!(classify(w), WriteOpKind::Delete))
|
||||
.map(&get_collection)
|
||||
.collect();
|
||||
|
||||
if auth.needs_scope_check() {
|
||||
create_collections.iter().try_for_each(|c| {
|
||||
auth.permissions()
|
||||
.assert_repo(RepoAction::Create, c)
|
||||
.map_err(|e| ScopeVerificationError::new(e.to_string()))
|
||||
})?;
|
||||
|
||||
update_collections.iter().try_for_each(|c| {
|
||||
auth.permissions()
|
||||
.assert_repo(RepoAction::Update, c)
|
||||
.map_err(|e| ScopeVerificationError::new(e.to_string()))
|
||||
})?;
|
||||
|
||||
delete_collections.iter().try_for_each(|c| {
|
||||
auth.permissions()
|
||||
.assert_repo(RepoAction::Delete, c)
|
||||
.map_err(|e| ScopeVerificationError::new(e.to_string()))
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(BatchWriteScopes {
|
||||
user,
|
||||
has_creates: !create_collections.is_empty(),
|
||||
has_updates: !update_collections.is_empty(),
|
||||
has_deletes: !delete_collections.is_empty(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum WriteOpKind {
|
||||
Create,
|
||||
Update,
|
||||
Delete,
|
||||
}
|
||||
|
||||
pub trait VerifyScope {
|
||||
fn needs_scope_check(&self) -> bool;
|
||||
fn permissions(&self) -> ScopePermissions;
|
||||
|
||||
fn verify_repo_create<'a>(
|
||||
&'a self,
|
||||
collection: &str,
|
||||
) -> Result<ScopeVerified<'a, RepoCreate>, ScopeVerificationError>
|
||||
where
|
||||
Self: AsRef<AuthenticatedUser>,
|
||||
{
|
||||
if !self.needs_scope_check() {
|
||||
return Ok(ScopeVerified {
|
||||
user: self.as_ref(),
|
||||
_action: PhantomData,
|
||||
});
|
||||
}
|
||||
self.permissions()
|
||||
.assert_repo(RepoAction::Create, collection)
|
||||
.map_err(|e| ScopeVerificationError::new(e.to_string()))?;
|
||||
Ok(ScopeVerified {
|
||||
user: self.as_ref(),
|
||||
_action: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_repo_update<'a>(
|
||||
&'a self,
|
||||
collection: &str,
|
||||
) -> Result<ScopeVerified<'a, RepoUpdate>, ScopeVerificationError>
|
||||
where
|
||||
Self: AsRef<AuthenticatedUser>,
|
||||
{
|
||||
if !self.needs_scope_check() {
|
||||
return Ok(ScopeVerified {
|
||||
user: self.as_ref(),
|
||||
_action: PhantomData,
|
||||
});
|
||||
}
|
||||
self.permissions()
|
||||
.assert_repo(RepoAction::Update, collection)
|
||||
.map_err(|e| ScopeVerificationError::new(e.to_string()))?;
|
||||
Ok(ScopeVerified {
|
||||
user: self.as_ref(),
|
||||
_action: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_repo_delete<'a>(
|
||||
&'a self,
|
||||
collection: &str,
|
||||
) -> Result<ScopeVerified<'a, RepoDelete>, ScopeVerificationError>
|
||||
where
|
||||
Self: AsRef<AuthenticatedUser>,
|
||||
{
|
||||
if !self.needs_scope_check() {
|
||||
return Ok(ScopeVerified {
|
||||
user: self.as_ref(),
|
||||
_action: PhantomData,
|
||||
});
|
||||
}
|
||||
self.permissions()
|
||||
.assert_repo(RepoAction::Delete, collection)
|
||||
.map_err(|e| ScopeVerificationError::new(e.to_string()))?;
|
||||
Ok(ScopeVerified {
|
||||
user: self.as_ref(),
|
||||
_action: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_repo_upsert<'a>(
|
||||
&'a self,
|
||||
collection: &str,
|
||||
) -> Result<ScopeVerified<'a, RepoUpsert>, ScopeVerificationError>
|
||||
where
|
||||
Self: AsRef<AuthenticatedUser>,
|
||||
{
|
||||
if !self.needs_scope_check() {
|
||||
return Ok(ScopeVerified {
|
||||
user: self.as_ref(),
|
||||
_action: PhantomData,
|
||||
});
|
||||
}
|
||||
self.permissions()
|
||||
.assert_repo(RepoAction::Create, collection)
|
||||
.map_err(|e| ScopeVerificationError::new(e.to_string()))?;
|
||||
self.permissions()
|
||||
.assert_repo(RepoAction::Update, collection)
|
||||
.map_err(|e| ScopeVerificationError::new(e.to_string()))?;
|
||||
Ok(ScopeVerified {
|
||||
user: self.as_ref(),
|
||||
_action: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_blob_upload<'a>(
|
||||
&'a self,
|
||||
mime_type: &str,
|
||||
) -> Result<ScopeVerified<'a, BlobUpload>, ScopeVerificationError>
|
||||
where
|
||||
Self: AsRef<AuthenticatedUser>,
|
||||
{
|
||||
if !self.needs_scope_check() {
|
||||
return Ok(ScopeVerified {
|
||||
user: self.as_ref(),
|
||||
_action: PhantomData,
|
||||
});
|
||||
}
|
||||
self.permissions()
|
||||
.assert_blob(mime_type)
|
||||
.map_err(|e| ScopeVerificationError::new(e.to_string()))?;
|
||||
Ok(ScopeVerified {
|
||||
user: self.as_ref(),
|
||||
_action: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_rpc<'a>(
|
||||
&'a self,
|
||||
aud: &str,
|
||||
lxm: &str,
|
||||
) -> Result<ScopeVerified<'a, RpcCall>, ScopeVerificationError>
|
||||
where
|
||||
Self: AsRef<AuthenticatedUser>,
|
||||
{
|
||||
if !self.needs_scope_check() {
|
||||
return Ok(ScopeVerified {
|
||||
user: self.as_ref(),
|
||||
_action: PhantomData,
|
||||
});
|
||||
}
|
||||
self.permissions()
|
||||
.assert_rpc(aud, lxm)
|
||||
.map_err(|e| ScopeVerificationError::new(e.to_string()))?;
|
||||
Ok(ScopeVerified {
|
||||
user: self.as_ref(),
|
||||
_action: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_account_read<'a>(
|
||||
&'a self,
|
||||
attr: AccountAttr,
|
||||
) -> Result<ScopeVerified<'a, AccountRead>, ScopeVerificationError>
|
||||
where
|
||||
Self: AsRef<AuthenticatedUser>,
|
||||
{
|
||||
if !self.needs_scope_check() {
|
||||
return Ok(ScopeVerified {
|
||||
user: self.as_ref(),
|
||||
_action: PhantomData,
|
||||
});
|
||||
}
|
||||
self.permissions()
|
||||
.assert_account(attr, AccountAction::Read)
|
||||
.map_err(|e| ScopeVerificationError::new(e.to_string()))?;
|
||||
Ok(ScopeVerified {
|
||||
user: self.as_ref(),
|
||||
_action: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_account_manage<'a>(
|
||||
&'a self,
|
||||
attr: AccountAttr,
|
||||
) -> Result<ScopeVerified<'a, AccountManage>, ScopeVerificationError>
|
||||
where
|
||||
Self: AsRef<AuthenticatedUser>,
|
||||
{
|
||||
if !self.needs_scope_check() {
|
||||
return Ok(ScopeVerified {
|
||||
user: self.as_ref(),
|
||||
_action: PhantomData,
|
||||
});
|
||||
}
|
||||
self.permissions()
|
||||
.assert_account(attr, AccountAction::Manage)
|
||||
.map_err(|e| ScopeVerificationError::new(e.to_string()))?;
|
||||
Ok(ScopeVerified {
|
||||
user: self.as_ref(),
|
||||
_action: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_identity<'a>(
|
||||
&'a self,
|
||||
attr: IdentityAttr,
|
||||
) -> Result<ScopeVerified<'a, IdentityAccess>, ScopeVerificationError>
|
||||
where
|
||||
Self: AsRef<AuthenticatedUser>,
|
||||
{
|
||||
if !self.needs_scope_check() {
|
||||
return Ok(ScopeVerified {
|
||||
user: self.as_ref(),
|
||||
_action: PhantomData,
|
||||
});
|
||||
}
|
||||
self.permissions()
|
||||
.assert_identity(attr)
|
||||
.map_err(|e| ScopeVerificationError::new(e.to_string()))?;
|
||||
Ok(ScopeVerified {
|
||||
user: self.as_ref(),
|
||||
_action: PhantomData,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::types::Did;
|
||||
use crate::util::pds_hostname;
|
||||
use anyhow::{Result, anyhow};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
@@ -42,10 +44,10 @@ pub struct DidService {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServiceTokenClaims {
|
||||
pub iss: String,
|
||||
pub iss: Did,
|
||||
#[serde(default)]
|
||||
pub sub: Option<String>,
|
||||
pub aud: String,
|
||||
pub sub: Option<Did>,
|
||||
pub aud: Did,
|
||||
pub exp: usize,
|
||||
#[serde(default)]
|
||||
pub iat: Option<usize>,
|
||||
@@ -56,8 +58,8 @@ pub struct ServiceTokenClaims {
|
||||
}
|
||||
|
||||
impl ServiceTokenClaims {
|
||||
pub fn subject(&self) -> &str {
|
||||
self.sub.as_deref().unwrap_or(&self.iss)
|
||||
pub fn subject(&self) -> &Did {
|
||||
self.sub.as_ref().unwrap_or(&self.iss)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,8 +80,7 @@ impl ServiceTokenVerifier {
|
||||
let plc_directory_url = std::env::var("PLC_DIRECTORY_URL")
|
||||
.unwrap_or_else(|_| "https://plc.directory".to_string());
|
||||
|
||||
let pds_hostname =
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let pds_hostname = pds_hostname();
|
||||
let pds_did = format!("did:web:{}", pds_hostname);
|
||||
|
||||
let client = Client::builder()
|
||||
@@ -130,7 +131,7 @@ impl ServiceTokenVerifier {
|
||||
return Err(anyhow!("Token expired"));
|
||||
}
|
||||
|
||||
if claims.aud != self.pds_did {
|
||||
if claims.aud.as_str() != self.pds_did {
|
||||
return Err(anyhow!(
|
||||
"Invalid audience: expected {}, got {}",
|
||||
self.pds_did,
|
||||
@@ -154,7 +155,7 @@ impl ServiceTokenVerifier {
|
||||
}
|
||||
}
|
||||
|
||||
let did = &claims.iss;
|
||||
let did = claims.iss.as_str();
|
||||
let public_key = self.resolve_signing_key(did).await?;
|
||||
|
||||
let signature_bytes = URL_SAFE_NO_PAD
|
||||
|
||||
@@ -7,7 +7,7 @@ pub struct WebAuthnConfig {
|
||||
|
||||
impl WebAuthnConfig {
|
||||
pub fn new(hostname: &str) -> Result<Self, String> {
|
||||
let rp_id = hostname.to_string();
|
||||
let rp_id = hostname.split(':').next().unwrap_or(hostname).to_string();
|
||||
let rp_origin = Url::parse(&format!("https://{}", hostname))
|
||||
.map_err(|e| format!("Invalid origin URL: {}", e))?;
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
use cid::Cid;
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct CommitCid(Cid);
|
||||
|
||||
impl CommitCid {
|
||||
pub fn new(cid: Cid) -> Self {
|
||||
Self(cid)
|
||||
}
|
||||
|
||||
pub fn as_cid(&self) -> &Cid {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_cid(self) -> Cid {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Cid> for CommitCid {
|
||||
fn from(cid: Cid) -> Self {
|
||||
Self(cid)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CommitCid> for Cid {
|
||||
fn from(commit_cid: CommitCid) -> Self {
|
||||
commit_cid.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for CommitCid {
|
||||
type Err = cid::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Cid::from_str(s).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CommitCid {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<Cid> for CommitCid {
|
||||
fn as_ref(&self) -> &Cid {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct RecordCid(Cid);
|
||||
|
||||
impl RecordCid {
|
||||
pub fn new(cid: Cid) -> Self {
|
||||
Self(cid)
|
||||
}
|
||||
|
||||
pub fn as_cid(&self) -> &Cid {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_cid(self) -> Cid {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Cid> for RecordCid {
|
||||
fn from(cid: Cid) -> Self {
|
||||
Self(cid)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RecordCid> for Cid {
|
||||
fn from(record_cid: RecordCid) -> Self {
|
||||
record_cid.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for RecordCid {
|
||||
type Err = cid::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Cid::from_str(s).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for RecordCid {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<Cid> for RecordCid {
|
||||
fn as_ref(&self) -> &Cid {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use tokio::sync::watch;
|
||||
use tokio::time::interval;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tranquil_comms::{
|
||||
CommsChannel, CommsSender, CommsStatus, CommsType, NewComms, SendError, format_message,
|
||||
@@ -96,7 +96,7 @@ impl CommsService {
|
||||
!self.senders.is_empty()
|
||||
}
|
||||
|
||||
pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
|
||||
pub async fn run(self, shutdown: CancellationToken) {
|
||||
if self.senders.is_empty() {
|
||||
warn!(
|
||||
"Comms service starting with no senders configured. Messages will be queued but not delivered until senders are configured."
|
||||
@@ -116,11 +116,9 @@ impl CommsService {
|
||||
error!(error = %e, "Failed to process comms batch");
|
||||
}
|
||||
}
|
||||
_ = shutdown.changed() => {
|
||||
if *shutdown.borrow() {
|
||||
info!("Comms service shutting down");
|
||||
break;
|
||||
}
|
||||
_ = shutdown.cancelled() => {
|
||||
info!("Comms service shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,7 +276,7 @@ pub mod repo {
|
||||
&[("hostname", hostname), ("handle", &prefs.handle)],
|
||||
);
|
||||
let subject = format_message(strings.welcome_subject, &[("hostname", hostname)]);
|
||||
let channel = channel_from_str(&prefs.preferred_channel);
|
||||
let channel = prefs.preferred_channel;
|
||||
infra_repo
|
||||
.enqueue_comms(
|
||||
Some(user_id),
|
||||
@@ -309,7 +307,7 @@ pub mod repo {
|
||||
&[("handle", &prefs.handle), ("code", code)],
|
||||
);
|
||||
let subject = format_message(strings.password_reset_subject, &[("hostname", hostname)]);
|
||||
let channel = channel_from_str(&prefs.preferred_channel);
|
||||
let channel = prefs.preferred_channel;
|
||||
infra_repo
|
||||
.enqueue_comms(
|
||||
Some(user_id),
|
||||
@@ -422,7 +420,7 @@ pub mod repo {
|
||||
&[("handle", &prefs.handle), ("code", code)],
|
||||
);
|
||||
let subject = format_message(strings.account_deletion_subject, &[("hostname", hostname)]);
|
||||
let channel = channel_from_str(&prefs.preferred_channel);
|
||||
let channel = prefs.preferred_channel;
|
||||
infra_repo
|
||||
.enqueue_comms(
|
||||
Some(user_id),
|
||||
@@ -453,7 +451,7 @@ pub mod repo {
|
||||
&[("handle", &prefs.handle), ("token", token)],
|
||||
);
|
||||
let subject = format_message(strings.plc_operation_subject, &[("hostname", hostname)]);
|
||||
let channel = channel_from_str(&prefs.preferred_channel);
|
||||
let channel = prefs.preferred_channel;
|
||||
infra_repo
|
||||
.enqueue_comms(
|
||||
Some(user_id),
|
||||
@@ -484,7 +482,7 @@ pub mod repo {
|
||||
&[("handle", &prefs.handle), ("url", recovery_url)],
|
||||
);
|
||||
let subject = format_message(strings.passkey_recovery_subject, &[("hostname", hostname)]);
|
||||
let channel = channel_from_str(&prefs.preferred_channel);
|
||||
let channel = prefs.preferred_channel;
|
||||
infra_repo
|
||||
.enqueue_comms(
|
||||
Some(user_id),
|
||||
@@ -614,7 +612,7 @@ pub mod repo {
|
||||
&[("handle", &prefs.handle), ("code", code)],
|
||||
);
|
||||
let subject = format_message(strings.two_factor_code_subject, &[("hostname", hostname)]);
|
||||
let channel = channel_from_str(&prefs.preferred_channel);
|
||||
let channel = prefs.preferred_channel;
|
||||
infra_repo
|
||||
.enqueue_comms(
|
||||
Some(user_id),
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use crate::circuit_breaker::CircuitBreaker;
|
||||
use crate::sync::firehose::SequencedEvent;
|
||||
use crate::util::pds_hostname;
|
||||
use reqwest::Client;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{broadcast, watch};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tranquil_db_traits::RepoEventType;
|
||||
|
||||
const NOTIFY_THRESHOLD_SECS: u64 = 20 * 60;
|
||||
|
||||
@@ -40,7 +43,10 @@ impl Crawlers {
|
||||
}
|
||||
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").ok()?;
|
||||
let hostname = pds_hostname();
|
||||
if hostname == "localhost" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let crawler_urls: Vec<String> = std::env::var("CRAWLERS")
|
||||
.unwrap_or_default()
|
||||
@@ -53,7 +59,7 @@ impl Crawlers {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self::new(hostname, crawler_urls))
|
||||
Some(Self::new(hostname.to_string(), crawler_urls))
|
||||
}
|
||||
|
||||
fn should_notify(&self) -> bool {
|
||||
@@ -143,7 +149,7 @@ impl Crawlers {
|
||||
pub async fn start_crawlers_service(
|
||||
crawlers: Arc<Crawlers>,
|
||||
mut firehose_rx: broadcast::Receiver<SequencedEvent>,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
shutdown: CancellationToken,
|
||||
) {
|
||||
info!(
|
||||
hostname = %crawlers.hostname,
|
||||
@@ -157,7 +163,7 @@ pub async fn start_crawlers_service(
|
||||
result = firehose_rx.recv() => {
|
||||
match result {
|
||||
Ok(event) => {
|
||||
if event.event_type == "commit" {
|
||||
if event.event_type == RepoEventType::Commit {
|
||||
crawlers.notify_of_update().await;
|
||||
}
|
||||
}
|
||||
@@ -171,11 +177,9 @@ pub async fn start_crawlers_service(
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown.changed() => {
|
||||
if *shutdown.borrow() {
|
||||
info!("Crawlers service shutting down");
|
||||
break;
|
||||
}
|
||||
_ = shutdown.cancelled() => {
|
||||
info!("Crawlers service shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
pub mod roles;
|
||||
pub mod scopes;
|
||||
|
||||
pub use scopes::{SCOPE_PRESETS, ScopePreset, intersect_scopes};
|
||||
pub use roles::{
|
||||
CanAddControllers, CanBeController, CanControlAccounts, verify_can_add_controllers,
|
||||
verify_can_be_controller, verify_can_control_accounts,
|
||||
};
|
||||
pub use scopes::{
|
||||
InvalidDelegationScopeError, SCOPE_PRESETS, ScopePreset, ValidatedDelegationScope,
|
||||
intersect_scopes, validate_delegation_scopes,
|
||||
};
|
||||
pub use tranquil_db_traits::DelegationActionType;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::auth::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
use crate::types::Did;
|
||||
|
||||
pub struct CanAddControllers<'a> {
|
||||
user: &'a AuthenticatedUser,
|
||||
}
|
||||
|
||||
pub struct CanControlAccounts<'a> {
|
||||
user: &'a AuthenticatedUser,
|
||||
}
|
||||
|
||||
pub struct CanBeController<'a> {
|
||||
controller_did: &'a Did,
|
||||
}
|
||||
|
||||
impl<'a> CanAddControllers<'a> {
|
||||
pub fn did(&self) -> &Did {
|
||||
&self.user.did
|
||||
}
|
||||
|
||||
pub fn user(&self) -> &AuthenticatedUser {
|
||||
self.user
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> CanControlAccounts<'a> {
|
||||
pub fn did(&self) -> &Did {
|
||||
&self.user.did
|
||||
}
|
||||
|
||||
pub fn user(&self) -> &AuthenticatedUser {
|
||||
self.user
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> CanBeController<'a> {
|
||||
pub fn did(&self) -> &Did {
|
||||
self.controller_did
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn verify_can_add_controllers<'a>(
|
||||
state: &AppState,
|
||||
user: &'a AuthenticatedUser,
|
||||
) -> Result<CanAddControllers<'a>, Response> {
|
||||
match state.delegation_repo.controls_any_accounts(&user.did).await {
|
||||
Ok(true) => Err(ApiError::InvalidDelegation(
|
||||
"Cannot add controllers to an account that controls other accounts".into(),
|
||||
)
|
||||
.into_response()),
|
||||
Ok(false) => Ok(CanAddControllers { user }),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to check delegation status: {:?}", e);
|
||||
Err(
|
||||
ApiError::InternalError(Some("Failed to verify delegation status".into()))
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn verify_can_control_accounts<'a>(
|
||||
state: &AppState,
|
||||
user: &'a AuthenticatedUser,
|
||||
) -> Result<CanControlAccounts<'a>, Response> {
|
||||
match state.delegation_repo.has_any_controllers(&user.did).await {
|
||||
Ok(true) => Err(ApiError::InvalidDelegation(
|
||||
"Cannot create delegated accounts from a controlled account".into(),
|
||||
)
|
||||
.into_response()),
|
||||
Ok(false) => Ok(CanControlAccounts { user }),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to check controller status: {:?}", e);
|
||||
Err(
|
||||
ApiError::InternalError(Some("Failed to verify controller status".into()))
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn verify_can_be_controller<'a>(
|
||||
state: &AppState,
|
||||
controller_did: &'a Did,
|
||||
) -> Result<CanBeController<'a>, Response> {
|
||||
match state
|
||||
.delegation_repo
|
||||
.has_any_controllers(controller_did)
|
||||
.await
|
||||
{
|
||||
Ok(true) => Err(ApiError::InvalidDelegation(
|
||||
"Cannot add a controlled account as a controller".into(),
|
||||
)
|
||||
.into_response()),
|
||||
Ok(false) => Ok(CanBeController { controller_did }),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to check controller status: {:?}", e);
|
||||
Err(
|
||||
ApiError::InternalError(Some("Failed to verify controller status".into()))
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub use tranquil_db_traits::{
|
||||
DbScope as ValidatedDelegationScope, InvalidScopeError as InvalidDelegationScopeError,
|
||||
};
|
||||
|
||||
pub struct ScopePreset {
|
||||
pub name: &'static str,
|
||||
pub label: &'static str,
|
||||
@@ -107,35 +111,9 @@ fn split_scope(scope: &str) -> (&str, Option<&str>) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_delegation_scopes(scopes: &str) -> Result<(), String> {
|
||||
if scopes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
scopes.split_whitespace().try_for_each(|scope| {
|
||||
let (base, _) = split_scope(scope);
|
||||
if is_valid_scope_prefix(base) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Invalid scope: {}", scope))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn is_valid_scope_prefix(base: &str) -> bool {
|
||||
const VALID_PREFIXES: [&str; 7] = [
|
||||
"atproto",
|
||||
"repo:",
|
||||
"blob:",
|
||||
"rpc:",
|
||||
"account:",
|
||||
"identity:",
|
||||
"transition:",
|
||||
];
|
||||
|
||||
VALID_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| base == prefix.trim_end_matches(':') || base.starts_with(prefix))
|
||||
pub fn validate_delegation_scopes(scopes: &str) -> Result<(), InvalidDelegationScopeError> {
|
||||
ValidatedDelegationScope::new(scopes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod api;
|
||||
pub mod appview;
|
||||
pub mod auth;
|
||||
pub mod cache;
|
||||
pub mod cid_types;
|
||||
pub mod circuit_breaker;
|
||||
pub mod comms;
|
||||
pub mod config;
|
||||
@@ -35,9 +36,9 @@ use axum::{
|
||||
use http::StatusCode;
|
||||
use serde_json::json;
|
||||
use state::AppState;
|
||||
pub use sync::util::AccountStatus;
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
pub use tranquil_db_traits::AccountStatus;
|
||||
pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey};
|
||||
|
||||
pub fn app(state: AppState) -> Router {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::watch;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{error, info, warn};
|
||||
use tranquil_pds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender};
|
||||
|
||||
@@ -34,10 +34,20 @@ async fn main() -> ExitCode {
|
||||
}
|
||||
|
||||
async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let state = AppState::new().await?;
|
||||
tranquil_pds::sync::listener::start_sequencer_listener(state.clone()).await;
|
||||
let shutdown = CancellationToken::new();
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let shutdown_for_panic = shutdown.clone();
|
||||
let default_panic_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
error!("PANIC: {}", info);
|
||||
shutdown_for_panic.cancel();
|
||||
default_panic_hook(info);
|
||||
}));
|
||||
|
||||
spawn_signal_handler(shutdown.clone());
|
||||
|
||||
let state = AppState::new(shutdown.clone()).await?;
|
||||
tranquil_pds::sync::listener::start_sequencer_listener(state.clone()).await;
|
||||
|
||||
let backfill_repo_repo = state.repo_repo.clone();
|
||||
let backfill_block_store = state.block_store.clone();
|
||||
@@ -77,7 +87,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
comms_service = comms_service.register_sender(signal_sender);
|
||||
}
|
||||
|
||||
let comms_handle = tokio::spawn(comms_service.run(shutdown_rx.clone()));
|
||||
let comms_handle = tokio::spawn(comms_service.run(shutdown.clone()));
|
||||
|
||||
let crawlers_handle = if let Some(crawlers) = Crawlers::from_env() {
|
||||
let crawlers = Arc::new(
|
||||
@@ -88,7 +98,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Some(tokio::spawn(start_crawlers_service(
|
||||
crawlers,
|
||||
firehose_rx,
|
||||
shutdown_rx.clone(),
|
||||
shutdown.clone(),
|
||||
)))
|
||||
} else {
|
||||
warn!("Crawlers notification service disabled (PDS_HOSTNAME or CRAWLERS not set)");
|
||||
@@ -102,7 +112,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
state.backup_repo.clone(),
|
||||
state.block_store.clone(),
|
||||
backup_storage,
|
||||
shutdown_rx.clone(),
|
||||
shutdown.clone(),
|
||||
)))
|
||||
} else {
|
||||
warn!("Backup service disabled (BACKUP_S3_BUCKET not set or BACKUP_ENABLED=false)");
|
||||
@@ -114,7 +124,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
state.blob_repo.clone(),
|
||||
state.blob_store.clone(),
|
||||
state.sso_repo.clone(),
|
||||
shutdown_rx,
|
||||
shutdown.clone(),
|
||||
));
|
||||
|
||||
let app = tranquil_pds::app(state);
|
||||
@@ -136,7 +146,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.map_err(|e| format!("Failed to bind to {}: {}", addr, e))?;
|
||||
|
||||
let server_result = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal(shutdown_tx))
|
||||
.with_graceful_shutdown(shutdown.clone().cancelled_owned())
|
||||
.await;
|
||||
|
||||
comms_handle.await.ok();
|
||||
@@ -158,37 +168,40 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal(shutdown_tx: watch::Sender<bool>) {
|
||||
let ctrl_c = async {
|
||||
match tokio::signal::ctrl_c().await {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
error!("Failed to install Ctrl+C handler: {}", e);
|
||||
fn spawn_signal_handler(shutdown: CancellationToken) {
|
||||
tokio::spawn(async move {
|
||||
let ctrl_c = async {
|
||||
match tokio::signal::ctrl_c().await {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
error!("Failed to install Ctrl+C handler: {}", e);
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
||||
Ok(mut signal) => {
|
||||
signal.recv().await;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to install SIGTERM handler: {}", e);
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
||||
Ok(mut signal) => {
|
||||
signal.recv().await;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to install SIGTERM handler: {}", e);
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
|
||||
info!("Shutdown signal received, stopping services...");
|
||||
shutdown_tx.send(true).ok();
|
||||
info!("Shutdown signal received, stopping services...");
|
||||
shutdown.cancel();
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user