Self-service password reset
This commit is contained in:
44
server/api/auth/forgot-password.post.ts
Normal file
44
server/api/auth/forgot-password.post.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getUserByEmail, createPasswordResetCode } from '~/server/utils/database'
|
||||
import { sendPasswordResetEmail, generateResetCode } from '~/server/utils/email'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const { email } = body
|
||||
|
||||
if (!email) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Email is required',
|
||||
})
|
||||
}
|
||||
|
||||
// Check if user exists with this email
|
||||
const user = getUserByEmail(email)
|
||||
|
||||
if (!user) {
|
||||
// Don't reveal if email exists or not for security
|
||||
return { success: true, message: 'If an account exists with this email, a reset code has been sent.' }
|
||||
}
|
||||
|
||||
// Generate 6-digit code
|
||||
const code = generateResetCode()
|
||||
|
||||
// Set expiration to 15 minutes from now
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString()
|
||||
|
||||
// Store code in database
|
||||
createPasswordResetCode(email, code, expiresAt)
|
||||
|
||||
// Send email
|
||||
try {
|
||||
await sendPasswordResetEmail(email, code)
|
||||
} catch (error) {
|
||||
console.error('Failed to send reset email:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: 'Failed to send reset email. Please try again later.',
|
||||
})
|
||||
}
|
||||
|
||||
return { success: true, message: 'If an account exists with this email, a reset code has been sent.' }
|
||||
})
|
||||
@@ -1,14 +1,23 @@
|
||||
import { createUser, getUserByUsername } from '~/server/utils/database'
|
||||
import { createUser, getUserByUsername, getUserByEmail } from '~/server/utils/database'
|
||||
import { setAuthCookie } from '~/server/utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const { username, password } = body
|
||||
const { username, password, email, firstName, lastName } = body
|
||||
|
||||
if (!username || !password) {
|
||||
if (!username || !password || !email || !firstName || !lastName) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Username and password are required'
|
||||
message: 'All fields are required'
|
||||
})
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(email)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Invalid email format'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,7 +58,7 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
// Check if username already exists
|
||||
const existingUser = getUserByUsername(username.toLowerCase())
|
||||
if (existingUser) {
|
||||
throw createError({
|
||||
@@ -58,9 +67,18 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
const existingEmail = getUserByEmail(email.toLowerCase())
|
||||
if (existingEmail) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: 'Email already exists'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// Create the new user
|
||||
createUser(username.toLowerCase(), password)
|
||||
// Create the new user with all fields
|
||||
createUser(username.toLowerCase(), password, email.toLowerCase(), firstName, lastName)
|
||||
|
||||
// Log them in automatically
|
||||
setAuthCookie(event, username.toLowerCase())
|
||||
|
||||
50
server/api/auth/reset-password.post.ts
Normal file
50
server/api/auth/reset-password.post.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { getPasswordResetCode, resetPasswordByEmail, deletePasswordResetCode } from '~/server/utils/database'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const { email, code, newPassword } = body
|
||||
|
||||
if (!email || !code || !newPassword) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Email, code, and new password are required',
|
||||
})
|
||||
}
|
||||
|
||||
// Validate password requirements
|
||||
if (newPassword.length < 8) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Password must be at least 8 characters long',
|
||||
})
|
||||
}
|
||||
|
||||
const hasUpperCase = /[A-Z]/.test(newPassword)
|
||||
const hasLowerCase = /[a-z]/.test(newPassword)
|
||||
const hasNumberOrSymbol = /[0-9!@#$%^&*(),.?":{}|<>]/.test(newPassword)
|
||||
|
||||
if (!hasUpperCase || !hasLowerCase || !hasNumberOrSymbol) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Password must contain uppercase, lowercase, and number/symbol',
|
||||
})
|
||||
}
|
||||
|
||||
// Verify code exists and hasn't expired
|
||||
const resetCode = getPasswordResetCode(email, code)
|
||||
|
||||
if (!resetCode) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Invalid or expired reset code',
|
||||
})
|
||||
}
|
||||
|
||||
// Reset password
|
||||
resetPasswordByEmail(email, newPassword)
|
||||
|
||||
// Delete used reset code
|
||||
deletePasswordResetCode(email)
|
||||
|
||||
return { success: true, message: 'Password reset successfully' }
|
||||
})
|
||||
25
server/api/auth/verify-reset-code.post.ts
Normal file
25
server/api/auth/verify-reset-code.post.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getPasswordResetCode } from '~/server/utils/database'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const { email, code } = body
|
||||
|
||||
if (!email || !code) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Email and code are required',
|
||||
})
|
||||
}
|
||||
|
||||
// Verify code exists and hasn't expired
|
||||
const resetCode = getPasswordResetCode(email, code)
|
||||
|
||||
if (!resetCode) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Invalid or expired reset code',
|
||||
})
|
||||
}
|
||||
|
||||
return { success: true, message: 'Code verified successfully' }
|
||||
})
|
||||
@@ -21,9 +21,20 @@ export interface User {
|
||||
id?: number
|
||||
username: string
|
||||
password: string
|
||||
email?: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
is_admin: number
|
||||
}
|
||||
|
||||
export interface PasswordResetCode {
|
||||
id?: number
|
||||
email: string
|
||||
code: string
|
||||
expires_at: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface SermonNote {
|
||||
id?: number
|
||||
user_id: number
|
||||
@@ -60,10 +71,23 @@ export function getDatabase() {
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
email TEXT,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
is_admin INTEGER DEFAULT 0
|
||||
)
|
||||
`)
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS password_reset_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sermon_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -143,16 +167,48 @@ export function getUserByUsername(username: string) {
|
||||
return db.prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined
|
||||
}
|
||||
|
||||
export function createUser(username: string, password: string) {
|
||||
export function createUser(username: string, password: string, email?: string, firstName?: string, lastName?: string) {
|
||||
const db = getDatabase()
|
||||
const saltRounds = 10
|
||||
const hashedPassword = bcrypt.hashSync(password, saltRounds)
|
||||
return db.prepare('INSERT INTO users (username, password, is_admin) VALUES (?, ?, 0)').run(username, hashedPassword)
|
||||
return db.prepare('INSERT INTO users (username, password, email, first_name, last_name, is_admin) VALUES (?, ?, ?, ?, ?, 0)')
|
||||
.run(username, hashedPassword, email || null, firstName || null, lastName || null)
|
||||
}
|
||||
|
||||
export function getUserByEmail(email: string) {
|
||||
const db = getDatabase()
|
||||
return db.prepare('SELECT * FROM users WHERE email = ?').get(email) as User | undefined
|
||||
}
|
||||
|
||||
export function createPasswordResetCode(email: string, code: string, expiresAt: string) {
|
||||
const db = getDatabase()
|
||||
// Delete any existing codes for this email
|
||||
db.prepare('DELETE FROM password_reset_codes WHERE email = ?').run(email)
|
||||
return db.prepare('INSERT INTO password_reset_codes (email, code, expires_at) VALUES (?, ?, ?)')
|
||||
.run(email, code, expiresAt)
|
||||
}
|
||||
|
||||
export function getPasswordResetCode(email: string, code: string) {
|
||||
const db = getDatabase()
|
||||
return db.prepare('SELECT * FROM password_reset_codes WHERE email = ? AND code = ? AND expires_at > datetime("now")')
|
||||
.get(email, code) as PasswordResetCode | undefined
|
||||
}
|
||||
|
||||
export function deletePasswordResetCode(email: string) {
|
||||
const db = getDatabase()
|
||||
return db.prepare('DELETE FROM password_reset_codes WHERE email = ?').run(email)
|
||||
}
|
||||
|
||||
export function resetPasswordByEmail(email: string, newPassword: string) {
|
||||
const db = getDatabase()
|
||||
const saltRounds = 10
|
||||
const hashedPassword = bcrypt.hashSync(newPassword, saltRounds)
|
||||
return db.prepare('UPDATE users SET password = ? WHERE email = ?').run(hashedPassword, email)
|
||||
}
|
||||
|
||||
export function getAllUsers() {
|
||||
const db = getDatabase()
|
||||
return db.prepare('SELECT id, username, is_admin FROM users ORDER BY username').all() as Omit<User, 'password'>[]
|
||||
return db.prepare('SELECT id, username, email, first_name, last_name, is_admin FROM users ORDER BY username').all() as Omit<User, 'password'>[]
|
||||
}
|
||||
|
||||
export function deleteUser(id: number) {
|
||||
|
||||
39
server/utils/email.ts
Normal file
39
server/utils/email.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
export async function sendPasswordResetEmail(email: string, code: string) {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.emailHost,
|
||||
port: parseInt(config.emailPort),
|
||||
secure: parseInt(config.emailPort) === 465,
|
||||
auth: {
|
||||
user: config.emailUser,
|
||||
pass: config.emailPassword,
|
||||
},
|
||||
})
|
||||
|
||||
const mailOptions = {
|
||||
from: config.emailFrom,
|
||||
to: email,
|
||||
subject: 'Password Reset Code - New Life Christian Church',
|
||||
text: `Please enter this 6 digit code to reset your password for the New Life Christian Church sermon page: ${code}`,
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #333;">Password Reset Request</h2>
|
||||
<p>Please enter this 6 digit code to reset your password for the New Life Christian Church sermon page:</p>
|
||||
<div style="background-color: #f4f4f4; padding: 20px; text-align: center; font-size: 32px; font-weight: bold; letter-spacing: 5px; margin: 20px 0;">
|
||||
${code}
|
||||
</div>
|
||||
<p style="color: #666;">This code will expire in 15 minutes.</p>
|
||||
<p style="color: #666;">If you did not request a password reset, please ignore this email.</p>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
|
||||
await transporter.sendMail(mailOptions)
|
||||
}
|
||||
|
||||
export function generateResetCode(): string {
|
||||
return Math.floor(100000 + Math.random() * 900000).toString()
|
||||
}
|
||||
Reference in New Issue
Block a user