mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-25 02:36:06 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34f050a1f0 | ||
|
|
52c22060f3 | ||
|
|
c250d51978 | ||
|
|
378cb7a7fb | ||
|
|
bf20b4a78f |
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1",
|
||||
"query": "SELECT did, password_hash FROM users WHERE handle = $1 OR did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -24,5 +24,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "c4621f6a8a1ab78a6355b09fdfc2bf8999d276564e93015792ec07cb05e79038"
|
||||
"hash": "053c971024b0d29a441c3597d760b3e21db2383442c3e6f09de4eb49ea437e7c"
|
||||
}
|
||||
+2
-2
@@ -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 as \"account_type!: AccountType\"\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 did = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -118,5 +118,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa"
|
||||
"hash": "060c285c93a05252aab7d474df0186e7b5083fafedc582b8eac9916983e8fc2d"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'discoverable'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3155ef4f35698a3fe6aa38d5d976fd51b7f6a0381c81c4907dad61d2f37992bd"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT state_json FROM webauthn_challenges\n WHERE did = $1 AND challenge_type = 'discoverable' AND expires_at > NOW()\n ORDER BY created_at DESC LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "state_json",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6969c478a0922bac4b79902313a0e28c94d6b8d6b16035474dd8f484e6171d60"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login, u.migrated_to_pds,\n u.preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled,\n COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as \"email_2fa_enabled!\"\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1",
|
||||
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login, u.migrated_to_pds,\n u.preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled,\n COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as \"email_2fa_enabled!\"\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -132,5 +132,5 @@
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a960b981a146a0e422ef53601dfc31e29cf777aa194227c48c6ebc6905ea3249"
|
||||
"hash": "aafc2a7e51200ca1e7071c63c13698bf34ef8b66758ca9ebab4ea706ffb62914"
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)\n VALUES ($1, $2, $3, 'discoverable', $4, $5)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Bytea",
|
||||
"Text",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c6e3388fc39983f1787917606ba3194c72322d2d1ec54402c262194791a2b06a"
|
||||
}
|
||||
Generated
+23
-22
@@ -7405,7 +7405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-api"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7456,7 +7456,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-auth"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base32",
|
||||
@@ -7479,7 +7479,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-cache"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7493,7 +7493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-comms"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7511,7 +7511,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-config"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"confique",
|
||||
"serde",
|
||||
@@ -7519,7 +7519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-crypto"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64 0.22.1",
|
||||
@@ -7535,7 +7535,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -7552,7 +7552,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db-traits"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7568,7 +7568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-infra"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -7579,7 +7579,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-lexicon"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hickory-resolver",
|
||||
@@ -7597,7 +7597,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7620,7 +7620,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth-server"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -7653,7 +7653,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-pds"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -7738,13 +7738,14 @@ dependencies = [
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"webauthn-rs",
|
||||
"webauthn-rs-proto",
|
||||
"wiremock",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-repo"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cid",
|
||||
@@ -7756,7 +7757,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-ripple"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"backon",
|
||||
@@ -7781,7 +7782,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-scopes"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures",
|
||||
@@ -7797,7 +7798,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-server"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"clap",
|
||||
@@ -7818,7 +7819,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-signal"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -7841,7 +7842,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-storage"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
@@ -7858,7 +7859,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-store"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -7904,7 +7905,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-sync"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7926,7 +7927,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-types"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cid",
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
@@ -126,7 +126,7 @@ tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
urlencoding = "2.1"
|
||||
uuid = { version = "1.19", features = ["v4", "v5", "v7", "fast-rng", "serde"] }
|
||||
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] }
|
||||
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys", "conditional-ui"] }
|
||||
webauthn-rs-proto = "0.5"
|
||||
zip = { version = "7.0", default-features = false, features = ["deflate"] }
|
||||
|
||||
|
||||
@@ -2,17 +2,25 @@
|
||||
|
||||
A Personal Data Server for the AT Protocol.
|
||||
|
||||
Bluesky runs on a federated protocol called AT Protocol. Your account lives on a PDS, a server that stores your posts, profile, follows, and cryptographic keys. Bluesky hosts one for you at bsky.social, but you can run your own. Self-hosting means you control your data; you're not dependent on any company's servers, and your account + data is actually yours.
|
||||
"A what for the what?" -> glad you asked: Bluesky, Tangled, and a bunch of other web applications use a federated protocol called AT Protocol (atproto). Your account lives on a PDS, a server that stores your posts, profile, follows, cryptographic keys, et cetera. The beauty is that a PDS is the *only* place your data lives permanently - so you can navigate any atproto app knowing that your data is yours and not getting locked behind any one app's walls.
|
||||
|
||||
This particular PDS thrives under harsh conditions. It is a dandelion growing through the cracks in the sidewalk concrete.
|
||||
We came together to make this PDS to enable and empower our users to better host their data on this shared protocol. All of our decisions as a project are guided by their usefulness to the community: PDS hosters and end-users both.
|
||||
|
||||
It has full compatibility with Bluesky's reference PDS.
|
||||
Comparatively: Bluesky the company created a "reference PDS" that we can self-host quite easily, and that's great, but Bluesky has an incentive to make software for themselvess first & foremost, then secondly their software can be useful for us self-hosters. In contrast, Tranquil is not from a company, and will never be.
|
||||
|
||||
## What's different about Tranquil PDS
|
||||
|
||||
It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), SSO login and signup, did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), and a built-in web UI for account management, repo browsing, and admin.
|
||||
It is a superset of the reference PDS, including:
|
||||
- passkeys and 2FA: WebAuthn/FIDO2, TOTP, backup codes, trusted devices
|
||||
- SSO login and signup
|
||||
- did:web support: PDS-hosted subdomains or bring-your-own
|
||||
- multi-channel communication: you can be notified via email, discord, telegram, and signal for verification and alerts
|
||||
- granular OAuth scopes with a consent UI that allows unchecking specific scopes
|
||||
- app passwords with the same granular permission scope system as OAuth
|
||||
- account delegation: letting others manage an account with configurable permission levels
|
||||
- a built-in web UI for account management, repo browsing, and admin
|
||||
|
||||
The PDS itself is a single binary with no nodeJS runtime. However, at time of writing, Tranquil requires postgres running separately. Blobs are stored on the local filesystem by default (S3 optional). Valkey is also optional (as an alternative to the built-in cache).
|
||||
Unlike the ref PDS, Tranquil itself is compiled to a single binary with no nodeJS runtime. However, at time of writing, Tranquil requires postgres running separately.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -55,14 +63,38 @@ podman-compose -f docker-compose.prod.yaml up -d
|
||||
- [Containers](docs/install-containers.md)
|
||||
- [Kubernetes](docs/install-kubernetes.md)
|
||||
|
||||
## Maintainers to ping
|
||||
## Community
|
||||
|
||||
- [@lewis.moe](https://bsky.app/profile/did:plc:3fwecdnvtcscjnrx2p4n7alz)
|
||||
- [@nel.pet](https://bsky.app/profile/did:plc:h5wsnqetncv6lu2weom35lg2)
|
||||
### "Let's connect!" or whatever linkedin-types say
|
||||
|
||||
## Thanks
|
||||
We currently don't have a shared space to chat and organize Tranquil things, but we're very interested in changing that in the near future. What do you suggest? Anything but a discord server.
|
||||
|
||||
This project is very grateful to [@nonbinary.computer](https://bsky.app/profile/did:plc:yfvwmnlztr4dwkb7hwz55r2g), [@juli.ee](https://bsky.app/profile/did:plc:7vimlesenouvuaqvle42yhvo), [@mary.my.id](https://bsky.app/profile/did:plc:ia76kvnndjutgedggx2ibrem), and [@baileytownsend.dev](https://bsky.app/profile/did:plc:rnpkyqnmsw4ipey6eotbdnnf) for their help and their code to lean on.
|
||||
### Core team
|
||||
|
||||
- [@oyster.cafe](https://tangled.org/did:plc:3fwecdnvtcscjnrx2p4n7alz)
|
||||
- [@nel.pet](https://tangled.org/did:plc:h5wsnqetncv6lu2weom35lg2)
|
||||
|
||||
### Amazing contributers
|
||||
|
||||
- [@isabelroses.com](https://tangled.org/did:plc:qxichs7jsycphrsmbujwqbfb)
|
||||
- [@quilling.dev](https://tangled.org/did:plc:jrtgsidnmxaen4offglr5lsh)
|
||||
- [@koi.rip](https://tangled.org/did:plc:b26ewgkrnx3yvsp2cdao3ntu)
|
||||
- [@bas.sh](https://tangled.org/did:plc:c52wep6lj4sfbsqiz3yvb55h)
|
||||
- [@nekomimi.pet](https://tangled.org/did:plc:ttdrpj45ibqunmfhdsb4zdwq)
|
||||
- [@islacant.win](https://tangled.org/did:plc:aut6evcs6d6ngaunqgfhdzzu)
|
||||
- [@a.starrysky.fyi](https://tangled.org/did:plc:uuyqs6y3pwtbteet4swt5i5y)
|
||||
- [@sans-self.org](https://tangled.org/did:plc:wydyrngmxbcsqdvhmd7whmye)
|
||||
- [@tachyonism.tngl.sh](https://tangled.org/did:plc:w6qiwij62bmdugsd3gemhpy2)
|
||||
- Could be your name here too!
|
||||
|
||||
### Tranquil PDS instances in the wild!
|
||||
|
||||
- [Tranquil Farm](https://tranquil.farm)
|
||||
- Your instance here!! Don't be a stranger.
|
||||
|
||||
### Special thanks
|
||||
|
||||
This project is very grateful to [@nonbinary.computer](https://tangled.org/did:plc:yfvwmnlztr4dwkb7hwz55r2g), [@juliet.paris](https://tangled.org/did:plc:hs3aly5l26pozymy4b6hz7ae), [@mary.my.id](https://tangled.org/did:plc:ia76kvnndjutgedggx2ibrem), [@baileytownsend.dev](https://tangled.org/did:plc:rnpkyqnmsw4ipey6eotbdnnf), and [@ptr.pet](https://tangled.org/did:plc:dfl62fgb7wtjj3fcbb72naae) for their help and their code to lean on.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -144,12 +144,12 @@ pub trait UserRepository: Send + Sync {
|
||||
|
||||
async fn get_by_email(&self, email: &str) -> Result<Option<UserForVerification>, DbError>;
|
||||
|
||||
async fn get_login_check_by_handle_or_email(
|
||||
async fn get_login_check_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
) -> Result<Option<UserLoginCheck>, DbError>;
|
||||
|
||||
async fn get_login_info_by_handle_or_email(
|
||||
async fn get_login_info_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
) -> Result<Option<UserLoginInfo>, DbError>;
|
||||
@@ -358,6 +358,19 @@ pub trait UserRepository: Send + Sync {
|
||||
challenge_type: WebauthnChallengeType,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn save_discoverable_challenge(
|
||||
&self,
|
||||
request_key: &str,
|
||||
state_json: &str,
|
||||
) -> Result<Uuid, DbError>;
|
||||
|
||||
async fn load_discoverable_challenge(
|
||||
&self,
|
||||
request_key: &str,
|
||||
) -> Result<Option<String>, DbError>;
|
||||
|
||||
async fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), DbError>;
|
||||
|
||||
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>;
|
||||
|
||||
@@ -1102,6 +1102,59 @@ impl UserRepository for PostgresUserRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_discoverable_challenge(
|
||||
&self,
|
||||
request_key: &str,
|
||||
state_json: &str,
|
||||
) -> Result<Uuid, DbError> {
|
||||
let id = Uuid::new_v4();
|
||||
let challenge = id.as_bytes().to_vec();
|
||||
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(5);
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)
|
||||
VALUES ($1, $2, $3, 'discoverable', $4, $5)"#,
|
||||
id,
|
||||
request_key,
|
||||
challenge,
|
||||
state_json,
|
||||
expires_at,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn load_discoverable_challenge(
|
||||
&self,
|
||||
request_key: &str,
|
||||
) -> Result<Option<String>, DbError> {
|
||||
let row = sqlx::query_scalar!(
|
||||
r#"SELECT state_json FROM webauthn_challenges
|
||||
WHERE did = $1 AND challenge_type = 'discoverable' AND expires_at > NOW()
|
||||
ORDER BY created_at DESC LIMIT 1"#,
|
||||
request_key,
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
async fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'discoverable'",
|
||||
request_key,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
|
||||
@@ -1330,12 +1383,12 @@ impl UserRepository for PostgresUserRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_login_check_by_handle_or_email(
|
||||
async fn get_login_check_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
) -> Result<Option<UserLoginCheck>, DbError> {
|
||||
sqlx::query!(
|
||||
"SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1",
|
||||
"SELECT did, password_hash FROM users WHERE handle = $1 OR did = $1",
|
||||
identifier
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -1349,7 +1402,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_login_info_by_handle_or_email(
|
||||
async fn get_login_info_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
) -> Result<Option<UserLoginInfo>, DbError> {
|
||||
@@ -1361,7 +1414,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
email_verified, discord_verified, telegram_verified, signal_verified,
|
||||
account_type as "account_type!: AccountType"
|
||||
FROM users
|
||||
WHERE handle = $1 OR email = $1
|
||||
WHERE handle = $1 OR did = $1
|
||||
"#,
|
||||
identifier
|
||||
)
|
||||
@@ -1524,7 +1577,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as "email_2fa_enabled!"
|
||||
FROM users u
|
||||
JOIN user_keys k ON u.id = k.user_id
|
||||
WHERE u.handle = $1 OR u.email = $1 OR u.did = $1"#,
|
||||
WHERE u.handle = $1 OR u.did = $1"#,
|
||||
identifier
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
|
||||
@@ -322,7 +322,9 @@ fn validate_blob_ref(
|
||||
|
||||
if let Some(ref accept) = lex_blob.accept {
|
||||
let mime_type = obj.get("mimeType").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let matched = accept.iter().any(|pattern| mime_type_matches_accept_pattern(mime_type, pattern));
|
||||
let matched = accept
|
||||
.iter()
|
||||
.any(|pattern| mime_type_matches_accept_pattern(mime_type, pattern));
|
||||
if !mime_type.is_empty() && !matched {
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
|
||||
@@ -108,7 +108,7 @@ pub async fn authorize_get(
|
||||
match state
|
||||
.repos
|
||||
.user
|
||||
.get_login_check_by_handle_or_email(normalized.as_str())
|
||||
.get_login_check_by_identifier(normalized.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(Some(user)) => {
|
||||
@@ -401,7 +401,7 @@ pub async fn authorize_post(
|
||||
let user = match state
|
||||
.repos
|
||||
.user
|
||||
.get_login_info_by_handle_or_email(normalized_username.as_str())
|
||||
.get_login_info_by_identifier(normalized_username.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
@@ -410,7 +410,7 @@ pub async fn authorize_post(
|
||||
&form.password,
|
||||
"$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK",
|
||||
);
|
||||
return show_login_error("Invalid handle/email or password.", json_response);
|
||||
return show_login_error("Invalid identifier or password.", json_response);
|
||||
}
|
||||
Err(_) => return show_login_error("An error occurred. Please try again.", json_response),
|
||||
};
|
||||
@@ -486,7 +486,7 @@ pub async fn authorize_post(
|
||||
None => false,
|
||||
};
|
||||
if !password_valid {
|
||||
return show_login_error("Invalid handle/email or password.", json_response);
|
||||
return show_login_error("Invalid identifier or password.", json_response);
|
||||
}
|
||||
let is_verified = user.channel_verification.has_any_verified();
|
||||
if !is_verified {
|
||||
|
||||
@@ -22,7 +22,7 @@ pub async fn check_user_has_passkeys(
|
||||
let user = state
|
||||
.repos
|
||||
.user
|
||||
.get_login_check_by_handle_or_email(bare_identifier.as_str())
|
||||
.get_login_check_by_identifier(bare_identifier.as_str())
|
||||
.await;
|
||||
|
||||
let has_passkeys = match user {
|
||||
@@ -55,7 +55,7 @@ pub async fn check_user_security_status(
|
||||
let user = state
|
||||
.repos
|
||||
.user
|
||||
.get_login_check_by_handle_or_email(normalized_identifier.as_str())
|
||||
.get_login_check_by_identifier(normalized_identifier.as_str())
|
||||
.await;
|
||||
|
||||
let (has_passkeys, has_totp, has_password, is_delegated, did): (
|
||||
@@ -99,7 +99,7 @@ pub async fn check_user_security_status(
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PasskeyStartInput {
|
||||
pub request_uri: String,
|
||||
pub identifier: String,
|
||||
pub identifier: Option<String>,
|
||||
pub delegated_did: Option<String>,
|
||||
}
|
||||
|
||||
@@ -160,14 +160,91 @@ pub async fn passkey_start(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match form.identifier.filter(|s| !s.trim().is_empty()) {
|
||||
Some(identifier) => {
|
||||
passkey_start_named(
|
||||
state,
|
||||
identifier,
|
||||
form.delegated_did,
|
||||
request_data,
|
||||
passkey_start_request_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => passkey_start_discoverable(state, passkey_start_request_id).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn passkey_start_discoverable(
|
||||
state: AppState,
|
||||
request_id: RequestId,
|
||||
) -> Response {
|
||||
let (rcr, auth_state) = match state.webauthn_config.start_discoverable_authentication() {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to start discoverable passkey authentication");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "Failed to start authentication."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let state_json = match serde_json::to_string(&auth_state) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to serialize authentication state");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "An error occurred."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = state
|
||||
.repos
|
||||
.user
|
||||
.save_discoverable_challenge(request_id.as_str(), &state_json)
|
||||
.await
|
||||
{
|
||||
tracing::error!(error = %e, "Failed to save discoverable authentication state");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "An error occurred."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({}));
|
||||
Json(PasskeyStartResponse { options }).into_response()
|
||||
}
|
||||
|
||||
async fn passkey_start_named(
|
||||
state: AppState,
|
||||
identifier: String,
|
||||
delegated_did: Option<String>,
|
||||
request_data: tranquil_pds::oauth::RequestData,
|
||||
passkey_start_request_id: RequestId,
|
||||
) -> Response {
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let normalized_username =
|
||||
NormalizedLoginIdentifier::normalize(&form.identifier, hostname_for_handles);
|
||||
NormalizedLoginIdentifier::normalize(&identifier, hostname_for_handles);
|
||||
|
||||
let user = match state
|
||||
.repos
|
||||
.user
|
||||
.get_login_info_by_handle_or_email(normalized_username.as_str())
|
||||
.get_login_info_by_identifier(normalized_username.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
@@ -325,7 +402,7 @@ pub async fn passkey_start(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let delegation_from_param = match &form.delegated_did {
|
||||
let delegation_from_param = match &delegated_did {
|
||||
Some(delegated_did_str) => match delegated_did_str.parse::<tranquil_types::Did>() {
|
||||
Ok(delegated_did) if delegated_did != user.did => {
|
||||
match state
|
||||
@@ -471,85 +548,6 @@ pub async fn passkey_finish(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let did_str = match request_data.did {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "No passkey authentication in progress."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let did: tranquil_types::Did = match did_str.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid DID format."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let controller_did: Option<tranquil_types::Did> = request_data
|
||||
.controller_did
|
||||
.as_ref()
|
||||
.and_then(|s| s.parse().ok());
|
||||
let passkey_owner_did = controller_did.as_ref().unwrap_or(&did);
|
||||
|
||||
let auth_state_json = match state
|
||||
.repos
|
||||
.user
|
||||
.load_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication)
|
||||
.await
|
||||
{
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "No passkey authentication in progress or challenge expired."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to load authentication state");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "An error occurred."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let auth_state: webauthn_rs::prelude::SecurityKeyAuthentication =
|
||||
match serde_json::from_str(&auth_state_json) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to deserialize authentication state");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "An error occurred."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let credential: webauthn_rs::prelude::PublicKeyCredential =
|
||||
match serde_json::from_value(form.credential) {
|
||||
Ok(c) => c,
|
||||
@@ -566,33 +564,35 @@ pub async fn passkey_finish(
|
||||
}
|
||||
};
|
||||
|
||||
let auth_result = match state
|
||||
.webauthn_config
|
||||
.finish_authentication(&credential, &auth_state)
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, did = %did, "Failed to verify passkey authentication");
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Passkey verification failed."
|
||||
})),
|
||||
let (did, auth_result) = match request_data.did.clone() {
|
||||
Some(did) => match passkey_finish_named(&state, did, &request_data, &credential).await {
|
||||
Ok(result) => result,
|
||||
Err(response) => return response,
|
||||
},
|
||||
None => {
|
||||
let result = match passkey_finish_discoverable(
|
||||
&state,
|
||||
&credential,
|
||||
&passkey_finish_request_id,
|
||||
)
|
||||
.into_response();
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(response) => return response,
|
||||
};
|
||||
if state
|
||||
.repos
|
||||
.oauth
|
||||
.set_authorization_did(&passkey_finish_request_id, &result.0, None)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return OAuthError::ServerError("An error occurred.".into()).into_response();
|
||||
}
|
||||
result
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = state
|
||||
.repos
|
||||
.user
|
||||
.delete_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "Failed to delete authentication state");
|
||||
}
|
||||
|
||||
if auth_result.needs_update() {
|
||||
let cred_id_bytes = auth_result.cred_id().as_slice();
|
||||
match state
|
||||
@@ -691,6 +691,187 @@ pub async fn passkey_finish(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn passkey_finish_named(
|
||||
state: &AppState,
|
||||
did: tranquil_types::Did,
|
||||
request_data: &tranquil_pds::oauth::RequestData,
|
||||
credential: &webauthn_rs::prelude::PublicKeyCredential,
|
||||
) -> Result<
|
||||
(
|
||||
tranquil_types::Did,
|
||||
webauthn_rs::prelude::AuthenticationResult,
|
||||
),
|
||||
Response,
|
||||
> {
|
||||
let passkey_owner_did = request_data.controller_did.as_ref().unwrap_or(&did);
|
||||
|
||||
let auth_state_json = state
|
||||
.repos
|
||||
.user
|
||||
.load_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to load authentication state");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
|
||||
).into_response()
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "No passkey authentication in progress or challenge expired."
|
||||
})),
|
||||
).into_response()
|
||||
})?;
|
||||
|
||||
let auth_state: webauthn_rs::prelude::SecurityKeyAuthentication =
|
||||
serde_json::from_str(&auth_state_json).map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to deserialize authentication state");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
|
||||
).into_response()
|
||||
})?;
|
||||
|
||||
let auth_result = state
|
||||
.webauthn_config
|
||||
.finish_authentication(credential, &auth_state)
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, did = %did, "Failed to verify passkey authentication");
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Passkey verification failed."
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
|
||||
let _ = state
|
||||
.repos
|
||||
.user
|
||||
.delete_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication)
|
||||
.await;
|
||||
|
||||
Ok((did, auth_result))
|
||||
}
|
||||
|
||||
async fn passkey_finish_discoverable(
|
||||
state: &AppState,
|
||||
credential: &webauthn_rs::prelude::PublicKeyCredential,
|
||||
request_id: &RequestId,
|
||||
) -> Result<
|
||||
(
|
||||
tranquil_types::Did,
|
||||
webauthn_rs::prelude::AuthenticationResult,
|
||||
),
|
||||
Response,
|
||||
> {
|
||||
let auth_state_json = state
|
||||
.repos
|
||||
.user
|
||||
.load_discoverable_challenge(request_id.as_str())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to load discoverable authentication state");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
|
||||
).into_response()
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "No passkey authentication in progress or challenge expired."
|
||||
})),
|
||||
).into_response()
|
||||
})?;
|
||||
|
||||
let auth_state: webauthn_rs::prelude::DiscoverableAuthentication =
|
||||
serde_json::from_str(&auth_state_json).map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to deserialize discoverable authentication state");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
|
||||
).into_response()
|
||||
})?;
|
||||
|
||||
let (_user_uuid, cred_id) = state
|
||||
.webauthn_config
|
||||
.identify_discoverable_authentication(credential)
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, "Failed to identify discoverable credential");
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Passkey verification failed."
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
|
||||
let stored_passkey = state
|
||||
.repos
|
||||
.user
|
||||
.get_passkey_by_credential_id(cred_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to look up passkey by credential ID");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
|
||||
).into_response()
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
tracing::warn!("Discoverable credential not found in database");
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Passkey not recognized."
|
||||
})),
|
||||
).into_response()
|
||||
})?;
|
||||
|
||||
let discoverable_key: webauthn_rs::prelude::DiscoverableKey =
|
||||
serde_json::from_slice(&stored_passkey.public_key).map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to deserialize stored passkey as DiscoverableKey");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
|
||||
).into_response()
|
||||
})?;
|
||||
|
||||
let auth_result = state
|
||||
.webauthn_config
|
||||
.finish_discoverable_authentication(credential, auth_state, &[discoverable_key])
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, did = %stored_passkey.did, "Failed to verify discoverable passkey authentication");
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Passkey verification failed."
|
||||
})),
|
||||
).into_response()
|
||||
})?;
|
||||
|
||||
let _ = state
|
||||
.repos
|
||||
.user
|
||||
.delete_discoverable_challenge(request_id.as_str())
|
||||
.await;
|
||||
|
||||
Ok((stored_passkey.did, auth_result))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthorizePasskeyQuery {
|
||||
pub request_uri: String,
|
||||
|
||||
@@ -78,6 +78,7 @@ tracing = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
webauthn-rs = { workspace = true }
|
||||
webauthn-rs-proto = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
aws-config = { workspace = true, optional = true }
|
||||
aws-sdk-s3 = { workspace = true, optional = true }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use uuid::Uuid;
|
||||
use webauthn_rs::prelude::*;
|
||||
use webauthn_rs_proto::{
|
||||
AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationPolicy,
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WebauthnError {
|
||||
@@ -57,6 +60,15 @@ impl WebAuthnConfig {
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.map(|(mut ccr, state)| {
|
||||
let sel = ccr
|
||||
.public_key
|
||||
.authenticator_selection
|
||||
.get_or_insert_with(AuthenticatorSelectionCriteria::default);
|
||||
sel.resident_key = Some(ResidentKeyRequirement::Required);
|
||||
sel.require_resident_key = true;
|
||||
(ccr, state)
|
||||
})
|
||||
.map_err(|e| WebauthnError::RegistrationFailed(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -88,4 +100,49 @@ impl WebAuthnConfig {
|
||||
.finish_securitykey_authentication(auth, state)
|
||||
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn start_discoverable_authentication(
|
||||
&self,
|
||||
) -> Result<(RequestChallengeResponse, DiscoverableAuthentication), WebauthnError> {
|
||||
let (mut rcr, state) = self
|
||||
.webauthn
|
||||
.start_discoverable_authentication()
|
||||
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))?;
|
||||
|
||||
rcr.mediation = None;
|
||||
rcr.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE;
|
||||
|
||||
let mut state_json = serde_json::to_value(&state)
|
||||
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))?;
|
||||
let ast = state_json
|
||||
.get_mut("ast")
|
||||
.ok_or_else(|| WebauthnError::AuthenticationFailed(
|
||||
"webauthn-rs DiscoverableAuthentication missing 'ast' field, library version incompatible".into(),
|
||||
))?;
|
||||
ast["policy"] = serde_json::json!("discouraged");
|
||||
let patched: DiscoverableAuthentication = serde_json::from_value(state_json)
|
||||
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))?;
|
||||
|
||||
Ok((rcr, patched))
|
||||
}
|
||||
|
||||
pub fn identify_discoverable_authentication<'a>(
|
||||
&self,
|
||||
credential: &'a PublicKeyCredential,
|
||||
) -> Result<(Uuid, &'a [u8]), WebauthnError> {
|
||||
self.webauthn
|
||||
.identify_discoverable_authentication(credential)
|
||||
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn finish_discoverable_authentication(
|
||||
&self,
|
||||
credential: &PublicKeyCredential,
|
||||
state: DiscoverableAuthentication,
|
||||
creds: &[DiscoverableKey],
|
||||
) -> Result<AuthenticationResult, WebauthnError> {
|
||||
self.webauthn
|
||||
.finish_discoverable_authentication(credential, state, creds)
|
||||
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +162,13 @@ fn stream_compact<S: StorageIO>(
|
||||
} => match index.get(&cid_bytes) {
|
||||
Some(e) if e.location.file_id == source_file_id && !e.refcount.is_zero() => {
|
||||
let loc = writer.append_block(&cid_bytes, &data)?;
|
||||
hint_writer.append_relocate(&cid_bytes, loc.file_id, loc.offset, loc.length)?;
|
||||
hint_writer.append_relocate(
|
||||
&cid_bytes,
|
||||
loc.file_id,
|
||||
loc.offset,
|
||||
loc.length,
|
||||
e.refcount.raw(),
|
||||
)?;
|
||||
relocations.push((cid_bytes, loc));
|
||||
live_count = live_count.saturating_add(1);
|
||||
}
|
||||
@@ -187,6 +193,7 @@ fn stream_compact<S: StorageIO>(
|
||||
loc.file_id,
|
||||
loc.offset,
|
||||
loc.length,
|
||||
e.refcount.raw(),
|
||||
)?;
|
||||
relocations.push((cid_bytes, loc));
|
||||
live_count = live_count.saturating_add(1);
|
||||
|
||||
@@ -378,6 +378,7 @@ impl HashTable {
|
||||
&mut self,
|
||||
cid: &[u8; CID_SIZE],
|
||||
new_location: BlockLocation,
|
||||
refcount: RefCount,
|
||||
) -> Result<bool, CapacityExhausted> {
|
||||
if is_empty(cid) {
|
||||
return Ok(false);
|
||||
@@ -394,7 +395,9 @@ impl HashTable {
|
||||
let slot_cid = self.slots[idx].cid;
|
||||
|
||||
if is_empty(&slot_cid) {
|
||||
self.slots[idx] = Slot::from_location(*cid, new_location);
|
||||
let mut slot = Slot::from_location(*cid, new_location);
|
||||
slot.refcount = refcount;
|
||||
self.slots[idx] = slot;
|
||||
self.count += 1;
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -411,6 +414,7 @@ impl HashTable {
|
||||
let slot_dist = self.probe_distance(idx, slot_home);
|
||||
if slot_dist < dist {
|
||||
let mut displaced = Slot::from_location(*cid, new_location);
|
||||
displaced.refcount = refcount;
|
||||
std::mem::swap(&mut self.slots[idx], &mut displaced);
|
||||
self.count += 1;
|
||||
self.relocate_displaced(displaced, idx, slot_dist);
|
||||
@@ -571,7 +575,7 @@ impl HashTable {
|
||||
removals: &[CidBytes],
|
||||
) {
|
||||
relocations.iter().for_each(|(cid, new_loc)| {
|
||||
if let Err(e) = self.relocate(cid, *new_loc) {
|
||||
if let Err(e) = self.relocate(cid, *new_loc, RefCount::one()) {
|
||||
tracing::error!(?e, "capacity exhausted during compaction relocation");
|
||||
}
|
||||
});
|
||||
@@ -1267,18 +1271,20 @@ impl BlockIndex {
|
||||
|
||||
pub fn batch_relocate(
|
||||
&self,
|
||||
relocations: &[(CidBytes, BlockLocation)],
|
||||
relocations: &[(CidBytes, BlockLocation, u32)],
|
||||
) -> Result<(), BlockIndexError> {
|
||||
if relocations.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut table = self.table.write();
|
||||
relocations.iter().try_for_each(|(cid, location)| {
|
||||
table
|
||||
.relocate(cid, *location)
|
||||
.map(|_| ())
|
||||
.map_err(|_| BlockIndexError::CapacityExhausted)
|
||||
})
|
||||
relocations
|
||||
.iter()
|
||||
.try_for_each(|(cid, location, refcount)| {
|
||||
table
|
||||
.relocate(cid, *location, RefCount::new(*refcount))
|
||||
.map(|_| ())
|
||||
.map_err(|_| BlockIndexError::CapacityExhausted)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn batch_remove(&self, cids: &[CidBytes]) {
|
||||
@@ -1499,15 +1505,18 @@ impl BlockIndex {
|
||||
file_id,
|
||||
offset,
|
||||
length,
|
||||
refcount,
|
||||
} => {
|
||||
let loc = BlockLocation {
|
||||
file_id,
|
||||
offset,
|
||||
length,
|
||||
};
|
||||
table.relocate(&cid_bytes, loc).map_err(|_| {
|
||||
io::Error::other("hash table capacity exhausted during rebuild")
|
||||
})?;
|
||||
table
|
||||
.relocate(&cid_bytes, loc, RefCount::new(refcount))
|
||||
.map_err(|_| {
|
||||
io::Error::other("hash table capacity exhausted during rebuild")
|
||||
})?;
|
||||
}
|
||||
ReadHintRecord::Remove { cid_bytes } => {
|
||||
let _ = table.remove(&cid_bytes);
|
||||
|
||||
@@ -78,6 +78,8 @@ pub(crate) fn encode_hint_record<S: StorageIO>(
|
||||
write_hint_record(io, fd, write_offset, &record)
|
||||
}
|
||||
|
||||
const REFCOUNT_OFFSET: usize = 2;
|
||||
|
||||
pub(crate) fn encode_relocate_record<S: StorageIO>(
|
||||
io: &S,
|
||||
fd: FileId,
|
||||
@@ -86,10 +88,13 @@ pub(crate) fn encode_relocate_record<S: StorageIO>(
|
||||
file_id: DataFileId,
|
||||
block_offset: BlockOffset,
|
||||
length: BlockLength,
|
||||
refcount: u32,
|
||||
) -> io::Result<()> {
|
||||
let mut record = [0u8; HINT_RECORD_SIZE];
|
||||
record[TYPE_OFFSET] = RECORD_TYPE_RELOCATE;
|
||||
record[VERSION_OFFSET] = HINT_FORMAT_VERSION;
|
||||
let rc16 = u16::try_from(refcount).unwrap_or(u16::MAX);
|
||||
record[REFCOUNT_OFFSET..REFCOUNT_OFFSET + 2].copy_from_slice(&rc16.to_le_bytes());
|
||||
record[CID_OFFSET..CID_OFFSET + CID_SIZE].copy_from_slice(cid_bytes);
|
||||
record[FIELD_A_OFFSET..FIELD_A_OFFSET + 4].copy_from_slice(&file_id.raw().to_le_bytes());
|
||||
record[FIELD_A_OFFSET + 4..FIELD_A_OFFSET + 8].copy_from_slice(&length.raw().to_le_bytes());
|
||||
@@ -158,6 +163,7 @@ pub enum ReadHintRecord {
|
||||
file_id: DataFileId,
|
||||
offset: BlockOffset,
|
||||
length: BlockLength,
|
||||
refcount: u32,
|
||||
},
|
||||
Remove {
|
||||
cid_bytes: [u8; CID_SIZE],
|
||||
@@ -255,6 +261,15 @@ pub fn decode_hint_record<S: StorageIO>(
|
||||
}))
|
||||
}
|
||||
RECORD_TYPE_RELOCATE => {
|
||||
let rc16 = u16::from_le_bytes(
|
||||
record[REFCOUNT_OFFSET..REFCOUNT_OFFSET + 2]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
let refcount = match rc16 {
|
||||
0 => 1,
|
||||
n => u32::from(n),
|
||||
};
|
||||
let file_id = DataFileId::new(u32::from_le_bytes(
|
||||
record[FIELD_A_OFFSET..FIELD_A_OFFSET + 4]
|
||||
.try_into()
|
||||
@@ -278,6 +293,7 @@ pub fn decode_hint_record<S: StorageIO>(
|
||||
file_id,
|
||||
offset: block_offset,
|
||||
length: BlockLength::new(raw_length),
|
||||
refcount,
|
||||
}))
|
||||
}
|
||||
RECORD_TYPE_REMOVE => Ok(Some(ReadHintRecord::Remove { cid_bytes })),
|
||||
@@ -341,6 +357,7 @@ impl<'a, S: StorageIO> HintFileWriter<'a, S> {
|
||||
file_id: DataFileId,
|
||||
offset: BlockOffset,
|
||||
length: BlockLength,
|
||||
refcount: u32,
|
||||
) -> io::Result<()> {
|
||||
encode_relocate_record(
|
||||
self.io,
|
||||
@@ -350,6 +367,7 @@ impl<'a, S: StorageIO> HintFileWriter<'a, S> {
|
||||
file_id,
|
||||
offset,
|
||||
length,
|
||||
refcount,
|
||||
)?;
|
||||
self.position = self.position.advance(HINT_RECORD_SIZE as u64);
|
||||
Ok(())
|
||||
@@ -575,7 +593,7 @@ pub fn replay_hints_into_block_index<S: StorageIO>(
|
||||
let mut replayed: u64 = 0;
|
||||
let mut put_buffer: Vec<([u8; CID_SIZE], BlockLocation)> =
|
||||
Vec::with_capacity(REPLAY_BATCH_SIZE);
|
||||
let mut relocate_buffer: Vec<([u8; CID_SIZE], BlockLocation)> =
|
||||
let mut relocate_buffer: Vec<([u8; CID_SIZE], BlockLocation, u32)> =
|
||||
Vec::with_capacity(REPLAY_BATCH_SIZE);
|
||||
let mut remove_buffer: Vec<[u8; CID_SIZE]> = Vec::with_capacity(REPLAY_BATCH_SIZE);
|
||||
|
||||
@@ -663,13 +681,14 @@ pub fn replay_hints_into_block_index<S: StorageIO>(
|
||||
file_id,
|
||||
offset,
|
||||
length,
|
||||
refcount,
|
||||
} => {
|
||||
let loc = BlockLocation {
|
||||
file_id,
|
||||
offset,
|
||||
length,
|
||||
};
|
||||
relocate_buffer.push((cid_bytes, loc));
|
||||
relocate_buffer.push((cid_bytes, loc, refcount));
|
||||
|
||||
let record_end =
|
||||
offset.advance(BLOCK_RECORD_OVERHEAD as u64 + length.as_u64());
|
||||
|
||||
@@ -3487,13 +3487,13 @@ impl<S: StorageIO + 'static> tranquil_db_traits::UserRepository for MetastoreCli
|
||||
recv(rx).await
|
||||
}
|
||||
|
||||
async fn get_login_check_by_handle_or_email(
|
||||
async fn get_login_check_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
) -> Result<Option<UserLoginCheck>, DbError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pool.send(MetastoreRequest::User(
|
||||
UserRequest::GetLoginCheckByHandleOrEmail {
|
||||
UserRequest::GetLoginCheckByIdentifier {
|
||||
identifier: identifier.to_owned(),
|
||||
tx,
|
||||
},
|
||||
@@ -3501,13 +3501,13 @@ impl<S: StorageIO + 'static> tranquil_db_traits::UserRepository for MetastoreCli
|
||||
recv(rx).await
|
||||
}
|
||||
|
||||
async fn get_login_info_by_handle_or_email(
|
||||
async fn get_login_info_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
) -> Result<Option<UserLoginInfo>, DbError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pool.send(MetastoreRequest::User(
|
||||
UserRequest::GetLoginInfoByHandleOrEmail {
|
||||
UserRequest::GetLoginInfoByIdentifier {
|
||||
identifier: identifier.to_owned(),
|
||||
tx,
|
||||
},
|
||||
@@ -4233,6 +4233,47 @@ impl<S: StorageIO + 'static> tranquil_db_traits::UserRepository for MetastoreCli
|
||||
recv(rx).await
|
||||
}
|
||||
|
||||
async fn save_discoverable_challenge(
|
||||
&self,
|
||||
request_key: &str,
|
||||
state_json: &str,
|
||||
) -> Result<Uuid, DbError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pool.send(MetastoreRequest::User(
|
||||
UserRequest::SaveDiscoverableChallenge {
|
||||
request_key: request_key.to_owned(),
|
||||
state_json: state_json.to_owned(),
|
||||
tx,
|
||||
},
|
||||
))?;
|
||||
recv(rx).await
|
||||
}
|
||||
|
||||
async fn load_discoverable_challenge(
|
||||
&self,
|
||||
request_key: &str,
|
||||
) -> Result<Option<String>, DbError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pool.send(MetastoreRequest::User(
|
||||
UserRequest::LoadDiscoverableChallenge {
|
||||
request_key: request_key.to_owned(),
|
||||
tx,
|
||||
},
|
||||
))?;
|
||||
recv(rx).await
|
||||
}
|
||||
|
||||
async fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), DbError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pool.send(MetastoreRequest::User(
|
||||
UserRequest::DeleteDiscoverableChallenge {
|
||||
request_key: request_key.to_owned(),
|
||||
tx,
|
||||
},
|
||||
))?;
|
||||
recv(rx).await
|
||||
}
|
||||
|
||||
async fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, DbError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pool
|
||||
|
||||
@@ -996,11 +996,11 @@ pub enum UserRequest {
|
||||
email: String,
|
||||
tx: Tx<Option<UserForVerification>>,
|
||||
},
|
||||
GetLoginCheckByHandleOrEmail {
|
||||
GetLoginCheckByIdentifier {
|
||||
identifier: String,
|
||||
tx: Tx<Option<UserLoginCheck>>,
|
||||
},
|
||||
GetLoginInfoByHandleOrEmail {
|
||||
GetLoginInfoByIdentifier {
|
||||
identifier: String,
|
||||
tx: Tx<Option<UserLoginInfo>>,
|
||||
},
|
||||
@@ -1273,6 +1273,19 @@ pub enum UserRequest {
|
||||
challenge_type: WebauthnChallengeType,
|
||||
tx: Tx<()>,
|
||||
},
|
||||
SaveDiscoverableChallenge {
|
||||
request_key: String,
|
||||
state_json: String,
|
||||
tx: Tx<Uuid>,
|
||||
},
|
||||
LoadDiscoverableChallenge {
|
||||
request_key: String,
|
||||
tx: Tx<Option<String>>,
|
||||
},
|
||||
DeleteDiscoverableChallenge {
|
||||
request_key: String,
|
||||
tx: Tx<()>,
|
||||
},
|
||||
GetTotpRecord {
|
||||
did: Did,
|
||||
tx: Tx<Option<TotpRecord>>,
|
||||
@@ -1726,8 +1739,8 @@ impl UserRequest {
|
||||
| Self::GetAnyAdminUserId { .. }
|
||||
| Self::SearchAccounts { .. }
|
||||
| Self::GetByEmail { .. }
|
||||
| Self::GetLoginCheckByHandleOrEmail { .. }
|
||||
| Self::GetLoginInfoByHandleOrEmail { .. }
|
||||
| Self::GetLoginCheckByIdentifier { .. }
|
||||
| Self::GetLoginInfoByIdentifier { .. }
|
||||
| Self::CheckEmailVerifiedByIdentifier { .. }
|
||||
| Self::StoreTelegramChatId { .. }
|
||||
| Self::StoreDiscordUserId { .. }
|
||||
@@ -1743,7 +1756,10 @@ impl UserRequest {
|
||||
| Self::CleanupExpiredHandleReservations { .. }
|
||||
| Self::CheckAndConsumeInviteCode { .. }
|
||||
| Self::GetPasswordResetInfo { .. }
|
||||
| Self::ExpirePasswordResetCode { .. } => Routing::Global,
|
||||
| Self::ExpirePasswordResetCode { .. }
|
||||
| Self::SaveDiscoverableChallenge { .. }
|
||||
| Self::LoadDiscoverableChallenge { .. }
|
||||
| Self::DeleteDiscoverableChallenge { .. } => Routing::Global,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5066,15 +5082,15 @@ fn dispatch_user<S: StorageIO + 'static>(state: &HandlerState<S>, req: UserReque
|
||||
UserRequest::GetByEmail { email, tx } => {
|
||||
let _ = tx.send(user.get_by_email(&email).map_err(metastore_to_db));
|
||||
}
|
||||
UserRequest::GetLoginCheckByHandleOrEmail { identifier, tx } => {
|
||||
UserRequest::GetLoginCheckByIdentifier { identifier, tx } => {
|
||||
let _ = tx.send(
|
||||
user.get_login_check_by_handle_or_email(&identifier)
|
||||
user.get_login_check_by_identifier(&identifier)
|
||||
.map_err(metastore_to_db),
|
||||
);
|
||||
}
|
||||
UserRequest::GetLoginInfoByHandleOrEmail { identifier, tx } => {
|
||||
UserRequest::GetLoginInfoByIdentifier { identifier, tx } => {
|
||||
let _ = tx.send(
|
||||
user.get_login_info_by_handle_or_email(&identifier)
|
||||
user.get_login_info_by_identifier(&identifier)
|
||||
.map_err(metastore_to_db),
|
||||
);
|
||||
}
|
||||
@@ -5434,6 +5450,28 @@ fn dispatch_user<S: StorageIO + 'static>(state: &HandlerState<S>, req: UserReque
|
||||
.map_err(metastore_to_db),
|
||||
);
|
||||
}
|
||||
UserRequest::SaveDiscoverableChallenge {
|
||||
request_key,
|
||||
state_json,
|
||||
tx,
|
||||
} => {
|
||||
let _ = tx.send(
|
||||
user.save_discoverable_challenge(&request_key, &state_json)
|
||||
.map_err(metastore_to_db),
|
||||
);
|
||||
}
|
||||
UserRequest::LoadDiscoverableChallenge { request_key, tx } => {
|
||||
let _ = tx.send(
|
||||
user.load_discoverable_challenge(&request_key)
|
||||
.map_err(metastore_to_db),
|
||||
);
|
||||
}
|
||||
UserRequest::DeleteDiscoverableChallenge { request_key, tx } => {
|
||||
let _ = tx.send(
|
||||
user.delete_discoverable_challenge(&request_key)
|
||||
.map_err(metastore_to_db),
|
||||
);
|
||||
}
|
||||
UserRequest::GetTotpRecord { did, tx } => {
|
||||
let _ = tx.send(user.get_totp_record(&did).map_err(metastore_to_db));
|
||||
}
|
||||
|
||||
@@ -125,15 +125,9 @@ impl UserOps {
|
||||
}
|
||||
|
||||
fn load_by_identifier(&self, identifier: &str) -> Result<Option<UserValue>, MetastoreError> {
|
||||
match identifier.contains('@') {
|
||||
true => self.load_by_email(identifier).and_then(|opt| match opt {
|
||||
Some(v) => Ok(Some(v)),
|
||||
None => self.load_by_handle(identifier),
|
||||
}),
|
||||
false => self.load_by_handle(identifier).and_then(|opt| match opt {
|
||||
Some(v) => Ok(Some(v)),
|
||||
None => self.load_by_email(identifier),
|
||||
}),
|
||||
match identifier.starts_with("did:") {
|
||||
true => self.load_user_by_did(identifier),
|
||||
false => self.load_by_handle(identifier),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,7 +466,7 @@ impl UserOps {
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub fn get_login_check_by_handle_or_email(
|
||||
pub fn get_login_check_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
) -> Result<Option<UserLoginCheck>, MetastoreError> {
|
||||
@@ -487,7 +481,7 @@ impl UserOps {
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub fn get_login_info_by_handle_or_email(
|
||||
pub fn get_login_info_by_identifier(
|
||||
&self,
|
||||
identifier: &str,
|
||||
) -> Result<Option<UserLoginInfo>, MetastoreError> {
|
||||
@@ -1511,6 +1505,57 @@ impl UserOps {
|
||||
.map_err(MetastoreError::Fjall)
|
||||
}
|
||||
|
||||
const DISCOVERABLE_CHALLENGE_TYPE: u8 = 2;
|
||||
|
||||
pub fn save_discoverable_challenge(
|
||||
&self,
|
||||
request_key: &str,
|
||||
state_json: &str,
|
||||
) -> Result<Uuid, MetastoreError> {
|
||||
let key_hash = UserHash::from_did(request_key);
|
||||
let id = Uuid::new_v4();
|
||||
let now_ms = Utc::now().timestamp_millis();
|
||||
|
||||
let value = WebauthnChallengeValue {
|
||||
id,
|
||||
challenge_type: Self::DISCOVERABLE_CHALLENGE_TYPE,
|
||||
state_json: state_json.to_owned(),
|
||||
created_at_ms: now_ms,
|
||||
};
|
||||
|
||||
let key = webauthn_challenge_key(key_hash, Self::DISCOVERABLE_CHALLENGE_TYPE);
|
||||
self.auth
|
||||
.insert(key.as_slice(), value.serialize_with_ttl())
|
||||
.map_err(MetastoreError::Fjall)?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn load_discoverable_challenge(
|
||||
&self,
|
||||
request_key: &str,
|
||||
) -> Result<Option<String>, MetastoreError> {
|
||||
let key_hash = UserHash::from_did(request_key);
|
||||
let key = webauthn_challenge_key(key_hash, Self::DISCOVERABLE_CHALLENGE_TYPE);
|
||||
|
||||
let val: Option<WebauthnChallengeValue> = point_lookup(
|
||||
&self.auth,
|
||||
key.as_slice(),
|
||||
WebauthnChallengeValue::deserialize,
|
||||
"corrupt webauthn challenge",
|
||||
)?;
|
||||
|
||||
Ok(val.map(|v| v.state_json))
|
||||
}
|
||||
|
||||
pub fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), MetastoreError> {
|
||||
let key_hash = UserHash::from_did(request_key);
|
||||
let key = webauthn_challenge_key(key_hash, Self::DISCOVERABLE_CHALLENGE_TYPE);
|
||||
self.auth
|
||||
.remove(key.as_slice())
|
||||
.map_err(MetastoreError::Fjall)
|
||||
}
|
||||
|
||||
pub fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, MetastoreError> {
|
||||
let user_hash = self.resolve_hash(did.as_str());
|
||||
let key = totp_key(user_hash);
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
mod common;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use tranquil_store::blockstore::{
|
||||
BlockStoreConfig, CidBytes, GroupCommitConfig, TranquilBlockStore,
|
||||
};
|
||||
|
||||
fn tiny_store_config(dir: &std::path::Path) -> BlockStoreConfig {
|
||||
BlockStoreConfig {
|
||||
data_dir: dir.join("data"),
|
||||
index_dir: dir.join("index"),
|
||||
max_file_size: 4096,
|
||||
group_commit: GroupCommitConfig {
|
||||
checkpoint_interval_ms: 600_000,
|
||||
checkpoint_write_threshold: 1_000_000,
|
||||
..GroupCommitConfig::default()
|
||||
},
|
||||
shard_count: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_block(seed: u32, size: usize) -> (CidBytes, Vec<u8>) {
|
||||
(
|
||||
common::test_cid(seed),
|
||||
common::block_data(seed)
|
||||
.into_iter()
|
||||
.cycle()
|
||||
.take(size)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_live_blocks(store: &TranquilBlockStore, live: &HashSet<u32>, context: &str) {
|
||||
let missing: Vec<u32> = live
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&seed| {
|
||||
store
|
||||
.get_block_sync(&common::test_cid(seed))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"{context}: {count} live blocks missing from store: {missing:?}",
|
||||
count = missing.len(),
|
||||
);
|
||||
}
|
||||
|
||||
fn compact_sealed(store: &TranquilBlockStore) {
|
||||
let files = store.list_data_files().unwrap();
|
||||
files
|
||||
.iter()
|
||||
.copied()
|
||||
.take(files.len().saturating_sub(1))
|
||||
.for_each(|fid| {
|
||||
let _ = store.compact_file(fid, 0);
|
||||
});
|
||||
}
|
||||
|
||||
fn delete_checkpoints(index_dir: &std::path::Path) {
|
||||
let _ = std::fs::remove_file(index_dir.join("checkpoint_a.tqc"));
|
||||
let _ = std::fs::remove_file(index_dir.join("checkpoint_b.tqc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relocate_loses_refcount_on_hint_rebuild() {
|
||||
common::with_runtime(|| {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let target = common::test_cid(1);
|
||||
let target_data = vec![0xABu8; 200];
|
||||
|
||||
{
|
||||
let store = TranquilBlockStore::open(tiny_store_config(dir.path())).unwrap();
|
||||
|
||||
store
|
||||
.put_blocks_blocking(vec![(target, target_data.clone())])
|
||||
.unwrap();
|
||||
store
|
||||
.put_blocks_blocking(vec![(target, target_data.clone())])
|
||||
.unwrap();
|
||||
|
||||
let padding: Vec<_> = (100..130u32).map(|s| make_block(s, 300)).collect();
|
||||
store.put_blocks_blocking(padding).unwrap();
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
compact_sealed(&store);
|
||||
|
||||
store.apply_commit_blocking(vec![], vec![target]).unwrap();
|
||||
|
||||
let data = store.get_block_sync(&target).unwrap();
|
||||
assert!(data.is_some(), "target should be live, refcount 2 - 1 = 1");
|
||||
}
|
||||
|
||||
delete_checkpoints(&dir.path().join("index"));
|
||||
|
||||
{
|
||||
let store = TranquilBlockStore::open(tiny_store_config(dir.path())).unwrap();
|
||||
|
||||
let data = store.get_block_sync(&target).unwrap();
|
||||
assert!(
|
||||
data.is_some(),
|
||||
"BUG: target missing after hint-only rebuild. \
|
||||
RELOCATE created entry with refcount 1 instead of 2, \
|
||||
then DEC brought it to 0 instead of 1."
|
||||
);
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
compact_sealed(&store);
|
||||
|
||||
let data = store.get_block_sync(&target).unwrap();
|
||||
assert!(
|
||||
data.is_some(),
|
||||
"BUG: target removed by compaction after hint rebuild \
|
||||
incorrectly set refcount to 0"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_restart_with_compaction_between_put_and_dec() {
|
||||
common::with_runtime(|| {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let shared = common::test_cid(42);
|
||||
let shared_data = vec![0xCDu8; 200];
|
||||
|
||||
{
|
||||
let store = TranquilBlockStore::open(tiny_store_config(dir.path())).unwrap();
|
||||
|
||||
store
|
||||
.put_blocks_blocking(vec![(shared, shared_data.clone())])
|
||||
.unwrap();
|
||||
store
|
||||
.put_blocks_blocking(vec![(shared, shared_data.clone())])
|
||||
.unwrap();
|
||||
store
|
||||
.put_blocks_blocking(vec![(shared, shared_data.clone())])
|
||||
.unwrap();
|
||||
|
||||
let filler: Vec<_> = (200..230u32).map(|s| make_block(s, 300)).collect();
|
||||
store.put_blocks_blocking(filler).unwrap();
|
||||
}
|
||||
|
||||
delete_checkpoints(&dir.path().join("index"));
|
||||
|
||||
{
|
||||
let store = TranquilBlockStore::open(tiny_store_config(dir.path())).unwrap();
|
||||
|
||||
let data = store.get_block_sync(&shared).unwrap();
|
||||
assert!(
|
||||
data.is_some(),
|
||||
"round 1: shared block present after rebuild"
|
||||
);
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
compact_sealed(&store);
|
||||
|
||||
store.apply_commit_blocking(vec![], vec![shared]).unwrap();
|
||||
|
||||
let data = store.get_block_sync(&shared).unwrap();
|
||||
assert!(
|
||||
data.is_some(),
|
||||
"round 1: shared block should survive, refcount 3 - 1 = 2"
|
||||
);
|
||||
}
|
||||
|
||||
delete_checkpoints(&dir.path().join("index"));
|
||||
|
||||
{
|
||||
let store = TranquilBlockStore::open(tiny_store_config(dir.path())).unwrap();
|
||||
|
||||
let data = store.get_block_sync(&shared).unwrap();
|
||||
assert!(
|
||||
data.is_some(),
|
||||
"round 2: shared block should survive hint rebuild, refcount should be 2"
|
||||
);
|
||||
|
||||
store.apply_commit_blocking(vec![], vec![shared]).unwrap();
|
||||
|
||||
let data = store.get_block_sync(&shared).unwrap();
|
||||
assert!(
|
||||
data.is_some(),
|
||||
"round 2: shared block should survive DEC, refcount 2 - 1 = 1"
|
||||
);
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
compact_sealed(&store);
|
||||
|
||||
let data = store.get_block_sync(&shared).unwrap();
|
||||
assert!(
|
||||
data.is_some(),
|
||||
"BUG: shared block removed by compaction. \
|
||||
Multiple restarts with RELOCATE collapsed refcount \
|
||||
from 3 down to 1, two DECs made it 0."
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stress_create_delete_restart_cycle_matches_bug_report() {
|
||||
common::with_runtime(|| {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let mut live: HashSet<u32> = HashSet::new();
|
||||
let mut rng = common::Rng::new(12345);
|
||||
let mut next_seed: u32 = 0;
|
||||
|
||||
(0..4).for_each(|cycle| {
|
||||
{
|
||||
let store = TranquilBlockStore::open(tiny_store_config(dir.path())).unwrap();
|
||||
|
||||
(0..20).for_each(|_| {
|
||||
let seed_a = next_seed;
|
||||
let seed_b = next_seed + 1;
|
||||
next_seed += 2;
|
||||
|
||||
store
|
||||
.put_blocks_blocking(vec![make_block(seed_a, 150), make_block(seed_b, 150)])
|
||||
.unwrap();
|
||||
live.insert(seed_a);
|
||||
live.insert(seed_b);
|
||||
|
||||
if rng.next_u32() % 2 == 0 {
|
||||
let victim: Option<u32> = live.iter().copied().next();
|
||||
if let Some(v) = victim {
|
||||
store
|
||||
.apply_commit_blocking(vec![], vec![common::test_cid(v)])
|
||||
.unwrap();
|
||||
live.remove(&v);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
compact_sealed(&store);
|
||||
|
||||
verify_live_blocks(&store, &live, &format!("cycle {cycle} before kill"));
|
||||
}
|
||||
|
||||
delete_checkpoints(&dir.path().join("index"));
|
||||
|
||||
{
|
||||
let store = TranquilBlockStore::open(tiny_store_config(dir.path())).unwrap();
|
||||
verify_live_blocks(&store, &live, &format!("cycle {cycle} after hint rebuild"));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,8 @@ Pod=tranquil-pds.pod
|
||||
Environment=POSTGRES_USER=tranquil_pds
|
||||
Environment=POSTGRES_DB=pds
|
||||
Secret=tranquil-pds-db-password,type=env,target=POSTGRES_PASSWORD
|
||||
Volume=/srv/tranquil-pds/postgres:/var/lib/postgresql/data:Z
|
||||
# In memory of @cute.kyu.re when Lewis forgot to remove "/data" here after removing it from the compose file and another account was tragically lost :(
|
||||
Volume=/srv/tranquil-pds/postgres:/var/lib/postgresql:Z
|
||||
HealthCmd=pg_isready -U tranquil_pds -d pds
|
||||
HealthInterval=10s
|
||||
HealthTimeout=5s
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" module>
|
||||
const EXAMPLE_HANDLES = [
|
||||
"nel.pet",
|
||||
"lewis.moe",
|
||||
"oyster.cafe",
|
||||
"llaama.bsky.social",
|
||||
"debugman.wizardry.systems",
|
||||
"nonbinary.computer",
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
session: Session
|
||||
hasPassword: boolean
|
||||
onPasskeysChanged?: (count: number) => void
|
||||
onReauthRequired: (methods: string[], retryAction: () => Promise<void>) => void
|
||||
}
|
||||
|
||||
let { session, hasPassword, onPasskeysChanged }: Props = $props()
|
||||
let { session, hasPassword, onPasskeysChanged, onReauthRequired }: Props = $props()
|
||||
|
||||
interface Passkey {
|
||||
id: string
|
||||
@@ -81,6 +82,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleReauthError(e: unknown, fallback: string, retryAction: () => Promise<void>) {
|
||||
if (e instanceof ApiError && e.error === 'ReauthRequired') {
|
||||
onReauthRequired(e.reauthMethods || ['password'], retryAction)
|
||||
} else {
|
||||
toast.error(e instanceof ApiError ? e.message : fallback)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePasskey(id: string) {
|
||||
const passkey = passkeys.find(p => p.id === id)
|
||||
if (!confirm($_('security.deletePasskeyConfirm', { values: { name: passkey?.friendlyName || 'this passkey' } }))) return
|
||||
@@ -89,7 +98,7 @@
|
||||
await loadPasskeys()
|
||||
toast.success($_('security.passkeyDeleted'))
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Failed to delete passkey')
|
||||
handleReauthError(e, 'Failed to delete passkey', () => handleDeletePasskey(id))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -282,6 +282,7 @@
|
||||
{session}
|
||||
{hasPassword}
|
||||
onPasskeysChanged={(count) => passkeyCount = count}
|
||||
onReauthRequired={handleReauthRequired}
|
||||
/>
|
||||
|
||||
<TotpSection
|
||||
|
||||
@@ -522,6 +522,7 @@
|
||||
"passkeyHintNotAvailable": "No passkey registered",
|
||||
"passwordPlaceholder": "Password",
|
||||
"usePasskey": "Use passkey",
|
||||
"passkeyNotAllowed": "No passkey available for this site",
|
||||
"orUseCredentials": "or",
|
||||
"verificationResent": "Verification code sent"
|
||||
},
|
||||
|
||||
@@ -509,6 +509,7 @@
|
||||
"passkeyHintNotAvailable": "Ei pääsyavainta",
|
||||
"passwordPlaceholder": "Salasana",
|
||||
"usePasskey": "Käytä pääsyavainta",
|
||||
"passkeyNotAllowed": "Pääsyavainta ei ole saatavilla tälle sivustolle",
|
||||
"orUseCredentials": "tai",
|
||||
"verificationResent": "Vahvistuskoodi lähetetty"
|
||||
},
|
||||
|
||||
@@ -509,6 +509,7 @@
|
||||
"passkeyHintNotAvailable": "パスキーなし",
|
||||
"passwordPlaceholder": "パスワード",
|
||||
"usePasskey": "パスキーを使用",
|
||||
"passkeyNotAllowed": "このサイトで利用可能なパスキーがありません",
|
||||
"orUseCredentials": "または",
|
||||
"verificationResent": "確認コードを送信しました"
|
||||
},
|
||||
|
||||
@@ -509,6 +509,7 @@
|
||||
"passkeyHintNotAvailable": "패스키 없음",
|
||||
"passwordPlaceholder": "비밀번호",
|
||||
"usePasskey": "패스키 사용",
|
||||
"passkeyNotAllowed": "이 사이트에 사용 가능한 패스키가 없습니다",
|
||||
"orUseCredentials": "또는",
|
||||
"verificationResent": "인증 코드 전송됨"
|
||||
},
|
||||
|
||||
@@ -509,6 +509,7 @@
|
||||
"passkeyHintNotAvailable": "Ingen nyckel registrerad",
|
||||
"passwordPlaceholder": "Lösenord",
|
||||
"usePasskey": "Använd nyckel",
|
||||
"passkeyNotAllowed": "Ingen nyckel tillgänglig för denna webbplats",
|
||||
"orUseCredentials": "eller",
|
||||
"verificationResent": "Verifieringskod skickad"
|
||||
},
|
||||
|
||||
@@ -509,6 +509,7 @@
|
||||
"passkeyHintNotAvailable": "未注册通行密钥",
|
||||
"passwordPlaceholder": "密码",
|
||||
"usePasskey": "使用通行密钥",
|
||||
"passkeyNotAllowed": "此站点没有可用的通行密钥",
|
||||
"orUseCredentials": "或",
|
||||
"verificationResent": "验证码已发送"
|
||||
},
|
||||
|
||||
@@ -38,13 +38,8 @@
|
||||
let submitting = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
let verificationResent = $state(false)
|
||||
let hasPasskeys = $state(false)
|
||||
let hasTotp = $state(false)
|
||||
let hasPassword = $state(true)
|
||||
let isDelegated = $state(false)
|
||||
let userDid = $state<string | null>(null)
|
||||
let checkingSecurityStatus = $state(false)
|
||||
let securityStatusChecked = $state(false)
|
||||
let passkeySupported = $state(false)
|
||||
let clientName = $state<string | null>(null)
|
||||
|
||||
@@ -160,31 +155,27 @@
|
||||
|
||||
let checkTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
let checkingDelegation = false
|
||||
|
||||
$effect(() => {
|
||||
if (checkTimeout) {
|
||||
clearTimeout(checkTimeout)
|
||||
}
|
||||
hasPasskeys = false
|
||||
hasTotp = false
|
||||
securityStatusChecked = false
|
||||
isDelegated = false
|
||||
if (username.length >= 3) {
|
||||
checkTimeout = setTimeout(() => checkUserSecurityStatus(), 500)
|
||||
checkTimeout = setTimeout(() => checkDelegationStatus(), 500)
|
||||
}
|
||||
})
|
||||
|
||||
async function checkUserSecurityStatus() {
|
||||
if (!username || checkingSecurityStatus) return
|
||||
checkingSecurityStatus = true
|
||||
async function checkDelegationStatus() {
|
||||
if (!username || checkingDelegation) return
|
||||
checkingDelegation = true
|
||||
try {
|
||||
const response = await fetch(`/oauth/security-status?identifier=${encodeURIComponent(username)}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
hasPasskeys = passkeySupported && data.hasPasskeys === true
|
||||
hasTotp = data.hasTotp === true
|
||||
hasPassword = data.hasPassword !== false
|
||||
isDelegated = data.isDelegated === true
|
||||
userDid = data.did || null
|
||||
securityStatusChecked = true
|
||||
|
||||
if (isDelegated && data.did) {
|
||||
const requestUri = getRequestUri()
|
||||
@@ -198,19 +189,16 @@
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
hasPasskeys = false
|
||||
hasTotp = false
|
||||
hasPassword = true
|
||||
isDelegated = false
|
||||
} finally {
|
||||
checkingSecurityStatus = false
|
||||
checkingDelegation = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function handlePasskeyLogin() {
|
||||
const requestUri = getRequestUri()
|
||||
if (!requestUri || !username) {
|
||||
if (!requestUri) {
|
||||
error = $_('common.error')
|
||||
return
|
||||
}
|
||||
@@ -220,16 +208,18 @@
|
||||
verificationResent = false
|
||||
|
||||
try {
|
||||
const body: Record<string, string> = { request_uri: requestUri }
|
||||
if (username.trim()) {
|
||||
body.identifier = username
|
||||
}
|
||||
|
||||
const startResponse = await fetch('/oauth/passkey/start', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
request_uri: requestUri,
|
||||
identifier: username
|
||||
})
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (!startResponse.ok) {
|
||||
@@ -306,9 +296,9 @@
|
||||
} catch (e) {
|
||||
console.error('Passkey login error:', e)
|
||||
if (e instanceof DOMException && e.name === 'NotAllowedError') {
|
||||
error = $_('common.error')
|
||||
error = $_('oauth.login.passkeyNotAllowed')
|
||||
} else {
|
||||
error = `${$_('common.error')}: ${e instanceof Error ? e.message : String(e)}`
|
||||
error = e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
submitting = false
|
||||
}
|
||||
@@ -439,17 +429,15 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if passkeySupported && username.length >= 3}
|
||||
<div class="auth-methods" class:single-method={!hasPassword}>
|
||||
{#if passkeySupported}
|
||||
<div class="auth-methods">
|
||||
<div class="passkey-method">
|
||||
<h3>{$_('oauth.login.signInWithPasskey')}</h3>
|
||||
<button
|
||||
type="button"
|
||||
style="width: 100%"
|
||||
class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked}
|
||||
onclick={handlePasskeyLogin}
|
||||
disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked}
|
||||
title={checkingSecurityStatus ? $_('oauth.login.passkeyHintChecking') : hasPasskeys ? $_('oauth.login.passkeyHintAvailable') : $_('oauth.login.passkeyHintNotAvailable')}
|
||||
disabled={submitting}
|
||||
>
|
||||
<svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" />
|
||||
@@ -459,80 +447,72 @@
|
||||
<span class="passkey-text">
|
||||
{#if submitting}
|
||||
{$_('oauth.login.authenticating')}
|
||||
{:else if checkingSecurityStatus || !securityStatusChecked}
|
||||
{$_('oauth.login.checkingPasskey')}
|
||||
{:else if hasPasskeys}
|
||||
{$_('oauth.login.usePasskey')}
|
||||
{:else}
|
||||
{$_('oauth.login.passkeyNotSetUp')}
|
||||
{$_('oauth.login.usePasskey')}
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if hasPassword}
|
||||
<div class="method-divider">
|
||||
<span>{$_('oauth.login.orUsePassword')}</span>
|
||||
<div class="method-divider">
|
||||
<span>{$_('oauth.login.orUsePassword')}</span>
|
||||
</div>
|
||||
|
||||
<div class="password-method">
|
||||
<h3>{$_('oauth.login.password')}</h3>
|
||||
<div class="field">
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
disabled={submitting}
|
||||
required
|
||||
autocomplete="current-password"
|
||||
placeholder={$_('oauth.login.passwordPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="password-method">
|
||||
<h3>{$_('oauth.login.password')}</h3>
|
||||
<div class="field">
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
disabled={submitting}
|
||||
required
|
||||
autocomplete="current-password"
|
||||
placeholder={$_('oauth.login.passwordPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<label class="remember-device">
|
||||
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
|
||||
<span>{$_('oauth.login.rememberDevice')}</span>
|
||||
</label>
|
||||
|
||||
<label class="remember-device">
|
||||
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
|
||||
<span>{$_('oauth.login.rememberDevice')}</span>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={submitting || !username || !password}>
|
||||
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={submitting || !username || !password}>
|
||||
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if hasPassword || !securityStatusChecked}
|
||||
<div>
|
||||
<label for="password">{$_('oauth.login.password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
disabled={submitting}
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">{$_('oauth.login.password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
disabled={submitting}
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="remember-device">
|
||||
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
|
||||
<span>{$_('oauth.login.rememberDevice')}</span>
|
||||
</label>
|
||||
<label class="remember-device">
|
||||
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
|
||||
<span>{$_('oauth.login.rememberDevice')}</span>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={submitting || !username || !password}>
|
||||
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={submitting || !username || !password}>
|
||||
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user