Add login security

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artem Savchenko
2026-04-22 12:04:53 +07:00
parent 2559a6a1ed
commit fa0931a157
29 changed files with 1437 additions and 33 deletions
@@ -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' }
})
})
})
@@ -51,6 +51,8 @@ import type {
PersonWithProfile,
ProviderInfo,
RegionInfo,
SecurityLoginHistoryEvent,
SecurityLoginHistoryParams,
SocialId,
Subscription,
SubscriptionData,
@@ -242,6 +244,10 @@ export interface AccountClient {
setMyProfile: (profile: Partial<Omit<UserProfile, 'personUuid'>>) => Promise<void>
getUserProfile: (personUuid?: PersonUuid) => Promise<PersonWithProfile | null>
getMySecurityLoginHistory: (params?: SecurityLoginHistoryParams) => Promise<SecurityLoginHistoryEvent[]>
exportMySecurityLoginHistory: (params?: SecurityLoginHistoryParams) => Promise<SecurityLoginHistoryEvent[]>
eraseMySecurityLoginHistory: () => Promise<void>
reportSecurityLoginConcern: (params?: { loginEventId?: string }) => Promise<void>
getSubscriptions: (workspaceUuid?: WorkspaceUuid | undefined, activeOnly?: boolean) => Promise<Subscription[]>
getSubscriptionByProviderId: (provider: string, providerSubscriptionId: string) => Promise<Subscription | null>
@@ -1268,6 +1274,34 @@ class AccountClientImpl implements AccountClient {
})
}
async getMySecurityLoginHistory (params: SecurityLoginHistoryParams = {}): Promise<SecurityLoginHistoryEvent[]> {
return await this._rpc({
method: 'getMySecurityLoginHistory',
params
})
}
async exportMySecurityLoginHistory (params: SecurityLoginHistoryParams = {}): Promise<SecurityLoginHistoryEvent[]> {
return await this._rpc({
method: 'exportMySecurityLoginHistory',
params
})
}
async eraseMySecurityLoginHistory (): Promise<void> {
await this._rpc({
method: 'eraseMySecurityLoginHistory',
params: {}
})
}
async reportSecurityLoginConcern (params?: { loginEventId?: string }): Promise<void> {
await this._rpc({
method: 'reportSecurityLoginConcern',
params: params ?? {}
})
}
async getSubscriptions (
workspaceUuid: WorkspaceUuid | undefined = undefined,
activeOnly: boolean = true
@@ -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
+16 -1
View File
@@ -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"
}
}
+16 -1
View File
@@ -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"
}
}
+16 -1
View File
@@ -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"
}
}
+16 -1
View File
@@ -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"
}
}
+16 -1
View File
@@ -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"
}
}
+16 -1
View File
@@ -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"
}
}
+16 -1
View File
@@ -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": "報告"
}
}
+16 -1
View File
@@ -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"
}
}
+16 -1
View File
@@ -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"
}
}
+16 -1
View File
@@ -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": "Сообщить"
}
}
+16 -1
View File
@@ -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"
}
}
+16 -1
View File
@@ -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": "报告"
}
}
@@ -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)
})
})
+16 -1
View File
@@ -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
}
})
@@ -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<Pick<SecurityLoginHistoryEvent, 'city' | 'country'>>): 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<SecurityLoginHistoryEvent, 'success'>): boolean {
return event.success
}
+32 -1
View File
@@ -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
}
+28 -1
View File
@@ -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'
}
}
])
})
})
@@ -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')
})
})
+26
View File
@@ -51,6 +51,7 @@ import type {
SocialId,
Sort,
UserProfile,
SecurityLoginEvent,
Subscription,
WorkspaceData,
WorkspaceInfoWithStatus,
@@ -408,6 +409,7 @@ export class MongoAccountDB implements AccountDB {
integrationSecret: MongoDbCollection<IntegrationSecret>
userProfile: MongoDbCollection<UserProfile, 'personUuid'>
subscription: MongoDbCollection<Subscription, 'id'>
securityLoginEvent: MongoDbCollection<SecurityLoginEvent, 'id'>
workspaceMembers: MongoDbCollection<WorkspaceMember>
workspacePermission: MongoDbCollection<WorkspacePermission>
@@ -428,6 +430,7 @@ export class MongoAccountDB implements AccountDB {
this.integrationSecret = new MongoDbCollection<IntegrationSecret>('integrationSecret', db)
this.userProfile = new MongoDbCollection<UserProfile, 'personUuid'>('user_profile', db, 'personUuid')
this.subscription = new MongoDbCollection<Subscription, 'id'>('subscription', db, 'id')
this.securityLoginEvent = new MongoDbCollection<SecurityLoginEvent, 'id'>('securityLoginEvent', db, 'id')
this.workspaceMembers = new MongoDbCollection<WorkspaceMember>('workspaceMembers', db)
this.workspacePermission = new MongoDbCollection<WorkspacePermission>('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<void> {
@@ -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 })
@@ -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);
`
]
}
@@ -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<IntegrationSecret>
userProfile: PostgresDbCollection<UserProfile, 'personUuid'>
subscription: PostgresDbCollection<Subscription, 'id'>
securityLoginEvent: PostgresDbCollection<SecurityLoginEvent, 'id'>
workspacePermission: PostgresDbCollection<WorkspacePermission>
constructor (
@@ -604,6 +606,12 @@ export class PostgresAccountDB implements AccountDB {
timestampFields: ['periodStart', 'periodEnd', 'trialEnd', 'canceledAt', 'willCancelAt', 'createdOn', 'updatedOn'],
withRetryClient
})
this.securityLoginEvent = new PostgresDbCollection<SecurityLoginEvent, 'id'>('security_login_event', client, {
ns,
idKey: 'id',
timestampFields: ['eventTime', 'createdOn'],
withRetryClient
})
this.workspacePermission = new PostgresDbCollection<WorkspacePermission>('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
+378 -16
View File
@@ -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<LoginInfo> {
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<OtpInfo> {
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<LoginInfo> {
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<SecurityLoginEvent[]> {
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<SecurityLoginEvent> = {
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<SecurityLoginEvent[]> {
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<SecurityLoginEvent[]> {
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<SecurityLoginEvent> = {
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<SecurityLoginEvent[]> {
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<string, never>
): Promise<void> {
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<void> {
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<string, unknown> | 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<Record<AccountMe
hasWorkspacePermission: wrap(hasWorkspacePermission),
getWorkspacePermissions: wrap(getWorkspacePermissions),
getWorkspaceUsersWithPermission: wrap(getWorkspaceUsersWithPermission),
getMySecurityLoginHistory: wrap(getMySecurityLoginHistory),
getWorkspaceSecurityLoginHistory: wrap(getWorkspaceSecurityLoginHistory),
exportMySecurityLoginHistory: wrap(exportMySecurityLoginHistory),
eraseMySecurityLoginHistory: wrap(eraseMySecurityLoginHistory),
reportSecurityLoginConcern: wrap(reportSecurityLoginConcern),
/* READ OPERATIONS */
getRegionInfo: wrap(getRegionInfo),
@@ -0,0 +1,35 @@
//
// 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 platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
const buckets = new Map<string, number[]>()
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)
}
+144
View File
@@ -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<SecurityLoginEvent, 'id' | 'createdOn'>
recentHistory: SecurityLoginEvent[]
}
export interface SecurityPolicyEvaluationResult {
policyVersion: string
anomalyCodes: string[]
}
export interface SecurityPolicyEngine {
evaluateEvent: (input: SecurityPolicyEvaluationInput) => Promise<SecurityPolicyEvaluationResult>
}
export class NoopPolicyEngine implements SecurityPolicyEngine {
async evaluateEvent (input: SecurityPolicyEvaluationInput): Promise<SecurityPolicyEvaluationResult> {
const { event, recentHistory } = input
const anomalyCodes = new Set<string>()
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<SecurityPolicyEngine> {
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
}
}
+26 -1
View File
@@ -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<IntegrationSecret>
userProfile: DbCollection<UserProfile>
subscription: DbCollection<Subscription>
securityLoginEvent: DbCollection<SecurityLoginEvent>
workspacePermission: DbCollection<WorkspacePermission>
init: () => Promise<void>
@@ -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<Account, 'hash' | 'salt'>, Person {
+140
View File
@@ -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<void> {
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<string, unknown>) => void }
): Promise<void> {
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<void> {
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<SecurityLoginEvent, 'id' | 'createdOn'> = {
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',