feat(opaque): improve password change
- rebuild the opaque envoloppe - revoke all other useer's sessions - send a security email to user
This commit is contained in:
@@ -68,12 +68,19 @@ export async function changePassword(currentPw: string, newPw: string): Promise<
|
||||
body: JSON.stringify({ current_password: currentPw, new_password: newPw })
|
||||
});
|
||||
if (!res.ok) throw new Error(`password change failed: ${res.status}`);
|
||||
// Re-mint the OPAQUE envelope under the new passphrase — session
|
||||
// stays valid across change-password (backend doesn't invalidate),
|
||||
// so the session-authenticated register endpoints are reachable
|
||||
// straight away. Non-fatal on failure: silent migration on next
|
||||
// legacy login recovers the envelope. See
|
||||
// `$lib/api/endpoints/opaque.ts::syncOpaqueEnvelope`.
|
||||
// Re-mint the OPAQUE envelope under the new passphrase — SAME
|
||||
// session is still valid after change_password (the backend now
|
||||
// preserves the caller's session via `revoke_other_user_sessions`;
|
||||
// only OTHER devices are logged out). That means the session-
|
||||
// authenticated register endpoints are reachable straight away,
|
||||
// no 401 race like the earlier `revoke_all_user_sessions` shape.
|
||||
//
|
||||
// This is the PRIMARY migration path: the envelope transitions
|
||||
// straight from OLD-password bound to NEW-password bound with no
|
||||
// null intermediate. `opaque_migrated_at` stays intact, admin
|
||||
// dashboards don't see a spurious "unmigrated" blip. Non-fatal
|
||||
// on failure — silent-migration on next legacy login (post
|
||||
// `oxicloud-cli opaque reset` recovery) is the fallback.
|
||||
//
|
||||
// Dynamic import keeps the ~200 KiB `@serenity-kit/opaque` WASM
|
||||
// bundle out of the profile route's initial chunk — the module
|
||||
|
||||
@@ -227,6 +227,17 @@ export interface User {
|
||||
* missing → `false`.
|
||||
*/
|
||||
force_password_change?: boolean;
|
||||
/**
|
||||
* TRUE when the account has a local Argon2id `password_hash` on
|
||||
* file. Distinct from `auth_provider`: an SSO-linked account can
|
||||
* ALSO carry a local password (hybrid posture — SSO for daily
|
||||
* login, local password as fallback). The profile page's
|
||||
* change-password card gates on this flag rather than on
|
||||
* `auth_provider === 'local'` so hybrid users can rotate their
|
||||
* local credential. Optional on the wire for older-backend
|
||||
* compatibility; missing → `false` (safe default: hide the card).
|
||||
*/
|
||||
has_password?: boolean;
|
||||
}
|
||||
|
||||
/** Fields rendered by the paginated admin table. Full account details remain
|
||||
|
||||
@@ -71,7 +71,17 @@
|
||||
const usernameClaimed = $derived(!!session.user?.username);
|
||||
const isAdmin = $derived(session.user?.role === 'admin');
|
||||
const canEditImage = $derived(session.user?.can_edit_image === true && isLocal);
|
||||
const showPasswordCard = $derived(isLocal && passwordLoginEnabled);
|
||||
// Show the change-password card when the user CAN change their
|
||||
// local password: they have `password_hash` on file AND the
|
||||
// deployment offers password login (backend `change_password`
|
||||
// refuses on either count — see `AuthApplicationService::change_password`).
|
||||
// Distinct from the OLD `isLocal && passwordLoginEnabled` gate,
|
||||
// which refused any SSO-linked account regardless of whether they
|
||||
// carried a local password. Hybrid accounts (OIDC + local
|
||||
// password) are a legitimate posture and MUST be able to rotate
|
||||
// their local credential; the new gate lets them, and the backend
|
||||
// refusal covers the pure-SSO case where has_password is false.
|
||||
const showPasswordCard = $derived((session.user?.has_password ?? false) && passwordLoginEnabled);
|
||||
|
||||
/**
|
||||
* Mandatory change-password mode. TRUE when the backend has
|
||||
|
||||
@@ -14,7 +14,8 @@ const { session, ui } = vi.hoisted(() => ({
|
||||
role: 'admin',
|
||||
storage_used_bytes: 100,
|
||||
storage_quota_bytes: 1000,
|
||||
is_external: false
|
||||
is_external: false,
|
||||
has_password: true
|
||||
}
|
||||
},
|
||||
ui: { notify: vi.fn() }
|
||||
@@ -54,7 +55,8 @@ beforeEach(() => {
|
||||
role: 'admin',
|
||||
storage_used_bytes: 100,
|
||||
storage_quota_bytes: 1000,
|
||||
is_external: false
|
||||
is_external: false,
|
||||
has_password: true
|
||||
};
|
||||
m(profile.listAppPasswords).mockResolvedValue([]);
|
||||
m(profile.updateProfile).mockResolvedValue(undefined);
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
"subject": "شارك {{inviter}} معك {{kind}} على OxiCloud",
|
||||
"body": "شارك {{inviter_full}} معك {{kind}} على OxiCloud.\n\nافتح OxiCloud لعرض مشاركتك الجديدة:\n{{login_link}}\n\nقد تكون لديك مشاركات جديدة أخرى من {{inviter}} — سجّل الدخول لرؤية جميع العناصر المشاركة معك.\n\n— OxiCloud\n\nأنت تتلقى هذه الرسالة لأن لديك حسابًا في OxiCloud وتفضيل إشعارات المشاركة مُفعّل. يمكنك تعطيله من ملفك الشخصي (راسلني عندما يشاركني شخص ما)."
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"password_changed": {
|
||||
"subject": "تم تغيير كلمة مرور OxiCloud الخاصة بك",
|
||||
"body": "مرحباً،\n\nتم تغيير كلمة مرور OxiCloud الخاصة بك للتو في {{timestamp}} (UTC) من عنوان IP {{ip}}.\n\nإذا كنت أنت من قام بذلك، فلا حاجة لأي إجراء — يمكنك تجاهل هذه الرسالة.\n\nإذا لم تكن أنت، فاتصل بمسؤول النظام فوراً. قد يكون شخص ما قد وصل إلى حسابك.\n\n— OxiCloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server_status": {
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
"subject": "{{inviter}} hat einen {{kind}} mit Ihnen auf OxiCloud geteilt",
|
||||
"body": "{{inviter_full}} hat einen {{kind}} mit Ihnen auf OxiCloud geteilt.\n\nÖffnen Sie OxiCloud, um Ihre neue Freigabe zu sehen:\n{{login_link}}\n\nMöglicherweise gibt es weitere neue Freigaben von {{inviter}} — melden Sie sich an, um alle Ihre freigegebenen Elemente zu sehen.\n\n— OxiCloud\n\nSie erhalten diese Nachricht, weil Sie ein OxiCloud-Konto haben und die Benachrichtigung über Freigaben aktiviert ist. Sie können sie in Ihrem Profil deaktivieren (Per E-Mail benachrichtigen, wenn jemand mit mir teilt)."
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"password_changed": {
|
||||
"subject": "Ihr OxiCloud-Passwort wurde geändert",
|
||||
"body": "Hallo,\n\nIhr OxiCloud-Passwort wurde soeben am {{timestamp}} (UTC) von der IP-Adresse {{ip}} geändert.\n\nWenn Sie das waren, ist keine Aktion erforderlich — Sie können diese Nachricht ignorieren.\n\nWenn Sie das NICHT waren, wenden Sie sich sofort an Ihren Administrator. Möglicherweise hat jemand Zugriff auf Ihr Konto erhalten.\n\n— OxiCloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server_status": {
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
"subject": "{{inviter}} shared a {{kind}} with you on OxiCloud",
|
||||
"body": "{{inviter_full}} shared a {{kind}} with you on OxiCloud.\n\nOpen OxiCloud to see your new share:\n{{login_link}}\n\nYou may have additional new shares from {{inviter}} — sign in to see all your shared items.\n\n— OxiCloud\n\nYou're receiving this message because you have an OxiCloud account and your share-notification preference is on. You can turn it off in your profile (Email me when someone shares with me)."
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"password_changed": {
|
||||
"subject": "Your OxiCloud password was changed",
|
||||
"body": "Hello,\n\nYour OxiCloud password was just changed at {{timestamp}} (UTC) from IP address {{ip}}.\n\nIf this was you, no action is needed — you can ignore this message.\n\nIf this was NOT you, contact your administrator immediately. Someone may have gained access to your account.\n\n— OxiCloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server_status": {
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
"subject": "{{inviter}} ha compartido un {{kind}} contigo en OxiCloud",
|
||||
"body": "{{inviter_full}} ha compartido un {{kind}} contigo en OxiCloud.\n\nAbre OxiCloud para ver tu nuevo recurso compartido:\n{{login_link}}\n\nPuede que tengas más recursos compartidos nuevos de {{inviter}} — inicia sesión para ver todos tus elementos compartidos.\n\n— OxiCloud\n\nRecibes este mensaje porque tienes una cuenta de OxiCloud y la preferencia de notificación de recursos compartidos está activada. Puedes desactivarla en tu perfil (Enviarme un correo cuando alguien comparta conmigo)."
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"password_changed": {
|
||||
"subject": "Se cambió su contraseña de OxiCloud",
|
||||
"body": "Hola,\n\nSu contraseña de OxiCloud acaba de cambiarse el {{timestamp}} (UTC) desde la dirección IP {{ip}}.\n\nSi fue usted, no se requiere ninguna acción — puede ignorar este mensaje.\n\nSi NO fue usted, contacte a su administrador inmediatamente. Alguien pudo haber obtenido acceso a su cuenta.\n\n— OxiCloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server_status": {
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
"subject": "{{inviter}} یک {{kind}} را با شما در OxiCloud به اشتراک گذاشت",
|
||||
"body": "{{inviter_full}} یک {{kind}} را با شما در OxiCloud به اشتراک گذاشت.\n\nبرای دیدن اشتراکگذاری جدید خود، OxiCloud را باز کنید:\n{{login_link}}\n\nممکن است اشتراکگذاریهای جدید دیگری از {{inviter}} داشته باشید — وارد شوید تا همه موارد به اشتراک گذاشتهشده با خود را ببینید.\n\n— OxiCloud\n\nشما این پیام را دریافت میکنید زیرا حساب OxiCloud دارید و گزینه اعلان اشتراکگذاری شما روشن است. میتوانید آن را در پروفایل خود خاموش کنید (وقتی کسی با من چیزی به اشتراک میگذارد، به من ایمیل بزن)."
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"password_changed": {
|
||||
"subject": "رمز عبور OxiCloud شما تغییر کرد",
|
||||
"body": "سلام،\n\nرمز عبور OxiCloud شما هماکنون در {{timestamp}} (UTC) از نشانی IP {{ip}} تغییر یافت.\n\nاگر خودتان این کار را کردهاید، نیازی به اقدامی نیست — میتوانید این پیام را نادیده بگیرید.\n\nاگر شما این کار را نکردهاید، فوراً با مدیر سیستم تماس بگیرید. ممکن است شخصی به حساب شما دسترسی پیدا کرده باشد.\n\n— OxiCloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server_status": {
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
"subject": "{{inviter}} a partagé un {{kind}} avec vous sur OxiCloud",
|
||||
"body": "{{inviter_full}} a partagé un {{kind}} avec vous sur OxiCloud.\n\nOuvrez OxiCloud pour voir votre nouveau partage :\n{{login_link}}\n\nVous avez peut-être d'autres nouveaux partages de {{inviter}} — connectez-vous pour voir tous vos éléments partagés.\n\n— OxiCloud\n\nVous recevez ce message parce que vous avez un compte OxiCloud et que la préférence de notification de partage est activée. Vous pouvez la désactiver dans votre profil (M'avertir par e-mail quand quelqu'un partage avec moi)."
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"password_changed": {
|
||||
"subject": "Votre mot de passe OxiCloud a été modifié",
|
||||
"body": "Bonjour,\n\nVotre mot de passe OxiCloud vient d'être modifié le {{timestamp}} (UTC) depuis l'adresse IP {{ip}}.\n\nSi c'était vous, aucune action n'est nécessaire — vous pouvez ignorer ce message.\n\nSi ce n'était PAS vous, contactez immédiatement votre administrateur. Quelqu'un a peut-être obtenu accès à votre compte.\n\n— OxiCloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server_status": {
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
"subject": "{{inviter}} ने OxiCloud पर आपके साथ एक {{kind}} साझा किया",
|
||||
"body": "{{inviter_full}} ने OxiCloud पर आपके साथ एक {{kind}} साझा किया है।\n\nअपना नया साझाकरण देखने के लिए OxiCloud खोलें:\n{{login_link}}\n\nहो सकता है आपके पास {{inviter}} से और भी नए साझाकरण हों — साइन इन करें और अपने सभी साझा किए गए आइटम देखें।\n\n— OxiCloud\n\nआपको यह संदेश इसलिए मिल रहा है क्योंकि आपका OxiCloud खाता है और साझाकरण-सूचना प्राथमिकता चालू है। आप इसे अपनी प्रोफ़ाइल में बंद कर सकते हैं (जब कोई मेरे साथ साझा करे तो मुझे ईमेल भेजें)।"
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"password_changed": {
|
||||
"subject": "आपका OxiCloud पासवर्ड बदल दिया गया",
|
||||
"body": "नमस्ते,\n\nआपका OxiCloud पासवर्ड अभी-अभी {{timestamp}} (UTC) पर IP पते {{ip}} से बदला गया।\n\nयदि यह आपने किया, तो कोई कार्रवाई आवश्यक नहीं है — आप इस संदेश को अनदेखा कर सकते हैं।\n\nयदि यह आपने नहीं किया, तो तुरंत अपने प्रशासक से संपर्क करें। हो सकता है किसी ने आपके खाते तक पहुंच प्राप्त कर ली हो।\n\n— OxiCloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server_status": {
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
"subject": "{{inviter}} ha condiviso un {{kind}} con te su OxiCloud",
|
||||
"body": "{{inviter_full}} ha condiviso un {{kind}} con te su OxiCloud.\n\nApri OxiCloud per vedere la tua nuova condivisione:\n{{login_link}}\n\nPotresti avere altre nuove condivisioni da {{inviter}} — accedi per vedere tutti gli elementi condivisi con te.\n\n— OxiCloud\n\nRicevi questo messaggio perché hai un account OxiCloud e la preferenza di notifica delle condivisioni è attiva. Puoi disattivarla dal tuo profilo (Avvisami via email quando qualcuno condivide con me)."
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"password_changed": {
|
||||
"subject": "La tua password OxiCloud è stata modificata",
|
||||
"body": "Ciao,\n\nLa tua password OxiCloud è stata appena modificata il {{timestamp}} (UTC) dall'indirizzo IP {{ip}}.\n\nSe sei stato tu, non è richiesta alcuna azione — puoi ignorare questo messaggio.\n\nSe NON sei stato tu, contatta immediatamente il tuo amministratore. Qualcuno potrebbe aver ottenuto accesso al tuo account.\n\n— OxiCloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server_status": {
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
"subject": "{{inviter}} さんが OxiCloud で {{kind}} をあなたと共有しました",
|
||||
"body": "{{inviter_full}} さんが OxiCloud で {{kind}} をあなたと共有しました。\n\n新しい共有を確認するには OxiCloud を開いてください:\n{{login_link}}\n\n{{inviter}} さんから他にも新しい共有があるかもしれません — サインインしてあなたと共有されたすべての項目を確認してください。\n\n— OxiCloud\n\nOxiCloud のアカウントをお持ちで、共有通知の設定が有効になっているため、このメッセージが届いています。プロフィールでオフにできます(誰かが共有したときにメールで通知する)。"
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"password_changed": {
|
||||
"subject": "OxiCloudのパスワードが変更されました",
|
||||
"body": "こんにちは、\n\nお使いのOxiCloudパスワードが {{timestamp}} (UTC) にIPアドレス {{ip}} から変更されました。\n\nご自身で行った場合は、対応は不要です — このメッセージは無視してください。\n\nご自身で行っていない場合は、直ちに管理者に連絡してください。第三者がアカウントにアクセスした可能性があります。\n\n— OxiCloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server_status": {
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
"subject": "{{inviter}}님이 OxiCloud에서 {{kind}}을(를) 공유했습니다",
|
||||
"body": "{{inviter_full}}님이 OxiCloud에서 {{kind}}을(를) 공유했습니다.\n\n새 공유 항목을 확인하려면 OxiCloud를 여세요:\n{{login_link}}\n\n{{inviter}}님이 추가로 공유한 항목이 있을 수 있습니다 — 로그인하여 공유받은 모든 항목을 확인하세요.\n\n— OxiCloud\n\nOxiCloud 계정이 있고 공유 알림 기본 설정이 켜져 있어 이 메시지를 받았습니다. 프로필에서 끌 수 있습니다(다른 사람이 나에게 공유할 때 이메일로 알림 받기)."
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"password_changed": {
|
||||
"subject": "OxiCloud 비밀번호가 변경되었습니다",
|
||||
"body": "안녕하세요,\n\n귀하의 OxiCloud 비밀번호가 방금 {{timestamp}} (UTC)에 IP 주소 {{ip}}에서 변경되었습니다.\n\n본인이 변경한 것이라면 별도의 조치가 필요하지 않습니다 — 이 메시지를 무시하셔도 됩니다.\n\n본인이 변경한 것이 아니라면 즉시 관리자에게 연락하십시오. 누군가 귀하의 계정에 접근했을 수 있습니다.\n\n— OxiCloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server_status": {
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
"subject": "{{inviter}} heeft een {{kind}} met je gedeeld op OxiCloud",
|
||||
"body": "{{inviter_full}} heeft een {{kind}} met je gedeeld op OxiCloud.\n\nOpen OxiCloud om je nieuwe gedeelde item te bekijken:\n{{login_link}}\n\nMisschien heb je nog meer nieuwe gedeelde items van {{inviter}} — meld je aan om al je gedeelde items te zien.\n\n— OxiCloud\n\nJe ontvangt dit bericht omdat je een OxiCloud-account hebt en je voorkeur voor deelmeldingen aanstaat. Je kunt het uitzetten in je profiel (Stuur me een e-mail wanneer iemand iets met mij deelt)."
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"password_changed": {
|
||||
"subject": "Uw OxiCloud-wachtwoord is gewijzigd",
|
||||
"body": "Hallo,\n\nUw OxiCloud-wachtwoord is zojuist gewijzigd op {{timestamp}} (UTC) vanaf IP-adres {{ip}}.\n\nAls u dit was, is er geen actie nodig — u kunt dit bericht negeren.\n\nAls u dit NIET was, neem dan onmiddellijk contact op met uw beheerder. Iemand kan toegang hebben verkregen tot uw account.\n\n— OxiCloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server_status": {
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
"subject": "{{inviter}} udostępnił Ci {{kind}} w OxiCloud",
|
||||
"body": "{{inviter_full}} udostępnił Ci {{kind}} w OxiCloud.\n\nOtwórz OxiCloud, aby zobaczyć nowe udostępnienie:\n{{login_link}}\n\nMożesz mieć dodatkowe nowe udostępnienia od {{inviter}} — zaloguj się, aby zobaczyć wszystkie udostępnione Ci elementy.\n\n— OxiCloud\n\nOtrzymujesz tę wiadomość, ponieważ masz konto OxiCloud i preferencja powiadomień o udostępnieniach jest włączona. Możesz ją wyłączyć w swoim profilu (Wyślij mi e-mail, gdy ktoś coś mi udostępni)."
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"password_changed": {
|
||||
"subject": "Twoje hasło OxiCloud zostało zmienione",
|
||||
"body": "Cześć,\n\nTwoje hasło OxiCloud zostało właśnie zmienione w dniu {{timestamp}} (UTC) z adresu IP {{ip}}.\n\nJeśli to byłeś Ty, żadne działanie nie jest wymagane — możesz zignorować tę wiadomość.\n\nJeśli to NIE byłeś Ty, natychmiast skontaktuj się z administratorem. Ktoś mógł uzyskać dostęp do Twojego konta.\n\n— OxiCloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server_status": {
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
"subject": "{{inviter}} partilhou um {{kind}} consigo no OxiCloud",
|
||||
"body": "{{inviter_full}} partilhou um {{kind}} consigo no OxiCloud.\n\nAbra o OxiCloud para ver a sua nova partilha:\n{{login_link}}\n\nPode ter mais partilhas novas de {{inviter}} — inicie sessão para ver todos os itens partilhados consigo.\n\n— OxiCloud\n\nRecebeu esta mensagem porque tem uma conta OxiCloud e a preferência de notificação de partilhas está ativada. Pode desativá-la no seu perfil (Avisar-me por e-mail quando alguém compartilhar comigo)."
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"password_changed": {
|
||||
"subject": "Sua senha do OxiCloud foi alterada",
|
||||
"body": "Olá,\n\nSua senha do OxiCloud foi alterada em {{timestamp}} (UTC) a partir do endereço IP {{ip}}.\n\nSe foi você, nenhuma ação é necessária — pode ignorar esta mensagem.\n\nSe NÃO foi você, entre em contato com o seu administrador imediatamente. Alguém pode ter obtido acesso à sua conta.\n\n— OxiCloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server_status": {
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
"subject": "{{inviter}} поделился(ась) с вами {{kind}} в OxiCloud",
|
||||
"body": "{{inviter_full}} поделился(ась) с вами {{kind}} в OxiCloud.\n\nОткройте OxiCloud, чтобы увидеть новый общий ресурс:\n{{login_link}}\n\nВозможно, у вас есть и другие новые общие ресурсы от {{inviter}} — войдите, чтобы увидеть все элементы, которыми с вами поделились.\n\n— OxiCloud\n\nВы получаете это сообщение, потому что у вас есть учётная запись OxiCloud и предпочтение уведомлений об общих ресурсах включено. Вы можете отключить его в своём профиле (Уведомлять меня по электронной почте, когда кто-то делится со мной)."
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"password_changed": {
|
||||
"subject": "Ваш пароль OxiCloud был изменён",
|
||||
"body": "Здравствуйте,\n\nВаш пароль OxiCloud только что был изменён {{timestamp}} (UTC) с IP-адреса {{ip}}.\n\nЕсли это были вы, никаких действий не требуется — можете проигнорировать это сообщение.\n\nЕсли это были НЕ вы, немедленно свяжитесь с администратором. Кто-то мог получить доступ к вашей учётной записи.\n\n— OxiCloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server_status": {
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
"subject": "{{inviter}} 在 OxiCloud 上與您分享了一個{{kind}}",
|
||||
"body": "{{inviter_full}} 在 OxiCloud 上與您分享了一個{{kind}}。\n\n開啟 OxiCloud 檢視您的新分享:\n{{login_link}}\n\n您可能還有來自 {{inviter}} 的其他新分享 — 登入以檢視所有與您分享的項目。\n\n— OxiCloud\n\n您收到此訊息是因為您擁有 OxiCloud 帳戶且分享通知偏好已開啟。您可以在個人資料中關閉它(當有人與我分享時透過電子郵件通知我)。"
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"password_changed": {
|
||||
"subject": "您的 OxiCloud 密碼已變更",
|
||||
"body": "您好,\n\n您的 OxiCloud 密碼剛剛於 {{timestamp}} (UTC) 從 IP 位址 {{ip}} 被變更。\n\n如果是您本人操作,無需採取任何行動 — 可以忽略此訊息。\n\n如果不是您本人操作,請立即聯絡您的管理員。可能有人取得了您帳號的存取權限。\n\n— OxiCloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server_status": {
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
"subject": "{{inviter}} 在 OxiCloud 上与您共享了一个{{kind}}",
|
||||
"body": "{{inviter_full}} 在 OxiCloud 上与您共享了一个{{kind}}。\n\n打开 OxiCloud 查看您的新共享:\n{{login_link}}\n\n您可能还有来自 {{inviter}} 的其他新共享 — 登录以查看所有共享给您的项目。\n\n— OxiCloud\n\n您收到此消息是因为您拥有 OxiCloud 账户且共享通知偏好已开启。您可以在个人资料中关闭它(当有人与我共享时通过电子邮件通知我)。"
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"password_changed": {
|
||||
"subject": "您的 OxiCloud 密码已更改",
|
||||
"body": "您好,\n\n您的 OxiCloud 密码刚刚于 {{timestamp}} (UTC) 从 IP 地址 {{ip}} 被更改。\n\n如果是您本人操作,无需采取任何行动 — 可以忽略此消息。\n\n如果不是您本人操作,请立即联系您的管理员。可能有人获得了对您账户的访问权限。\n\n— OxiCloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server_status": {
|
||||
|
||||
@@ -94,6 +94,20 @@ pub struct UserDto {
|
||||
/// field is what the SPA reads to render the mandatory-mode UI.
|
||||
#[serde(default)]
|
||||
pub force_password_change: bool,
|
||||
/// TRUE when the account has a local Argon2id `password_hash` on
|
||||
/// file. Distinct from `auth_provider`: an SSO-linked account
|
||||
/// (auth_provider != "local") can ALSO carry a local password if
|
||||
/// it was set at signup or later — a hybrid posture. The SPA
|
||||
/// gates the profile page's change-password card on this flag,
|
||||
/// so hybrid users can rotate their local password even though
|
||||
/// they normally sign in via SSO.
|
||||
///
|
||||
/// Populated only by the `/api/auth/me` handler. `From<User>` in
|
||||
/// this file leaves it `false` — other UserDto emitters (admin
|
||||
/// listings, share-recipient responses, group members) do not
|
||||
/// need to surface per-user credential state.
|
||||
#[serde(default)]
|
||||
pub has_password: bool,
|
||||
}
|
||||
|
||||
/// Compact row returned by the paginated admin user table.
|
||||
@@ -176,6 +190,11 @@ impl From<User> for UserDto {
|
||||
// entity before the move.
|
||||
let role = format!("{}", user.role());
|
||||
let can_edit_image = !user.is_oidc_user();
|
||||
// has_password is derivable from the entity — read before the
|
||||
// move. Cheap (bool from Option::is_some), no extra DB round-
|
||||
// trip, so From<User> can populate it uniformly rather than
|
||||
// leaving it false and requiring per-call-site backfill.
|
||||
let has_password = user.has_password();
|
||||
let p = user.into_parts();
|
||||
Self {
|
||||
id: p.id.to_string(),
|
||||
@@ -206,6 +225,7 @@ impl From<User> for UserDto {
|
||||
// leave it false — the flag is per-session-account state,
|
||||
// not a general user attribute.
|
||||
force_password_change: false,
|
||||
has_password,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,6 +363,16 @@ pub trait SessionStoragePort: Send + Sync + 'static {
|
||||
/// Revokes all sessions of a user
|
||||
async fn revoke_all_user_sessions(&self, user_id: Uuid) -> Result<u64, DomainError>;
|
||||
|
||||
/// Revokes every session of a user EXCEPT `keep_session_id`.
|
||||
/// Classic "password change" pattern: kills OTHER devices' sessions
|
||||
/// while keeping the caller's current session alive so the SPA can
|
||||
/// complete follow-up work without a session-death race.
|
||||
async fn revoke_other_user_sessions(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
keep_session_id: Uuid,
|
||||
) -> Result<u64, DomainError>;
|
||||
|
||||
/// Revokes all sessions in a token family (used when replay of a revoked token is detected)
|
||||
async fn revoke_session_family(&self, family_id: Uuid) -> Result<u64, DomainError>;
|
||||
|
||||
|
||||
@@ -122,6 +122,26 @@ pub trait OpaqueRepositoryPort: Send + Sync + 'static {
|
||||
/// (that's the point of the admin call).
|
||||
async fn clear_registration(&self, user_id: Uuid) -> Result<()>;
|
||||
|
||||
/// Invalidate the OPAQUE envelope for `user_id` WITHOUT touching
|
||||
/// `force_password_change_at_next_login`. Used by the self-service
|
||||
/// `change_password` path: the user just proved and rotated their
|
||||
/// legacy password, so the OLD envelope (bound to the OLD
|
||||
/// passphrase) MUST go, but no forced-change prompt is needed on
|
||||
/// the next login (the user did just change it themselves).
|
||||
///
|
||||
/// Distinct from [`clear_registration`], which co-flips
|
||||
/// `force_password_change` because that path represents an admin
|
||||
/// override — the user did NOT choose the new value, so they must
|
||||
/// pick their own on next login. Change-password is the inverse:
|
||||
/// user chose the value, no re-choice needed.
|
||||
///
|
||||
/// Also used by `oxicloud-cli opaque reset --user X` for KSF
|
||||
/// rotation recovery — same "envelope stale, don't touch other
|
||||
/// state" semantics.
|
||||
///
|
||||
/// Idempotent: nulling already-null columns is a no-op.
|
||||
async fn clear_envelope_only(&self, user_id: Uuid) -> Result<()>;
|
||||
|
||||
/// Stamp `opaque_migrated_at` on `user_id` if it isn't set yet.
|
||||
/// Called by the login-KE3 handler after a successful OPAQUE
|
||||
/// handshake — the presence of this timestamp is the Phase 3+
|
||||
|
||||
@@ -1816,22 +1816,63 @@ impl AuthApplicationService {
|
||||
Ok(UserDto::from(updated))
|
||||
}
|
||||
|
||||
/// `keep_session_id` — when `Some`, revoke every OTHER session for
|
||||
/// this user but leave the identified one alive. Classic
|
||||
/// "password change" pattern: log the user out from other devices
|
||||
/// but keep the current one authenticated so the SPA can complete
|
||||
/// follow-up work (OPAQUE envelope re-registration) without
|
||||
/// racing a session-death 401. When `None`, revokes all sessions
|
||||
/// (preserves the original behaviour for callers without session
|
||||
/// context).
|
||||
///
|
||||
/// Handler-layer callers should extract the current session_id
|
||||
/// from the request's refresh-token cookie and pass it in; other
|
||||
/// callers (CLI, tests, admin flows that don't have a specific
|
||||
/// current session) leave it `None`.
|
||||
pub async fn change_password(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
dto: ChangePasswordDto,
|
||||
keep_session_id: Option<Uuid>,
|
||||
) -> Result<(), DomainError> {
|
||||
// Get user
|
||||
let mut user = self.user_storage.get_user_by_id(user_id).await?;
|
||||
|
||||
// Block password changes for OIDC-provisioned users
|
||||
if user.is_oidc_user() {
|
||||
// Two structural refusals. Order chosen so the more-specific
|
||||
// "your credential is IdP-managed" wins for pure-OIDC users
|
||||
// (which is the case the message text addresses); the
|
||||
// deployment-wide "password auth is off" wins for everyone
|
||||
// else on an SSO-only deployment.
|
||||
//
|
||||
// 1. Pure-OIDC user (SSO-linked AND no local password).
|
||||
// Hybrid accounts with an OIDC linkage BUT also a
|
||||
// `password_hash` on file are a legitimate posture on
|
||||
// deployments that offer SSO alongside password auth —
|
||||
// they can and must be able to rotate the local
|
||||
// credential from this endpoint.
|
||||
//
|
||||
// 2. Deployment has password auth disabled globally
|
||||
// (`OXICLOUD_AUTH_METHODS` missing `password`, or the
|
||||
// legacy `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN` alias).
|
||||
// Even a user who still has `password_hash` from before
|
||||
// the operator flipped this shouldn't be updating that
|
||||
// hash — they can't USE it to log in, and leaving a
|
||||
// write path exposed keeps a live credential the
|
||||
// operator likely wanted retired.
|
||||
if user.is_oidc_user() && !user.has_password() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Auth",
|
||||
"Password changes are not available for SSO/OIDC accounts. Your password is managed by your identity provider.",
|
||||
));
|
||||
}
|
||||
if !self.is_password_login_allowed() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Auth",
|
||||
"Password login is disabled on this deployment; password change is not available.",
|
||||
));
|
||||
}
|
||||
|
||||
// Verify current password using the injected hasher
|
||||
let Some(hash) = user.password_hash() else {
|
||||
@@ -1894,6 +1935,34 @@ impl AuthApplicationService {
|
||||
// Save updated user
|
||||
self.user_storage.update_user(user.clone()).await?;
|
||||
|
||||
// OPAQUE envelope handling: the OLD envelope was bound to the
|
||||
// OLD passphrase via the OPRF. Left in place, the next OPAQUE
|
||||
// login with the NEW password would derive a mismatched OPRF
|
||||
// output and fail the AKE with InvalidCredentials → user
|
||||
// locked out (Phase 3 SPA doesn't fall back OPAQUE→legacy).
|
||||
//
|
||||
// The RESPONSIBILITY for re-minting the envelope belongs to
|
||||
// the SPA — see `frontend/src/lib/api/endpoints/profile.ts`
|
||||
// → `changePassword` → `syncOpaqueEnvelope(newPw)`. That call
|
||||
// hits the session-authenticated `/register/*` endpoints
|
||||
// immediately after this handler returns 200. It works
|
||||
// because we keep the current session alive below
|
||||
// (`revoke_other_user_sessions` instead of the full-revocation
|
||||
// call this handler used to make).
|
||||
//
|
||||
// The server does NOT clear the envelope here. The SPA
|
||||
// re-registration is monotonic — the envelope transitions
|
||||
// straight from OLD-password bound to NEW-password bound
|
||||
// without a null intermediate. This matters for the migration
|
||||
// ledger: `opaque_migrated_at` stays intact, admin dashboards
|
||||
// don't see a spurious "unmigrated" blip.
|
||||
//
|
||||
// Recovery for the rare SPA-failure case: the operator runs
|
||||
// `oxicloud-cli opaque reset --user <id>` to null the
|
||||
// envelope; the user's next login goes through legacy path
|
||||
// (since `hasOpaque: false` after the CLI reset) and silent-
|
||||
// migration mints a fresh envelope under the new password.
|
||||
|
||||
// Clear the admin-set "temporary password" marker — the user
|
||||
// has just picked their own password, so the next-login prompt
|
||||
// has served its purpose. Failure here is non-fatal (login
|
||||
@@ -1919,10 +1988,29 @@ impl AuthApplicationService {
|
||||
// between change_password success and the new session mint.)
|
||||
self.user_flags_cache.invalidate(&user_id).await;
|
||||
|
||||
// Optional: revoke all sessions to force re-login with new password
|
||||
// Session revocation posture: classic "password change" pattern
|
||||
// — kill every OTHER session for this user (any device / tab
|
||||
// that had cached the old credential), but keep the caller's
|
||||
// CURRENT session alive so the SPA can complete the OPAQUE
|
||||
// envelope re-registration on the same session cookie that
|
||||
// successfully hit this endpoint. Without the `keep_session_id`
|
||||
// preservation, `syncOpaqueEnvelope` in profile.ts would 401
|
||||
// (session gone), the envelope would stay bound to the OLD
|
||||
// password, and the user would be locked out on next OPAQUE
|
||||
// login. `None` = caller has no session context (CLI, admin
|
||||
// flows), fall back to full revocation.
|
||||
match keep_session_id {
|
||||
Some(keep) => {
|
||||
self.session_storage
|
||||
.revoke_other_user_sessions(user_id, keep)
|
||||
.await?;
|
||||
}
|
||||
None => {
|
||||
self.session_storage
|
||||
.revoke_all_user_sessions(user_id)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle: PasswordChanged logout — fired once per logical
|
||||
// revoke-all call. PR 4 may refine to per-session firing.
|
||||
@@ -1994,6 +2082,31 @@ impl AuthApplicationService {
|
||||
///
|
||||
/// Staleness is bounded by [`USER_FLAGS_CACHE_TTL`]; role and active
|
||||
/// changes made through this service invalidate the entry eagerly.
|
||||
/// Look up the session id for a refresh token string. Returns
|
||||
/// `Ok(None)` when the token doesn't match any session (typo,
|
||||
/// revoked, expired), `Err` only on real storage errors. Used by
|
||||
/// the change-password handler to identify the caller's current
|
||||
/// session so `revoke_other_user_sessions` can spare it while
|
||||
/// killing the rest.
|
||||
///
|
||||
/// Kept as a thin lookup — this handler doesn't care about the
|
||||
/// full Session entity, only its id, so the caller doesn't have
|
||||
/// to reason about the wire shape of `Session`.
|
||||
pub async fn get_session_id_by_refresh_token(
|
||||
&self,
|
||||
refresh_token: &str,
|
||||
) -> Result<Option<Uuid>, DomainError> {
|
||||
match self
|
||||
.session_storage
|
||||
.get_session_by_refresh_token(refresh_token)
|
||||
.await
|
||||
{
|
||||
Ok(session) => Ok(Some(session.id())),
|
||||
Err(e) if e.kind == ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_user_flags(&self, user_id: Uuid) -> Result<UserFlags, DomainError> {
|
||||
// Single-flight: concurrent misses for the same user coalesce
|
||||
// into ONE storage lookup; errors are never cached (same herd
|
||||
|
||||
@@ -733,6 +733,109 @@ impl MagicLinkInviteService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fire-and-forget security notification: a password change just
|
||||
/// completed on this account. Sends a bilingual email with the
|
||||
/// timestamp and originating IP so a compromised-account victim
|
||||
/// notices out-of-band and can contact their admin. Fired by
|
||||
/// `change_password` after a successful update.
|
||||
///
|
||||
/// NOT a magic-link — no token minted, no link in the body, no
|
||||
/// TTL to reason about. Pure informational. Distinct audit event
|
||||
/// (`auth.password_changed_notification_*`) so operators can
|
||||
/// spot delivery issues separately from magic-link sends.
|
||||
///
|
||||
/// Deactivated accounts skip — no point mailing someone the
|
||||
/// operator just locked out.
|
||||
///
|
||||
/// NOTE: we intentionally do NOT skip OIDC-linked users. Hybrid
|
||||
/// accounts (SSO + local password) are a real posture: users who
|
||||
/// sign in daily via SSO but ALSO keep a local password as a
|
||||
/// fallback. When they rotate that local password, they DO need
|
||||
/// the notification — the fact that they also have an SSO
|
||||
/// linkage doesn't change the "someone touched my local
|
||||
/// credential" signal. If `change_password` reached success and
|
||||
/// we're here, there was a password worth notifying about (the
|
||||
/// upstream `is_oidc_user() && !has_password()` refusal ensured
|
||||
/// pure-OIDC users never reach this point).
|
||||
///
|
||||
/// `client_ip` is the string the request-scope span already
|
||||
/// stamped (via `trusted_proxy::client_ip_from_parts`). We do NOT
|
||||
/// re-derive it here; caller passes exactly what the audit log
|
||||
/// sees, so the recipient can cross-reference with support.
|
||||
pub async fn send_password_changed_notification(
|
||||
&self,
|
||||
user: &User,
|
||||
client_ip: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
if !user.is_active() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.password_changed_notification_skipped",
|
||||
reason = "account_deactivated",
|
||||
user_id = %user.id(),
|
||||
"🔔 password-change notification skipped: account deactivated",
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let locale = self.locale_for(user);
|
||||
// ISO-8601 UTC — machine-parseable, unambiguous across time
|
||||
// zones. Human-friendly formatting is a translator concern
|
||||
// for a future iteration; for a security email the exact
|
||||
// timestamp matters more than the pretty rendering.
|
||||
let timestamp = chrono::Utc::now()
|
||||
.format("%Y-%m-%d %H:%M:%S UTC")
|
||||
.to_string();
|
||||
let args: Vec<(&str, &str)> = vec![("ip", client_ip), ("timestamp", ×tamp)];
|
||||
|
||||
let subject = self
|
||||
.i18n_or("server.security.password_changed.subject", &locale, &args)
|
||||
.await;
|
||||
let text_body = self
|
||||
.render_bilingual("server.security.password_changed.body", &locale, &args)
|
||||
.await;
|
||||
|
||||
let message = EmailMessage {
|
||||
to: user.email().to_string(),
|
||||
subject,
|
||||
text_body,
|
||||
html_body: None,
|
||||
};
|
||||
|
||||
match self.email_sender.send(message).await {
|
||||
Ok(outcome) => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.password_changed_notification_sent",
|
||||
user_id = %user.id(),
|
||||
email = %user.email(),
|
||||
client_ip = %client_ip,
|
||||
smtp_code = outcome.code,
|
||||
smtp_message = %outcome.message,
|
||||
"🔔 password-change notification sent to '{}'",
|
||||
user.email(),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
// Non-fatal: change_password already succeeded; a
|
||||
// delivery failure here just means the victim of a
|
||||
// hypothetical compromise won't be notified out-of-
|
||||
// band. Log at warn so ops sees consistent SMTP issues.
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.password_changed_notification_failed",
|
||||
user_id = %user.id(),
|
||||
email = %user.email(),
|
||||
error = %e.message,
|
||||
"🔔 password-change notification SMTP send failed for '{}'",
|
||||
user.email(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a translation, falling back to the literal key on any
|
||||
/// lookup error. Identical to the handler-side helper — kept inline
|
||||
/// here because the service layer can't pull in a UI util module
|
||||
|
||||
@@ -52,6 +52,22 @@ pub trait SessionRepository: Send + Sync + 'static {
|
||||
/// Revokes all sessions for a user
|
||||
async fn revoke_all_user_sessions(&self, user_id: Uuid) -> SessionRepositoryResult<u64>;
|
||||
|
||||
/// Revokes every session for `user_id` EXCEPT the one identified by
|
||||
/// `keep_session_id`. Classic "password change" pattern: log the
|
||||
/// user out from every OTHER device, but keep the current device's
|
||||
/// session alive so the SPA can complete follow-up work (e.g. OPAQUE
|
||||
/// envelope re-registration) without a session-death race.
|
||||
///
|
||||
/// Returns the count of revoked rows (excluding the kept one).
|
||||
/// If `keep_session_id` doesn't belong to `user_id` (defensive),
|
||||
/// the WHERE clause still matches nothing to revoke on that row —
|
||||
/// no cross-user side effect.
|
||||
async fn revoke_other_user_sessions(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
keep_session_id: Uuid,
|
||||
) -> SessionRepositoryResult<u64>;
|
||||
|
||||
/// Revokes all sessions in a token family (theft response)
|
||||
async fn revoke_session_family(&self, family_id: Uuid) -> SessionRepositoryResult<u64>;
|
||||
|
||||
|
||||
@@ -218,6 +218,46 @@ impl OpaqueRepositoryPort for OpaquePgRepository {
|
||||
Ok(row.and_then(|(t,)| t).is_some())
|
||||
}
|
||||
|
||||
async fn clear_envelope_only(&self, user_id: Uuid) -> Result<()> {
|
||||
// Nulls the OPAQUE columns (envelope + ciphersuite + registered
|
||||
// + migrated + KSF triple) but DOES NOT touch
|
||||
// `force_password_change_at_next_login`. Called by
|
||||
// `AuthApplicationService::change_password` to invalidate an
|
||||
// envelope bound to the OLD passphrase after the user rotates
|
||||
// their legacy password; silent-migration on the next login
|
||||
// re-mints an envelope under the new passphrase. Distinct
|
||||
// from `clear_registration` (which co-flips force_change) —
|
||||
// see the port doc for the "user chose the new value" vs
|
||||
// "admin picked it" split.
|
||||
//
|
||||
// rows_affected is intentionally NOT checked: `change_password`
|
||||
// may run against a user who never had an OPAQUE envelope
|
||||
// (legacy-only account, or `OXICLOUD_AUTH_OPAQUE_MODE=off`
|
||||
// was in effect during their entire lifetime), and that's not
|
||||
// an error — the WHERE just matches nothing. Only real DB
|
||||
// errors propagate.
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.users
|
||||
SET opaque_envelope = NULL,
|
||||
opaque_ciphersuite_version = NULL,
|
||||
opaque_registered_at = NULL,
|
||||
opaque_migrated_at = NULL,
|
||||
opaque_ksf_memory_kib = NULL,
|
||||
opaque_ksf_iterations = NULL,
|
||||
opaque_ksf_parallelism = NULL
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("OpaquePg", format!("clear_envelope_only: {e}"))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clear_registration(&self, user_id: Uuid) -> Result<()> {
|
||||
// One UPDATE nulls the whole OPAQUE column set AND flips the
|
||||
// force-change flag — matches the atomicity we promise in
|
||||
|
||||
@@ -287,6 +287,50 @@ impl SessionRepository for SessionPgRepository {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn revoke_other_user_sessions(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
keep_session_id: Uuid,
|
||||
) -> SessionRepositoryResult<u64> {
|
||||
// Classic "password change" revocation: kill every OTHER
|
||||
// session for this user so a stolen credential elsewhere is
|
||||
// invalidated, but leave the caller's own session alive so
|
||||
// the SPA can complete follow-up work (envelope re-register,
|
||||
// etc.) without racing a session-death 401.
|
||||
let user_id_copy = user_id;
|
||||
let keep = keep_session_id;
|
||||
with_transaction(&self.pool, "revoke_other_user_sessions", |tx| {
|
||||
Box::pin(async move {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.sessions
|
||||
SET revoked = true
|
||||
WHERE user_id = $1
|
||||
AND id != $2
|
||||
AND revoked = false
|
||||
"#,
|
||||
)
|
||||
.bind(user_id_copy)
|
||||
.bind(keep)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
|
||||
let affected = result.rows_affected();
|
||||
if affected > 0 {
|
||||
tracing::info!(
|
||||
"Revoked {} other sessions for user {} (kept {})",
|
||||
affected,
|
||||
user_id_copy,
|
||||
keep
|
||||
);
|
||||
}
|
||||
Ok(affected)
|
||||
}) as BoxFuture<'_, SessionRepositoryResult<u64>>
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Revokes all sessions in a token family (theft response)
|
||||
async fn revoke_session_family(&self, family_id: Uuid) -> SessionRepositoryResult<u64> {
|
||||
let result = sqlx::query(
|
||||
@@ -520,6 +564,16 @@ impl SessionStoragePort for SessionPgRepository {
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn revoke_other_user_sessions(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
keep_session_id: Uuid,
|
||||
) -> Result<u64, DomainError> {
|
||||
SessionRepository::revoke_other_user_sessions(self, user_id, keep_session_id)
|
||||
.await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn revoke_session_family(&self, family_id: Uuid) -> Result<u64, DomainError> {
|
||||
SessionRepository::revoke_session_family(self, family_id)
|
||||
.await
|
||||
|
||||
@@ -657,6 +657,8 @@ pub struct UpdateUserImageDto {
|
||||
)]
|
||||
pub async fn change_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
CurrentUserId(user_id): CurrentUserId,
|
||||
Json(dto): Json<ChangePasswordDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
@@ -665,12 +667,86 @@ pub async fn change_password(
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
|
||||
|
||||
// Resolve the CURRENT session id from the refresh-token cookie so
|
||||
// the service layer can revoke every OTHER session (classic
|
||||
// password-change security posture) while keeping THIS session
|
||||
// alive — the SPA needs to hit `/api/auth/opaque/register/*`
|
||||
// immediately after this response to re-mint the OPAQUE envelope
|
||||
// under the new password. If we revoked the current session too
|
||||
// (the old behaviour), the follow-up register requests would 401
|
||||
// silently and the envelope would stay bound to the OLD password.
|
||||
//
|
||||
// Best-effort: an unauthenticated or cookie-less caller (a CLI
|
||||
// hitting this endpoint with just a bearer, no refresh cookie)
|
||||
// falls back to `None` → the service revokes ALL sessions, same
|
||||
// as the pre-refactor behaviour. That's the safer default when we
|
||||
// can't identify "this" session.
|
||||
let keep_session_id: Option<Uuid> = {
|
||||
let refresh_tok = cookie_auth::extract_cookie_value(&headers, cookie_auth::REFRESH_COOKIE);
|
||||
match refresh_tok {
|
||||
Some(tok) => auth_service
|
||||
.auth_application_service
|
||||
.get_session_id_by_refresh_token(&tok)
|
||||
.await
|
||||
.ok()
|
||||
.flatten(),
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
|
||||
match auth_service
|
||||
.auth_application_service
|
||||
.change_password(user_id, dto)
|
||||
.change_password(user_id, dto, keep_session_id)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(StatusCode::OK),
|
||||
Ok(()) => {
|
||||
// Fire-and-forget security notification: password changed
|
||||
// at [now] from [client_ip]. Reaches the user out-of-band
|
||||
// so a compromised-account victim can notice and alert
|
||||
// their admin. SMTP delivery failures don't affect the
|
||||
// 200 response (the change already succeeded); the
|
||||
// service's own audit log tracks send outcomes.
|
||||
//
|
||||
// Runs on a background task so a slow SMTP handshake
|
||||
// (30-60 s under a marginal mail server) can't stall the
|
||||
// response to the SPA. Cloning the `Arc<MagicLinkInviteService>`
|
||||
// is a refcount bump; the User entity is re-fetched inside
|
||||
// the task from the same user_id we just verified.
|
||||
if let Some(invite_svc) = state.magic_link_invite_service.as_ref() {
|
||||
let client_ip = crate::interfaces::middleware::trusted_proxy::client_ip_from_parts(
|
||||
&headers,
|
||||
Some(peer),
|
||||
false,
|
||||
)
|
||||
.to_string();
|
||||
let invite = invite_svc.clone();
|
||||
let auth = auth_service.auth_application_service.clone();
|
||||
tokio::spawn(async move {
|
||||
// Refetch the user entity fresh — the change we
|
||||
// just made rewrote the row (password_hash), and
|
||||
// the notification method reads `is_active` +
|
||||
// `is_oidc_user` + `email` off the entity to
|
||||
// decide whether to send and where.
|
||||
match auth.get_user_entity(user_id).await {
|
||||
Ok(u) => {
|
||||
let _ = invite
|
||||
.send_password_changed_notification(&u, &client_ip)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.password_changed_notification_lookup_failed",
|
||||
user_id = %user_id,
|
||||
error = %e.message,
|
||||
"🔔 skipped notification: could not re-fetch user after change_password"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
Err(err) => {
|
||||
// Remap the same-as-current guard into a stable error_type
|
||||
// the SPA can surface as "pick a different one" without
|
||||
|
||||
Reference in New Issue
Block a user