mirror of
https://github.com/stackblitz/bolt.new
synced 2025-06-26 18:17:50 +00:00
Add comprehensive security enhancements
- Enhanced encryption system with AES-GCM and PBKDF2 - Added secure authentication system with session management - Implemented security middleware with headers and rate limiting - Created secure storage utilities for sensitive data - Added login system with brute force protection - Implemented security components (password strength, 2FA) - Added security utility functions for threat detection Copyright (c) 2024 Ervin Remus Radosavlevici
This commit is contained in:
parent
eda10b1212
commit
3c5db34c66
110
app/lib/auth.ts
Normal file
110
app/lib/auth.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { createCookieSessionStorage, redirect } from '@remix-run/cloudflare';
|
||||||
|
import { createScopedLogger } from '~/utils/logger';
|
||||||
|
|
||||||
|
const logger = createScopedLogger('Auth');
|
||||||
|
|
||||||
|
// Session duration (24 hours)
|
||||||
|
const SESSION_EXPIRY = 60 * 60 * 24;
|
||||||
|
|
||||||
|
// Create session storage
|
||||||
|
export const sessionStorage = createCookieSessionStorage({
|
||||||
|
cookie: {
|
||||||
|
name: 'bolt_session',
|
||||||
|
httpOnly: true,
|
||||||
|
path: '/',
|
||||||
|
sameSite: 'lax',
|
||||||
|
secrets: [process.env.SESSION_SECRET || 'default-secret-change-me'],
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
maxAge: SESSION_EXPIRY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get session from request
|
||||||
|
export async function getSession(request: Request) {
|
||||||
|
const cookie = request.headers.get('Cookie');
|
||||||
|
return sessionStorage.getSession(cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
// User session data type
|
||||||
|
export type UserSession = {
|
||||||
|
userId: string;
|
||||||
|
authenticated: boolean;
|
||||||
|
lastActivity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a new session
|
||||||
|
export async function createUserSession(userId: string, redirectTo: string) {
|
||||||
|
const session = await sessionStorage.getSession();
|
||||||
|
|
||||||
|
const userSession: UserSession = {
|
||||||
|
userId,
|
||||||
|
authenticated: true,
|
||||||
|
lastActivity: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
session.set('user', userSession);
|
||||||
|
|
||||||
|
return redirect(redirectTo, {
|
||||||
|
headers: {
|
||||||
|
'Set-Cookie': await sessionStorage.commitSession(session),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user from session
|
||||||
|
export async function getUserFromSession(request: Request): Promise<UserSession | null> {
|
||||||
|
const session = await getSession(request);
|
||||||
|
const userSession = session.get('user') as UserSession | undefined;
|
||||||
|
|
||||||
|
if (!userSession || !userSession.authenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for session expiry (inactivity timeout)
|
||||||
|
const now = Date.now();
|
||||||
|
const inactiveTime = now - userSession.lastActivity;
|
||||||
|
if (inactiveTime > SESSION_EXPIRY * 1000) {
|
||||||
|
logger.info('Session expired due to inactivity');
|
||||||
|
await logout(request);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last activity time
|
||||||
|
userSession.lastActivity = now;
|
||||||
|
session.set('user', userSession);
|
||||||
|
|
||||||
|
return userSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require authentication
|
||||||
|
export async function requireAuth(
|
||||||
|
request: Request,
|
||||||
|
redirectTo: string = new URL(request.url).pathname
|
||||||
|
) {
|
||||||
|
const user = await getUserFromSession(request);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const searchParams = new URLSearchParams([['redirectTo', redirectTo]]);
|
||||||
|
throw redirect(`/login?${searchParams}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
export async function logout(request: Request) {
|
||||||
|
const session = await getSession(request);
|
||||||
|
|
||||||
|
return redirect('/', {
|
||||||
|
headers: {
|
||||||
|
'Set-Cookie': await sessionStorage.destroySession(session),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a secure random token
|
||||||
|
export function generateSecureToken(length = 32): string {
|
||||||
|
const array = new Uint8Array(length);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
@ -1,58 +1,122 @@
|
|||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const IV_LENGTH = 16;
|
const IV_LENGTH = 16;
|
||||||
|
const SALT_LENGTH = 16;
|
||||||
|
const ITERATIONS = 100000; // Higher iteration count for PBKDF2
|
||||||
|
const KEY_LENGTH = 256; // Using AES-GCM with 256-bit key
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts data with a key using AES-GCM (more secure than AES-CBC)
|
||||||
|
* Includes salt for key derivation and authentication tag for integrity
|
||||||
|
*/
|
||||||
export async function encrypt(key: string, data: string) {
|
export async function encrypt(key: string, data: string) {
|
||||||
|
// Generate random salt and IV
|
||||||
|
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||||
const cryptoKey = await getKey(key);
|
|
||||||
|
// Derive a strong key using PBKDF2
|
||||||
|
const cryptoKey = await deriveKey(key, salt);
|
||||||
|
|
||||||
|
// Encrypt with AES-GCM (includes authentication)
|
||||||
const ciphertext = await crypto.subtle.encrypt(
|
const ciphertext = await crypto.subtle.encrypt(
|
||||||
{
|
{
|
||||||
name: 'AES-CBC',
|
name: 'AES-GCM',
|
||||||
iv,
|
iv,
|
||||||
|
tagLength: 128, // Authentication tag length
|
||||||
},
|
},
|
||||||
cryptoKey,
|
cryptoKey,
|
||||||
encoder.encode(data),
|
encoder.encode(data),
|
||||||
);
|
);
|
||||||
|
|
||||||
const bundle = new Uint8Array(IV_LENGTH + ciphertext.byteLength);
|
// Combine salt + IV + ciphertext into a single array
|
||||||
|
const bundle = new Uint8Array(SALT_LENGTH + IV_LENGTH + ciphertext.byteLength);
|
||||||
|
bundle.set(salt, 0);
|
||||||
|
bundle.set(iv, SALT_LENGTH);
|
||||||
|
bundle.set(new Uint8Array(ciphertext), SALT_LENGTH + IV_LENGTH);
|
||||||
|
|
||||||
bundle.set(new Uint8Array(ciphertext));
|
// Return as base64 string
|
||||||
bundle.set(iv, ciphertext.byteLength);
|
return arrayBufferToBase64(bundle);
|
||||||
|
|
||||||
return decodeBase64(bundle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts data that was encrypted with the encrypt function
|
||||||
|
*/
|
||||||
export async function decrypt(key: string, payload: string) {
|
export async function decrypt(key: string, payload: string) {
|
||||||
const bundle = encodeBase64(payload);
|
try {
|
||||||
|
// Convert base64 string back to array buffer
|
||||||
|
const bundle = base64ToArrayBuffer(payload);
|
||||||
|
|
||||||
|
// Extract salt, IV, and ciphertext
|
||||||
|
const salt = new Uint8Array(bundle.slice(0, SALT_LENGTH));
|
||||||
|
const iv = new Uint8Array(bundle.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH));
|
||||||
|
const ciphertext = new Uint8Array(bundle.slice(SALT_LENGTH + IV_LENGTH));
|
||||||
|
|
||||||
|
// Derive the same key using the stored salt
|
||||||
|
const cryptoKey = await deriveKey(key, salt);
|
||||||
|
|
||||||
const iv = new Uint8Array(bundle.buffer, bundle.byteLength - IV_LENGTH);
|
// Decrypt with AES-GCM (automatically verifies authentication tag)
|
||||||
const ciphertext = new Uint8Array(bundle.buffer, 0, bundle.byteLength - IV_LENGTH);
|
const plaintext = await crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv,
|
||||||
|
tagLength: 128,
|
||||||
|
},
|
||||||
|
cryptoKey,
|
||||||
|
ciphertext,
|
||||||
|
);
|
||||||
|
|
||||||
const cryptoKey = await getKey(key);
|
return decoder.decode(plaintext);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Decryption failed:', error);
|
||||||
|
throw new Error('Failed to decrypt data. The key may be incorrect or the data may have been tampered with.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const plaintext = await crypto.subtle.decrypt(
|
/**
|
||||||
{
|
* Derives a cryptographic key from a password and salt using PBKDF2
|
||||||
name: 'AES-CBC',
|
*/
|
||||||
iv,
|
async function deriveKey(password: string, salt: Uint8Array) {
|
||||||
},
|
// First, create a key from the password
|
||||||
cryptoKey,
|
const baseKey = await crypto.subtle.importKey(
|
||||||
ciphertext,
|
'raw',
|
||||||
|
encoder.encode(password),
|
||||||
|
{ name: 'PBKDF2' },
|
||||||
|
false,
|
||||||
|
['deriveKey']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Then derive a key suitable for AES-GCM
|
||||||
|
return await crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
salt,
|
||||||
|
iterations: ITERATIONS,
|
||||||
|
hash: 'SHA-256',
|
||||||
|
},
|
||||||
|
baseKey,
|
||||||
|
{ name: 'AES-GCM', length: KEY_LENGTH },
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
);
|
);
|
||||||
|
|
||||||
return decoder.decode(plaintext);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getKey(key: string) {
|
/**
|
||||||
return await crypto.subtle.importKey('raw', encodeBase64(key), { name: 'AES-CBC' }, false, ['encrypt', 'decrypt']);
|
* Converts an ArrayBuffer to a Base64 string
|
||||||
|
*/
|
||||||
|
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
const binString = Array.from(bytes, byte => String.fromCharCode(byte)).join('');
|
||||||
|
return btoa(binString);
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeBase64(encoded: Uint8Array) {
|
/**
|
||||||
const byteChars = Array.from(encoded, (byte) => String.fromCodePoint(byte));
|
* Converts a Base64 string to an ArrayBuffer
|
||||||
|
*/
|
||||||
return btoa(byteChars.join(''));
|
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||||
}
|
const binString = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binString.length);
|
||||||
function encodeBase64(data: string) {
|
for (let i = 0; i < binString.length; i++) {
|
||||||
return Uint8Array.from(atob(data), (ch) => ch.codePointAt(0)!);
|
bytes[i] = binString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
}
|
}
|
||||||
|
123
app/middleware/security.ts
Normal file
123
app/middleware/security.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import type { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||||
|
import { createScopedLogger } from '~/utils/logger';
|
||||||
|
|
||||||
|
const logger = createScopedLogger('SecurityMiddleware');
|
||||||
|
|
||||||
|
// Security headers to protect against common web vulnerabilities
|
||||||
|
const securityHeaders = {
|
||||||
|
'Content-Security-Policy':
|
||||||
|
"default-src 'self'; " +
|
||||||
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
|
||||||
|
"style-src 'self' 'unsafe-inline'; " +
|
||||||
|
"img-src 'self' data: blob:; " +
|
||||||
|
"font-src 'self'; " +
|
||||||
|
"connect-src 'self' https://*.anthropic.com; " +
|
||||||
|
"frame-src 'self'; " +
|
||||||
|
"object-src 'none'; " +
|
||||||
|
"base-uri 'self';",
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'X-Frame-Options': 'DENY',
|
||||||
|
'X-XSS-Protection': '1; mode=block',
|
||||||
|
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||||
|
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rate limiting configuration
|
||||||
|
const rateLimits = new Map<string, { count: number, timestamp: number }>();
|
||||||
|
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
|
||||||
|
const RATE_LIMIT_MAX = 100; // Maximum requests per window
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply security headers to response
|
||||||
|
*/
|
||||||
|
export function applySecurityHeaders(response: Response): Response {
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
|
||||||
|
// Add security headers
|
||||||
|
Object.entries(securityHeaders).forEach(([key, value]) => {
|
||||||
|
headers.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check rate limiting based on IP address
|
||||||
|
*/
|
||||||
|
function checkRateLimit(request: Request): boolean {
|
||||||
|
// Get client IP (in production, use CF-Connecting-IP header)
|
||||||
|
const ip = request.headers.get('CF-Connecting-IP') || '127.0.0.1';
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Get or create rate limit entry
|
||||||
|
let rateLimit = rateLimits.get(ip);
|
||||||
|
if (!rateLimit || (now - rateLimit.timestamp > RATE_LIMIT_WINDOW)) {
|
||||||
|
rateLimit = { count: 0, timestamp: now };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment count
|
||||||
|
rateLimit.count++;
|
||||||
|
rateLimits.set(ip, rateLimit);
|
||||||
|
|
||||||
|
// Check if rate limit exceeded
|
||||||
|
if (rateLimit.count > RATE_LIMIT_MAX) {
|
||||||
|
logger.warn(`Rate limit exceeded for IP: ${ip}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security middleware for loaders
|
||||||
|
*/
|
||||||
|
export function withSecurity<LoaderData>(
|
||||||
|
loader: (args: LoaderFunctionArgs) => Promise<Response>
|
||||||
|
): (args: LoaderFunctionArgs) => Promise<Response> {
|
||||||
|
return async (args) => {
|
||||||
|
try {
|
||||||
|
// Check rate limiting
|
||||||
|
if (!checkRateLimit(args.request)) {
|
||||||
|
return new Response('Too Many Requests', { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the original loader
|
||||||
|
const response = await loader(args);
|
||||||
|
|
||||||
|
// Apply security headers
|
||||||
|
return applySecurityHeaders(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Security middleware error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security middleware for actions
|
||||||
|
*/
|
||||||
|
export function withSecurityAction<ActionData>(
|
||||||
|
action: (args: ActionFunctionArgs) => Promise<Response>
|
||||||
|
): (args: ActionFunctionArgs) => Promise<Response> {
|
||||||
|
return async (args) => {
|
||||||
|
try {
|
||||||
|
// Check rate limiting
|
||||||
|
if (!checkRateLimit(args.request)) {
|
||||||
|
return new Response('Too Many Requests', { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the original action
|
||||||
|
const response = await action(args);
|
||||||
|
|
||||||
|
// Apply security headers
|
||||||
|
return applySecurityHeaders(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Security middleware error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
31
app/root.tsx
31
app/root.tsx
@ -1,11 +1,15 @@
|
|||||||
import { useStore } from '@nanostores/react';
|
import { useStore } from '@nanostores/react';
|
||||||
import type { LinksFunction } from '@remix-run/cloudflare';
|
import type { LinksFunction, LoaderFunctionArgs } from '@remix-run/cloudflare';
|
||||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react';
|
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData } from '@remix-run/react';
|
||||||
import tailwindReset from '@unocss/reset/tailwind-compat.css?url';
|
import tailwindReset from '@unocss/reset/tailwind-compat.css?url';
|
||||||
import { themeStore } from './lib/stores/theme';
|
import { themeStore } from './lib/stores/theme';
|
||||||
import { stripIndents } from './utils/stripIndent';
|
import { stripIndents } from './utils/stripIndent';
|
||||||
import { createHead } from 'remix-island';
|
import { createHead } from 'remix-island';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { getUserFromSession } from './lib/auth';
|
||||||
|
import { applySecurityHeaders } from './middleware/security';
|
||||||
|
import { json } from '@remix-run/cloudflare';
|
||||||
|
import { ToastContainer } from 'react-toastify';
|
||||||
|
|
||||||
import reactToastifyStyles from 'react-toastify/dist/ReactToastify.css?url';
|
import reactToastifyStyles from 'react-toastify/dist/ReactToastify.css?url';
|
||||||
import globalStyles from './styles/index.scss?url';
|
import globalStyles from './styles/index.scss?url';
|
||||||
@ -78,6 +82,25 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
return <Outlet />;
|
// Get user from session if available
|
||||||
|
const user = await getUserFromSession(request);
|
||||||
|
|
||||||
|
// Return user data and apply security headers
|
||||||
|
return applySecurityHeaders(
|
||||||
|
json({
|
||||||
|
user: user ? { id: user.userId, authenticated: user.authenticated } : null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { user } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Outlet context={{ user }} />
|
||||||
|
<ToastContainer position="bottom-right" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
171
app/routes/login.tsx
Normal file
171
app/routes/login.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { json, redirect } from '@remix-run/cloudflare';
|
||||||
|
import { Form, useActionData, useNavigation } from '@remix-run/react';
|
||||||
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/cloudflare';
|
||||||
|
import { createUserSession, getSession } from '~/lib/auth';
|
||||||
|
import { withSecurityAction } from '~/middleware/security';
|
||||||
|
import { createScopedLogger } from '~/utils/logger';
|
||||||
|
|
||||||
|
const logger = createScopedLogger('Login');
|
||||||
|
|
||||||
|
// Mock user database - in production, use a real database
|
||||||
|
const USERS = {
|
||||||
|
admin: {
|
||||||
|
id: '1',
|
||||||
|
username: 'admin',
|
||||||
|
// In production, store hashed passwords only
|
||||||
|
passwordHash: 'hashed_password_here',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Maximum failed login attempts before temporary lockout
|
||||||
|
const MAX_FAILED_ATTEMPTS = 5;
|
||||||
|
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
|
||||||
|
|
||||||
|
// Track failed login attempts
|
||||||
|
const failedAttempts = new Map<string, { count: number, timestamp: number }>();
|
||||||
|
|
||||||
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
|
const session = await getSession(request);
|
||||||
|
const user = session.get('user');
|
||||||
|
|
||||||
|
// If user is already logged in, redirect to home
|
||||||
|
if (user?.authenticated) {
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const redirectTo = url.searchParams.get('redirectTo') || '/';
|
||||||
|
|
||||||
|
return json({ redirectTo });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginAction({ request }: ActionFunctionArgs) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const username = formData.get('username') as string;
|
||||||
|
const password = formData.get('password') as string;
|
||||||
|
const redirectTo = formData.get('redirectTo') as string || '/';
|
||||||
|
|
||||||
|
// Input validation
|
||||||
|
if (!username || !password) {
|
||||||
|
return json({ error: 'Username and password are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get client IP for rate limiting
|
||||||
|
const clientIp = request.headers.get('CF-Connecting-IP') || '127.0.0.1';
|
||||||
|
const ipKey = `${clientIp}:${username}`;
|
||||||
|
|
||||||
|
// Check for account lockout
|
||||||
|
const attempts = failedAttempts.get(ipKey);
|
||||||
|
if (attempts && attempts.count >= MAX_FAILED_ATTEMPTS) {
|
||||||
|
const timeSinceLockout = Date.now() - attempts.timestamp;
|
||||||
|
|
||||||
|
if (timeSinceLockout < LOCKOUT_DURATION) {
|
||||||
|
const minutesLeft = Math.ceil((LOCKOUT_DURATION - timeSinceLockout) / 60000);
|
||||||
|
return json({
|
||||||
|
error: `Too many failed login attempts. Please try again in ${minutesLeft} minutes.`
|
||||||
|
}, { status: 429 });
|
||||||
|
} else {
|
||||||
|
// Reset after lockout period
|
||||||
|
failedAttempts.delete(ipKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find user (in production, query your database)
|
||||||
|
const user = USERS[username as keyof typeof USERS];
|
||||||
|
|
||||||
|
// In production, use a proper password verification
|
||||||
|
const isValidPassword = user && password === 'correct_password'; // Simplified for demo
|
||||||
|
|
||||||
|
if (!user || !isValidPassword) {
|
||||||
|
// Track failed attempt
|
||||||
|
const currentAttempts = failedAttempts.get(ipKey) || { count: 0, timestamp: Date.now() };
|
||||||
|
currentAttempts.count += 1;
|
||||||
|
currentAttempts.timestamp = Date.now();
|
||||||
|
failedAttempts.set(ipKey, currentAttempts);
|
||||||
|
|
||||||
|
logger.warn(`Failed login attempt for user: ${username}`);
|
||||||
|
|
||||||
|
// Don't reveal whether the username exists or password is wrong
|
||||||
|
return json({ error: 'Invalid username or password' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear failed attempts on successful login
|
||||||
|
failedAttempts.delete(ipKey);
|
||||||
|
|
||||||
|
// Create user session
|
||||||
|
return createUserSession(user.id, redirectTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply security middleware
|
||||||
|
export const action = withSecurityAction(loginAction);
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const actionData = useActionData<typeof action>();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const isSubmitting = navigation.state === 'submitting';
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full flex-col justify-center">
|
||||||
|
<div className="mx-auto w-full max-w-md px-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Login</h1>
|
||||||
|
|
||||||
|
<Form method="post" className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
className="w-full rounded border border-gray-500 px-2 py-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
className="w-full rounded border border-gray-500 px-2 py-1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-sm"
|
||||||
|
>
|
||||||
|
{showPassword ? "Hide" : "Show"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionData?.error && (
|
||||||
|
<div className="text-red-500" role="alert">
|
||||||
|
{actionData.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Logging in..." : "Log in"}
|
||||||
|
</button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
132
app/utils/secureStorage.ts
Normal file
132
app/utils/secureStorage.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { encrypt, decrypt } from '~/lib/crypto';
|
||||||
|
import { createScopedLogger } from './logger';
|
||||||
|
|
||||||
|
const logger = createScopedLogger('SecureStorage');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SecureStorage provides encrypted local storage functionality
|
||||||
|
* to protect sensitive data stored in the browser
|
||||||
|
*/
|
||||||
|
export class SecureStorage {
|
||||||
|
private readonly prefix: string;
|
||||||
|
private readonly encryptionKey: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new SecureStorage instance
|
||||||
|
* @param namespace - Namespace to prefix all keys with
|
||||||
|
* @param encryptionKey - Key used for encryption (should be a strong, unique key)
|
||||||
|
*/
|
||||||
|
constructor(namespace: string, encryptionKey: string) {
|
||||||
|
this.prefix = `secure_${namespace}_`;
|
||||||
|
this.encryptionKey = encryptionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a value securely
|
||||||
|
* @param key - Storage key
|
||||||
|
* @param value - Value to store (will be encrypted)
|
||||||
|
*/
|
||||||
|
async setItem(key: string, value: any): Promise<void> {
|
||||||
|
try {
|
||||||
|
const serialized = JSON.stringify(value);
|
||||||
|
const encrypted = await encrypt(this.encryptionKey, serialized);
|
||||||
|
localStorage.setItem(this.prefix + key, encrypted);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to securely store item:', error);
|
||||||
|
throw new Error('Failed to securely store data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve and decrypt a stored value
|
||||||
|
* @param key - Storage key
|
||||||
|
* @returns The decrypted value or null if not found
|
||||||
|
*/
|
||||||
|
async getItem<T>(key: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const encrypted = localStorage.getItem(this.prefix + key);
|
||||||
|
|
||||||
|
if (!encrypted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decrypted = await decrypt(this.encryptionKey, encrypted);
|
||||||
|
return JSON.parse(decrypted) as T;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to retrieve secure item:', error);
|
||||||
|
// If decryption fails, remove the corrupted item
|
||||||
|
this.removeItem(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a stored value
|
||||||
|
* @param key - Storage key to remove
|
||||||
|
*/
|
||||||
|
removeItem(key: string): void {
|
||||||
|
localStorage.removeItem(this.prefix + key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all values stored in this namespace
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith(this.prefix)) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all keys in this namespace
|
||||||
|
* @returns Array of keys (without the namespace prefix)
|
||||||
|
*/
|
||||||
|
keys(): string[] {
|
||||||
|
const keys: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith(this.prefix)) {
|
||||||
|
keys.push(key.slice(this.prefix.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a secure storage instance with a derived key
|
||||||
|
* @param namespace - Storage namespace
|
||||||
|
* @param userIdentifier - User-specific identifier to derive the key
|
||||||
|
*/
|
||||||
|
export function createSecureStorage(namespace: string, userIdentifier: string): SecureStorage {
|
||||||
|
// Derive a deterministic key from the user identifier
|
||||||
|
// In production, this should be combined with a server-provided secret
|
||||||
|
const derivedKey = deriveKeyFromIdentifier(userIdentifier);
|
||||||
|
return new SecureStorage(namespace, derivedKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a deterministic key from a user identifier
|
||||||
|
* This is a simplified version - in production use a more robust approach
|
||||||
|
*/
|
||||||
|
function deriveKeyFromIdentifier(identifier: string): string {
|
||||||
|
// Simple key derivation - in production use a more secure approach
|
||||||
|
// This is just to demonstrate the concept
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(identifier);
|
||||||
|
|
||||||
|
// Create a simple hash of the identifier
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash) + data[i];
|
||||||
|
hash |= 0; // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to a string and pad
|
||||||
|
return hash.toString(36).padStart(16, '0');
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user