From fa0931a1577f3347f3a4913f3adb82fd525fccbf Mon Sep 17 00:00:00 2001 From: Artem Savchenko Date: Wed, 22 Apr 2026 12:04:53 +0700 Subject: [PATCH] Add login security Signed-off-by: Artem Savchenko --- .../client.security-login-history.test.ts | 119 ++++++ .../packages/account-client/src/client.ts | 34 ++ .../core/packages/account-client/src/types.ts | 31 ++ plugins/setting-assets/lang/cs.json | 17 +- plugins/setting-assets/lang/de.json | 17 +- plugins/setting-assets/lang/en.json | 17 +- plugins/setting-assets/lang/es.json | 17 +- plugins/setting-assets/lang/fr.json | 17 +- plugins/setting-assets/lang/it.json | 17 +- plugins/setting-assets/lang/ja.json | 17 +- plugins/setting-assets/lang/pt-br.json | 17 +- plugins/setting-assets/lang/pt.json | 17 +- plugins/setting-assets/lang/ru.json | 17 +- plugins/setting-assets/lang/tr.json | 17 +- plugins/setting-assets/lang/zh.json | 17 +- .../__tests__/securityLoginActivity.test.ts | 51 +++ plugins/setting-resources/src/plugin.ts | 17 +- .../src/securityLoginActivity.ts | 49 +++ server/account-service/src/index.ts | 33 +- server/account/src/__tests__/mongo.test.ts | 29 +- .../src/__tests__/securityPolicy.test.ts | 85 ++++ server/account/src/collections/mongo.ts | 26 ++ .../src/collections/postgres/migrations.ts | 42 +- .../src/collections/postgres/postgres.ts | 10 + server/account/src/operations.ts | 394 +++++++++++++++++- .../src/securityLoginTelemetryRateLimit.ts | 35 ++ server/account/src/securityPolicy.ts | 144 +++++++ server/account/src/types.ts | 27 +- server/account/src/utils.ts | 140 +++++++ 29 files changed, 1437 insertions(+), 33 deletions(-) create mode 100644 foundations/core/packages/account-client/src/__tests__/client.security-login-history.test.ts create mode 100644 plugins/setting-resources/src/__tests__/securityLoginActivity.test.ts create mode 100644 plugins/setting-resources/src/securityLoginActivity.ts create mode 100644 server/account/src/__tests__/securityPolicy.test.ts create mode 100644 server/account/src/securityLoginTelemetryRateLimit.ts create mode 100644 server/account/src/securityPolicy.ts diff --git a/foundations/core/packages/account-client/src/__tests__/client.security-login-history.test.ts b/foundations/core/packages/account-client/src/__tests__/client.security-login-history.test.ts new file mode 100644 index 0000000000..3591b8cfb9 --- /dev/null +++ b/foundations/core/packages/account-client/src/__tests__/client.security-login-history.test.ts @@ -0,0 +1,119 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// + +import { getClient } from '../client' + +describe('AccountClient.getMySecurityLoginHistory', () => { + const mockFetch = jest.fn() + const originalFetch = globalThis.fetch + + beforeAll(() => { + ;(globalThis as any).fetch = mockFetch as any + }) + + afterAll(() => { + if (originalFetch !== undefined) { + ;(globalThis as any).fetch = originalFetch + } else { + delete (globalThis as any).fetch + } + }) + + beforeEach(() => { + mockFetch.mockClear() + }) + + it('sends getMySecurityLoginHistory with filters', async () => { + const payload = [{ id: 'evt-1', success: true }] + mockFetch.mockResolvedValue({ + json: async () => ({ result: payload }) + }) + + const client = getClient('https://accounts.example.com', 'token') + const result = await client.getMySecurityLoginHistory({ + limit: 20, + success: true, + authMethod: 'password', + ip: '10.0.0.1', + redact: true + }) + + const request = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(request).toEqual({ + method: 'getMySecurityLoginHistory', + params: { + limit: 20, + success: true, + authMethod: 'password', + ip: '10.0.0.1', + redact: true + } + }) + expect(result).toEqual(payload) + }) + + it('sends empty params when no filters provided', async () => { + mockFetch.mockResolvedValue({ + json: async () => ({ result: [] }) + }) + + const client = getClient('https://accounts.example.com', 'token') + await client.getMySecurityLoginHistory() + + const request = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(request).toEqual({ + method: 'getMySecurityLoginHistory', + params: {} + }) + }) + + it('sends exportMySecurityLoginHistory', async () => { + mockFetch.mockResolvedValue({ + json: async () => ({ result: [] }) + }) + + const client = getClient('https://accounts.example.com', 'token') + await client.exportMySecurityLoginHistory({ since: 1 }) + + const request = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(request).toEqual({ + method: 'exportMySecurityLoginHistory', + params: { since: 1 } + }) + }) + + it('sends eraseMySecurityLoginHistory', async () => { + mockFetch.mockResolvedValue({ + json: async () => ({ result: undefined }) + }) + + const client = getClient('https://accounts.example.com', 'token') + await client.eraseMySecurityLoginHistory() + + const request = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(request).toEqual({ + method: 'eraseMySecurityLoginHistory', + params: {} + }) + }) + + it('sends reportSecurityLoginConcern with optional loginEventId', async () => { + mockFetch.mockResolvedValue({ + json: async () => ({ result: undefined }) + }) + + const client = getClient('https://accounts.example.com', 'token') + await client.reportSecurityLoginConcern({ loginEventId: 'evt-1' }) + + const request = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(request).toEqual({ + method: 'reportSecurityLoginConcern', + params: { loginEventId: 'evt-1' } + }) + }) +}) diff --git a/foundations/core/packages/account-client/src/client.ts b/foundations/core/packages/account-client/src/client.ts index 1a8e6ea430..8defafd653 100644 --- a/foundations/core/packages/account-client/src/client.ts +++ b/foundations/core/packages/account-client/src/client.ts @@ -51,6 +51,8 @@ import type { PersonWithProfile, ProviderInfo, RegionInfo, + SecurityLoginHistoryEvent, + SecurityLoginHistoryParams, SocialId, Subscription, SubscriptionData, @@ -242,6 +244,10 @@ export interface AccountClient { setMyProfile: (profile: Partial>) => Promise getUserProfile: (personUuid?: PersonUuid) => Promise + getMySecurityLoginHistory: (params?: SecurityLoginHistoryParams) => Promise + exportMySecurityLoginHistory: (params?: SecurityLoginHistoryParams) => Promise + eraseMySecurityLoginHistory: () => Promise + reportSecurityLoginConcern: (params?: { loginEventId?: string }) => Promise getSubscriptions: (workspaceUuid?: WorkspaceUuid | undefined, activeOnly?: boolean) => Promise getSubscriptionByProviderId: (provider: string, providerSubscriptionId: string) => Promise @@ -1268,6 +1274,34 @@ class AccountClientImpl implements AccountClient { }) } + async getMySecurityLoginHistory (params: SecurityLoginHistoryParams = {}): Promise { + return await this._rpc({ + method: 'getMySecurityLoginHistory', + params + }) + } + + async exportMySecurityLoginHistory (params: SecurityLoginHistoryParams = {}): Promise { + return await this._rpc({ + method: 'exportMySecurityLoginHistory', + params + }) + } + + async eraseMySecurityLoginHistory (): Promise { + await this._rpc({ + method: 'eraseMySecurityLoginHistory', + params: {} + }) + } + + async reportSecurityLoginConcern (params?: { loginEventId?: string }): Promise { + await this._rpc({ + method: 'reportSecurityLoginConcern', + params: params ?? {} + }) + } + async getSubscriptions ( workspaceUuid: WorkspaceUuid | undefined = undefined, activeOnly: boolean = true diff --git a/foundations/core/packages/account-client/src/types.ts b/foundations/core/packages/account-client/src/types.ts index c9f791f3c2..5470d4129e 100644 --- a/foundations/core/packages/account-client/src/types.ts +++ b/foundations/core/packages/account-client/src/types.ts @@ -92,6 +92,37 @@ export interface OtpInfo { retryOn: Timestamp } +export type SecurityAuthMethod = 'password' | 'otp' | 'token' | 'session' | 'unknown' + +export interface SecurityLoginHistoryEvent { + id: string + accountUuid: AccountUuid + workspaceUuid?: WorkspaceUuid + eventTime: Timestamp + ip?: string + country?: string + city?: string + userAgent?: string + success: boolean + authMethod: SecurityAuthMethod + reason?: string + sessionId?: string + anomalyCodes?: string[] + policyVersion?: string + createdOn: Timestamp +} + +export interface SecurityLoginHistoryParams { + since?: number + until?: number + success?: boolean + authMethod?: SecurityAuthMethod + ip?: string + limit?: number + /** When true, masks IP, truncates user agent, and omits session id in the response. */ + redact?: boolean +} + export interface RegionInfo { region: string name: string diff --git a/plugins/setting-assets/lang/cs.json b/plugins/setting-assets/lang/cs.json index 7fa5f9c30d..dcc8921c11 100644 --- a/plugins/setting-assets/lang/cs.json +++ b/plugins/setting-assets/lang/cs.json @@ -234,6 +234,21 @@ "TwoFactorAuthEnabled": "Dvoufaktorové ověřování je povoleno", "TwoFactorAuthDisabled": "Dvoufaktorové ověřování je zakázáno", "ShowQRCode": "Zobrazit QR kód", - "EnterVerificationCode": "Zadejte ověřovací kód" + "EnterVerificationCode": "Zadejte ověřovací kód", + "RecentLoginActivityTitle": "Nedávná aktivita přihlášení", + "RecentLoginActivityLoading": "Načítá se nedávná aktivita přihlášení...", + "RecentLoginActivityEmpty": "Zatím žádné nedávné události přihlášení.", + "RecentLoginActivityError": "Nepodařilo se načíst nedávnou aktivitu přihlášení.", + "RecentLoginActivityRetry": "Zkusit znovu", + "RecentLoginActivityMethod": "Metoda", + "RecentLoginActivityIp": "IP", + "RecentLoginActivityLocation": "Poloha", + "RecentLoginActivityDevice": "Zařízení", + "RecentLoginActivitySuccess": "Úspěšné", + "RecentLoginActivityFailure": "Neúspěšné", + "NotMeAction": "To jsem nebyl já", + "NotMeDialogTitle": "Nahlásit podezřelé přihlášení", + "NotMeDialogMessage": "Pokud toto přihlášení nebylo vaše, změňte heslo, zkontrolujte nastavení 2FA a projděte aktivní relace. Nahlášení přidá záznam do auditu účtu pro následné řešení; automaticky vás neodhlásí ani neukončí ostatní relace.", + "NotMeDialogAction": "Nahlásit" } } diff --git a/plugins/setting-assets/lang/de.json b/plugins/setting-assets/lang/de.json index e255cb52df..a92eaa1f67 100644 --- a/plugins/setting-assets/lang/de.json +++ b/plugins/setting-assets/lang/de.json @@ -236,6 +236,21 @@ "TwoFactorAuthEnabled": "Zweistufige Authentifizierung ist aktiviert", "TwoFactorAuthDisabled": "Zweistufige Authentifizierung ist deaktiviert", "ShowQRCode": "QR-Code anzeigen", - "EnterVerificationCode": "Verifizierungscode eingeben" + "EnterVerificationCode": "Verifizierungscode eingeben", + "RecentLoginActivityTitle": "Letzte Anmeldeaktivität", + "RecentLoginActivityLoading": "Letzte Anmeldeaktivität wird geladen...", + "RecentLoginActivityEmpty": "Noch keine letzten Anmeldeereignisse.", + "RecentLoginActivityError": "Die zuletzt angezeigte Anmeldeaktivität konnte nicht geladen werden.", + "RecentLoginActivityRetry": "Erneut versuchen", + "RecentLoginActivityMethod": "Methode", + "RecentLoginActivityIp": "IP", + "RecentLoginActivityLocation": "Standort", + "RecentLoginActivityDevice": "Gerät", + "RecentLoginActivitySuccess": "Erfolgreich", + "RecentLoginActivityFailure": "Fehlgeschlagen", + "NotMeAction": "Ich war das nicht", + "NotMeDialogTitle": "Verdächtige Anmeldung melden", + "NotMeDialogMessage": "Wenn diese Anmeldung nicht von Ihnen war, ändern Sie Ihr Passwort, prüfen Sie die 2FA-Einstellungen und überprüfen Sie aktive Sitzungen. Mit „Melden“ wird ein Eintrag im Kontoauditprotokoll für die Nachverfolgung gespeichert; Sie werden dadurch nicht automatisch abgemeldet und andere Sitzungen werden nicht beendet.", + "NotMeDialogAction": "Melden" } } diff --git a/plugins/setting-assets/lang/en.json b/plugins/setting-assets/lang/en.json index d5c9d48cc8..4ab204d2b0 100644 --- a/plugins/setting-assets/lang/en.json +++ b/plugins/setting-assets/lang/en.json @@ -236,6 +236,21 @@ "TwoFactorAuthEnabled": "Two-factor authentication is enabled", "TwoFactorAuthDisabled": "Two-factor authentication is disabled", "ShowQRCode": "Show QR code", - "EnterVerificationCode": "Enter verification code" + "EnterVerificationCode": "Enter verification code", + "RecentLoginActivityTitle": "Recent login activity", + "RecentLoginActivityLoading": "Loading recent login activity...", + "RecentLoginActivityEmpty": "No recent login events yet.", + "RecentLoginActivityError": "Failed to load recent login activity.", + "RecentLoginActivityRetry": "Retry", + "RecentLoginActivityMethod": "Method", + "RecentLoginActivityIp": "IP", + "RecentLoginActivityLocation": "Location", + "RecentLoginActivityDevice": "Device", + "RecentLoginActivitySuccess": "Success", + "RecentLoginActivityFailure": "Failed", + "NotMeAction": "This wasn't me", + "NotMeDialogTitle": "Report suspicious login", + "NotMeDialogMessage": "If this login was not you, change your password, verify 2FA settings, and review active sessions. Reporting adds an entry to your account audit log for follow-up; it does not automatically sign you out or end other sessions.", + "NotMeDialogAction": "Report" } } diff --git a/plugins/setting-assets/lang/es.json b/plugins/setting-assets/lang/es.json index 791fbc0aa5..5c2d424040 100644 --- a/plugins/setting-assets/lang/es.json +++ b/plugins/setting-assets/lang/es.json @@ -227,6 +227,21 @@ "TwoFactorAuthEnabled": "La autenticación de dos factores está habilitada", "TwoFactorAuthDisabled": "La autenticación de dos factores está deshabilitada", "ShowQRCode": "Mostrar código QR", - "EnterVerificationCode": "Introducir código de verificación" + "EnterVerificationCode": "Introducir código de verificación", + "RecentLoginActivityTitle": "Actividad reciente de inicio de sesión", + "RecentLoginActivityLoading": "Cargando actividad reciente de inicio de sesión...", + "RecentLoginActivityEmpty": "Todavía no hay eventos recientes de inicio de sesión.", + "RecentLoginActivityError": "No se pudo cargar la actividad reciente de inicio de sesión.", + "RecentLoginActivityRetry": "Reintentar", + "RecentLoginActivityMethod": "Método", + "RecentLoginActivityIp": "IP", + "RecentLoginActivityLocation": "Ubicación", + "RecentLoginActivityDevice": "Dispositivo", + "RecentLoginActivitySuccess": "Éxito", + "RecentLoginActivityFailure": "Fallido", + "NotMeAction": "No fui yo", + "NotMeDialogTitle": "Reportar inicio de sesión sospechoso", + "NotMeDialogMessage": "Si este inicio de sesión no fue tuyo, cambia tu contraseña, verifica la configuración de 2FA y revisa las sesiones activas. Informar añade una entrada al registro de auditoría de la cuenta para su seguimiento; no cierra la sesión automáticamente ni finaliza otras sesiones.", + "NotMeDialogAction": "Reportar" } } diff --git a/plugins/setting-assets/lang/fr.json b/plugins/setting-assets/lang/fr.json index 3a274b517c..5bd3e1235e 100644 --- a/plugins/setting-assets/lang/fr.json +++ b/plugins/setting-assets/lang/fr.json @@ -236,6 +236,21 @@ "TwoFactorAuthEnabled": "L'authentification à deux facteurs est activée", "TwoFactorAuthDisabled": "L'authentification à deux facteurs est désactivée", "ShowQRCode": "Afficher le code QR", - "EnterVerificationCode": "Entrer le code de vérification" + "EnterVerificationCode": "Entrer le code de vérification", + "RecentLoginActivityTitle": "Activité de connexion récente", + "RecentLoginActivityLoading": "Chargement de l'activité de connexion récente...", + "RecentLoginActivityEmpty": "Aucun événement de connexion récent pour le moment.", + "RecentLoginActivityError": "Impossible de charger l'activité de connexion récente.", + "RecentLoginActivityRetry": "Réessayer", + "RecentLoginActivityMethod": "Méthode", + "RecentLoginActivityIp": "IP", + "RecentLoginActivityLocation": "Emplacement", + "RecentLoginActivityDevice": "Appareil", + "RecentLoginActivitySuccess": "Réussi", + "RecentLoginActivityFailure": "Échec", + "NotMeAction": "Ce n'était pas moi", + "NotMeDialogTitle": "Signaler une connexion suspecte", + "NotMeDialogMessage": "Si cette connexion ne vient pas de vous, changez votre mot de passe, vérifiez les paramètres 2FA et examinez les sessions actives. Le signalement ajoute une entrée au journal d’audit du compte pour suivi ; il ne vous déconnecte pas automatiquement et ne met pas fin aux autres sessions.", + "NotMeDialogAction": "Signaler" } } diff --git a/plugins/setting-assets/lang/it.json b/plugins/setting-assets/lang/it.json index 6259e62b9d..cc19acd007 100644 --- a/plugins/setting-assets/lang/it.json +++ b/plugins/setting-assets/lang/it.json @@ -236,6 +236,21 @@ "TwoFactorAuthEnabled": "L'autenticazione a due fattori è abilitata", "TwoFactorAuthDisabled": "L'autenticazione a due fattori è disabilitata", "ShowQRCode": "Mostra codice QR", - "EnterVerificationCode": "Inserisci codice di verifica" + "EnterVerificationCode": "Inserisci codice di verifica", + "RecentLoginActivityTitle": "Attività di accesso recente", + "RecentLoginActivityLoading": "Caricamento attività di accesso recente...", + "RecentLoginActivityEmpty": "Nessun evento di accesso recente.", + "RecentLoginActivityError": "Impossibile caricare l'attività di accesso recente.", + "RecentLoginActivityRetry": "Riprova", + "RecentLoginActivityMethod": "Metodo", + "RecentLoginActivityIp": "IP", + "RecentLoginActivityLocation": "Posizione", + "RecentLoginActivityDevice": "Dispositivo", + "RecentLoginActivitySuccess": "Riuscito", + "RecentLoginActivityFailure": "Fallito", + "NotMeAction": "Non sono stato io", + "NotMeDialogTitle": "Segnala accesso sospetto", + "NotMeDialogMessage": "Se questo accesso non è stato effettuato da te, cambia la password, verifica le impostazioni 2FA e controlla le sessioni attive. La segnalazione aggiunge una voce al registro di audit dell’account per il follow-up; non disconnette automaticamente né termina altre sessioni.", + "NotMeDialogAction": "Segnala" } } diff --git a/plugins/setting-assets/lang/ja.json b/plugins/setting-assets/lang/ja.json index 13eb5ceb2b..30707aff9b 100644 --- a/plugins/setting-assets/lang/ja.json +++ b/plugins/setting-assets/lang/ja.json @@ -236,6 +236,21 @@ "TwoFactorAuthEnabled": "二要素認証は有効です", "TwoFactorAuthDisabled": "二要素認証は無効です", "ShowQRCode": "QRコードを表示", - "EnterVerificationCode": "確認コードを入力" + "EnterVerificationCode": "確認コードを入力", + "RecentLoginActivityTitle": "最近のログインアクティビティ", + "RecentLoginActivityLoading": "最近のログインアクティビティを読み込み中...", + "RecentLoginActivityEmpty": "最近のログインイベントはまだありません。", + "RecentLoginActivityError": "最近のログインアクティビティの読み込みに失敗しました。", + "RecentLoginActivityRetry": "再試行", + "RecentLoginActivityMethod": "方法", + "RecentLoginActivityIp": "IP", + "RecentLoginActivityLocation": "場所", + "RecentLoginActivityDevice": "デバイス", + "RecentLoginActivitySuccess": "成功", + "RecentLoginActivityFailure": "失敗", + "NotMeAction": "これは私ではありません", + "NotMeDialogTitle": "不審なログインを報告", + "NotMeDialogMessage": "このログインに心当たりがない場合は、パスワードを変更し、2FA設定を確認し、アクティブなセッションを見直してください。報告するとアカウントの監査ログに記録され、後続の確認に利用されます。自動的にサインアウトされたり、他のセッションが終了することはありません。", + "NotMeDialogAction": "報告" } } diff --git a/plugins/setting-assets/lang/pt-br.json b/plugins/setting-assets/lang/pt-br.json index bd18a749e0..98c63cdee3 100644 --- a/plugins/setting-assets/lang/pt-br.json +++ b/plugins/setting-assets/lang/pt-br.json @@ -227,6 +227,21 @@ "TwoFactorAuthEnabled": "Autenticação de dois fatores está ativada", "TwoFactorAuthDisabled": "Autenticação de dois fatores está desativada", "ShowQRCode": "Mostrar código QR", - "EnterVerificationCode": "Inserir código de verificação" + "EnterVerificationCode": "Inserir código de verificação", + "RecentLoginActivityTitle": "Atividade recente de login", + "RecentLoginActivityLoading": "Carregando atividade recente de login...", + "RecentLoginActivityEmpty": "Ainda não há eventos recentes de login.", + "RecentLoginActivityError": "Falha ao carregar a atividade recente de login.", + "RecentLoginActivityRetry": "Tentar novamente", + "RecentLoginActivityMethod": "Método", + "RecentLoginActivityIp": "IP", + "RecentLoginActivityLocation": "Localização", + "RecentLoginActivityDevice": "Dispositivo", + "RecentLoginActivitySuccess": "Sucesso", + "RecentLoginActivityFailure": "Falhou", + "NotMeAction": "Não fui eu", + "NotMeDialogTitle": "Reportar login suspeito", + "NotMeDialogMessage": "Se este login não foi seu, altere sua senha, verifique as configurações de 2FA e revise as sessões ativas. Denunciar adiciona uma entrada ao registro de auditoria da conta para acompanhamento; não encerra a sessão automaticamente nem encerra outras sessões.", + "NotMeDialogAction": "Reportar" } } diff --git a/plugins/setting-assets/lang/pt.json b/plugins/setting-assets/lang/pt.json index 2179253c34..c638af3471 100644 --- a/plugins/setting-assets/lang/pt.json +++ b/plugins/setting-assets/lang/pt.json @@ -227,6 +227,21 @@ "TwoFactorAuthEnabled": "Autenticação de dois fatores está ativada", "TwoFactorAuthDisabled": "Autenticação de dois fatores está desativada", "ShowQRCode": "Mostrar código QR", - "EnterVerificationCode": "Inserir código de verificação" + "EnterVerificationCode": "Inserir código de verificação", + "RecentLoginActivityTitle": "Atividade recente de login", + "RecentLoginActivityLoading": "Carregando atividade recente de login...", + "RecentLoginActivityEmpty": "Ainda não há eventos recentes de login.", + "RecentLoginActivityError": "Falha ao carregar a atividade recente de login.", + "RecentLoginActivityRetry": "Tentar novamente", + "RecentLoginActivityMethod": "Método", + "RecentLoginActivityIp": "IP", + "RecentLoginActivityLocation": "Localização", + "RecentLoginActivityDevice": "Dispositivo", + "RecentLoginActivitySuccess": "Sucesso", + "RecentLoginActivityFailure": "Falhou", + "NotMeAction": "Não fui eu", + "NotMeDialogTitle": "Reportar login suspeito", + "NotMeDialogMessage": "Se este login não foi seu, altere sua senha, verifique as configurações de 2FA e revise as sessões ativas. Denunciar adiciona uma entrada ao registo de auditoria da conta para acompanhamento; não termina a sessão automaticamente nem encerra outras sessões.", + "NotMeDialogAction": "Reportar" } } diff --git a/plugins/setting-assets/lang/ru.json b/plugins/setting-assets/lang/ru.json index 12f9db7a54..512fbb9661 100644 --- a/plugins/setting-assets/lang/ru.json +++ b/plugins/setting-assets/lang/ru.json @@ -236,6 +236,21 @@ "TwoFactorAuthEnabled": "Двухфакторная аутентификация включена", "TwoFactorAuthDisabled": "Двухфакторная аутентификация отключена", "ShowQRCode": "Показать QR-код", - "EnterVerificationCode": "Введите код подтверждения" + "EnterVerificationCode": "Введите код подтверждения", + "RecentLoginActivityTitle": "Последняя активность входов", + "RecentLoginActivityLoading": "Загружаем историю входов...", + "RecentLoginActivityEmpty": "История входов пока пуста.", + "RecentLoginActivityError": "Не удалось загрузить историю входов.", + "RecentLoginActivityRetry": "Повторить", + "RecentLoginActivityMethod": "Метод", + "RecentLoginActivityIp": "IP", + "RecentLoginActivityLocation": "Локация", + "RecentLoginActivityDevice": "Устройство", + "RecentLoginActivitySuccess": "Успешно", + "RecentLoginActivityFailure": "Ошибка", + "NotMeAction": "Это был не я", + "NotMeDialogTitle": "Сообщить о подозрительном входе", + "NotMeDialogMessage": "Если это были не вы, смените пароль, проверьте настройки 2FA и активные сессии. Сообщение добавляет запись в журнал аудита учётной записи для последующей проверки; оно не завершает текущий сеанс и не завершает другие сеансы автоматически.", + "NotMeDialogAction": "Сообщить" } } diff --git a/plugins/setting-assets/lang/tr.json b/plugins/setting-assets/lang/tr.json index adc0851a45..fa4cff6f16 100644 --- a/plugins/setting-assets/lang/tr.json +++ b/plugins/setting-assets/lang/tr.json @@ -236,6 +236,21 @@ "TwoFactorAuthEnabled": "İki faktörlü kimlik doğrulama etkin", "TwoFactorAuthDisabled": "İki faktörlü kimlik doğrulama devre dışı", "ShowQRCode": "QR kodu göster", - "EnterVerificationCode": "Doğrulama kodunu gir" + "EnterVerificationCode": "Doğrulama kodunu gir", + "RecentLoginActivityTitle": "Son giriş etkinliği", + "RecentLoginActivityLoading": "Son giriş etkinliği yükleniyor...", + "RecentLoginActivityEmpty": "Henüz son giriş etkinliği yok.", + "RecentLoginActivityError": "Son giriş etkinliği yüklenemedi.", + "RecentLoginActivityRetry": "Tekrar dene", + "RecentLoginActivityMethod": "Yöntem", + "RecentLoginActivityIp": "IP", + "RecentLoginActivityLocation": "Konum", + "RecentLoginActivityDevice": "Cihaz", + "RecentLoginActivitySuccess": "Başarılı", + "RecentLoginActivityFailure": "Başarısız", + "NotMeAction": "Bu ben değildim", + "NotMeDialogTitle": "Şüpheli girişi bildir", + "NotMeDialogMessage": "Bu giriş size ait değilse parolanızı değiştirin, 2FA ayarlarını kontrol edin ve aktif oturumları gözden geçirin. Bildirmek, takip için hesap denetim günlüğüne bir kayıt ekler; sizi otomatik olarak oturumdan çıkarmaz veya diğer oturumları sonlandırmaz.", + "NotMeDialogAction": "Bildir" } } diff --git a/plugins/setting-assets/lang/zh.json b/plugins/setting-assets/lang/zh.json index 34557ed907..beccd27aef 100644 --- a/plugins/setting-assets/lang/zh.json +++ b/plugins/setting-assets/lang/zh.json @@ -236,6 +236,21 @@ "TwoFactorAuthEnabled": "双因素认证已启用", "TwoFactorAuthDisabled": "双因素认证已禁用", "ShowQRCode": "显示QR码", - "EnterVerificationCode": "输入验证码" + "EnterVerificationCode": "输入验证码", + "RecentLoginActivityTitle": "最近登录活动", + "RecentLoginActivityLoading": "正在加载最近登录活动...", + "RecentLoginActivityEmpty": "暂无最近登录事件。", + "RecentLoginActivityError": "加载最近登录活动失败。", + "RecentLoginActivityRetry": "重试", + "RecentLoginActivityMethod": "方式", + "RecentLoginActivityIp": "IP", + "RecentLoginActivityLocation": "位置", + "RecentLoginActivityDevice": "设备", + "RecentLoginActivitySuccess": "成功", + "RecentLoginActivityFailure": "失败", + "NotMeAction": "这不是我", + "NotMeDialogTitle": "报告可疑登录", + "NotMeDialogMessage": "如果这次登录不是您本人,请修改密码、检查 2FA 设置,并查看活动会话。提交报告会在账户审计日志中新增一条记录以便跟进;不会自动注销您,也不会结束其他会话。", + "NotMeDialogAction": "报告" } } diff --git a/plugins/setting-resources/src/__tests__/securityLoginActivity.test.ts b/plugins/setting-resources/src/__tests__/securityLoginActivity.test.ts new file mode 100644 index 0000000000..62e88f6611 --- /dev/null +++ b/plugins/setting-resources/src/__tests__/securityLoginActivity.test.ts @@ -0,0 +1,51 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// + +import { + formatLocation, + getShortUserAgent, + maskIpAddress, + shouldShowNotMeAction +} from '../securityLoginActivity' + +describe('securityLoginActivity helpers', () => { + it('masks IPv4 addresses for profile display', () => { + expect(maskIpAddress('192.168.12.200')).toBe('192.168.***.***') + }) + + it('masks IPv6 and short IPv6 forms', () => { + expect(maskIpAddress('2001:db8::1')).toBe('2001:db8:***') + expect(maskIpAddress('::1')).toMatch(/\*\*\*/) + }) + + it('masks non-dotted IP strings', () => { + expect(maskIpAddress('not-an-ip')).toBe('***') + }) + + it('uses fallback for empty ip', () => { + expect(maskIpAddress('')).toBe('Unknown IP') + }) + + it('formats location from city and country', () => { + expect(formatLocation({ city: 'Berlin', country: 'DE' })).toBe('Berlin, DE') + }) + + it('uses unknown location when location fields are missing', () => { + expect(formatLocation({})).toBe('Unknown location') + }) + + it('trims long user agent strings', () => { + const ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0_0) AppleWebKit/537.36 Gecko/123' + expect(getShortUserAgent(ua).length).toBeLessThanOrEqual(80) + }) + + it('shows not-me action only for successful logins', () => { + expect(shouldShowNotMeAction({ success: true })).toBe(true) + expect(shouldShowNotMeAction({ success: false })).toBe(false) + }) +}) diff --git a/plugins/setting-resources/src/plugin.ts b/plugins/setting-resources/src/plugin.ts index 9edb294c9d..2035ca1769 100644 --- a/plugins/setting-resources/src/plugin.ts +++ b/plugins/setting-resources/src/plugin.ts @@ -163,6 +163,21 @@ export default mergeIds(settingId, setting, { ShowInTitle: '' as IntlString, SpaceMembersOnly: '' as IntlString, LastOwnerLeaveTitle: '' as IntlString, - LastOwnerLeaveMessage: '' as IntlString + LastOwnerLeaveMessage: '' as IntlString, + RecentLoginActivityTitle: '' as IntlString, + RecentLoginActivityLoading: '' as IntlString, + RecentLoginActivityEmpty: '' as IntlString, + RecentLoginActivityError: '' as IntlString, + RecentLoginActivityRetry: '' as IntlString, + RecentLoginActivityMethod: '' as IntlString, + RecentLoginActivityIp: '' as IntlString, + RecentLoginActivityLocation: '' as IntlString, + RecentLoginActivityDevice: '' as IntlString, + RecentLoginActivitySuccess: '' as IntlString, + RecentLoginActivityFailure: '' as IntlString, + NotMeAction: '' as IntlString, + NotMeDialogTitle: '' as IntlString, + NotMeDialogMessage: '' as IntlString, + NotMeDialogAction: '' as IntlString } }) diff --git a/plugins/setting-resources/src/securityLoginActivity.ts b/plugins/setting-resources/src/securityLoginActivity.ts new file mode 100644 index 0000000000..22d330c341 --- /dev/null +++ b/plugins/setting-resources/src/securityLoginActivity.ts @@ -0,0 +1,49 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import type { SecurityLoginHistoryEvent } from '@hcengineering/account-client' + +const MAX_USER_AGENT_LENGTH = 80 + +export function maskIpAddress (ip?: string): string { + if (ip == null || ip.trim() === '') return 'Unknown IP' + + const trimmed = ip.trim() + + if (trimmed.includes(':')) { + const parts = trimmed.split(':').filter((part) => part.length > 0) + if (parts.length === 0) return '***' + if (parts.length === 1) return `${parts[0].slice(0, 8)}:***` + return `${parts.slice(0, 2).join(':')}:***` + } + + const octets = trimmed.split('.') + if (octets.length !== 4) return '***' + return `${octets[0]}.${octets[1]}.***.***` +} + +export function formatLocation (event: Partial>): string { + const location = [event.city, event.country].filter((value): value is string => value != null && value.trim() !== '') + return location.length > 0 ? location.join(', ') : 'Unknown location' +} + +export function getShortUserAgent (userAgent?: string): string { + if (userAgent == null || userAgent.trim() === '') return 'Unknown device' + if (userAgent.length <= MAX_USER_AGENT_LENGTH) return userAgent + return `${userAgent.slice(0, MAX_USER_AGENT_LENGTH - 1)}…` +} + +export function shouldShowNotMeAction (event: Pick): boolean { + return event.success +} diff --git a/server/account-service/src/index.ts b/server/account-service/src/index.ts index 188d2caf81..68afb0203a 100644 --- a/server/account-service/src/index.ts +++ b/server/account-service/src/index.ts @@ -11,7 +11,8 @@ import account, { getAccountDB, getAllTransactors, getMethods, - cleanExpiredOtp + cleanExpiredOtp, + purgeExpiredSecurityLoginEvents } from '@hcengineering/account' import accountEn from '@hcengineering/account/lang/en.json' import accountRu from '@hcengineering/account/lang/ru.json' @@ -180,6 +181,12 @@ export function serveAccount (measureCtx: MeasureContext, brandings: BrandingMap }, 3 * 60 * 1000 ) + setInterval( + () => { + void purgeExpiredSecurityLoginEvents(db, measureCtx) + }, + 3 * 60 * 1000 + ) }) const extractCookieToken = (headers: IncomingHttpHeaders): string | undefined => { @@ -204,6 +211,23 @@ export function serveAccount (measureCtx: MeasureContext, brandings: BrandingMap return extractAuthorizationToken(headers) ?? extractCookieToken(headers) } + const getClientIp = (headers: IncomingHttpHeaders): string | undefined => { + const forwardedFor = headers['x-forwarded-for'] + if (typeof forwardedFor === 'string' && forwardedFor.length > 0) { + return forwardedFor.split(',')[0].trim() + } + + const candidates = ['cf-connecting-ip', 'x-real-ip', 'x-client-ip', 'true-client-ip'] as const + for (const header of candidates) { + const value = headers[header] + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim() + } + } + + return undefined + } + const getRequestMeta = (headers: IncomingHttpHeaders, isServiceRequest: boolean): Meta => { const meta: Meta = {} @@ -218,6 +242,13 @@ export function serveAccount (measureCtx: MeasureContext, brandings: BrandingMap } } + if (!isServiceRequest) { + meta.ip = getClientIp(headers) + if (typeof headers['user-agent'] === 'string') { + meta.userAgent = headers['user-agent'] + } + } + return meta } diff --git a/server/account/src/__tests__/mongo.test.ts b/server/account/src/__tests__/mongo.test.ts index 3934b7a47f..3e58d3f188 100644 --- a/server/account/src/__tests__/mongo.test.ts +++ b/server/account/src/__tests__/mongo.test.ts @@ -680,6 +680,7 @@ describe('MongoAccountDB', () => { let mockWorkspaceMembers: any let mockWorkspaceStatus: any let mockMigration: any + let mockSecurityLoginEvent: any beforeEach(() => { mockDb = {} @@ -733,6 +734,10 @@ describe('MongoAccountDB', () => { findOne: jest.fn() } + mockSecurityLoginEvent = { + ensureIndices: jest.fn() + } + accountDb = new MongoAccountDB(mockDb) // Override the getters to return our mocks @@ -742,7 +747,8 @@ describe('MongoAccountDB', () => { workspace: { get: () => mockWorkspace }, workspaceMembers: { get: () => mockWorkspaceMembers }, workspaceStatus: { get: () => mockWorkspaceStatus }, - migration: { get: () => mockMigration } + migration: { get: () => mockMigration }, + securityLoginEvent: { get: () => mockSecurityLoginEvent } }) }) @@ -791,6 +797,27 @@ describe('MongoAccountDB', () => { } } ]) + + expect(accountDb.securityLoginEvent.ensureIndices).toHaveBeenCalledWith([ + { + key: { accountUuid: 1, eventTime: -1 }, + options: { + name: 'hc_account_security_login_event_account_uuid_event_time_1' + } + }, + { + key: { ip: 1, eventTime: -1 }, + options: { + name: 'hc_account_security_login_event_ip_event_time_1' + } + }, + { + key: { success: 1, eventTime: -1 }, + options: { + name: 'hc_account_security_login_event_success_event_time_1' + } + } + ]) }) }) diff --git a/server/account/src/__tests__/securityPolicy.test.ts b/server/account/src/__tests__/securityPolicy.test.ts new file mode 100644 index 0000000000..04ee45c23f --- /dev/null +++ b/server/account/src/__tests__/securityPolicy.test.ts @@ -0,0 +1,85 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// + +import { isSafeSecurityPolicyModuleSpecifier, NoopPolicyEngine } from '../securityPolicy' + +describe('isSafeSecurityPolicyModuleSpecifier', () => { + it('accepts scoped and unscoped package names', () => { + expect(isSafeSecurityPolicyModuleSpecifier('@acme/security-policy')).toBe(true) + expect(isSafeSecurityPolicyModuleSpecifier('my-security-policy')).toBe(true) + }) + + it('accepts at most one extra path segment after the package name', () => { + expect(isSafeSecurityPolicyModuleSpecifier('@acme/security-policy/engine')).toBe(true) + expect(isSafeSecurityPolicyModuleSpecifier('my-security-policy/sub')).toBe(true) + }) + + it('rejects more than one subpath segment', () => { + expect(isSafeSecurityPolicyModuleSpecifier('@acme/security-policy/a/b')).toBe(false) + expect(isSafeSecurityPolicyModuleSpecifier('my-security-policy/a/b')).toBe(false) + }) + + it('rejects path traversal and absolute paths', () => { + expect(isSafeSecurityPolicyModuleSpecifier('../evil')).toBe(false) + expect(isSafeSecurityPolicyModuleSpecifier('@scope/../../evil')).toBe(false) + expect(isSafeSecurityPolicyModuleSpecifier('/tmp/evil')).toBe(false) + expect(isSafeSecurityPolicyModuleSpecifier('.\\evil')).toBe(false) + }) + + it('rejects file and remote URL schemes', () => { + expect(isSafeSecurityPolicyModuleSpecifier('file:///tmp/x')).toBe(false) + expect(isSafeSecurityPolicyModuleSpecifier('https://example.com/x')).toBe(false) + }) +}) + +describe('NoopPolicyEngine country heuristics', () => { + it('does not flag new_country when there is no geo baseline yet', async () => { + const engine = new NoopPolicyEngine() + const result = await engine.evaluateEvent({ + event: { + accountUuid: 'acc-1' as any, + eventTime: 1, + country: 'DE', + success: true, + authMethod: 'password' + } as any, + recentHistory: [ + { + accountUuid: 'acc-1' as any, + eventTime: 0, + success: true, + authMethod: 'password' + } as any + ] + }) + expect(result.anomalyCodes).not.toContain('new_country_for_account') + }) + + it('flags new_country when baseline exists and country is new', async () => { + const engine = new NoopPolicyEngine() + const result = await engine.evaluateEvent({ + event: { + accountUuid: 'acc-1' as any, + eventTime: 2, + country: 'FR', + success: true, + authMethod: 'password' + } as any, + recentHistory: [ + { + accountUuid: 'acc-1' as any, + eventTime: 1, + country: 'DE', + success: true, + authMethod: 'password' + } as any + ] + }) + expect(result.anomalyCodes).toContain('new_country_for_account') + }) +}) diff --git a/server/account/src/collections/mongo.ts b/server/account/src/collections/mongo.ts index b4e5e5e7af..8e3189d695 100644 --- a/server/account/src/collections/mongo.ts +++ b/server/account/src/collections/mongo.ts @@ -51,6 +51,7 @@ import type { SocialId, Sort, UserProfile, + SecurityLoginEvent, Subscription, WorkspaceData, WorkspaceInfoWithStatus, @@ -408,6 +409,7 @@ export class MongoAccountDB implements AccountDB { integrationSecret: MongoDbCollection userProfile: MongoDbCollection subscription: MongoDbCollection + securityLoginEvent: MongoDbCollection workspaceMembers: MongoDbCollection workspacePermission: MongoDbCollection @@ -428,6 +430,7 @@ export class MongoAccountDB implements AccountDB { this.integrationSecret = new MongoDbCollection('integrationSecret', db) this.userProfile = new MongoDbCollection('user_profile', db, 'personUuid') this.subscription = new MongoDbCollection('subscription', db, 'id') + this.securityLoginEvent = new MongoDbCollection('securityLoginEvent', db, 'id') this.workspaceMembers = new MongoDbCollection('workspaceMembers', db) this.workspacePermission = new MongoDbCollection('workspacePermissions', db) @@ -484,6 +487,27 @@ export class MongoAccountDB implements AccountDB { } } ]) + + await this.securityLoginEvent.ensureIndices([ + { + key: { accountUuid: 1, eventTime: -1 }, + options: { + name: 'hc_account_security_login_event_account_uuid_event_time_1' + } + }, + { + key: { ip: 1, eventTime: -1 }, + options: { + name: 'hc_account_security_login_event_ip_event_time_1' + } + }, + { + key: { success: 1, eventTime: -1 }, + options: { + name: 'hc_account_security_login_event_success_event_time_1' + } + } + ]) } async migrate ({ key, op }: Migration): Promise { @@ -866,6 +890,8 @@ export class MongoAccountDB implements AccountDB { await this.mailbox.deleteMany({ accountUuid }) + await this.securityLoginEvent.deleteMany({ accountUuid }) + await this.socialId.update({ personUuid: accountUuid }, { verifiedOn: undefined }) await this.workspaceMembers.deleteMany({ accountUuid }) await this.account.deleteMany({ uuid: accountUuid }) diff --git a/server/account/src/collections/postgres/migrations.ts b/server/account/src/collections/postgres/migrations.ts index adaef2de9c..9e61082b3e 100644 --- a/server/account/src/collections/postgres/migrations.ts +++ b/server/account/src/collections/postgres/migrations.ts @@ -82,7 +82,8 @@ export function getMigrations (ns: string, flavor: DBFlavor): [string, string][] getV22Migration(ns, flavor), getV23Migration(ns, flavor), getV24Migration(ns, flavor), - getV25Migration(ns, flavor) + getV25Migration(ns, flavor), + getV26Migration(ns, flavor) ] } @@ -794,3 +795,42 @@ function getV25Migration (ns: string, flavor: DBFlavor): [string, string] { ` ] } + +function getV26Migration (ns: string, flavor: DBFlavor): [string, string] { + const types = dbTypes[flavor] + return [ + 'account_db_v26_add_security_login_event_table', + ` + /* ======= S E C U R I T Y L O G I N E V E N T ======= */ + CREATE TABLE IF NOT EXISTS ${ns}.security_login_event ( + id ${types.string} NOT NULL DEFAULT gen_random_uuid()::TEXT, + account_uuid UUID NOT NULL, + workspace_uuid UUID, + event_time BIGINT NOT NULL DEFAULT current_epoch_ms(), + ip ${types.string}, + country ${types.string}, + city ${types.string}, + user_agent ${types.string}, + success ${types.bool} NOT NULL, + auth_method ${types.string} NOT NULL, + reason ${types.string}, + session_id ${types.string}, + anomaly_codes JSONB, + policy_version ${types.string}, + created_on BIGINT NOT NULL DEFAULT current_epoch_ms(), + CONSTRAINT security_login_event_pk PRIMARY KEY (id), + CONSTRAINT security_login_event_account_fk FOREIGN KEY (account_uuid) REFERENCES ${ns}.account(uuid), + CONSTRAINT security_login_event_workspace_fk FOREIGN KEY (workspace_uuid) REFERENCES ${ns}.workspace(uuid) + ); + + CREATE INDEX IF NOT EXISTS security_login_event_account_time_idx + ON ${ns}.security_login_event (account_uuid, event_time DESC); + + CREATE INDEX IF NOT EXISTS security_login_event_ip_time_idx + ON ${ns}.security_login_event (ip, event_time DESC); + + CREATE INDEX IF NOT EXISTS security_login_event_success_time_idx + ON ${ns}.security_login_event (success, event_time DESC); + ` + ] +} diff --git a/server/account/src/collections/postgres/postgres.ts b/server/account/src/collections/postgres/postgres.ts index 4ab5aea2b1..05f24779e3 100644 --- a/server/account/src/collections/postgres/postgres.ts +++ b/server/account/src/collections/postgres/postgres.ts @@ -49,6 +49,7 @@ import type { AccountAggregatedInfo, UserProfile, Subscription, + SecurityLoginEvent, WorkspacePermission, DBFlavor } from '../../types' @@ -539,6 +540,7 @@ export class PostgresAccountDB implements AccountDB { integrationSecret: PostgresDbCollection userProfile: PostgresDbCollection subscription: PostgresDbCollection + securityLoginEvent: PostgresDbCollection workspacePermission: PostgresDbCollection constructor ( @@ -604,6 +606,12 @@ export class PostgresAccountDB implements AccountDB { timestampFields: ['periodStart', 'periodEnd', 'trialEnd', 'canceledAt', 'willCancelAt', 'createdOn', 'updatedOn'], withRetryClient }) + this.securityLoginEvent = new PostgresDbCollection('security_login_event', client, { + ns, + idKey: 'id', + timestampFields: ['eventTime', 'createdOn'], + withRetryClient + }) this.workspacePermission = new PostgresDbCollection('workspace_permissions', client, { ns, timestampFields: ['createdOn'], @@ -1080,6 +1088,8 @@ export class PostgresAccountDB implements AccountDB { await this.mailbox.deleteMany({ accountUuid }, rTx) + await this.securityLoginEvent.deleteMany({ accountUuid }, rTx) + await this.socialId.update({ personUuid: accountUuid }, { verifiedOn: undefined }, rTx) // Unassign from all workspaces diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index a1869b41c9..6e7e358829 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -62,11 +62,14 @@ import { type LoginInfoRequestData, type Account, type PersonWithProfile, + type SecurityAuthMethod, + type SecurityLoginEvent, type Subscription, SubscriptionStatus, type Query, type InviteInfo } from './types' +import { assertSecurityLoginTelemetryRateLimit } from './securityLoginTelemetryRateLimit' import { addSocialIdBase, checkInvite, @@ -126,7 +129,8 @@ import { checkPasswordAging, generateTotpSecret, verifyTotpCode, - getTotpUrl + getTotpUrl, + recordSecurityLoginEvent } from './utils' const NIL_UUID = '00000000-0000-0000-0000-000000000000' as AccountUuid @@ -174,7 +178,8 @@ export async function login ( params: { email: string password: string - } + }, + meta?: Meta ): Promise { const { email, password } = params @@ -183,6 +188,7 @@ export async function login ( } const normalizedEmail = cleanEmail(email) + let existingAccount: Account | null = null try { const emailSocialId = await getEmailSocialId(db, normalizedEmail) @@ -191,7 +197,7 @@ export async function login ( throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})) } - const existingAccount = await db.account.findOne({ uuid: emailSocialId.personUuid as AccountUuid }) + existingAccount = await db.account.findOne({ uuid: emailSocialId.personUuid as AccountUuid }) if (existingAccount == null) { throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})) @@ -203,6 +209,14 @@ export async function login ( email: normalizedEmail, failedAttempts: existingAccount.failedLoginAttempts }) + await recordSecurityLoginEvent(ctx, db, { + accountUuid: existingAccount.uuid, + success: false, + authMethod: 'password', + reason: 'password_login_locked', + ip: meta?.ip, + userAgent: meta?.userAgent + }) throw new PlatformError( new Status(Severity.ERROR, platform.status.PasswordLoginLocked, { account: normalizedEmail }) ) @@ -219,6 +233,14 @@ export async function login ( } catch (err) { ctx.warn('Failed to record failed login attempt', { error: err, account: existingAccount.uuid }) } + await recordSecurityLoginEvent(ctx, db, { + accountUuid: existingAccount.uuid, + success: false, + authMethod: 'password', + reason: 'invalid_password', + ip: meta?.ip, + userAgent: meta?.userAgent + }) throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})) } @@ -231,6 +253,14 @@ export async function login ( ? { admin: 'true', authMethod: 'password' } : { authMethod: 'password' } ctx.info('Login succeeded', { email, normalizedEmail, isConfirmed, emailSocialId, ...extraToken }) + await recordSecurityLoginEvent(ctx, db, { + accountUuid: existingAccount.uuid, + success: true, + authMethod: 'password', + reason: isConfirmed ? 'login_success' : 'email_not_confirmed', + ip: meta?.ip, + userAgent: meta?.userAgent + }) return { account: existingAccount.uuid, @@ -248,6 +278,16 @@ export async function login ( } catch (err: any) { Analytics.handleError(err) ctx.error('Login failed', { email, normalizedEmail, err }) + if (existingAccount != null) { + await recordSecurityLoginEvent(ctx, db, { + accountUuid: existingAccount.uuid, + success: false, + authMethod: 'password', + reason: 'login_failed', + ip: meta?.ip, + userAgent: meta?.userAgent + }) + } throw err } } @@ -260,7 +300,8 @@ export async function loginOtp ( db: AccountDB, branding: Branding | null, token: string, - params: { email: string } + params: { email: string }, + meta?: Meta ): Promise { const { email } = params @@ -270,19 +311,46 @@ export async function loginOtp ( // Note: can support OTP based on any other social logins later const normalizedEmail = cleanEmail(email) - const emailSocialId = await getEmailSocialId(db, normalizedEmail) + let accountUuid: AccountUuid | undefined - if (emailSocialId == null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})) + try { + const emailSocialId = await getEmailSocialId(db, normalizedEmail) + + if (emailSocialId == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})) + } + + accountUuid = emailSocialId.personUuid as AccountUuid + const account = await getAccount(db, accountUuid) + + if (account == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})) + } + + const otpInfo = await sendOtp(ctx, db, branding, emailSocialId) + await recordSecurityLoginEvent(ctx, db, { + accountUuid: account.uuid, + success: true, + authMethod: 'otp', + reason: 'otp_requested', + ip: meta?.ip, + userAgent: meta?.userAgent + }) + + return otpInfo + } catch (err) { + if (accountUuid != null) { + await recordSecurityLoginEvent(ctx, db, { + accountUuid, + success: false, + authMethod: 'otp', + reason: 'otp_request_failed', + ip: meta?.ip, + userAgent: meta?.userAgent + }) + } + throw err } - - const account = await getAccount(db, emailSocialId.personUuid as AccountUuid) - - if (account == null) { - throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})) - } - - return await sendOtp(ctx, db, branding, emailSocialId) } /** @@ -394,7 +462,8 @@ export async function validateOtp ( code: string password?: string action?: 'verify' - } + }, + meta?: Meta ): Promise { const { email, code, password, action } = params @@ -534,6 +603,15 @@ export async function validateOtp ( ) : undefined + await recordSecurityLoginEvent(ctx, db, { + accountUuid: emailSocialId.personUuid as AccountUuid, + success: true, + authMethod: 'otp', + reason: action === 'verify' ? 'otp_verified_social_id' : 'otp_login_success', + ip: meta?.ip, + userAgent: meta?.userAgent + }) + return { account: emailSocialId.personUuid as AccountUuid, name: getPersonName(person), @@ -544,6 +622,22 @@ export async function validateOtp ( } catch (err: any) { Analytics.handleError(err) ctx.error(action === 'verify' ? 'OTP verification error' : 'OTP login/sign up error', { email, err }) + try { + const normalizedEmail = cleanEmail(email) + const emailSocialId = await getEmailSocialId(db, normalizedEmail) + if (emailSocialId != null) { + await recordSecurityLoginEvent(ctx, db, { + accountUuid: emailSocialId.personUuid as AccountUuid, + success: false, + authMethod: 'otp', + reason: action === 'verify' ? 'otp_verify_failed' : 'otp_login_failed', + ip: meta?.ip, + userAgent: meta?.userAgent + }) + } + } catch (recordErr) { + ctx.warn('Failed to write OTP failure security event', { recordErr }) + } throw err } } @@ -2100,6 +2194,16 @@ export async function getLoginInfoByToken ( token: generateToken(accountUuid, workspaceUuid, extra, undefined, { grant, nbf, exp, sub }) } + await recordSecurityLoginEvent(ctx, db, { + accountUuid, + workspaceUuid: workspaceUuid === '' ? undefined : workspaceUuid, + success: true, + authMethod: 'token', + reason: 'token_refresh', + ip: meta?.ip, + userAgent: meta?.userAgent + }) + if (!isSystem) { void setTimezone(ctx, db, accountUuid, null, meta) } @@ -3187,6 +3291,254 @@ export async function getWorkspaceUsersWithPermission ( return await db.getWorkspaceUsersWithPermission(workspace, permission) } +const SECURITY_AUTH_METHODS: readonly SecurityAuthMethod[] = ['password', 'otp', 'token', 'session', 'unknown'] + +const UA_REDACT_LEN = 80 + +function maskIpForApiResponse (ip?: string): string | undefined { + if (ip == null || ip.trim() === '') return undefined + const t = ip.trim() + if (t.includes(':')) { + const parts = t.split(':').filter((p) => p.length > 0) + if (parts.length === 0) return '***' + if (parts.length === 1) return `${parts[0].slice(0, 8)}:***` + return `${parts.slice(0, 2).join(':')}:***` + } + const octets = t.split('.') + if (octets.length !== 4) return '***' + return `${octets[0]}.${octets[1]}.***.***` +} + +function redactSecurityLoginEventRow (row: SecurityLoginEvent): SecurityLoginEvent { + const ua = row.userAgent?.trim() ?? '' + const shortUa = + ua === '' + ? undefined + : ua.length <= UA_REDACT_LEN + ? ua + : `${ua.slice(0, UA_REDACT_LEN - 1)}…` + return { + ...row, + ip: maskIpForApiResponse(row.ip), + userAgent: shortUa, + sessionId: undefined + } +} + +function assertAuthMethodFilter (authMethod: string | undefined): SecurityAuthMethod | undefined { + if (authMethod === undefined) return undefined + if (!SECURITY_AUTH_METHODS.includes(authMethod as SecurityAuthMethod)) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + return authMethod as SecurityAuthMethod +} + +interface MySecurityLoginHistoryFilterParams { + since?: number + until?: number + success?: boolean + authMethod?: string + ip?: string + limit?: number +} + +async function findMySecurityLoginEventRows ( + db: AccountDB, + account: AccountUuid, + params: MySecurityLoginHistoryFilterParams +): Promise { + const { since, until, success, ip } = params + const authMethod = assertAuthMethodFilter(params.authMethod) + const limit = Math.min(Math.max(params.limit ?? 100, 1), 500) + + const query: Query = { + accountUuid: account + } + + if (success !== undefined) { + query.success = success + } + if (authMethod !== undefined) { + query.authMethod = authMethod + } + if (ip !== undefined) { + query.ip = ip + } + + if (since !== undefined || until !== undefined) { + query.eventTime = {} + if (since !== undefined) { + query.eventTime.$gte = since + } + if (until !== undefined) { + query.eventTime.$lte = until + } + } + + return await db.securityLoginEvent.find(query, { eventTime: 'descending' }, limit) +} + +export async function getMySecurityLoginHistory ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { + since?: number + until?: number + success?: boolean + authMethod?: string + ip?: string + limit?: number + redact?: boolean + } +): Promise { + const { account } = decodeTokenVerbose(ctx, token) + assertSecurityLoginTelemetryRateLimit(account, 'getMySecurityLoginHistory', 'SECURITY_LOGIN_HISTORY_READ_RPM', 120) + const redact = params.redact === true + const rows = await findMySecurityLoginEventRows(db, account, params) + return redact ? rows.map(redactSecurityLoginEventRow) : rows +} + +export async function getWorkspaceSecurityLoginHistory ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { + accountUuid?: AccountUuid + since?: number + until?: number + success?: boolean + authMethod?: string + ip?: string + limit?: number + } +): Promise { + const { account, workspace } = decodeTokenVerbose(ctx, token) + if (workspace == null || workspace === '') { + throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid: workspace })) + } + + const role = account === systemAccountUuid ? AccountRole.Owner : await db.getWorkspaceRole(account, workspace) + if (role == null || getRolePower(role) < getRolePower(AccountRole.Maintainer)) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + assertSecurityLoginTelemetryRateLimit(account, 'getWorkspaceSecurityLoginHistory', 'SECURITY_LOGIN_HISTORY_READ_RPM', 120) + + const { since, until, success, ip } = params + let accountUuid = params.accountUuid + // Non-system callers must scope to their own account unless they pass an explicit accountUuid + // (avoids returning all workspace members' login telemetry by default). + if (account !== systemAccountUuid && accountUuid === undefined) { + accountUuid = account + } + const authMethod = assertAuthMethodFilter(params.authMethod) + const limit = Math.min(Math.max(params.limit ?? 100, 1), 500) + + const query: Query = { + workspaceUuid: workspace + } + + if (accountUuid !== undefined) { + query.accountUuid = accountUuid + } + if (success !== undefined) { + query.success = success + } + if (authMethod !== undefined) { + query.authMethod = authMethod + } + if (ip !== undefined) { + query.ip = ip + } + + if (since !== undefined || until !== undefined) { + query.eventTime = {} + if (since !== undefined) { + query.eventTime.$gte = since + } + if (until !== undefined) { + query.eventTime.$lte = until + } + } + + return await db.securityLoginEvent.find(query, { eventTime: 'descending' }, limit) +} + +const MAX_SECURITY_LOGIN_EVENT_ID_LEN = 128 + +export async function exportMySecurityLoginHistory ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params?: MySecurityLoginHistoryFilterParams +): Promise { + const { account } = decodeTokenVerbose(ctx, token) + assertSecurityLoginTelemetryRateLimit(account, 'exportMySecurityLoginHistory', 'SECURITY_LOGIN_EXPORT_RPM', 5) + return await findMySecurityLoginEventRows(db, account, { + since: params?.since, + until: params?.until, + success: params?.success, + authMethod: params?.authMethod, + ip: params?.ip, + limit: 500 + }) +} + +export async function eraseMySecurityLoginHistory ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + _params?: Record +): Promise { + const { account } = decodeTokenVerbose(ctx, token) + assertSecurityLoginTelemetryRateLimit(account, 'eraseMySecurityLoginHistory', 'SECURITY_LOGIN_ERASE_RPM', 10) + await db.securityLoginEvent.deleteMany({ accountUuid: account }) +} + +export async function reportSecurityLoginConcern ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params?: { loginEventId?: string } +): Promise { + const { account } = decodeTokenVerbose(ctx, token) + assertSecurityLoginTelemetryRateLimit(account, 'reportSecurityLoginConcern', 'SECURITY_LOGIN_REPORT_RPM', 20) + + let loginEventId = params?.loginEventId?.trim() + if (loginEventId === '') loginEventId = undefined + if (loginEventId !== undefined && loginEventId.length > MAX_SECURITY_LOGIN_EVENT_ID_LEN) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + + let data: Record | undefined + if (loginEventId !== undefined) { + const row = await db.securityLoginEvent.findOne({ id: loginEventId, accountUuid: account }) + if (row == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + data = { + loginEventId: row.id, + eventTime: row.eventTime, + workspaceUuid: row.workspaceUuid + } + } else { + data = { source: 'profile_recent_activity' } + } + + await db.accountEvent.insertOne({ + accountUuid: account, + eventType: AccountEventType.SECURITY_LOGIN_CONCERN_REPORTED, + time: Date.now(), + data + }) +} + export type AccountMethods = | AccountServiceMethods | 'login' @@ -3264,6 +3616,11 @@ export type AccountMethods = | 'hasWorkspacePermission' | 'getWorkspacePermissions' | 'getWorkspaceUsersWithPermission' + | 'getMySecurityLoginHistory' + | 'getWorkspaceSecurityLoginHistory' + | 'exportMySecurityLoginHistory' + | 'eraseMySecurityLoginHistory' + | 'reportSecurityLoginConcern' /** * @public @@ -3330,6 +3687,11 @@ export function getMethods (hasSignUp: boolean = true): Partial() + +function parsePositiveInt (raw: string | undefined, fallback: number): number { + if (raw === undefined || raw.trim() === '') return fallback + const n = parseInt(raw.trim(), 10) + return Number.isFinite(n) && n > 0 ? Math.min(n, 10_000) : fallback +} + +/** + * In-process sliding-window rate limiter (per account + RPC name). + * Multi-instance deployments only get per-process limits unless replaced with shared storage. + */ +export function assertSecurityLoginTelemetryRateLimit (accountKey: string, rpcName: string, envVar: string, fallbackRpm: number): void { + const maxPerMinute = parsePositiveInt(process.env[envVar], fallbackRpm) + const key = `${accountKey}:${rpcName}` + const now = Date.now() + const windowMs = 60_000 + let stamps = buckets.get(key) ?? [] + stamps = stamps.filter((t) => now - t < windowMs) + if (stamps.length >= maxPerMinute) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) + } + stamps.push(now) + buckets.set(key, stamps) +} diff --git a/server/account/src/securityPolicy.ts b/server/account/src/securityPolicy.ts new file mode 100644 index 0000000000..2830fe8b0d --- /dev/null +++ b/server/account/src/securityPolicy.ts @@ -0,0 +1,144 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { MeasureContext } from '@hcengineering/core' +import type { SecurityLoginEvent } from './types' + +export interface SecurityPolicyEvaluationInput { + event: Omit + recentHistory: SecurityLoginEvent[] +} + +export interface SecurityPolicyEvaluationResult { + policyVersion: string + anomalyCodes: string[] +} + +export interface SecurityPolicyEngine { + evaluateEvent: (input: SecurityPolicyEvaluationInput) => Promise +} + +export class NoopPolicyEngine implements SecurityPolicyEngine { + async evaluateEvent (input: SecurityPolicyEvaluationInput): Promise { + const { event, recentHistory } = input + const anomalyCodes = new Set() + + const sameIpFailures = recentHistory.filter((entry) => !entry.success && entry.ip != null && event.ip != null && entry.ip === event.ip) + if (!event.success && sameIpFailures.length >= 4) { + anomalyCodes.add('repeated_failed_attempts_from_ip') + } + + if (event.country != null) { + const hadGeoBaseline = recentHistory.some((entry) => entry.country != null && entry.country.trim() !== '') + if (hadGeoBaseline) { + const hadCountryBefore = recentHistory.some((entry) => entry.country === event.country) + if (!hadCountryBefore) { + anomalyCodes.add('new_country_for_account') + } + } + } + + const latestSuccessful = recentHistory.find((entry) => entry.success) + if ( + event.success && + latestSuccessful?.country != null && + event.country != null && + latestSuccessful.country !== event.country && + Math.abs(event.eventTime - latestSuccessful.eventTime) < 60 * 60 * 1000 + ) { + anomalyCodes.add('impossible_travel_suspected') + } + + return { + policyVersion: 'noop-v1', + anomalyCodes: Array.from(anomalyCodes) + } + } +} + +let cachedPolicyEngine: SecurityPolicyEngine | undefined + +const POLICY_SCOPED_PREFIX = /^@[a-z0-9-~][a-z0-9-._~]*$/i +const POLICY_UNSCOPED_ROOT = /^[a-z0-9][a-z0-9-._]*$/i +/** Scoped/unscoped package path segment (name or single optional subpath). */ +const POLICY_SEGMENT = /^[a-z0-9-._~]+$/i + +function isValidPolicyPathSegment (s: string): boolean { + return s.length > 0 && s.length <= 200 && POLICY_SEGMENT.test(s) +} + +/** + * Restrict dynamic policy loading to npm-style package specifiers (no arbitrary paths, URLs, or traversal). + * Allows at most one extra path segment after the package name (`@org/pkg/sub` or `pkg/sub`). + */ +export function isSafeSecurityPolicyModuleSpecifier (moduleName: string): boolean { + if (moduleName.length === 0 || moduleName.length > 256) return false + if (moduleName.includes('..') || moduleName.includes('\\')) return false + if (moduleName.startsWith('/') || moduleName.startsWith('.')) return false + if (/^(file|node|data|https?|worker):/i.test(moduleName)) return false + if (moduleName.includes('//')) return false + + const parts = moduleName.split('/') + if (parts.some((p) => p.length === 0)) return false + + if (parts[0].startsWith('@')) { + if (!POLICY_SCOPED_PREFIX.test(parts[0])) return false + if (parts.length === 2) return isValidPolicyPathSegment(parts[1]) + if (parts.length === 3) return isValidPolicyPathSegment(parts[1]) && isValidPolicyPathSegment(parts[2]) + return false + } + + if (parts.length === 1) return POLICY_UNSCOPED_ROOT.test(parts[0]) + if (parts.length === 2) return POLICY_UNSCOPED_ROOT.test(parts[0]) && isValidPolicyPathSegment(parts[1]) + return false +} + +export async function resolveSecurityPolicyEngine (ctx: MeasureContext): Promise { + if (cachedPolicyEngine != null) { + return cachedPolicyEngine + } + + const moduleName = process.env.SECURITY_POLICY_MODULE?.trim() + if (moduleName == null || moduleName === '') { + cachedPolicyEngine = new NoopPolicyEngine() + return cachedPolicyEngine + } + + if (!isSafeSecurityPolicyModuleSpecifier(moduleName)) { + ctx.warn('SECURITY_POLICY_MODULE rejected (unsafe specifier), fallback to noop', { moduleName }) + cachedPolicyEngine = new NoopPolicyEngine() + return cachedPolicyEngine + } + + try { + const moduleExports = await import(moduleName) + const createEngine = moduleExports.createSecurityPolicyEngine as + | ((ctx: MeasureContext) => SecurityPolicyEngine) + | undefined + + if (typeof createEngine !== 'function') { + ctx.warn('SECURITY_POLICY_MODULE loaded but createSecurityPolicyEngine is missing, fallback to noop', { moduleName }) + cachedPolicyEngine = new NoopPolicyEngine() + return cachedPolicyEngine + } + + cachedPolicyEngine = createEngine(ctx) + return cachedPolicyEngine + } catch (err) { + ctx.warn('Failed to load private security policy module, fallback to noop', { moduleName, err }) + cachedPolicyEngine = new NoopPolicyEngine() + return cachedPolicyEngine + } +} diff --git a/server/account/src/types.ts b/server/account/src/types.ts index d946491c25..257c2dd3ed 100644 --- a/server/account/src/types.ts +++ b/server/account/src/types.ts @@ -76,11 +76,33 @@ export interface AccountEvent { time: Timestamp } +export type SecurityAuthMethod = 'password' | 'otp' | 'token' | 'session' | 'unknown' + +export interface SecurityLoginEvent { + id: string + accountUuid: AccountUuid + workspaceUuid?: WorkspaceUuid + eventTime: Timestamp + ip?: string + country?: string + city?: string + userAgent?: string + success: boolean + authMethod: SecurityAuthMethod + reason?: string + sessionId?: string + anomalyCodes?: string[] + policyVersion?: string + createdOn: Timestamp +} + export enum AccountEventType { ACCOUNT_CREATED = 'account_created', SOCIAL_ID_RELEASED = 'social_id_released', ACCOUNT_DELETED = 'account_deleted', - PASSWORD_CHANGED = 'password_changed' + PASSWORD_CHANGED = 'password_changed', + /** User reported a login row as suspicious (audit / support follow-up). */ + SECURITY_LOGIN_CONCERN_REPORTED = 'security_login_concern_reported' } export interface Member { @@ -323,6 +345,7 @@ export interface AccountDB { integrationSecret: DbCollection userProfile: DbCollection subscription: DbCollection + securityLoginEvent: DbCollection workspacePermission: DbCollection init: () => Promise @@ -505,6 +528,8 @@ export type ClientNetworkPosition = 'internal' | 'external' export interface Meta { timezone?: string clientNetworkPosition?: ClientNetworkPosition + ip?: string + userAgent?: string } export interface AccountAggregatedInfo extends Omit, Person { diff --git a/server/account/src/utils.ts b/server/account/src/utils.ts index 3b787ec7a6..1e24b11a70 100644 --- a/server/account/src/utils.ts +++ b/server/account/src/utils.ts @@ -45,6 +45,7 @@ import { Analytics } from '@hcengineering/analytics' import { decodeTokenVerbose, generateToken, type PermissionsGrant, TokenError } from '@hcengineering/server-token' import { MongoAccountDB } from './collections/mongo' import { PostgresAccountDB } from './collections/postgres/postgres' +import { resolveSecurityPolicyEngine } from './securityPolicy' import { accountPlugin } from './plugin' import { type Account, @@ -56,6 +57,8 @@ import { type LoginInfo, type LoginInfoRequestData, type Meta, + type SecurityAuthMethod, + type SecurityLoginEvent, type Operations, type OtpInfo, type RegionInfo, @@ -800,6 +803,15 @@ export async function selectWorkspace ( } // Guest mode select workspace + await recordSecurityLoginEvent(ctx, db, { + accountUuid, + workspaceUuid: workspace.uuid, + success: true, + authMethod: 'session', + reason: 'workspace_select_guest', + ip: meta?.ip, + userAgent: meta?.userAgent + }) return { account: accountUuid, endpoint: getEndpoint(workspace.uuid, workspace.region, getKind(workspace.region)), @@ -812,6 +824,15 @@ export async function selectWorkspace ( } if (accountUuid === systemAccountUuid) { + await recordSecurityLoginEvent(ctx, db, { + accountUuid, + workspaceUuid: workspace.uuid, + success: true, + authMethod: 'session', + reason: 'workspace_select_system', + ip: meta?.ip, + userAgent: meta?.userAgent + }) return { account: accountUuid, token: generateToken(accountUuid, workspace.uuid, extra, undefined, { @@ -874,6 +895,16 @@ export async function selectWorkspace ( throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {})) } + await recordSecurityLoginEvent(ctx, db, { + accountUuid, + workspaceUuid: workspace.uuid, + success: true, + authMethod: 'session', + reason: 'workspace_select', + ip: meta?.ip, + userAgent: meta?.userAgent + }) + return { account: accountUuid, token: generateToken(accountUuid, workspace.uuid, extra, undefined, { @@ -1690,6 +1721,36 @@ export async function cleanExpiredOtp (db: AccountDB): Promise { await db.otp.deleteMany({ expiresOn: { $lte: Date.now() } }) } +const DEFAULT_SECURITY_LOGIN_RETENTION_DAYS = 365 + +/** + * Deletes security_login_event rows older than SECURITY_LOGIN_EVENT_RETENTION_DAYS (default 365). + * Set SECURITY_LOGIN_EVENT_RETENTION_DAYS=0 (or "off"/"false") to disable purging. + */ +export async function purgeExpiredSecurityLoginEvents ( + db: AccountDB, + log?: { warn: (msg: string, data?: Record) => void } +): Promise { + const rawTrim = process.env.SECURITY_LOGIN_EVENT_RETENTION_DAYS?.trim() + const rawLower = rawTrim?.toLowerCase() + if (rawLower === '0' || rawLower === 'off' || rawLower === 'false') { + return + } + const days = + rawTrim !== undefined && rawTrim !== '' + ? parseInt(rawTrim, 10) + : DEFAULT_SECURITY_LOGIN_RETENTION_DAYS + if (!Number.isFinite(days) || days <= 0) { + return + } + const cutoff = Date.now() - days * 24 * 60 * 60 * 1000 + try { + await db.securityLoginEvent.deleteMany({ eventTime: { $lt: cutoff } }) + } catch (err) { + log?.warn('purgeExpiredSecurityLoginEvents failed', { err, days, cutoff }) + } +} + export async function getWorkspaces ( db: AccountDB, isDisabled?: boolean | null, @@ -1989,6 +2050,85 @@ export async function setTimezone ( } } +export interface SecurityEventInput { + accountUuid: AccountUuid + workspaceUuid?: WorkspaceUuid + success: boolean + authMethod: SecurityAuthMethod + reason?: string + eventTime?: number + ip?: string + userAgent?: string + country?: string + city?: string + sessionId?: string +} + +function trimOptional (value: string | undefined, maxLen: number): string | undefined { + if (value == null) { + return undefined + } + + const normalized = value.trim() + if (normalized === '') { + return undefined + } + + return normalized.length > maxLen ? normalized.slice(0, maxLen) : normalized +} + +export async function recordSecurityLoginEvent ( + ctx: MeasureContext, + db: AccountDB, + input: SecurityEventInput +): Promise { + const logWarn = + typeof (ctx as any).warn === 'function' + ? (ctx as any).warn.bind(ctx) + : typeof (ctx as any).error === 'function' + ? (ctx as any).error.bind(ctx) + : console.warn + + try { + const eventTime = input.eventTime ?? Date.now() + const eventData: Omit = { + accountUuid: input.accountUuid, + workspaceUuid: input.workspaceUuid, + eventTime, + ip: trimOptional(input.ip, 128), + country: trimOptional(input.country, 8), + city: trimOptional(input.city, 128), + userAgent: trimOptional(input.userAgent, 1024), + success: input.success, + authMethod: input.authMethod, + reason: trimOptional(input.reason, 256), + sessionId: trimOptional(input.sessionId, 128) + } + + const recentHistory = await db.securityLoginEvent.find( + { accountUuid: input.accountUuid }, + { eventTime: 'descending' }, + 50 + ) + const policyEngine = await resolveSecurityPolicyEngine(ctx) + const policyResult = await policyEngine.evaluateEvent({ event: eventData, recentHistory }) + + await db.securityLoginEvent.insertOne({ + ...eventData, + anomalyCodes: policyResult.anomalyCodes, + policyVersion: policyResult.policyVersion, + createdOn: eventTime + }) + } catch (err) { + const payload = { err, accountUuid: input.accountUuid, authMethod: input.authMethod } + if (typeof (ctx as any).error === 'function') { + ;(ctx as any).error('Failed to persist security login event', payload) + } else { + logWarn('Failed to persist security login event', payload) + } + } +} + // Move to config? export const integrationServices = [ 'github',