A system for building static webapps
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add email verification and reset

+420 -61
+176 -38
packages/hono/auth/adapters/deno-kv.ts
··· 20 20 import type { 21 21 AuthAdapter, 22 22 AuthContext, 23 + EmailAdapter, 23 24 Session, 24 25 User, 25 26 } from '../../shared/types.ts' ··· 42 43 username: string 43 44 email: string 44 45 passwordHash: string 46 + emailVerified: boolean 45 47 createdAt: string 46 48 updatedAt: string 49 + } 50 + 51 + interface StoredToken { 52 + userId: string 53 + expiresAt: string 47 54 } 48 55 49 56 export interface StoredApiKey { ··· 94 101 username: string, 95 102 email: string, 96 103 password: string, 104 + emailVerified: boolean = true, 97 105 ): Promise<User> { 98 106 const id = crypto.randomUUID() 99 107 const passwordHash = await this.hashPassword(password) ··· 104 112 username, 105 113 email, 106 114 passwordHash, 115 + emailVerified, 107 116 createdAt: now, 108 117 updatedAt: now, 109 118 } ··· 120 129 throw new Error('Username or email already exists') 121 130 } 122 131 123 - return { id, username, email } 132 + return { id, username, email, emailVerified } 124 133 } 125 134 126 135 async getUserByUsername(username: string): Promise<User | null> { ··· 128 137 if (!r.value) return null 129 138 const user = (await this.#kv.get<StoredUser>(['users', r.value])).value 130 139 if (!user) return null 131 - return { id: user.id, username: user.username, email: user.email } 140 + return { 141 + id: user.id, 142 + username: user.username, 143 + email: user.email, 144 + emailVerified: user.emailVerified ?? true, 145 + } 132 146 } 133 147 134 148 async getUserByEmail(email: string): Promise<User | null> { ··· 136 150 if (!r.value) return null 137 151 const user = (await this.#kv.get<StoredUser>(['users', r.value])).value 138 152 if (!user) return null 139 - return { id: user.id, username: user.username, email: user.email } 153 + return { 154 + id: user.id, 155 + username: user.username, 156 + email: user.email, 157 + emailVerified: user.emailVerified ?? true, 158 + } 140 159 } 141 160 142 161 async getUser(userId: string): Promise<User | null> { 143 162 const user = (await this.#kv.get<StoredUser>(['users', userId])).value 144 163 if (!user) return null 145 - return { id: user.id, username: user.username, email: user.email } 164 + return { 165 + id: user.id, 166 + username: user.username, 167 + email: user.email, 168 + emailVerified: user.emailVerified ?? true, 169 + } 170 + } 171 + 172 + async invalidateAllUserSessions(userId: string): Promise<void> { 173 + const sessions = this.#kv.list<StoredSession>({ prefix: ['sessions'] }) 174 + for await (const entry of sessions) { 175 + if (entry.value.userId === userId) { 176 + await this.#kv.delete(entry.key) 177 + } 178 + } 179 + } 180 + 181 + async markEmailVerified(userId: string): Promise<void> { 182 + const user = (await this.#kv.get<StoredUser>(['users', userId])).value 183 + if (!user) return 184 + user.emailVerified = true 185 + user.updatedAt = new Date().toISOString() 186 + await this.#kv.set(['users', userId], user) 187 + } 188 + 189 + async isEmailVerified(userId: string): Promise<boolean> { 190 + const user = (await this.#kv.get<StoredUser>(['users', userId])).value 191 + return user?.emailVerified ?? true 192 + } 193 + 194 + async createPasswordResetToken(userId: string): Promise<string> { 195 + const token = generateId(32) 196 + const stored: StoredToken = { 197 + userId, 198 + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour 199 + } 200 + await this.#kv.set(['password_reset_tokens', token], stored, { 201 + expireIn: 60 * 60 * 1000, 202 + }) 203 + return token 204 + } 205 + 206 + async validatePasswordResetToken(token: string): Promise<string | null> { 207 + const stored = ( 208 + await this.#kv.get<StoredToken>(['password_reset_tokens', token]) 209 + ).value 210 + if (!stored) return null 211 + if (Date.now() >= new Date(stored.expiresAt).getTime()) { 212 + await this.#kv.delete(['password_reset_tokens', token]) 213 + return null 214 + } 215 + await this.#kv.delete(['password_reset_tokens', token]) // single-use 216 + return stored.userId 217 + } 218 + 219 + async createEmailVerificationToken(userId: string): Promise<string> { 220 + const token = generateId(32) 221 + const stored: StoredToken = { 222 + userId, 223 + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 hours 224 + } 225 + await this.#kv.set(['email_verification_tokens', token], stored, { 226 + expireIn: 24 * 60 * 60 * 1000, 227 + }) 228 + return token 229 + } 230 + 231 + async validateEmailVerificationToken(token: string): Promise<string | null> { 232 + const stored = ( 233 + await this.#kv.get<StoredToken>(['email_verification_tokens', token]) 234 + ).value 235 + if (!stored) return null 236 + if (Date.now() >= new Date(stored.expiresAt).getTime()) { 237 + await this.#kv.delete(['email_verification_tokens', token]) 238 + return null 239 + } 240 + await this.#kv.delete(['email_verification_tokens', token]) // single-use 241 + return stored.userId 146 242 } 147 243 148 244 async getUserWithPasswordHash(userId: string): Promise<StoredUser | null> { ··· 173 269 const user = (await this.#kv.get<StoredUser>(['users', userId])).value 174 270 if (!user) return 175 271 176 - // Delete all sessions 177 - const sessions = this.#kv.list<StoredSession>({ prefix: ['sessions'] }) 178 - for await (const entry of sessions) { 179 - if (entry.value.userId === userId) { 180 - await this.#kv.delete(entry.key) 181 - } 182 - } 272 + await this.invalidateAllUserSessions(userId) 183 273 184 274 // Delete all API keys 185 275 const apiKeys = this.#kv.list<StoredApiKey>({ prefix: ['api_keys'] }) ··· 385 475 } 386 476 } 387 477 478 + type StoredUserRow = { 479 + id: string 480 + username: string 481 + email: string 482 + emailVerified: boolean 483 + password_hash: string 484 + } 485 + 388 486 // Helper to create auth handlers from DenoKVAuth instance 389 - export function createAuthHandlers(auth: DenoKVAuth): { 487 + export function createAuthHandlers( 488 + auth: DenoKVAuth, 489 + opts?: { email?: EmailAdapter }, 490 + ): { 390 491 createSession(userId: string): Promise<Session> 391 - createUser( 392 - username: string, 393 - email: string, 394 - password: string, 395 - ): Promise<User> 396 - getUserByEmail( 397 - email: string, 398 - ): Promise< 399 - | { id: string; username: string; email: string; password_hash: string } 400 - | null 401 - > 402 - getUserByUsername( 403 - username: string, 404 - ): Promise< 405 - | { id: string; username: string; email: string; password_hash: string } 406 - | null 407 - > 408 - getUser(userId: string): Promise< 409 - { 410 - id: string 411 - username: string 412 - email: string 413 - password_hash: string 414 - } | null 415 - > 492 + createUser(username: string, email: string, password: string): Promise<User> 493 + getUserByEmail(email: string): Promise<StoredUserRow | null> 494 + getUserByUsername(username: string): Promise<StoredUserRow | null> 495 + getUser(userId: string): Promise<StoredUserRow | null> 416 496 invalidateSession(sessionId: string): Promise<void> 417 497 validateSessionId( 418 498 sessionId: string, ··· 421 501 hashPassword(password: string): Promise<string> 422 502 updatePassword(userId: string, newPasswordHash: string): Promise<void> 423 503 deleteUser(userId: string): Promise<void> 504 + invalidateAllUserSessions(userId: string): Promise<void> 505 + requestPasswordReset(email: string, resetUrl: string): Promise<void> 506 + validatePasswordResetToken(token: string): Promise<string | null> 507 + sendVerificationEmail(userId: string, verifyUrl: string): Promise<void> 508 + validateEmailVerificationToken(token: string): Promise<string | null> 509 + markEmailVerified(userId: string): Promise<void> 424 510 } { 511 + const { email: emailAdapter } = opts ?? {} 512 + 425 513 return { 426 514 async getUserByEmail(email: string) { 427 515 const user = await auth.getStoredUserByEmail(email) ··· 430 518 id: user.id, 431 519 username: user.username, 432 520 email: user.email, 521 + emailVerified: user.emailVerified ?? true, 433 522 password_hash: user.passwordHash, 434 523 } 435 524 }, ··· 440 529 id: user.id, 441 530 username: user.username, 442 531 email: user.email, 532 + emailVerified: user.emailVerified ?? true, 443 533 password_hash: user.passwordHash, 444 534 } 445 535 }, ··· 450 540 id: user.id, 451 541 username: user.username, 452 542 email: user.email, 543 + emailVerified: user.emailVerified ?? true, 453 544 password_hash: user.passwordHash, 454 545 } 455 546 }, 456 547 createUser(username: string, email: string, password: string) { 457 - return auth.createUser(username, email, password) 548 + // When an email adapter is configured, new accounts start unverified 549 + return auth.createUser(username, email, password, !emailAdapter) 458 550 }, 459 551 createSession(userId: string) { 460 552 return auth.createSession(userId) ··· 476 568 }, 477 569 deleteUser(userId: string) { 478 570 return auth.deleteUser(userId) 571 + }, 572 + invalidateAllUserSessions(userId: string) { 573 + return auth.invalidateAllUserSessions(userId) 574 + }, 575 + async requestPasswordReset(userEmail: string, resetUrl: string) { 576 + const user = await auth.getStoredUserByEmail(userEmail) 577 + if (!user) return // silent — don't reveal if email exists 578 + const token = await auth.createPasswordResetToken(user.id) 579 + const link = `${resetUrl}?token=${token}` 580 + if (emailAdapter) { 581 + await emailAdapter.send({ 582 + to: userEmail, 583 + subject: 'Reset your password', 584 + html: `<p>Click the link below to reset your password. This link expires in 1 hour.</p><p><a href="${link}">${link}</a></p><p>If you did not request a password reset, you can ignore this email.</p>`, 585 + text: `Reset your password: ${link}\n\nThis link expires in 1 hour. If you did not request a reset, ignore this email.`, 586 + }) 587 + } else { 588 + console.log(`[civility] Password reset link for ${userEmail}: ${link}`) 589 + } 590 + }, 591 + validatePasswordResetToken(token: string) { 592 + return auth.validatePasswordResetToken(token) 593 + }, 594 + async sendVerificationEmail(userId: string, verifyUrl: string) { 595 + const user = await auth.getUserWithPasswordHash(userId) 596 + if (!user) return 597 + const token = await auth.createEmailVerificationToken(userId) 598 + const link = `${verifyUrl}?token=${token}` 599 + if (emailAdapter) { 600 + await emailAdapter.send({ 601 + to: user.email, 602 + subject: 'Verify your email address', 603 + html: `<p>Click the link below to verify your email address.</p><p><a href="${link}">${link}</a></p>`, 604 + text: `Verify your email: ${link}`, 605 + }) 606 + } else { 607 + console.log( 608 + `[civility] Email verification link for ${user.email}: ${link}`, 609 + ) 610 + } 611 + }, 612 + validateEmailVerificationToken(token: string) { 613 + return auth.validateEmailVerificationToken(token) 614 + }, 615 + markEmailVerified(userId: string) { 616 + return auth.markEmailVerified(userId) 479 617 }, 480 618 } 481 619 }
+1
packages/hono/auth/mod.ts
··· 10 10 export type { 11 11 AuthAdapter, 12 12 AuthContext, 13 + EmailAdapter, 13 14 RouterConfig, 14 15 Session, 15 16 User,
+114 -19
packages/hono/auth/router.ts
··· 19 19 export type { AuthAdapter, AuthContext, RouterConfig, Session, User } 20 20 21 21 function transformUser( 22 - user: { id: string; username: string; email: string }, 22 + user: { 23 + id: string 24 + username: string 25 + email: string 26 + emailVerified?: boolean 27 + }, 23 28 ): User { 24 29 return { 25 30 id: user.id, 26 31 username: user.username, 27 32 email: user.email, 33 + emailVerified: user.emailVerified, 28 34 } 29 35 } 30 36 37 + type UserRow = { 38 + id: string 39 + username: string 40 + email: string 41 + emailVerified?: boolean 42 + password_hash: string 43 + } 44 + 31 45 export interface AuthHandlers { 32 - getUserByEmail( 33 - email: string, 34 - ): Promise< 35 - | { id: string; username: string; email: string; password_hash: string } 36 - | null 37 - > 38 - getUserByUsername( 39 - username: string, 40 - ): Promise< 41 - | { id: string; username: string; email: string; password_hash: string } 42 - | null 43 - > 44 - getUser( 45 - userId: string, 46 - ): Promise< 47 - | { id: string; username: string; email: string; password_hash: string } 48 - | null 49 - > 46 + getUserByEmail(email: string): Promise<UserRow | null> 47 + getUserByUsername(username: string): Promise<UserRow | null> 48 + getUser(userId: string): Promise<UserRow | null> 50 49 createUser( 51 50 username: string, 52 51 email: string, ··· 66 65 hashPassword(password: string): Promise<string> 67 66 updatePassword(userId: string, newPasswordHash: string): Promise<void> 68 67 deleteUser(userId: string): Promise<void> 68 + // Email-related handlers — optional, present when an EmailAdapter is configured 69 + invalidateAllUserSessions?(userId: string): Promise<void> 70 + requestPasswordReset?(email: string, resetUrl: string): Promise<void> 71 + validatePasswordResetToken?(token: string): Promise<string | null> 72 + sendVerificationEmail?(userId: string, verifyUrl: string): Promise<void> 73 + validateEmailVerificationToken?(token: string): Promise<string | null> 74 + markEmailVerified?(userId: string): Promise<void> 69 75 } 70 76 71 77 export function createAuthRouterWithHandlers<TApp extends Hono = Hono>( ··· 144 150 30 * 24 * 60 * 60 145 151 }` 146 152 c.header('Set-Cookie', cookie) 153 + 154 + // Send verification email if adapter is configured 155 + const origin = new URL(c.req.url).origin 156 + await handlers.sendVerificationEmail?.( 157 + user.id, 158 + `${origin}/verify-email`, 159 + ) 147 160 148 161 if (!contentType.includes('application/json')) { 149 162 return c.redirect('/dashboard') ··· 309 322 310 323 c.header('Set-Cookie', 'auth_token=; HttpOnly; Path=/; Max-Age=0') 311 324 return c.redirect('/?deleted=true') 325 + }) 326 + 327 + app.post('/forgot-password', async (c: Context) => { 328 + const contentType = c.req.header('Content-Type') ?? '' 329 + let email: string 330 + if (contentType.includes('application/json')) { 331 + const body = await c.req.json() 332 + email = body.email 333 + } else { 334 + const body = await c.req.parseBody() 335 + email = body.email as string 336 + } 337 + 338 + const origin = new URL(c.req.url).origin 339 + await handlers.requestPasswordReset?.(email, `${origin}/reset-password`) 340 + 341 + // Always return success — never reveal whether the email exists 342 + if (!contentType.includes('application/json')) { 343 + return c.redirect('/forgot-password?success=sent') 344 + } 345 + return ok( 346 + c, 347 + null, 348 + 'If an account exists with that email, a reset link has been sent', 349 + ) 350 + }) 351 + 352 + app.post('/reset-password', async (c: Context) => { 353 + const contentType = c.req.header('Content-Type') ?? '' 354 + const isForm = !contentType.includes('application/json') 355 + 356 + let token: string, newPassword: string 357 + if (isForm) { 358 + const body = await c.req.parseBody() 359 + const f = body as Record<string, string | File> 360 + token = typeof f.token === 'string' ? f.token : '' 361 + newPassword = typeof f.password === 'string' ? f.password : '' 362 + const confirm = typeof f.confirm === 'string' ? f.confirm : '' 363 + if (newPassword !== confirm) { 364 + return c.redirect(`/reset-password?token=${token}&error=mismatch`) 365 + } 366 + } else { 367 + const body = await c.req.json() 368 + token = body.token 369 + newPassword = body.password 370 + } 371 + 372 + const userId = await handlers.validatePasswordResetToken?.(token) 373 + if (!userId) { 374 + if (isForm) return c.redirect('/reset-password?error=invalid') 375 + return err(c, 'auth.session_expired', 'Reset token is invalid or expired') 376 + } 377 + 378 + const passwordHash = await handlers.hashPassword(newPassword) 379 + await handlers.updatePassword(userId, passwordHash) 380 + await handlers.invalidateAllUserSessions?.(userId) 381 + 382 + if (isForm) return c.redirect('/login?success=password_reset') 383 + return ok(c, null, 'Password reset successfully') 384 + }) 385 + 386 + app.get('/verify-email', async (c: Context) => { 387 + const token = c.req.query('token') ?? '' 388 + const userId = await handlers.validateEmailVerificationToken?.(token) 389 + if (!userId) return c.redirect('/login?error=invalid_token') 390 + await handlers.markEmailVerified?.(userId) 391 + return c.redirect('/dashboard?verified=true') 392 + }) 393 + 394 + app.post('/resend-verification', async (c: Context) => { 395 + const cookie = c.req.header('Cookie') ?? '' 396 + const match = cookie.match(/auth_token=([^;]+)/) 397 + const token = match?.[1] ?? c.req.header('Authorization')?.slice(7) 398 + if (!token) return err(c, 'auth.token_required', 'Token required') 399 + 400 + const ctx = await handlers.validateSessionId(token) 401 + if (!ctx || !ctx.user) return err(c, 'auth.unauthorized', 'Unauthorized') 402 + 403 + const origin = new URL(c.req.url).origin 404 + await handlers.sendVerificationEmail?.(ctx.user.id, `${origin}/verify-email`) 405 + 406 + return ok(c, null, 'Verification email sent') 312 407 }) 313 408 314 409 return app
+108 -3
packages/hono/auth/ui_router.ts
··· 53 53 } 54 54 55 55 function dashboardLayout( 56 - user: { username: string; email: string }, 56 + user: { username: string; email: string; emailVerified?: boolean }, 57 57 content: ReturnType<typeof html>, 58 58 ) { 59 + const verificationBanner = user.emailVerified === false 60 + ? html` 61 + <div class="card" style="border-left: 4px solid #d97706; background: #fffbeb;"> 62 + <p style="margin: 0;"> 63 + Please verify your email address (${user.email}). 64 + <form method="POST" action="/api/v1/auth/resend-verification" style="display:inline; margin-left: 0.5rem;"> 65 + <button type="submit" style="background:none;border:none;cursor:pointer;color:#2563eb;text-decoration:underline;padding:0;font-size:inherit;">Resend email</button> 66 + </form> 67 + </p> 68 + </div> 69 + ` 70 + : null 71 + 59 72 return layout( 60 73 'Dashboard', 61 74 html` 75 + ${verificationBanner} 62 76 <div class="card"> 63 77 <h2>Account</h2> 64 78 <p><strong>Username:</strong> ${user.username}</p> ··· 125 139 'Login', 126 140 html` 127 141 ${authMessage( 128 - error === 'invalid' ? 'Invalid credentials' : null, 129 - success === 'logged_out' ? 'You have been logged out.' : null, 142 + error === 'invalid' 143 + ? 'Invalid credentials' 144 + : error === 'invalid_token' 145 + ? 'That link is invalid or has expired.' 146 + : null, 147 + success === 'logged_out' 148 + ? 'You have been logged out.' 149 + : success === 'password_reset' 150 + ? 'Password reset successfully. Please sign in.' 151 + : null, 130 152 )} 131 153 <div class="card"> 132 154 <h2>Sign In</h2> ··· 142 164 <button type="submit">Sign In</button> 143 165 </form> 144 166 <p style="margin-top: 1rem;"> 167 + <a href="/forgot-password">Forgot your password?</a> 168 + </p> 169 + <p style="margin-top: 0.5rem;"> 145 170 Don't have an account? <a href="/signup">Sign up</a> 146 171 </p> 147 172 </div> ··· 350 375 > 351 376 Delete Account 352 377 </button> 378 + </form> 379 + </div> 380 + `, 381 + )) 382 + }) 383 + 384 + app.get('/forgot-password', (c) => { 385 + const url = new URL(c.req.url) 386 + const success = url.searchParams.get('success') 387 + 388 + return c.html(layout( 389 + 'Forgot Password', 390 + html` 391 + <div class="card"> 392 + <h2>Reset Password</h2> 393 + ${ 394 + success === 'sent' 395 + ? html`<p class="success">If an account exists with that email, a reset link has been sent.</p>` 396 + : null 397 + } 398 + <p style="margin-bottom: 1rem; color: #666;"> 399 + Enter your email address and we'll send you a link to reset your password. 400 + </p> 401 + <form method="POST" action="/api/v1/auth/forgot-password"> 402 + <div class="form-group"> 403 + <label for="email">Email address</label> 404 + <input type="email" id="email" name="email" required> 405 + </div> 406 + <button type="submit">Send Reset Link</button> 407 + </form> 408 + <p style="margin-top: 1rem;"> 409 + <a href="/login">Back to sign in</a> 410 + </p> 411 + </div> 412 + `, 413 + )) 414 + }) 415 + 416 + app.get('/reset-password', (c) => { 417 + const url = new URL(c.req.url) 418 + const token = url.searchParams.get('token') ?? '' 419 + const error = url.searchParams.get('error') 420 + 421 + if (!token) return c.redirect('/forgot-password') 422 + 423 + return c.html(layout( 424 + 'Set New Password', 425 + html` 426 + <div class="card"> 427 + <h2>Set New Password</h2> 428 + ${ 429 + error === 'mismatch' 430 + ? html`<p class="error">Passwords do not match.</p>` 431 + : error === 'invalid' 432 + ? html`<p class="error">This reset link is invalid or has expired.</p>` 433 + : null 434 + } 435 + <form method="POST" action="/api/v1/auth/reset-password"> 436 + <input type="hidden" name="token" value="${token}"> 437 + <div class="form-group"> 438 + <label for="password">New password</label> 439 + <input 440 + type="password" 441 + id="password" 442 + name="password" 443 + required 444 + minlength="8" 445 + > 446 + </div> 447 + <div class="form-group"> 448 + <label for="confirm">Confirm new password</label> 449 + <input 450 + type="password" 451 + id="confirm" 452 + name="confirm" 453 + required 454 + minlength="8" 455 + > 456 + </div> 457 + <button type="submit">Reset Password</button> 353 458 </form> 354 459 </div> 355 460 `,
+1
packages/hono/deno.json
··· 26 26 "hono": "npm:hono@^4.12.16" 27 27 } 28 28 } 29 +
+10 -1
packages/hono/mod.ts
··· 66 66 import type { 67 67 AuthAdapter, 68 68 Context, 69 + EmailAdapter, 69 70 Next, 70 71 ObjectStorage, 71 72 } from './shared/types.ts' ··· 96 97 export type { 97 98 AuthAdapter, 98 99 AuthContext, 100 + EmailAdapter, 99 101 ObjectStorage, 100 102 RouterConfig, 101 103 Session, ··· 132 134 kv: Deno.Kv 133 135 /** Override the default auth adapter */ 134 136 auth?: AuthAdapter 137 + /** 138 + * Email adapter for password reset and email verification. 139 + * When omitted, reset/verification links are logged to the console 140 + * instead — suitable for self-hosted deployments with a trusted user base. 141 + */ 142 + email?: EmailAdapter 135 143 /** Object storage adapter for blobs (optional - creates default if not provided) */ 136 144 objectStorage?: ObjectStorage 137 145 /** OpenAPI document config */ ··· 152 160 path = '/api/v1', 153 161 kv, 154 162 auth: customAuth, 163 + email, 155 164 objectStorage, 156 165 openapi: openapiConfig, 157 166 limits: limitsOption, ··· 165 174 secret: crypto.getRandomValues(new Uint8Array(32)).toString(), 166 175 }) 167 176 const authAdapter = customAuth ?? auth 168 - const authHandlers = createAuthHandlers(auth) 177 + const authHandlers = createAuthHandlers(auth, { email }) 169 178 const db = new DenoKVDatabase(kv) 170 179 const store = new DenoKVSyncStore(kv) 171 180
+10
packages/hono/shared/types.ts
··· 6 6 id: string 7 7 email: string 8 8 username: string 9 + emailVerified?: boolean 10 + } 11 + 12 + export interface EmailAdapter { 13 + send(opts: { 14 + to: string 15 + subject: string 16 + html: string 17 + text?: string 18 + }): Promise<void> 9 19 } 10 20 11 21 export interface Session {