mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
Basic success
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { Env, JWTPayload, User } from '../types';
|
||||
import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt';
|
||||
import { StorageService } from './storage';
|
||||
|
||||
export class AuthService {
|
||||
private storage: StorageService;
|
||||
|
||||
constructor(private env: Env) {
|
||||
this.storage = new StorageService(env.VAULT);
|
||||
}
|
||||
|
||||
// Verify password hash (compare with stored hash)
|
||||
async verifyPassword(inputHash: string, storedHash: string): Promise<boolean> {
|
||||
// In Bitwarden, the client sends the password hash directly
|
||||
// We compare the hashes
|
||||
return inputHash === storedHash;
|
||||
}
|
||||
|
||||
// Generate access token
|
||||
async generateAccessToken(user: User): Promise<string> {
|
||||
return createJWT(
|
||||
{
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
sstamp: user.securityStamp,
|
||||
},
|
||||
this.env.JWT_SECRET
|
||||
);
|
||||
}
|
||||
|
||||
// Generate refresh token
|
||||
async generateRefreshToken(userId: string): Promise<string> {
|
||||
const token = createRefreshToken();
|
||||
await this.storage.saveRefreshToken(token, userId);
|
||||
return token;
|
||||
}
|
||||
|
||||
// Verify access token from Authorization header
|
||||
async verifyAccessToken(authHeader: string | null): Promise<JWTPayload | null> {
|
||||
if (!authHeader) return null;
|
||||
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await verifyJWT(parts[1], this.env.JWT_SECRET);
|
||||
if (!payload) return null;
|
||||
|
||||
// Verify security stamp - ensures token is invalidated after password change
|
||||
const user = await this.storage.getUserById(payload.sub);
|
||||
if (!user) return null;
|
||||
|
||||
if (payload.sstamp !== user.securityStamp) {
|
||||
return null; // Token was issued before password change
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
// Refresh access token
|
||||
async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; user: User } | null> {
|
||||
const userId = await this.storage.getRefreshTokenUserId(refreshToken);
|
||||
if (!userId) return null;
|
||||
|
||||
const user = await this.storage.getUserById(userId);
|
||||
if (!user) return null;
|
||||
|
||||
const accessToken = await this.generateAccessToken(user);
|
||||
return { accessToken, user };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Env } from '../types';
|
||||
|
||||
// Rate limit configuration
|
||||
const CONFIG = {
|
||||
// Login attempt limits
|
||||
LOGIN_MAX_ATTEMPTS: 5, // Max failed login attempts
|
||||
LOGIN_LOCKOUT_MINUTES: 15, // Lockout duration after max attempts
|
||||
|
||||
// API rate limits (per minute)
|
||||
API_REQUESTS_PER_MINUTE: 60, // General API rate limit
|
||||
API_WINDOW_SECONDS: 60, // Rate limit window
|
||||
};
|
||||
|
||||
// KV key prefixes
|
||||
const KEYS = {
|
||||
LOGIN_ATTEMPTS: 'ratelimit:login:',
|
||||
API_RATE: 'ratelimit:api:',
|
||||
};
|
||||
|
||||
export class RateLimitService {
|
||||
constructor(private kv: KVNamespace) {}
|
||||
|
||||
/**
|
||||
* Check and record login attempt
|
||||
* Returns { allowed: boolean, remainingAttempts: number, retryAfterSeconds?: number }
|
||||
*/
|
||||
async checkLoginAttempt(email: string): Promise<{
|
||||
allowed: boolean;
|
||||
remainingAttempts: number;
|
||||
retryAfterSeconds?: number;
|
||||
}> {
|
||||
const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`;
|
||||
const data = await this.kv.get(key);
|
||||
|
||||
if (!data) {
|
||||
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
|
||||
}
|
||||
|
||||
const record: { attempts: number; lockedUntil?: number } = JSON.parse(data);
|
||||
const now = Date.now();
|
||||
|
||||
// Check if currently locked out
|
||||
if (record.lockedUntil && record.lockedUntil > now) {
|
||||
const retryAfterSeconds = Math.ceil((record.lockedUntil - now) / 1000);
|
||||
return {
|
||||
allowed: false,
|
||||
remainingAttempts: 0,
|
||||
retryAfterSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
// If lockout expired, reset
|
||||
if (record.lockedUntil && record.lockedUntil <= now) {
|
||||
await this.kv.delete(key);
|
||||
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
|
||||
}
|
||||
|
||||
const remainingAttempts = CONFIG.LOGIN_MAX_ATTEMPTS - record.attempts;
|
||||
return { allowed: true, remainingAttempts };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed login attempt
|
||||
*/
|
||||
async recordFailedLogin(email: string): Promise<{
|
||||
locked: boolean;
|
||||
retryAfterSeconds?: number;
|
||||
}> {
|
||||
const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`;
|
||||
const data = await this.kv.get(key);
|
||||
|
||||
let record: { attempts: number; lockedUntil?: number };
|
||||
|
||||
if (data) {
|
||||
record = JSON.parse(data);
|
||||
record.attempts += 1;
|
||||
} else {
|
||||
record = { attempts: 1 };
|
||||
}
|
||||
|
||||
// Check if should lock out
|
||||
if (record.attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) {
|
||||
record.lockedUntil = Date.now() + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000;
|
||||
await this.kv.put(key, JSON.stringify(record), {
|
||||
expirationTtl: CONFIG.LOGIN_LOCKOUT_MINUTES * 60 + 60, // Extra minute buffer
|
||||
});
|
||||
return {
|
||||
locked: true,
|
||||
retryAfterSeconds: CONFIG.LOGIN_LOCKOUT_MINUTES * 60,
|
||||
};
|
||||
}
|
||||
|
||||
// Store with expiration (auto-reset after lockout period even without lockout)
|
||||
await this.kv.put(key, JSON.stringify(record), {
|
||||
expirationTtl: CONFIG.LOGIN_LOCKOUT_MINUTES * 60,
|
||||
});
|
||||
|
||||
return { locked: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear login attempts on successful login
|
||||
*/
|
||||
async clearLoginAttempts(email: string): Promise<void> {
|
||||
const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`;
|
||||
await this.kv.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check API rate limit for a user or IP
|
||||
* Returns { allowed: boolean, remaining: number, retryAfterSeconds?: number }
|
||||
*/
|
||||
async checkApiRateLimit(identifier: string): Promise<{
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
retryAfterSeconds?: number;
|
||||
}> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const windowStart = now - (now % CONFIG.API_WINDOW_SECONDS);
|
||||
const key = `${KEYS.API_RATE}${identifier}:${windowStart}`;
|
||||
|
||||
const countStr = await this.kv.get(key);
|
||||
const count = countStr ? parseInt(countStr, 10) : 0;
|
||||
|
||||
if (count >= CONFIG.API_REQUESTS_PER_MINUTE) {
|
||||
const retryAfterSeconds = CONFIG.API_WINDOW_SECONDS - (now % CONFIG.API_WINDOW_SECONDS);
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
retryAfterSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: CONFIG.API_REQUESTS_PER_MINUTE - count,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment API request count
|
||||
*/
|
||||
async incrementApiCount(identifier: string): Promise<void> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const windowStart = now - (now % CONFIG.API_WINDOW_SECONDS);
|
||||
const key = `${KEYS.API_RATE}${identifier}:${windowStart}`;
|
||||
|
||||
const countStr = await this.kv.get(key);
|
||||
const count = countStr ? parseInt(countStr, 10) : 0;
|
||||
|
||||
await this.kv.put(key, (count + 1).toString(), {
|
||||
expirationTtl: CONFIG.API_WINDOW_SECONDS + 10, // Slight buffer
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client identifier from request (IP or CF-Connecting-IP)
|
||||
*/
|
||||
export function getClientIdentifier(request: Request): string {
|
||||
// Cloudflare provides the real client IP
|
||||
const cfIp = request.headers.get('CF-Connecting-IP');
|
||||
if (cfIp) return cfIp;
|
||||
|
||||
// Fallback for local development
|
||||
const forwardedFor = request.headers.get('X-Forwarded-For');
|
||||
if (forwardedFor) return forwardedFor.split(',')[0].trim();
|
||||
|
||||
// Last resort
|
||||
return 'unknown';
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { Env, User, Cipher, Folder, Attachment } from '../types';
|
||||
|
||||
const KEYS = {
|
||||
CONFIG_REGISTERED: 'config:registered',
|
||||
USER_PREFIX: 'user:',
|
||||
CIPHER_PREFIX: 'cipher:',
|
||||
FOLDER_PREFIX: 'folder:',
|
||||
ATTACHMENT_PREFIX: 'attachment:',
|
||||
CIPHERS_INDEX: 'index:ciphers',
|
||||
FOLDERS_INDEX: 'index:folders',
|
||||
ATTACHMENTS_INDEX: 'index:attachments',
|
||||
REFRESH_TOKEN_PREFIX: 'refresh:',
|
||||
REVISION_DATE_PREFIX: 'revision:',
|
||||
};
|
||||
|
||||
export class StorageService {
|
||||
constructor(private kv: KVNamespace) {}
|
||||
|
||||
// Registration status
|
||||
async isRegistered(): Promise<boolean> {
|
||||
const value = await this.kv.get(KEYS.CONFIG_REGISTERED);
|
||||
return value === 'true';
|
||||
}
|
||||
|
||||
async setRegistered(): Promise<void> {
|
||||
await this.kv.put(KEYS.CONFIG_REGISTERED, 'true');
|
||||
}
|
||||
|
||||
// User operations
|
||||
async getUser(email: string): Promise<User | null> {
|
||||
const data = await this.kv.get(`${KEYS.USER_PREFIX}${email.toLowerCase()}`);
|
||||
return data ? JSON.parse(data) : null;
|
||||
}
|
||||
|
||||
async getUserById(id: string): Promise<User | null> {
|
||||
// Get user email from id mapping
|
||||
const email = await this.kv.get(`userid:${id}`);
|
||||
if (!email) return null;
|
||||
return this.getUser(email);
|
||||
}
|
||||
|
||||
async saveUser(user: User): Promise<void> {
|
||||
await this.kv.put(`${KEYS.USER_PREFIX}${user.email.toLowerCase()}`, JSON.stringify(user));
|
||||
await this.kv.put(`userid:${user.id}`, user.email.toLowerCase());
|
||||
}
|
||||
|
||||
// Cipher operations
|
||||
async getCipher(id: string): Promise<Cipher | null> {
|
||||
const data = await this.kv.get(`${KEYS.CIPHER_PREFIX}${id}`);
|
||||
return data ? JSON.parse(data) : null;
|
||||
}
|
||||
|
||||
async saveCipher(cipher: Cipher): Promise<void> {
|
||||
await this.kv.put(`${KEYS.CIPHER_PREFIX}${cipher.id}`, JSON.stringify(cipher));
|
||||
|
||||
// Update index
|
||||
const index = await this.getCipherIds(cipher.userId);
|
||||
if (!index.includes(cipher.id)) {
|
||||
index.push(cipher.id);
|
||||
await this.kv.put(`${KEYS.CIPHERS_INDEX}:${cipher.userId}`, JSON.stringify(index));
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCipher(id: string, userId: string): Promise<void> {
|
||||
await this.kv.delete(`${KEYS.CIPHER_PREFIX}${id}`);
|
||||
|
||||
// Update index
|
||||
const index = await this.getCipherIds(userId);
|
||||
const newIndex = index.filter(cid => cid !== id);
|
||||
await this.kv.put(`${KEYS.CIPHERS_INDEX}:${userId}`, JSON.stringify(newIndex));
|
||||
}
|
||||
|
||||
async getCipherIds(userId: string): Promise<string[]> {
|
||||
const data = await this.kv.get(`${KEYS.CIPHERS_INDEX}:${userId}`);
|
||||
return data ? JSON.parse(data) : [];
|
||||
}
|
||||
|
||||
async getAllCiphers(userId: string): Promise<Cipher[]> {
|
||||
const ids = await this.getCipherIds(userId);
|
||||
const ciphers: Cipher[] = [];
|
||||
|
||||
for (const id of ids) {
|
||||
const cipher = await this.getCipher(id);
|
||||
if (cipher) ciphers.push(cipher);
|
||||
}
|
||||
|
||||
return ciphers;
|
||||
}
|
||||
|
||||
// Folder operations
|
||||
async getFolder(id: string): Promise<Folder | null> {
|
||||
const data = await this.kv.get(`${KEYS.FOLDER_PREFIX}${id}`);
|
||||
return data ? JSON.parse(data) : null;
|
||||
}
|
||||
|
||||
async saveFolder(folder: Folder): Promise<void> {
|
||||
await this.kv.put(`${KEYS.FOLDER_PREFIX}${folder.id}`, JSON.stringify(folder));
|
||||
|
||||
// Update index
|
||||
const index = await this.getFolderIds(folder.userId);
|
||||
if (!index.includes(folder.id)) {
|
||||
index.push(folder.id);
|
||||
await this.kv.put(`${KEYS.FOLDERS_INDEX}:${folder.userId}`, JSON.stringify(index));
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFolder(id: string, userId: string): Promise<void> {
|
||||
await this.kv.delete(`${KEYS.FOLDER_PREFIX}${id}`);
|
||||
|
||||
// Update index
|
||||
const index = await this.getFolderIds(userId);
|
||||
const newIndex = index.filter(fid => fid !== id);
|
||||
await this.kv.put(`${KEYS.FOLDERS_INDEX}:${userId}`, JSON.stringify(newIndex));
|
||||
}
|
||||
|
||||
async getFolderIds(userId: string): Promise<string[]> {
|
||||
const data = await this.kv.get(`${KEYS.FOLDERS_INDEX}:${userId}`);
|
||||
return data ? JSON.parse(data) : [];
|
||||
}
|
||||
|
||||
async getAllFolders(userId: string): Promise<Folder[]> {
|
||||
const ids = await this.getFolderIds(userId);
|
||||
const folders: Folder[] = [];
|
||||
|
||||
for (const id of ids) {
|
||||
const folder = await this.getFolder(id);
|
||||
if (folder) folders.push(folder);
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
// Refresh token operations
|
||||
async saveRefreshToken(token: string, userId: string): Promise<void> {
|
||||
// Store refresh token with 30 day expiry
|
||||
await this.kv.put(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`, userId, {
|
||||
expirationTtl: 30 * 24 * 60 * 60,
|
||||
});
|
||||
}
|
||||
|
||||
async getRefreshTokenUserId(token: string): Promise<string | null> {
|
||||
return await this.kv.get(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`);
|
||||
}
|
||||
|
||||
async deleteRefreshToken(token: string): Promise<void> {
|
||||
await this.kv.delete(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`);
|
||||
}
|
||||
|
||||
// Revision date operations (for incremental sync)
|
||||
async getRevisionDate(userId: string): Promise<string> {
|
||||
const date = await this.kv.get(`${KEYS.REVISION_DATE_PREFIX}${userId}`);
|
||||
return date || new Date().toISOString();
|
||||
}
|
||||
|
||||
async updateRevisionDate(userId: string): Promise<string> {
|
||||
const date = new Date().toISOString();
|
||||
await this.kv.put(`${KEYS.REVISION_DATE_PREFIX}${userId}`, date);
|
||||
return date;
|
||||
}
|
||||
|
||||
// Bulk cipher operations
|
||||
async getCiphersByIds(ids: string[], userId: string): Promise<Cipher[]> {
|
||||
const ciphers: Cipher[] = [];
|
||||
for (const id of ids) {
|
||||
const cipher = await this.getCipher(id);
|
||||
if (cipher && cipher.userId === userId) {
|
||||
ciphers.push(cipher);
|
||||
}
|
||||
}
|
||||
return ciphers;
|
||||
}
|
||||
|
||||
async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
for (const id of ids) {
|
||||
const cipher = await this.getCipher(id);
|
||||
if (cipher && cipher.userId === userId) {
|
||||
cipher.folderId = folderId;
|
||||
cipher.updatedAt = now;
|
||||
await this.saveCipher(cipher);
|
||||
}
|
||||
}
|
||||
await this.updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
// Attachment operations
|
||||
async getAttachment(id: string): Promise<Attachment | null> {
|
||||
const data = await this.kv.get(`${KEYS.ATTACHMENT_PREFIX}${id}`);
|
||||
return data ? JSON.parse(data) : null;
|
||||
}
|
||||
|
||||
async saveAttachment(attachment: Attachment): Promise<void> {
|
||||
await this.kv.put(`${KEYS.ATTACHMENT_PREFIX}${attachment.id}`, JSON.stringify(attachment));
|
||||
}
|
||||
|
||||
async deleteAttachment(id: string): Promise<void> {
|
||||
await this.kv.delete(`${KEYS.ATTACHMENT_PREFIX}${id}`);
|
||||
}
|
||||
|
||||
async getAttachmentIdsByCipher(cipherId: string): Promise<string[]> {
|
||||
const data = await this.kv.get(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`);
|
||||
return data ? JSON.parse(data) : [];
|
||||
}
|
||||
|
||||
async getAttachmentsByCipher(cipherId: string): Promise<Attachment[]> {
|
||||
const ids = await this.getAttachmentIdsByCipher(cipherId);
|
||||
const attachments: Attachment[] = [];
|
||||
for (const id of ids) {
|
||||
const attachment = await this.getAttachment(id);
|
||||
if (attachment) attachments.push(attachment);
|
||||
}
|
||||
return attachments;
|
||||
}
|
||||
|
||||
async addAttachmentToCipher(cipherId: string, attachmentId: string): Promise<void> {
|
||||
const ids = await this.getAttachmentIdsByCipher(cipherId);
|
||||
if (!ids.includes(attachmentId)) {
|
||||
ids.push(attachmentId);
|
||||
await this.kv.put(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`, JSON.stringify(ids));
|
||||
}
|
||||
}
|
||||
|
||||
async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
|
||||
const ids = await this.getAttachmentIdsByCipher(cipherId);
|
||||
const newIds = ids.filter(id => id !== attachmentId);
|
||||
await this.kv.put(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`, JSON.stringify(newIds));
|
||||
}
|
||||
|
||||
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
|
||||
const ids = await this.getAttachmentIdsByCipher(cipherId);
|
||||
for (const id of ids) {
|
||||
await this.deleteAttachment(id);
|
||||
}
|
||||
await this.kv.delete(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`);
|
||||
}
|
||||
|
||||
async updateCipherRevisionDate(cipherId: string): Promise<void> {
|
||||
const cipher = await this.getCipher(cipherId);
|
||||
if (cipher) {
|
||||
cipher.updatedAt = new Date().toISOString();
|
||||
await this.saveCipher(cipher);
|
||||
await this.updateRevisionDate(cipher.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user