diff --git a/apps/frontend/app/auth/login/page.tsx b/apps/frontend/app/auth/login/page.tsx index a199b2e..515569c 100644 --- a/apps/frontend/app/auth/login/page.tsx +++ b/apps/frontend/app/auth/login/page.tsx @@ -23,6 +23,8 @@ import { useToast } from '@/components/id/toast-provider'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { PinInput } from '@/components/ui/pin-input'; +import { isPinInputComplete } from '@/lib/pin-input'; import { OtpInput } from '@/components/ui/otp-input'; @@ -1369,7 +1371,7 @@ export default function LoginPage() {
- setPin(event.target.value)} + onChange={setPin} required @@ -1387,7 +1389,7 @@ export default function LoginPage() { /> -
diff --git a/apps/frontend/app/security/page.tsx b/apps/frontend/app/security/page.tsx index 648ae6b..7119801 100644 --- a/apps/frontend/app/security/page.tsx +++ b/apps/frontend/app/security/page.tsx @@ -23,8 +23,11 @@ import { useToast } from '@/components/id/toast-provider'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; +import { PinInput } from '@/components/ui/pin-input'; +import { isPinInputComplete } from '@/lib/pin-input'; import { OtpInput } from '@/components/ui/otp-input'; import { ActiveDevice, ActiveSession, apiFetch, AUTH_SESSION_KEY, getApiErrorMessage, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api'; +import { markPinIdleWatch } from '@/lib/pin-idle-storage'; import { formatUserAgentLabel } from '@/lib/device-client'; import { cn } from '@/lib/utils'; import { QRCodeSVG } from 'qrcode.react'; @@ -51,8 +54,8 @@ const deviceIcons: Record = { type DialogMode = 'pin' | 'password' | 'backupEmail' | 'backupPhone' | 'totpSetup' | 'totpDisable' | null; -const dialogConfig: Record, { title: string; placeholder: string; type: string }> = { - pin: { title: 'Установить PIN-код', placeholder: 'PIN из 4-6 цифр', type: 'password' }, +const dialogConfig: Record, { title: string; placeholder: string; type?: string }> = { + pin: { title: 'Установить PIN-код', placeholder: 'PIN из 4-6 цифр' }, password: { title: 'Установить пароль', placeholder: 'Новый пароль (мин. 8 символов)', type: 'password' }, backupEmail: { title: 'Резервная почта', placeholder: 'backup@example.com', type: 'email' }, backupPhone: { title: 'Резервный телефон', placeholder: '+79991234567', type: 'tel' } @@ -336,6 +339,7 @@ export default function SecurityPage() { try { if (dialogMode === 'pin') { await apiFetch(`/security/users/${user.id}/pin/setup`, { method: 'POST', body: JSON.stringify({ pin: dialogValue.trim() }) }, token); + markPinIdleWatch(user.id); showToast('PIN-код установлен'); } else if (dialogMode === 'password') { await apiFetch(`/profile/users/${user.id}/password`, { method: 'POST', body: JSON.stringify({ password: dialogValue }) }, token); @@ -694,15 +698,29 @@ export default function SecurityPage() { <>
- setDialogValue(event.target.value)} - /> + {dialogMode === 'pin' ? ( + + ) : ( + setDialogValue(event.target.value)} + /> + )}
- diff --git a/apps/frontend/components/id/auth-provider.tsx b/apps/frontend/components/id/auth-provider.tsx index 50d22e4..6b49a8a 100644 --- a/apps/frontend/components/id/auth-provider.tsx +++ b/apps/frontend/components/id/auth-provider.tsx @@ -41,6 +41,8 @@ import { useToast } from './toast-provider'; import { PinLockModal } from './pin-lock-modal'; import { AppBootstrapScreen } from './app-bootstrap-screen'; import { EMBEDDED_AUTH_MESSAGE, type EmbeddedAuthPayload } from '@/lib/embedded-auth-bridge'; +import { usePinIdleLock } from '@/hooks/use-pin-idle-lock'; +import { clearPinIdleWatch, isPinIdleWatchActive, markPinIdleWatch } from '@/lib/pin-idle-storage'; interface AuthContextValue { user: PublicUser | null; @@ -126,7 +128,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const router = useRouter(); const pathname = usePathname(); const { showToast } = useToast(); - const { publicApiUrl } = usePublicSettings(); + const { publicApiUrl, pinLockTimeoutMinutes } = usePublicSettings(); const initialAuth = React.useMemo(() => readInitialAuthState(), []); const [token, setToken] = React.useState(initialAuth.token); const [user, setUser] = React.useState(null); @@ -188,6 +190,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const activatePinLock = React.useCallback((sessionId: string) => { const resolvedSessionId = sessionId || window.localStorage.getItem(AUTH_SESSION_KEY); if (!resolvedSessionId) return; + markPinIdleWatch(user?.id); setLockedSessionId(resolvedSessionId); setIsPinLocked(true); setPinError(null); @@ -196,7 +199,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (cachedUser) { setUser(cachedUser); } - }, []); + }, [user?.id]); const warmUpAndApplySession = React.useCallback( async (accessToken: string) => { @@ -275,6 +278,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { window.localStorage.removeItem(AUTH_REFRESH_KEY); window.localStorage.removeItem(AUTH_SESSION_KEY); window.localStorage.removeItem(AUTH_USER_CACHE_KEY); + clearPinIdleWatch(); initialBootstrapDoneRef.current = false; setToken(null); setUser(null); @@ -456,22 +460,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { void refreshProfile(); }, [refreshProfile]); - React.useEffect(() => { - if (!isSessionReady || isPinLocked) return; - if (pathname.startsWith('/auth/')) return; - const accessToken = token ?? window.localStorage.getItem(AUTH_TOKEN_KEY); - if (!accessToken) return; + const pinIdleLockEnabled = + isSessionReady && + !isPinLocked && + !pathname.startsWith('/auth/') && + Boolean(token ?? (typeof window !== 'undefined' ? window.localStorage.getItem(AUTH_TOKEN_KEY) : null)) && + isPinIdleWatchActive(user?.id); - void fetchAuthSession(accessToken) - .then((session) => { - applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); - }) - .catch((error) => { - if (isPinRequiredError(error)) { - activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); - } - }); - }, [activatePinLock, clearPinLock, isPinLocked, isSessionReady, pathname, token]); + usePinIdleLock({ + enabled: pinIdleLockEnabled, + timeoutMinutes: pinLockTimeoutMinutes, + accessToken: token, + onLockRequired: activatePinLock, + }); React.useEffect(() => { function handleVisibilityChange() { @@ -515,6 +516,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { await establishSession(auth); } else { persistPartialAuth(auth); + markPinIdleWatch(auth.user.id); setHasStoredSession(true); } return auth; @@ -550,6 +552,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { await establishSession(response.auth); } else if (response.auth) { persistPartialAuth(response.auth); + markPinIdleWatch(response.auth.user.id); setHasStoredSession(true); } return response; @@ -571,6 +574,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { await establishSession(auth); } else { persistPartialAuth(auth); + markPinIdleWatch(auth.user.id); setHasStoredSession(true); } return auth; @@ -592,6 +596,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { await establishSession(auth); } else { persistPartialAuth(auth); + markPinIdleWatch(auth.user.id); setHasStoredSession(true); } return auth; @@ -620,6 +625,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { await establishSession(auth); } else { persistPartialAuth(auth); + markPinIdleWatch(auth.user.id); setHasStoredSession(true); } return auth; @@ -650,6 +656,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { window.localStorage.setItem(AUTH_TOKEN_KEY, response.accessToken); window.localStorage.setItem(AUTH_SESSION_KEY, sessionId); setToken(response.accessToken); + markPinIdleWatch(user?.id); try { await warmUpAndApplySession(response.accessToken); @@ -673,7 +680,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { router.replace('/'); } }, - [clearPinLock, pathname, router, warmUpAndApplySession] + [clearPinLock, pathname, router, user?.id, warmUpAndApplySession] ); const handlePinUnlock = React.useCallback( diff --git a/apps/frontend/components/id/pin-lock-modal.tsx b/apps/frontend/components/id/pin-lock-modal.tsx index e20797b..218c829 100644 --- a/apps/frontend/components/id/pin-lock-modal.tsx +++ b/apps/frontend/components/id/pin-lock-modal.tsx @@ -4,7 +4,8 @@ import * as React from 'react'; import { KeyRound } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; +import { PinInput } from '@/components/ui/pin-input'; +import { isPinInputComplete } from '@/lib/pin-input'; interface PinLockModalProps { open: boolean; @@ -39,20 +40,18 @@ export function PinLockModal({ open, isSubmitting, error, onSubmit }: PinLockMod
- setPin(event.target.value)} + onChange={setPin} required minLength={4} maxLength={6} - inputMode="numeric" - autoComplete="one-time-code" /> {error ?

{error}

: null} -
diff --git a/apps/frontend/components/id/public-settings-provider.tsx b/apps/frontend/components/id/public-settings-provider.tsx index 2d87005..ef9f2a5 100644 --- a/apps/frontend/components/id/public-settings-provider.tsx +++ b/apps/frontend/components/id/public-settings-provider.tsx @@ -11,6 +11,7 @@ interface PublicSettingsContextValue { publicFrontendUrl: string; ldapEnabled: boolean; ldapUseLdaps: boolean; + pinLockTimeoutMinutes: number; isLoading: boolean; refreshPublicSettings: () => Promise; } @@ -22,6 +23,7 @@ const PublicSettingsContext = createContext({ publicFrontendUrl: DEFAULT_PUBLIC_FRONTEND_URL, ldapEnabled: false, ldapUseLdaps: false, + pinLockTimeoutMinutes: 15, isLoading: true, refreshPublicSettings: async () => undefined }); @@ -65,6 +67,7 @@ export function PublicSettingsProvider({ children }: { children: React.ReactNode publicFrontendUrl: publicFrontendUrl.replace(/\/+$/, ''), ldapEnabled: ['true', '1', 'yes'].includes((settings.LDAP_ENABLED ?? '').trim().toLowerCase()), ldapUseLdaps: ['true', '1', 'yes'].includes((settings.LDAP_USE_LDAPS ?? '').trim().toLowerCase()), + pinLockTimeoutMinutes: Math.max(1, Number.parseInt(settings.PIN_LOCK_TIMEOUT_MINUTES ?? '15', 10) || 15), isLoading, refreshPublicSettings }; diff --git a/apps/frontend/components/ui/pin-input.tsx b/apps/frontend/components/ui/pin-input.tsx new file mode 100644 index 0000000..7dbe8b2 --- /dev/null +++ b/apps/frontend/components/ui/pin-input.tsx @@ -0,0 +1,65 @@ +'use client'; + +import * as React from 'react'; +import { Input } from '@/components/ui/input'; +import { PIN_MAX_LENGTH, sanitizePinInput } from '@/lib/pin-input'; + +const PIN_ALLOWED_KEYS = new Set([ + 'Backspace', + 'Delete', + 'Tab', + 'Escape', + 'Enter', + 'ArrowLeft', + 'ArrowRight', + 'Home', + 'End', +]); + +export interface PinInputProps extends Omit, 'value' | 'onChange' | 'inputMode'> { + value: string; + onChange: (value: string) => void; + maxLength?: number; +} + +export function PinInput({ + value, + onChange, + maxLength = PIN_MAX_LENGTH, + onKeyDown, + onPaste, + type = 'password', + ...props +}: PinInputProps) { + function handleChange(event: React.ChangeEvent) { + onChange(sanitizePinInput(event.target.value, maxLength)); + } + + function handleKeyDown(event: React.KeyboardEvent) { + if (!PIN_ALLOWED_KEYS.has(event.key) && !event.ctrlKey && !event.metaKey && !/^\d$/.test(event.key)) { + event.preventDefault(); + } + onKeyDown?.(event); + } + + function handlePaste(event: React.ClipboardEvent) { + event.preventDefault(); + onChange(sanitizePinInput(event.clipboardData.getData('text'), maxLength)); + onPaste?.(event); + } + + return ( + + ); +} diff --git a/apps/frontend/hooks/use-pin-idle-lock.ts b/apps/frontend/hooks/use-pin-idle-lock.ts new file mode 100644 index 0000000..0b1bb0f --- /dev/null +++ b/apps/frontend/hooks/use-pin-idle-lock.ts @@ -0,0 +1,192 @@ +'use client'; + +import { useCallback, useEffect, useRef } from 'react'; +import { + AUTH_SESSION_KEY, + AUTH_TOKEN_KEY, + fetchAuthSession, + isPinRequiredError, +} from '@/lib/api'; + +const ACTIVITY_EVENTS = ['mousedown', 'keydown', 'touchstart', 'scroll', 'wheel', 'pointerdown', 'click'] as const; +const ACTIVITY_DEBOUNCE_MS = 250; +const SERVER_TOUCH_INTERVAL_MS = 30_000; + +interface UsePinIdleLockOptions { + enabled: boolean; + timeoutMinutes: number; + accessToken: string | null; + onLockRequired: (sessionId: string) => void; +} + +export function usePinIdleLock({ + enabled, + timeoutMinutes, + accessToken, + onLockRequired, +}: UsePinIdleLockOptions) { + const onLockRequiredRef = useRef(onLockRequired); + const accessTokenRef = useRef(accessToken); + const timeoutMsRef = useRef(Math.max(1, timeoutMinutes) * 60_000); + const idleDeadlineRef = useRef(Date.now() + timeoutMsRef.current); + const idleTimerRef = useRef(null); + const activityDebounceRef = useRef(null); + const lastServerTouchRef = useRef(0); + const checkInFlightRef = useRef(false); + const enabledRef = useRef(enabled); + + useEffect(() => { + onLockRequiredRef.current = onLockRequired; + }, [onLockRequired]); + + useEffect(() => { + accessTokenRef.current = accessToken; + }, [accessToken]); + + useEffect(() => { + enabledRef.current = enabled; + }, [enabled]); + + useEffect(() => { + timeoutMsRef.current = Math.max(1, timeoutMinutes) * 60_000; + if (enabledRef.current) { + idleDeadlineRef.current = Date.now() + timeoutMsRef.current; + } + }, [timeoutMinutes]); + + const clearIdleTimer = useCallback(() => { + if (idleTimerRef.current !== null) { + window.clearTimeout(idleTimerRef.current); + idleTimerRef.current = null; + } + }, []); + + const syncServerActivity = useCallback(async () => { + const token = accessTokenRef.current ?? window.localStorage.getItem(AUTH_TOKEN_KEY); + if (!token) return; + + const now = Date.now(); + if (now - lastServerTouchRef.current < SERVER_TOUCH_INTERVAL_MS) return; + lastServerTouchRef.current = now; + + try { + await fetchAuthSession(token); + } catch (error) { + if (isPinRequiredError(error)) { + onLockRequiredRef.current(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); + } + } + }, []); + + const checkIdleLock = useCallback(async () => { + if (!enabledRef.current || checkInFlightRef.current) return; + + const remainingMs = idleDeadlineRef.current - Date.now(); + if (remainingMs > 0) { + clearIdleTimer(); + idleTimerRef.current = window.setTimeout(() => { + void checkIdleLock(); + }, remainingMs); + return; + } + + checkInFlightRef.current = true; + clearIdleTimer(); + + const token = accessTokenRef.current ?? window.localStorage.getItem(AUTH_TOKEN_KEY); + if (!token) { + checkInFlightRef.current = false; + return; + } + + try { + const session = await fetchAuthSession(token); + if (session.requiresPin) { + onLockRequiredRef.current(session.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); + return; + } + + idleDeadlineRef.current = Date.now() + timeoutMsRef.current; + clearIdleTimer(); + idleTimerRef.current = window.setTimeout(() => { + void checkIdleLock(); + }, timeoutMsRef.current); + } catch (error) { + if (isPinRequiredError(error)) { + onLockRequiredRef.current(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); + return; + } + + idleDeadlineRef.current = Date.now() + timeoutMsRef.current; + clearIdleTimer(); + idleTimerRef.current = window.setTimeout(() => { + void checkIdleLock(); + }, timeoutMsRef.current); + } finally { + checkInFlightRef.current = false; + } + }, [clearIdleTimer]); + + const registerActivity = useCallback(() => { + if (!enabledRef.current) return; + + idleDeadlineRef.current = Date.now() + timeoutMsRef.current; + clearIdleTimer(); + idleTimerRef.current = window.setTimeout(() => { + void checkIdleLock(); + }, timeoutMsRef.current); + + void syncServerActivity(); + }, [checkIdleLock, clearIdleTimer, syncServerActivity]); + + const scheduleActivityReset = useCallback(() => { + if (activityDebounceRef.current !== null) { + window.clearTimeout(activityDebounceRef.current); + } + activityDebounceRef.current = window.setTimeout(() => { + activityDebounceRef.current = null; + registerActivity(); + }, ACTIVITY_DEBOUNCE_MS); + }, [registerActivity]); + + useEffect(() => { + if (!enabled) { + clearIdleTimer(); + if (activityDebounceRef.current !== null) { + window.clearTimeout(activityDebounceRef.current); + activityDebounceRef.current = null; + } + return; + } + + idleDeadlineRef.current = Date.now() + timeoutMsRef.current; + lastServerTouchRef.current = Date.now(); + void checkIdleLock(); + + for (const eventName of ACTIVITY_EVENTS) { + window.addEventListener(eventName, scheduleActivityReset, { passive: true }); + } + + function handleVisibilityChange() { + if (document.visibilityState !== 'visible') { + clearIdleTimer(); + return; + } + void checkIdleLock(); + } + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + clearIdleTimer(); + if (activityDebounceRef.current !== null) { + window.clearTimeout(activityDebounceRef.current); + activityDebounceRef.current = null; + } + for (const eventName of ACTIVITY_EVENTS) { + window.removeEventListener(eventName, scheduleActivityReset); + } + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [checkIdleLock, clearIdleTimer, enabled, scheduleActivityReset]); +} diff --git a/apps/frontend/lib/pin-idle-storage.ts b/apps/frontend/lib/pin-idle-storage.ts new file mode 100644 index 0000000..902ae63 --- /dev/null +++ b/apps/frontend/lib/pin-idle-storage.ts @@ -0,0 +1,35 @@ +import { AUTH_USER_CACHE_KEY } from '@/lib/api'; + +const PIN_IDLE_WATCH_KEY = 'auth:pin-idle-watch-user'; + +function readCachedUserId(): string | null { + if (typeof window === 'undefined') return null; + const raw = window.localStorage.getItem(AUTH_USER_CACHE_KEY); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as { id?: string }; + return parsed.id ?? null; + } catch { + return null; + } +} + +export function markPinIdleWatch(userId?: string | null) { + if (typeof window === 'undefined') return; + const resolvedUserId = userId ?? readCachedUserId(); + if (!resolvedUserId) return; + window.localStorage.setItem(PIN_IDLE_WATCH_KEY, resolvedUserId); +} + +export function clearPinIdleWatch() { + if (typeof window === 'undefined') return; + window.localStorage.removeItem(PIN_IDLE_WATCH_KEY); +} + +export function isPinIdleWatchActive(userId?: string | null) { + if (typeof window === 'undefined') return false; + const stored = window.localStorage.getItem(PIN_IDLE_WATCH_KEY); + if (!stored) return false; + const resolvedUserId = userId ?? readCachedUserId(); + return Boolean(resolvedUserId && stored === resolvedUserId); +} diff --git a/apps/frontend/lib/pin-input.ts b/apps/frontend/lib/pin-input.ts new file mode 100644 index 0000000..c870496 --- /dev/null +++ b/apps/frontend/lib/pin-input.ts @@ -0,0 +1,10 @@ +export const PIN_MIN_LENGTH = 4; +export const PIN_MAX_LENGTH = 6; + +export function sanitizePinInput(value: string, maxLength: number = PIN_MAX_LENGTH): string { + return value.replace(/\D/g, '').slice(0, maxLength); +} + +export function isPinInputComplete(value: string, minLength: number = PIN_MIN_LENGTH): boolean { + return /^\d+$/.test(value) && value.length >= minLength && value.length <= PIN_MAX_LENGTH; +} diff --git a/apps/sso-core/src/domain/system-settings.seed.ts b/apps/sso-core/src/domain/system-settings.seed.ts index f35b6dc..2bbdfa1 100644 --- a/apps/sso-core/src/domain/system-settings.seed.ts +++ b/apps/sso-core/src/domain/system-settings.seed.ts @@ -132,7 +132,8 @@ export const PUBLIC_SETTING_KEYS = [ 'REGISTRATION_ENABLED', 'MAINTENANCE_MODE', 'LDAP_ENABLED', - 'LDAP_USE_LDAPS' + 'LDAP_USE_LDAPS', + 'PIN_LOCK_TIMEOUT_MINUTES' ] as const; @Injectable()