userdo
Version:
A Durable Object base class that provides user authentication, per-user data storage, and real-time updates for Cloudflare Workers applications.
429 lines (428 loc) • 16.9 kB
JavaScript
import { DurableObject } from 'cloudflare:workers';
import { z } from 'zod';
import jwt from '@tsndr/cloudflare-worker-jwt';
import { UserDODatabase } from './database/index';
// --- User Schema ---
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
passwordHash: z.string(),
salt: z.string(),
createdAt: z.string(),
refreshTokens: z.array(z.string()).default([]),
});
// --- Zod Schemas for endpoint validation ---
const SignupSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
const LoginSchema = SignupSchema;
const InitSchema = UserSchema;
// --- Password Hashing ---
const PASSWORD_CONFIG = {
iterations: 100_000,
saltLength: 16,
};
const RESERVED_PREFIX = "__";
const AUTH_DATA_KEY = "__user";
const RATE_LIMIT_KEY = "__rl";
const RATE_LIMIT_MAX = 5;
const RATE_LIMIT_WINDOW = 60_000; // 1 minute
function isReservedKey(key) {
return key.startsWith(RESERVED_PREFIX);
}
// Hash email for use as DO ID to prevent PII leaking in logs
export async function hashEmailForId(email) {
const encoder = new TextEncoder();
const data = encoder.encode(email.toLowerCase());
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = new Uint8Array(hashBuffer);
const hashHex = Array.from(hashArray)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
return hashHex;
}
// Helper function to get UserDO with automatic email hashing
// Maintains almost the same API as env.MY_APP_DO.get(env.MY_APP_DO.idFromName(email))
export async function getUserDO(namespace, email) {
const hashedEmail = await hashEmailForId(email);
return namespace.get(namespace.idFromName(hashedEmail));
}
const getDO = async (env, email) => {
const hashedEmail = await hashEmailForId(email);
return env.USERDO.get(env.USERDO.idFromName(hashedEmail));
};
async function hashPassword(password) {
const encoder = new TextEncoder();
const saltBytes = crypto.getRandomValues(new Uint8Array(PASSWORD_CONFIG.saltLength));
const salt = btoa(String.fromCharCode(...saltBytes));
const key = await crypto.subtle.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, ['deriveBits']);
const derivedBits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: saltBytes, iterations: PASSWORD_CONFIG.iterations, hash: 'SHA-256' }, key, 256);
const hash = btoa(String.fromCharCode(...new Uint8Array(derivedBits)));
return { hash, salt };
}
async function verifyPassword(password, salt, expectedHash) {
const encoder = new TextEncoder();
const saltBytes = Uint8Array.from(atob(salt), c => c.charCodeAt(0));
const key = await crypto.subtle.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, ['deriveBits']);
const derivedBits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: saltBytes, iterations: PASSWORD_CONFIG.iterations, hash: 'SHA-256' }, key, 256);
const hash = btoa(String.fromCharCode(...new Uint8Array(derivedBits)));
return hash === expectedHash;
}
// Atomic migration helper (outside the class)
export async function migrateUserEmail({ env, oldEmail, newEmail }) {
oldEmail = oldEmail.toLowerCase();
newEmail = newEmail.toLowerCase();
const oldDO = await getDO(env, oldEmail);
const newDO = await getDO(env, newEmail);
try {
const user = await oldDO.raw();
user.email = newEmail;
await newDO.init(user);
await oldDO.deleteUser();
return { ok: true };
}
catch (err) {
// Optionally, add rollback logic here
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export class UserDO extends DurableObject {
constructor(state, env) {
super(state, env);
this.state = state;
this.storage = state.storage;
this.env = env;
this.database = new UserDODatabase(this.storage, this.getCurrentUserId(), this.broadcast.bind(this));
}
async checkRateLimit() {
const now = Date.now();
const record = await this.storage.get(RATE_LIMIT_KEY);
if (record && record.resetAt > now) {
if (record.count >= RATE_LIMIT_MAX) {
throw new Error('Too many requests');
}
record.count += 1;
await this.storage.put(RATE_LIMIT_KEY, record);
}
else {
const resetAt = now + RATE_LIMIT_WINDOW;
await this.storage.put(RATE_LIMIT_KEY, { count: 1, resetAt });
}
}
async signup({ email, password }) {
email = email.toLowerCase();
await this.checkRateLimit();
const parsed = SignupSchema.safeParse({ email, password });
if (!parsed.success) {
throw new Error('Invalid input: ' + JSON.stringify(parsed.error.flatten()));
}
// Check if user already exists
const existing = await this.storage.get(AUTH_DATA_KEY);
if (existing)
throw new Error('Email already registered');
const id = this.state.id.toString();
const createdAt = new Date().toISOString();
const { hash, salt } = await hashPassword(password);
const user = {
id,
email,
passwordHash: hash,
salt,
createdAt,
refreshTokens: []
};
await this.storage.put(AUTH_DATA_KEY, user);
// Generate access token (15 minutes)
const accessExp = Math.floor(Date.now() / 1000) + 15 * 60;
const token = await jwt.sign({
sub: user.id,
email: user.email,
exp: accessExp
}, this.env.JWT_SECRET);
// Generate refresh token (7 days)
const refreshExp = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60;
const refreshToken = await jwt.sign({
sub: user.id,
type: 'refresh',
exp: refreshExp
}, this.env.JWT_SECRET);
// Store refresh token
if (!user.refreshTokens)
user.refreshTokens = [];
user.refreshTokens.push(refreshToken);
await this.storage.put(AUTH_DATA_KEY, user);
return { user, token, refreshToken };
}
async login({ email, password }) {
email = email.toLowerCase();
await this.checkRateLimit();
const parsed = LoginSchema.safeParse({ email, password });
if (!parsed.success) {
throw new Error('Invalid input: ' + JSON.stringify(parsed.error.flatten()));
}
const user = await this.storage.get(AUTH_DATA_KEY);
if (!user || user.email !== email)
throw new Error('Invalid credentials');
const ok = await verifyPassword(password, user.salt, user.passwordHash);
if (!ok)
throw new Error('Invalid credentials');
// Generate access token (15 minutes)
const accessExp = Math.floor(Date.now() / 1000) + 15 * 60;
const token = await jwt.sign({
sub: user.id,
email: user.email,
exp: accessExp
}, this.env.JWT_SECRET);
// Generate refresh token (7 days)
const refreshExp = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60;
const refreshToken = await jwt.sign({
sub: user.id,
type: 'refresh',
exp: refreshExp
}, this.env.JWT_SECRET);
// Store refresh token
if (!user.refreshTokens)
user.refreshTokens = [];
user.refreshTokens.push(refreshToken);
await this.storage.put(AUTH_DATA_KEY, user);
return { user, token, refreshToken };
}
async raw() {
const user = await this.storage.get(AUTH_DATA_KEY);
if (!user)
throw new Error('User not found');
return user;
}
async init(user) {
const parsed = InitSchema.safeParse(user);
if (!parsed.success) {
throw new Error('Invalid input: ' + JSON.stringify(parsed.error.flatten()));
}
await this.storage.put(AUTH_DATA_KEY, user);
return { ok: true };
}
async deleteUser() {
await this.storage.delete(AUTH_DATA_KEY);
return { ok: true };
}
// Change password method
async changePassword({ oldPassword, newPassword }) {
const user = await this.storage.get(AUTH_DATA_KEY);
if (!user)
throw new Error('User not found');
// Validate old password
const ok = await verifyPassword(oldPassword, user.salt, user.passwordHash);
if (!ok)
throw new Error('Invalid current password');
// Validate new password
const parsed = SignupSchema.shape.password.safeParse(newPassword);
if (!parsed.success) {
throw new Error('Invalid new password: ' + JSON.stringify(parsed.error.flatten()));
}
// Hash new password
const { hash, salt } = await hashPassword(newPassword);
user.passwordHash = hash;
user.salt = salt;
await this.storage.put(AUTH_DATA_KEY, user);
return { ok: true };
}
// Reset password method (for use after verifying a reset token)
async resetPassword({ newPassword }) {
const user = await this.storage.get(AUTH_DATA_KEY);
if (!user)
throw new Error('User not found');
// Validate new password
const parsed = SignupSchema.shape.password.safeParse(newPassword);
if (!parsed.success) {
throw new Error('Invalid new password: ' + JSON.stringify(parsed.error.flatten()));
}
// Hash new password
const { hash, salt } = await hashPassword(newPassword);
user.passwordHash = hash;
user.salt = salt;
await this.storage.put(AUTH_DATA_KEY, user);
return { ok: true };
}
// Generate password reset token (expires in 1 hour)
async generatePasswordResetToken() {
const user = await this.storage.get(AUTH_DATA_KEY);
if (!user)
throw new Error('User not found');
const resetExp = Math.floor(Date.now() / 1000) + 60 * 60; // 1 hour
const resetToken = await jwt.sign({
sub: user.id,
email: user.email,
type: 'password_reset',
exp: resetExp
}, this.env.JWT_SECRET);
return { resetToken };
}
// Reset password with token verification
async resetPasswordWithToken({ resetToken, newPassword }) {
try {
const verify = await jwt.verify(resetToken, this.env.JWT_SECRET);
if (!verify || !verify.payload || verify.payload.type !== 'password_reset') {
throw new Error('Invalid reset token');
}
// Token is valid, proceed with password reset
return await this.resetPassword({ newPassword });
}
catch (err) {
throw new Error('Invalid or expired reset token');
}
}
async verifyToken({ token }) {
try {
const verify = await jwt.verify(token, this.env.JWT_SECRET);
if (!verify)
throw new Error('Invalid token');
const { payload } = verify;
if (!payload)
throw new Error('Invalid token');
const { sub, email } = payload;
if (!sub || !email)
throw new Error('Invalid token');
const user = await this.storage.get(AUTH_DATA_KEY);
if (!user)
throw new Error('User not found');
if (payload.sub !== user.id) {
throw new Error('Token subject mismatch');
}
return { ok: true, user: { id: user.id, email: user.email } };
}
catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
async set(key, value) {
if (isReservedKey(key))
throw new Error("Key is reserved");
await this.storage.put(key, value);
// Broadcast KV change
this.broadcast(`kv:${key}`, { key, value });
return { ok: true };
}
async get(key) {
if (isReservedKey(key))
throw new Error("Key is reserved");
const value = await this.storage.get(key);
return value;
}
async refreshToken({ refreshToken }) {
try {
const verify = await jwt.verify(refreshToken, this.env.JWT_SECRET);
if (!verify || !verify.payload || verify.payload.type !== 'refresh') {
throw new Error('Invalid refresh token');
}
const user = await this.storage.get(AUTH_DATA_KEY);
if (!user)
throw new Error('User not found');
// Verify refresh token is in user's list
if (!user.refreshTokens.includes(refreshToken)) {
throw new Error('Refresh token not found');
}
// Generate new access token
const accessExp = Math.floor(Date.now() / 1000) + 15 * 60;
const token = await jwt.sign({
sub: user.id,
email: user.email,
exp: accessExp
}, this.env.JWT_SECRET);
return { token };
}
catch (err) {
throw new Error('Invalid refresh token');
}
}
async revokeRefreshToken({ refreshToken }) {
const user = await this.storage.get(AUTH_DATA_KEY);
if (!user)
throw new Error('User not found');
user.refreshTokens = user.refreshTokens.filter(token => token !== refreshToken);
await this.storage.put(AUTH_DATA_KEY, user);
return { ok: true };
}
async revokeAllRefreshTokens() {
const user = await this.storage.get(AUTH_DATA_KEY);
if (!user)
throw new Error('User not found');
user.refreshTokens = [];
await this.storage.put(AUTH_DATA_KEY, user);
return { ok: true };
}
async logout() {
return this.revokeAllRefreshTokens();
}
table(name, schema, options) {
return this.database.table(name, schema, options);
}
get db() {
return this.database.raw;
}
getCurrentUserId() {
return this.state.id.toString();
}
// WebSocket connection handling using Hibernation API
async fetch(request) {
// Handle WebSocket upgrades directly in the UserDO
if (request.headers.get('upgrade') === 'websocket') {
const webSocketPair = new WebSocketPair();
const [client, server] = Object.values(webSocketPair);
// Use hibernation API - this makes the WebSocket hibernatable
this.ctx.acceptWebSocket(server);
console.log('🔌 WebSocket accepted by UserDO with hibernation');
// Send welcome message
server.send(JSON.stringify({
event: 'connected',
message: 'WebSocket connected to UserDO!',
timestamp: Date.now()
}));
return new Response(null, {
status: 101,
webSocket: client,
});
}
// Handle other requests normally
return new Response('Not Found', { status: 404 });
}
// WebSocket message handler (called by runtime when hibernated)
async webSocketMessage(ws, message) {
try {
const data = typeof message === 'string' ? message : new TextDecoder().decode(message);
const parsed = JSON.parse(data);
console.log('📨 UserDO WebSocket message received:', parsed);
// Echo back
ws.send(JSON.stringify({
event: 'echo',
original: parsed,
message: 'Message received by UserDO',
timestamp: Date.now()
}));
}
catch (error) {
console.error('WebSocket message error:', error);
}
}
// WebSocket close handler (called by runtime when hibernated)
async webSocketClose(ws, code, reason, wasClean) {
console.log('🔌 UserDO WebSocket closed:', { code, reason, wasClean });
}
// Broadcast to all connected WebSocket clients using hibernation API
broadcast(event, data) {
const message = JSON.stringify({ event, data, timestamp: Date.now() });
// Use hibernation API to get all connected WebSockets
const webSockets = this.ctx.getWebSockets();
console.log(`📡 UserDO Broadcasting to ${webSockets.length} WebSocket clients:`, { event, data });
for (const ws of webSockets) {
try {
ws.send(message);
}
catch (error) {
console.error('Broadcast error:', error);
// WebSocket will be automatically cleaned up by runtime
}
}
}
}
export default {};