Passkey-only accounts can opt into a password

This commit is contained in:
lewis
2026-01-10 11:10:17 +02:00
parent bbb650ffdb
commit fbc2477771
26 changed files with 396 additions and 137 deletions
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET password_hash = $1, password_required = TRUE WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid"
]
},
"nullable": []
},
"hash": "3a8b7a2773033c85cd03dd6f906a9d335019f9b7145e1bee6175d01d0c98b8b4"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT block_cid FROM user_blocks WHERE user_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "block_cid",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "e70fc3dced4eb7dc220ca2a18cdfcbd5f2d66dff2262bb083fd4118b032ff978"
}
+2 -2
View File
@@ -330,7 +330,7 @@
.tab.active {
background: var(--accent);
border-color: var(--accent);
color: white;
color: var(--text-inverse);
}
.modal-content {
@@ -375,7 +375,7 @@
width: 100%;
padding: 0.75rem 1.5rem;
background: var(--accent);
color: white;
color: var(--text-inverse);
border: none;
border-radius: 4px;
font-size: 1rem;
+8
View File
@@ -540,6 +540,14 @@ export const api = {
});
},
setPassword(token: AccessToken, newPassword: string): Promise<SuccessResponse> {
return xrpc("_account.setPassword", {
method: "POST",
token,
body: { newPassword },
});
},
getPasswordStatus(token: AccessToken): Promise<PasswordStatus> {
return xrpc("_account.getPasswordStatus", { token });
},
+8
View File
@@ -303,6 +303,10 @@
"confirmNewPasswordPlaceholder": "Confirm new password",
"changePasswordButton": "Change Password",
"changing": "Changing...",
"setPassword": "Set Password",
"setPasswordDescription": "Your account is currently passkey-only. You can add a password to enable traditional login alongside your passkeys.",
"setPasswordButton": "Set Password",
"setting": "Setting...",
"exportData": "Export Data",
"exportDataDescription": "Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.",
"downloadRepo": "Download Repository",
@@ -352,6 +356,8 @@
"handleUpdateFailed": "Failed to update handle",
"passwordChanged": "Password changed successfully",
"passwordChangeFailed": "Failed to change password",
"passwordSet": "Password set successfully",
"passwordSetFailed": "Failed to set password",
"passwordsMismatch": "Passwords do not match",
"passwordsDoNotMatch": "Passwords do not match",
"passwordLength": "Password must be at least 8 characters",
@@ -509,6 +515,8 @@
"beforeProceedingItem3": "Ensure your recovery notification channel is up to date",
"addPasskeyFirst": "Add at least one passkey before you can remove your password.",
"passkeyOnlyHint": "You sign in using passkeys only. If you ever lose access to your passkeys, you can recover your account using the \"Lost passkey?\" link on the login page.",
"addPasswordHint": "Want to add a password? Go to Settings to set one up.",
"goToSettings": "Go to Settings",
"trustedDevices": "Trusted Devices",
"trustedDevicesDescription": "Manage devices that can skip two-factor authentication when signing in. Trust is granted for 30 days and automatically extends when you use the device.",
"manageTrustedDevices": "Manage Trusted Devices",
+8
View File
@@ -303,6 +303,10 @@
"confirmNewPasswordPlaceholder": "Vahvista uusi salasana",
"changePasswordButton": "Vaihda salasana",
"changing": "Vaihdetaan...",
"setPassword": "Aseta salasana",
"setPasswordDescription": "Tilisi on tällä hetkellä vain pääsyavain-tili. Voit lisätä salasanan ottaaksesi käyttöön perinteisen kirjautumisen pääsyavainten rinnalla.",
"setPasswordButton": "Aseta salasana",
"setting": "Asetetaan...",
"exportData": "Vie tiedot",
"exportDataDescription": "Lataa koko tietovarastosi CAR-tiedostona (Content Addressable Archive). Tämä sisältää kaikki julkaisusi, tykkäyksesi, seuraamisesi ja muut tiedot.",
"downloadRepo": "Lataa tietovarasto",
@@ -352,6 +356,8 @@
"handleUpdateFailed": "Käyttäjänimen päivitys epäonnistui",
"passwordChanged": "Salasana vaihdettu",
"passwordChangeFailed": "Salasanan vaihto epäonnistui",
"passwordSet": "Salasana asetettu onnistuneesti",
"passwordSetFailed": "Salasanan asettaminen epäonnistui",
"passwordsMismatch": "Salasanat eivät täsmää",
"passwordsDoNotMatch": "Salasanat eivät täsmää",
"passwordLength": "Salasanan on oltava vähintään 8 merkkiä",
@@ -509,6 +515,8 @@
"beforeProceedingItem3": "Varmista, että palautusilmoituskanavasi on ajan tasalla",
"addPasskeyFirst": "Lisää vähintään yksi pääsyavain ennen kuin voit poistaa salasanasi.",
"passkeyOnlyHint": "Kirjaudut sisään vain pääsyavaimilla. Jos menetät pääsyn pääsyavaimeesi, voit palauttaa tilisi käyttämällä \"Kadotitko pääsyavaimen?\" -linkkiä kirjautumissivulla.",
"addPasswordHint": "Haluatko lisätä salasanan? Siirry Asetuksiin määrittääksesi sellaisen.",
"goToSettings": "Siirry asetuksiin",
"trustedDevices": "Luotetut laitteet",
"trustedDevicesDescription": "Hallitse laitteita, jotka voivat ohittaa kaksivaiheisen tunnistautumisen kirjautuessaan. Luottamus myönnetään 30 päiväksi ja jatkuu automaattisesti, kun käytät laitetta.",
"manageTrustedDevices": "Hallitse luotettuja laitteita",
+8
View File
@@ -296,6 +296,10 @@
"confirmNewPasswordPlaceholder": "新しいパスワードを再入力",
"changePasswordButton": "パスワードを変更",
"changing": "変更中...",
"setPassword": "パスワードを設定",
"setPasswordDescription": "現在、あなたのアカウントはパスキーのみです。パスワードを追加すると、パスキーと併せて従来のログインも使用できます。",
"setPasswordButton": "パスワードを設定",
"setting": "設定中...",
"exportData": "データエクスポート",
"exportDataDescription": "リポジトリ全体を CARContent Addressable Archive)ファイルとしてダウンロードします。投稿、いいね、フォローなどすべてのデータが含まれます。",
"downloadRepo": "リポジトリをダウンロード",
@@ -345,6 +349,8 @@
"handleUpdateFailed": "ハンドルの更新に失敗しました",
"passwordChanged": "パスワードを変更しました",
"passwordChangeFailed": "パスワードの変更に失敗しました",
"passwordSet": "パスワードを設定しました",
"passwordSetFailed": "パスワードの設定に失敗しました",
"passwordsMismatch": "パスワードが一致しません",
"passwordsDoNotMatch": "パスワードが一致しません",
"passwordLength": "パスワードは8文字以上である必要があります",
@@ -502,6 +508,8 @@
"beforeProceedingItem3": "復旧用の通知チャンネルが最新であることを確認",
"addPasskeyFirst": "パスワードを削除する前に、少なくとも1つのパスキーを追加してください。",
"passkeyOnlyHint": "パスキーのみでサインインしています。パスキーにアクセスできなくなった場合、ログインページの「パスキーを紛失しましたか?」リンクからアカウントを復旧できます。",
"addPasswordHint": "パスワードを追加しますか?設定で追加できます。",
"goToSettings": "設定へ移動",
"trustedDevices": "信頼済みデバイス",
"trustedDevicesDescription": "サインイン時に二要素認証をスキップできるデバイスを管理します。信頼は30日間有効で、デバイスを使用すると自動的に延長されます。",
"manageTrustedDevices": "信頼済みデバイスを管理",
+8
View File
@@ -296,6 +296,10 @@
"confirmNewPasswordPlaceholder": "새 비밀번호 재입력",
"changePasswordButton": "비밀번호 변경",
"changing": "변경 중...",
"setPassword": "비밀번호 설정",
"setPasswordDescription": "현재 계정은 패스키 전용입니다. 비밀번호를 추가하면 패스키와 함께 기존 로그인 방식도 사용할 수 있습니다.",
"setPasswordButton": "비밀번호 설정",
"setting": "설정 중...",
"exportData": "데이터 내보내기",
"exportDataDescription": "전체 저장소를 CAR (Content Addressable Archive) 파일로 다운로드합니다. 모든 게시물, 좋아요, 팔로우 및 기타 데이터가 포함됩니다.",
"downloadRepo": "저장소 다운로드",
@@ -345,6 +349,8 @@
"handleUpdateFailed": "핸들 업데이트에 실패했습니다",
"passwordChanged": "비밀번호가 변경되었습니다",
"passwordChangeFailed": "비밀번호 변경에 실패했습니다",
"passwordSet": "비밀번호가 설정되었습니다",
"passwordSetFailed": "비밀번호 설정에 실패했습니다",
"passwordsMismatch": "비밀번호가 일치하지 않습니다",
"passwordsDoNotMatch": "비밀번호가 일치하지 않습니다",
"passwordLength": "비밀번호는 8자 이상이어야 합니다",
@@ -502,6 +508,8 @@
"beforeProceedingItem3": "복구 알림 채널이 최신인지 확인",
"addPasskeyFirst": "비밀번호를 제거하려면 먼저 최소 하나의 패스키를 추가하세요.",
"passkeyOnlyHint": "패스키로만 로그인합니다. 패스키에 액세스할 수 없게 되면 로그인 페이지의 '패스키를 분실하셨나요?' 링크를 사용하여 계정을 복구할 수 있습니다.",
"addPasswordHint": "비밀번호를 추가하시겠습니까? 설정에서 설정하세요.",
"goToSettings": "설정으로 이동",
"trustedDevices": "신뢰할 수 있는 기기",
"trustedDevicesDescription": "로그인 시 2단계 인증을 건너뛸 수 있는 기기를 관리합니다. 신뢰는 30일간 유효하며 기기를 사용하면 자동으로 연장됩니다.",
"manageTrustedDevices": "신뢰할 수 있는 기기 관리",
+8
View File
@@ -296,6 +296,10 @@
"confirmNewPasswordPlaceholder": "Bekräfta nytt lösenord",
"changePasswordButton": "Ändra lösenord",
"changing": "Ändrar...",
"setPassword": "Ange lösenord",
"setPasswordDescription": "Ditt konto är för närvarande endast passnycklar. Du kan lägga till ett lösenord för att aktivera traditionell inloggning tillsammans med dina passnycklar.",
"setPasswordButton": "Ange lösenord",
"setting": "Anger...",
"exportData": "Exportera data",
"exportDataDescription": "Ladda ner hela ditt arkiv som en CAR-fil (Content Addressable Archive). Detta inkluderar alla dina inlägg, gillanden, följningar och annan data.",
"downloadRepo": "Ladda ner arkiv",
@@ -345,6 +349,8 @@
"handleUpdateFailed": "Kunde inte uppdatera användarnamn",
"passwordChanged": "Lösenord ändrat",
"passwordChangeFailed": "Kunde inte ändra lösenord",
"passwordSet": "Lösenord har angetts",
"passwordSetFailed": "Kunde inte ange lösenord",
"passwordsMismatch": "Lösenorden matchar inte",
"passwordsDoNotMatch": "Lösenorden matchar inte",
"passwordLength": "Lösenordet måste vara minst 8 tecken",
@@ -502,6 +508,8 @@
"beforeProceedingItem3": "Se till att din meddelandekanal för återställning är uppdaterad",
"addPasskeyFirst": "Lägg till minst en nyckel innan du kan ta bort ditt lösenord.",
"passkeyOnlyHint": "Du loggar in med endast nycklar. Om du förlorar tillgång till dina nycklar kan du återställa ditt konto med länken \"Tappat bort nyckeln?\" på inloggningssidan.",
"addPasswordHint": "Vill du lägga till ett lösenord? Gå till Inställningar för att ställa in ett.",
"goToSettings": "Gå till inställningar",
"trustedDevices": "Betrodda enheter",
"trustedDevicesDescription": "Hantera enheter som kan hoppa över tvåfaktorsautentisering vid inloggning. Förtroende beviljas i 30 dagar och förlängs automatiskt när du använder enheten.",
"manageTrustedDevices": "Hantera betrodda enheter",
+8
View File
@@ -296,6 +296,10 @@
"confirmNewPasswordPlaceholder": "再次输入新密码",
"changePasswordButton": "更改密码",
"changing": "更改中...",
"setPassword": "设置密码",
"setPasswordDescription": "您的账户当前仅使用通行密钥。您可以添加密码以启用传统登录方式与通行密钥并用。",
"setPasswordButton": "设置密码",
"setting": "设置中...",
"exportData": "导出数据",
"exportDataDescription": "将您的所有数据下载为 CAR 文件。包括您的所有帖子、点赞、关注等数据。",
"downloadRepo": "下载数据",
@@ -345,6 +349,8 @@
"handleUpdateFailed": "用户名更新失败",
"passwordChanged": "密码更改成功",
"passwordChangeFailed": "密码更改失败",
"passwordSet": "密码设置成功",
"passwordSetFailed": "密码设置失败",
"passwordsMismatch": "两次输入的密码不一致",
"passwordsDoNotMatch": "两次输入的密码不一致",
"passwordLength": "密码至少需要8位字符",
@@ -502,6 +508,8 @@
"beforeProceedingItem3": "确保您的恢复通知渠道是最新的",
"addPasskeyFirst": "请先添加至少一个通行密钥才能移除密码。",
"passkeyOnlyHint": "您使用通行密钥登录。如果您丢失了通行密钥,可以使用登录页面上的「丢失通行密钥?」链接恢复账户。",
"addPasswordHint": "想要添加密码?前往设置进行设置。",
"goToSettings": "前往设置",
"trustedDevices": "受信任设备",
"trustedDevicesDescription": "管理可以跳过双重身份验证的设备。信任有效期为30天,使用设备时自动延长。",
"manageTrustedDevices": "管理受信任设备",
+6
View File
@@ -678,6 +678,12 @@
<p class="hint">
{$_('security.passkeyOnlyHint')}
</p>
<p class="hint">
{$_('security.addPasswordHint')}
</p>
<a href={getFullUrl(routes.settings)} class="section-link">
{$_('security.goToSettings')}
</a>
{/if}
</section>
+160 -42
View File
@@ -8,6 +8,7 @@
import { unsafeAsHandle } from '../lib/types/branded'
import type { Session } from '../lib/types/api'
import { toast } from '../lib/toast.svelte'
import ReauthModal from '../components/ReauthModal.svelte'
const auth = $derived(getAuthState())
const supportedLocales = getSupportedLocales()
@@ -63,6 +64,12 @@
let newPassword = $state('')
let confirmNewPassword = $state('')
let showBYOHandle = $state(false)
let hasPassword = $state(true)
let passwordStatusLoading = $state(true)
let setPasswordLoading = $state(false)
let showReauthModal = $state(false)
let reauthMethods = $state<string[]>(['passkey'])
let pendingAction = $state<(() => Promise<void>) | null>(null)
$effect(() => {
if (!loading && !session) {
@@ -70,6 +77,25 @@
}
})
$effect(() => {
if (session) {
loadPasswordStatus()
}
})
async function loadPasswordStatus() {
if (!session) return
passwordStatusLoading = true
try {
const status = await api.getPasswordStatus(session.accessJwt)
hasPassword = status.hasPassword
} catch {
hasPassword = true
} finally {
passwordStatusLoading = false
}
}
async function handleRequestEmailUpdate() {
if (!session) return
emailLoading = true
@@ -374,6 +400,52 @@
passwordLoading = false
}
}
async function handleSetPassword(e: Event) {
e.preventDefault()
if (!session || !newPassword || !confirmNewPassword) return
if (newPassword !== confirmNewPassword) {
toast.error($_('settings.messages.passwordsDoNotMatch'))
return
}
if (newPassword.length < 8) {
toast.error($_('settings.messages.passwordTooShort'))
return
}
setPasswordLoading = true
try {
await api.setPassword(session.accessJwt, newPassword)
toast.success($_('settings.messages.passwordSet'))
hasPassword = true
newPassword = ''
confirmNewPassword = ''
} catch (e) {
if (e instanceof ApiError) {
if (e.error === 'ReauthRequired') {
reauthMethods = e.reauthMethods || ['passkey']
pendingAction = () => handleSetPassword(new Event('submit'))
showReauthModal = true
} else {
toast.error(e.message)
}
} else {
toast.error($_('settings.messages.passwordSetFailed'))
}
} finally {
setPasswordLoading = false
}
}
function handleReauthSuccess() {
if (pendingAction) {
pendingAction()
pendingAction = null
}
}
function handleReauthCancel() {
pendingAction = null
}
</script>
<div class="page">
<header>
@@ -522,48 +594,85 @@
</form>
{/if}
</section>
<section>
<h2>{$_('settings.changePassword')}</h2>
<form onsubmit={handleChangePassword}>
<div class="field">
<label for="current-password">{$_('settings.currentPassword')}</label>
<input
id="current-password"
type="password"
bind:value={currentPassword}
placeholder={$_('settings.currentPasswordPlaceholder')}
disabled={passwordLoading}
required
/>
</div>
<div class="field">
<label for="new-password">{$_('settings.newPassword')}</label>
<input
id="new-password"
type="password"
bind:value={newPassword}
placeholder={$_('settings.newPasswordPlaceholder')}
disabled={passwordLoading}
required
minlength="8"
/>
</div>
<div class="field">
<label for="confirm-new-password">{$_('settings.confirmNewPassword')}</label>
<input
id="confirm-new-password"
type="password"
bind:value={confirmNewPassword}
placeholder={$_('settings.confirmNewPasswordPlaceholder')}
disabled={passwordLoading}
required
/>
</div>
<button type="submit" disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword}>
{passwordLoading ? $_('settings.changing') : $_('settings.changePasswordButton')}
</button>
</form>
</section>
{#if !passwordStatusLoading}
{#if hasPassword}
<section>
<h2>{$_('settings.changePassword')}</h2>
<form onsubmit={handleChangePassword}>
<div class="field">
<label for="current-password">{$_('settings.currentPassword')}</label>
<input
id="current-password"
type="password"
bind:value={currentPassword}
placeholder={$_('settings.currentPasswordPlaceholder')}
disabled={passwordLoading}
required
/>
</div>
<div class="field">
<label for="new-password">{$_('settings.newPassword')}</label>
<input
id="new-password"
type="password"
bind:value={newPassword}
placeholder={$_('settings.newPasswordPlaceholder')}
disabled={passwordLoading}
required
minlength="8"
/>
</div>
<div class="field">
<label for="confirm-new-password">{$_('settings.confirmNewPassword')}</label>
<input
id="confirm-new-password"
type="password"
bind:value={confirmNewPassword}
placeholder={$_('settings.confirmNewPasswordPlaceholder')}
disabled={passwordLoading}
required
/>
</div>
<button type="submit" disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword}>
{passwordLoading ? $_('settings.changing') : $_('settings.changePasswordButton')}
</button>
</form>
</section>
{:else}
<section>
<h2>{$_('settings.setPassword')}</h2>
<p class="description">{$_('settings.setPasswordDescription')}</p>
<form onsubmit={handleSetPassword}>
<div class="field">
<label for="set-new-password">{$_('settings.newPassword')}</label>
<input
id="set-new-password"
type="password"
bind:value={newPassword}
placeholder={$_('settings.newPasswordPlaceholder')}
disabled={setPasswordLoading}
required
minlength="8"
/>
</div>
<div class="field">
<label for="set-confirm-password">{$_('settings.confirmNewPassword')}</label>
<input
id="set-confirm-password"
type="password"
bind:value={confirmNewPassword}
placeholder={$_('settings.confirmNewPasswordPlaceholder')}
disabled={setPasswordLoading}
required
/>
</div>
<button type="submit" disabled={setPasswordLoading || !newPassword || !confirmNewPassword}>
{setPasswordLoading ? $_('settings.setting') : $_('settings.setPasswordButton')}
</button>
</form>
</section>
{/if}
{/if}
<section>
<h2>{$_('settings.exportData')}</h2>
<p class="description">{$_('settings.exportDataDescription')}</p>
@@ -681,6 +790,15 @@
{/if}
</section>
</div>
{#if showReauthModal && session}
<ReauthModal
bind:show={showReauthModal}
availableMethods={reauthMethods}
onSuccess={handleReauthSuccess}
onCancel={handleReauthCancel}
/>
{/if}
<style>
.page {
max-width: var(--width-lg);
+1 -1
View File
@@ -127,8 +127,8 @@ impl ApiError {
| Self::InvalidCode(_)
| Self::InvalidPassword(_)
| Self::InvalidToken(_)
| Self::ExpiredToken(_)
| Self::PasskeyCounterAnomaly => StatusCode::UNAUTHORIZED,
Self::ExpiredToken(_) => StatusCode::BAD_REQUEST,
Self::Forbidden
| Self::AdminRequired
| Self::InsufficientScope(_)
+10 -13
View File
@@ -268,24 +268,21 @@ async fn proxy_handler(
}
Err(e) => {
warn!("Token validation failed: {:?}", e);
if matches!(e, crate::auth::TokenValidationError::TokenExpired) {
let is_dpop = extracted.is_dpop;
let scheme = if is_dpop { "DPoP" } else { "Bearer" };
let www_auth = format!(
"{} error=\"invalid_token\", error_description=\"Token has expired\"",
scheme
);
if matches!(e, crate::auth::TokenValidationError::TokenExpired)
&& extracted.is_dpop
{
let www_auth =
"DPoP error=\"invalid_token\", error_description=\"Token has expired\"";
let mut response =
ApiError::ExpiredToken(Some("Token has expired".into())).into_response();
*response.status_mut() = axum::http::StatusCode::UNAUTHORIZED;
response
.headers_mut()
.insert("WWW-Authenticate", www_auth.parse().unwrap());
if is_dpop {
let nonce = crate::oauth::verify::generate_dpop_nonce();
response
.headers_mut()
.insert("DPoP-Nonce", nonce.parse().unwrap());
}
let nonce = crate::oauth::verify::generate_dpop_nonce();
response
.headers_mut()
.insert("DPoP-Nonce", nonce.parse().unwrap());
return response;
}
}
+3
View File
@@ -444,6 +444,9 @@ pub async fn apply_writes(
.await
{
Ok(res) => res,
Err(e) if e.contains("ConcurrentModification") => {
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
}
Err(e) => {
error!("Commit failed: {}", e);
return ApiError::InternalError(Some("Failed to commit changes".into()))
+3
View File
@@ -183,6 +183,9 @@ pub async fn delete_record(
.await
{
Ok(res) => res,
Err(e) if e.contains("ConcurrentModification") => {
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
}
Err(e) => return ApiError::InternalError(Some(e)).into_response(),
};
+12 -10
View File
@@ -83,20 +83,16 @@ pub async fn prepare_repo_write(
.map_err(|e| {
tracing::warn!(error = ?e, is_dpop = extracted.is_dpop, "Token validation failed in prepare_repo_write");
let mut response = ApiError::from(e).into_response();
if matches!(e, crate::auth::TokenValidationError::TokenExpired) {
let scheme = if extracted.is_dpop { "DPoP" } else { "Bearer" };
let www_auth = format!(
"{} error=\"invalid_token\", error_description=\"Token has expired\"",
scheme
);
if matches!(e, crate::auth::TokenValidationError::TokenExpired) && extracted.is_dpop {
*response.status_mut() = axum::http::StatusCode::UNAUTHORIZED;
let www_auth =
"DPoP error=\"invalid_token\", error_description=\"Token has expired\"";
response.headers_mut().insert(
"WWW-Authenticate",
www_auth.parse().unwrap(),
);
if extracted.is_dpop {
let nonce = crate::oauth::verify::generate_dpop_nonce();
response.headers_mut().insert("DPoP-Nonce", nonce.parse().unwrap());
}
let nonce = crate::oauth::verify::generate_dpop_nonce();
response.headers_mut().insert("DPoP-Nonce", nonce.parse().unwrap());
}
response
})?;
@@ -322,6 +318,9 @@ pub async fn create_record(
.await
{
Ok(res) => res,
Err(e) if e.contains("ConcurrentModification") => {
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
}
Err(e) => return ApiError::InternalError(Some(e)).into_response(),
};
@@ -580,6 +579,9 @@ pub async fn put_record(
.await
{
Ok(res) => res,
Err(e) if e.contains("ConcurrentModification") => {
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
}
Err(e) => return ApiError::InternalError(Some(e)).into_response(),
};
+1
View File
@@ -37,6 +37,7 @@ pub use passkeys::{
};
pub use password::{
change_password, get_password_status, remove_password, request_password_reset, reset_password,
set_password,
};
pub use reauth::{
check_legacy_session_mfa, check_reauth_required, get_reauth_status,
+84
View File
@@ -412,3 +412,87 @@ pub async fn remove_password(State(state): State<AppState>, auth: BearerAuth) ->
info!(did = %&auth.0.did, "Password removed - account is now passkey-only");
SuccessResponse::ok().into_response()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetPasswordInput {
pub new_password: PlainPassword,
}
pub async fn set_password(
State(state): State<AppState>,
auth: BearerAuth,
Json(input): Json<SetPasswordInput>,
) -> Response {
if crate::api::server::reauth::check_reauth_required_cached(
&state.db,
&state.cache,
&auth.0.did,
)
.await
{
return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await;
}
let new_password = &input.new_password;
if new_password.is_empty() {
return ApiError::InvalidRequest("newPassword is required".into()).into_response();
}
if let Err(e) = validate_password(new_password) {
return ApiError::InvalidRequest(e.to_string()).into_response();
}
let user = sqlx::query!(
"SELECT id, password_hash FROM users WHERE did = $1",
&auth.0.did
)
.fetch_optional(&state.db)
.await;
let user = match user {
Ok(Some(u)) => u,
Ok(None) => {
return ApiError::AccountNotFound.into_response();
}
Err(e) => {
error!("DB error: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
if user.password_hash.is_some() {
return ApiError::InvalidRequest(
"Account already has a password. Use changePassword instead.".into(),
)
.into_response();
}
let new_password_clone = new_password.to_string();
let new_hash =
match tokio::task::spawn_blocking(move || hash(new_password_clone, DEFAULT_COST)).await {
Ok(Ok(h)) => h,
Ok(Err(e)) => {
error!("Failed to hash password: {:?}", e);
return ApiError::InternalError(None).into_response();
}
Err(e) => {
error!("Failed to spawn blocking task: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
if let Err(e) = sqlx::query!(
"UPDATE users SET password_hash = $1, password_required = TRUE WHERE id = $2",
new_hash,
user.id
)
.execute(&state.db)
.await
{
error!("DB error setting password: {:?}", e);
return ApiError::InternalError(None).into_response();
}
info!(did = %&auth.0.did, "Password set for passkey-only account");
SuccessResponse::ok().into_response()
}
+14 -1
View File
@@ -197,6 +197,10 @@ pub fn app(state: AppState) -> Router {
"/_account.removePassword",
post(api::server::remove_password),
)
.route(
"/_account.setPassword",
post(api::server::set_password),
)
.route(
"/_account.getPasswordStatus",
get(api::server::get_password_status),
@@ -590,7 +594,16 @@ pub fn app(state: AppState) -> Router {
CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
.allow_headers(Any)
.allow_headers([
"Authorization".parse().unwrap(),
"Content-Type".parse().unwrap(),
"Content-Encoding".parse().unwrap(),
"Accept-Encoding".parse().unwrap(),
"DPoP".parse().unwrap(),
"atproto-proxy".parse().unwrap(),
"atproto-accept-labelers".parse().unwrap(),
"x-bsky-topics".parse().unwrap(),
])
.expose_headers([
"WWW-Authenticate".parse().unwrap(),
"DPoP-Nonce".parse().unwrap(),
-3
View File
@@ -116,9 +116,6 @@ pub async fn handle_authorization_code_grant(
} else {
None
};
if let Err(e) = db::revoke_tokens_for_client(&state.db, &did, &auth_request.client_id).await {
tracing::warn!("Failed to revoke previous tokens for client: {:?}", e);
}
let token_id = TokenId::generate();
let refresh_token = RefreshToken::generate();
let now = Utc::now();
+10 -36
View File
@@ -803,49 +803,23 @@ pub async fn generate_repo_car_from_user_blocks(
db: &PgPool,
block_store: &PostgresBlockStore,
user_id: uuid::Uuid,
head_cid: &Cid,
_head_cid: &Cid,
) -> Result<Vec<u8>, String> {
use jacquard_repo::storage::BlockStore;
use std::str::FromStr;
let block_cid_bytes: Vec<Vec<u8>> = sqlx::query_scalar!(
"SELECT block_cid FROM user_blocks WHERE user_id = $1",
let repo_root_cid_str: String = sqlx::query_scalar!(
"SELECT repo_root_cid FROM repos WHERE user_id = $1",
user_id
)
.fetch_all(db)
.fetch_optional(db)
.await
.map_err(|e| format!("Failed to fetch user_blocks: {}", e))?;
.map_err(|e| format!("Failed to fetch repo: {}", e))?
.ok_or_else(|| "Repository not found".to_string())?;
if block_cid_bytes.is_empty() {
let cids = collect_current_repo_blocks(block_store, head_cid).await?;
if cids.is_empty() {
return Err("No blocks found for repo".to_string());
}
return generate_repo_car(block_store, head_cid).await;
}
let actual_head_cid = Cid::from_str(&repo_root_cid_str)
.map_err(|e| format!("Invalid repo_root_cid: {}", e))?;
let block_cids: Vec<Cid> = block_cid_bytes
.iter()
.filter_map(|bytes| Cid::try_from(bytes.as_slice()).ok())
.collect();
let car_bytes =
encode_car_header(head_cid).map_err(|e| format!("Failed to encode CAR header: {}", e))?;
let blocks = block_store
.get_many(&block_cids)
.await
.map_err(|e| format!("Failed to fetch blocks: {:?}", e))?;
let car_bytes = block_cids
.iter()
.zip(blocks.iter())
.filter_map(|(cid, block_opt)| block_opt.as_ref().map(|block| (cid, block)))
.fold(car_bytes, |mut acc, (cid, block)| {
acc.extend(encode_car_block(cid, block));
acc
});
Ok(car_bytes)
generate_repo_car(block_store, &actual_head_cid).await
}
pub async fn generate_full_backup(
+2
View File
@@ -136,6 +136,7 @@ async fn setup_with_external_infra() -> String {
);
std::env::set_var("S3_ENDPOINT", &s3_endpoint);
std::env::set_var("MAX_IMPORT_SIZE", "100000000");
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
}
let mock_server = MockServer::start().await;
setup_mock_appview(&mock_server).await;
@@ -170,6 +171,7 @@ async fn setup_with_testcontainers() -> String {
std::env::set_var("AWS_REGION", "us-east-1");
std::env::set_var("S3_ENDPOINT", &s3_endpoint);
std::env::set_var("MAX_IMPORT_SIZE", "100000000");
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
}
let sdk_config = aws_config::defaults(BehaviorVersion::latest())
.region("us-east-1")
+1 -1
View File
@@ -228,7 +228,7 @@ async fn test_delete_account_expired_token() {
.send()
.await
.expect("Failed to send delete request");
assert_eq!(delete_res.status(), StatusCode::UNAUTHORIZED);
assert_eq!(delete_res.status(), StatusCode::BAD_REQUEST);
let body: Value = delete_res.json().await.unwrap();
assert_eq!(body["error"], "ExpiredToken");
}
+15 -5
View File
@@ -156,7 +156,14 @@ async fn test_import_accepts_own_exported_repo() {
.send()
.await
.expect("Failed to import repo");
assert_eq!(import_res.status(), StatusCode::OK);
let status = import_res.status();
if status != StatusCode::OK {
let body = import_res.text().await.unwrap_or_default();
panic!(
"Import failed with status {}: {}",
status, body
);
}
}
#[tokio::test]
@@ -285,7 +292,7 @@ async fn test_import_car_with_no_roots() {
async fn test_import_preserves_records_after_reimport() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let mut rkeys = Vec::new();
let mut rkeys = Vec::with_capacity(3);
for i in 0..3 {
let post_payload = json!({
"repo": did,
@@ -309,8 +316,7 @@ async fn test_import_preserves_records_after_reimport() {
assert_eq!(res.status(), StatusCode::OK);
let body: serde_json::Value = res.json().await.unwrap();
let uri = body["uri"].as_str().unwrap();
let rkey = uri.split('/').next_back().unwrap().to_string();
rkeys.push(rkey);
rkeys.push(uri.split('/').next_back().unwrap().to_string());
}
for rkey in &rkeys {
let get_res = client
@@ -352,7 +358,11 @@ async fn test_import_preserves_records_after_reimport() {
.send()
.await
.expect("Failed to import repo");
assert_eq!(import_res.status(), StatusCode::OK);
let status = import_res.status();
if status != StatusCode::OK {
let body = import_res.text().await.unwrap_or_default();
panic!("Import failed with status {}: {}", status, body);
}
let list_res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords?repo={}&collection=app.bsky.feed.post",
+1 -1
View File
@@ -241,7 +241,7 @@ async fn test_reset_password_with_expired_token() {
.send()
.await
.expect("Failed to reset password");
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let body: Value = res.json().await.expect("Invalid JSON");
assert_eq!(body["error"], "ExpiredToken");
}