Last active
March 7, 2024 20:30
-
-
Save andyjessop/6d92cf0576b2b3a02e1d896203e3048d to your computer and use it in GitHub Desktop.
Cloudflare Worker Auth
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Hono } from 'hono'; | |
import { jwt } from 'hono/jwt'; | |
import { D1 } from '@cloudflare/workers-types'; | |
import { hash, compare } from 'bcrypt'; | |
import { v4 as uuidv4 } from 'uuid'; | |
import { sendEmail } from './email'; | |
import { isEmail, sanitize } from 'validator'; | |
import { createHash } from 'crypto'; | |
type Env = { | |
DB: D1; | |
JWT_SECRET: string; | |
REFRESH_TOKEN_SECRET: string; | |
EMAIL_FROM: string; | |
EMAIL_SERVICE: string; | |
EMAIL_USER: string; | |
EMAIL_PASSWORD: string; | |
}; | |
const app = new Hono<{ Bindings: Env }>(); | |
// JWT middleware | |
app.use('/api/*', jwt({ secret: app.env.JWT_SECRET })); | |
// Create user route | |
app.post('/api/register', async (c) => { | |
const { email, password } = await c.req.json(); | |
// Validate and sanitize input | |
if (!isEmail(email)) { | |
return c.json({ error: 'Invalid email address' }, 400); | |
} | |
const sanitizedEmail = sanitize(email).trim(); | |
const sanitizedPassword = sanitize(password).trim(); | |
if (sanitizedPassword.length < 8) { | |
return c.json({ error: 'Password must be at least 8 characters long' }, 400); | |
} | |
// Check if user already exists | |
const existingUser = await c.env.DB.prepare(` | |
SELECT * FROM users WHERE email = ? | |
`).bind(sanitizedEmail).first(); | |
if (existingUser) { | |
return c.json({ error: 'Email already exists' }, 400); | |
} | |
const salt = await bcrypt.genSalt(10); | |
const hashedPassword = await hash(sanitizedPassword, salt); | |
const activationToken = uuidv4(); | |
await c.env.DB.prepare(` | |
INSERT INTO users (id, email, password, salt, activation_token) | |
VALUES (?, ?, ?, ?, ?) | |
`).bind(uuidv4(), sanitizedEmail, hashedPassword, salt, activationToken).run(); | |
// Send activation email | |
const activationLink = `https://your-app.com/activate?token=${activationToken}`; | |
await sendEmail(c.env, { | |
to: sanitizedEmail, | |
subject: 'Activate Your Account', | |
text: `Please click the following link to activate your account: ${activationLink}`, | |
html: `<p>Please click the following link to activate your account: <a href="${activationLink}">${activationLink}</a></p>`, | |
}); | |
return c.json({ message: 'User created successfully. Please check your email to activate your account.' }); | |
}); | |
// Activate account route | |
app.get('/api/activate', async (c) => { | |
const { token } = c.req.query(); | |
const user = await c.env.DB.prepare(` | |
SELECT * FROM users WHERE activation_token = ? | |
`).bind(token).first(); | |
if (!user) { | |
return c.json({ error: 'Invalid or expired activation token' }, 400); | |
} | |
await c.env.DB.prepare(` | |
UPDATE users SET activation_token = NULL, is_active = true WHERE id = ? | |
`).bind(user.id).run(); | |
return c.json({ message: 'Account activated successfully. You can now log in.' }); | |
}); | |
// Login route | |
app.post('/api/login', async (c) => { | |
const { email, password } = await c.req.json(); | |
// Validate and sanitize input | |
if (!isEmail(email)) { | |
return c.json({ error: 'Invalid email address' }, 400); | |
} | |
const sanitizedEmail = sanitize(email).trim(); | |
const sanitizedPassword = sanitize(password).trim(); | |
const user = await c.env.DB.prepare(` | |
SELECT * FROM users WHERE email = ? | |
`).bind(sanitizedEmail).first(); | |
if (!user) { | |
return c.json({ error: 'Invalid email or password' }, 401); | |
} | |
if (!user.is_active) { | |
return c.json({ error: 'Account is not activated. Please check your email to activate your account.' }, 401); | |
} | |
const isPasswordValid = await compare(sanitizedPassword, user.password); | |
if (!isPasswordValid) { | |
return c.json({ error: 'Invalid email or password' }, 401); | |
} | |
const accessToken = await c.jwt.sign({ userId: user.id }, { expiresIn: '15m' }); | |
const refreshToken = await c.jwt.sign({ userId: user.id }, { secret: c.env.REFRESH_TOKEN_SECRET, expiresIn: '7d' }); | |
const hashedRefreshToken = createHash('sha256').update(refreshToken).digest('hex'); | |
await c.env.DB.prepare(` | |
UPDATE users SET refresh_token = ?, refresh_token_expires_at = DATE_ADD(NOW(), INTERVAL 7 DAY) WHERE id = ? | |
`).bind(hashedRefreshToken, user.id).run(); | |
return c.json({ accessToken, refreshToken }); | |
}); | |
// Refresh token route | |
app.post('/api/refresh-token', async (c) => { | |
const { refreshToken } = await c.req.json(); | |
try { | |
const { userId } = await c.jwt.verify(refreshToken, { secret: c.env.REFRESH_TOKEN_SECRET }); | |
const hashedRefreshToken = createHash('sha256').update(refreshToken).digest('hex'); | |
const user = await c.env.DB.prepare(` | |
SELECT * FROM users WHERE id = ? AND refresh_token = ? AND refresh_token_expires_at > NOW() | |
`).bind(userId, hashedRefreshToken).first(); | |
if (!user) { | |
return c.json({ error: 'Invalid or expired refresh token' }, 401); | |
} | |
const accessToken = await c.jwt.sign({ userId: user.id }, { expiresIn: '15m' }); | |
return c.json({ accessToken }); | |
} catch (error) { | |
return c.json({ error: 'Invalid refresh token' }, 401); | |
} | |
}); | |
// Logout route | |
app.post('/api/logout', async (c) => { | |
const { userId } = c.req.user; | |
await c.env.DB.prepare(` | |
UPDATE users SET refresh_token = NULL, refresh_token_expires_at = NULL WHERE id = ? | |
`).bind(userId).run(); | |
return c.json({ message: 'Logged out successfully' }); | |
}); | |
// Forgot password route | |
app.post('/api/forgot-password', async (c) => { | |
const { email } = await c.req.json(); | |
// Validate and sanitize input | |
if (!isEmail(email)) { | |
return c.json({ error: 'Invalid email address' }, 400); | |
} | |
const sanitizedEmail = sanitize(email).trim(); | |
const user = await c.env.DB.prepare(` | |
SELECT * FROM users WHERE email = ? | |
`).bind(sanitizedEmail).first(); | |
if (!user) { | |
return c.json({ error: 'User not found' }, 404); | |
} | |
const resetToken = uuidv4(); | |
await c.env.DB.prepare(` | |
UPDATE users SET reset_token = ?, reset_token_expires_at = DATE_ADD(NOW(), INTERVAL 1 HOUR) WHERE id = ? | |
`).bind(resetToken, user.id).run(); | |
// Send password reset email | |
const resetLink = `https://your-app.com/reset-password?token=${resetToken}`; | |
await sendEmail(c.env, { | |
to: sanitizedEmail, | |
subject: 'Reset Your Password', | |
text: `Please click the following link to reset your password: ${resetLink}`, | |
html: `<p>Please click the following link to reset your password: <a href="${resetLink}">${resetLink}</a></p>`, | |
}); | |
return c.json({ message: 'Password reset email sent' }); | |
}); | |
// Reset password route | |
app.post('/api/reset-password', async (c) => { | |
const { token, password } = await c.req.json(); | |
// Validate and sanitize input | |
const sanitizedPassword = sanitize(password).trim(); | |
if (sanitizedPassword.length < 8) { | |
return c.json({ error: 'Password must be at least 8 characters long' }, 400); | |
} | |
const user = await c.env.DB.prepare(` | |
SELECT * FROM users WHERE reset_token = ? AND reset_token_expires_at > NOW() | |
`).bind(token).first(); | |
if (!user) { | |
return c.json({ error: 'Invalid or expired reset token' }, 400); | |
} | |
const salt = await bcrypt.genSalt(10); | |
const hashedPassword = await hash(sanitizedPassword, salt); | |
await c.env.DB.prepare(` | |
UPDATE users SET password = ?, salt = ?, reset_token = NULL, reset_token_expires_at = NULL WHERE id = ? | |
`).bind(hashedPassword, salt, user.id).run(); | |
return c.json({ message: 'Password reset successfully' }); | |
}); | |
// Change password route | |
app.post('/api/change-password', async (c) => { | |
const { userId } = c.req.user; | |
const { currentPassword, newPassword } = await c.req.json(); | |
// Validate and sanitize input | |
const sanitizedCurrentPassword = sanitize(currentPassword).trim(); | |
const sanitizedNewPassword = sanitize(newPassword).trim(); | |
if (sanitizedNewPassword.length < 8) { | |
return c.json({ error: 'New password must be at least 8 characters long' }, 400); | |
} | |
const user = await c.env.DB.prepare(` | |
SELECT * FROM users WHERE id = ? | |
`).bind(userId).first(); | |
const isPasswordValid = await compare(sanitizedCurrentPassword, user.password); | |
if (!isPasswordValid) { | |
return c.json({ error: 'Invalid current password' }, 401); | |
} | |
const salt = await bcrypt.genSalt(10); | |
const hashedPassword = await hash(sanitizedNewPassword, salt); | |
await c.env.DB.prepare(` | |
UPDATE users SET password = ?, salt = ? WHERE id = ? | |
`).bind(hashedPassword, salt, userId).run(); | |
return c.json({ message: 'Password changed successfully' }); | |
}); | |
// Protected route example | |
app.get('/api/protected', (c) => { | |
return c.json({ message: 'Access granted to protected route' }); | |
}); | |
export default app; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment