source: Klonkt/src/routes/auth.js@ 942028f

main
Last change on this file since 942028f was 7bc636b, checked in by Robin <robin@…>, 4 months ago

Initial commit — PrutFolio v1 source (pulled from Hetzner /srv/prutfolio)

  • Property mode set to 100644
File size: 8.2 KB
Line 
1import express from 'express';
2import crypto from 'crypto';
3import bcrypt from 'bcryptjs';
4import { v4 as uuid } from 'uuid';
5import db from '../config/database.js';
6import { renderPage } from '../middleware/render.js';
7import { loginLimiter, registerLimiter } from '../middleware/rate-limit.js';
8import { safeNext } from '../middleware/auth.js';
9
10const router = express.Router();
11
12// ==================== LOGIN ====================
13router.get('/login', (req, res) => {
14 const next = safeNext(req.query.next) || '';
15 if (req.session.user) return res.redirect(next || '/');
16 renderPage(req, res, 'pages/auth-login', {
17 pageTitle: 'Login',
18 bodyClass: 'on-special',
19 error: null,
20 success: req.query.success || null,
21 username: '',
22 next,
23 });
24});
25
26router.post('/login', loginLimiter, (req, res) => {
27 const { username, password } = req.body;
28 const next = safeNext(req.body.next) || '';
29
30 if (!username || !password) {
31 res.status(400);
32 return renderPage(req, res, 'pages/auth-login', {
33 pageTitle: 'Login',
34 bodyClass: 'on-special',
35 error: 'Username and password required',
36 username: username || '',
37 next,
38 });
39 }
40
41 const user = db.prepare(
42 'SELECT * FROM users WHERE username = ? OR email = ?'
43 ).get(username, username);
44
45 if (!user || !bcrypt.compareSync(password, user.password_hash)) {
46 res.status(401);
47 return renderPage(req, res, 'pages/auth-login', {
48 pageTitle: 'Login',
49 bodyClass: 'on-special',
50 error: 'Invalid credentials',
51 username: username,
52 next,
53 });
54 }
55
56 req.session.user = {
57 id: user.id,
58 username: user.username,
59 email: user.email,
60 role: user.role,
61 avatar_url: user.avatar_url,
62 palette: user.palette,
63 theme: user.theme,
64 };
65
66 res.redirect(next || '/');
67});
68
69// ==================== REGISTER ====================
70router.get('/register', (req, res) => {
71 const next = safeNext(req.query.next) || '';
72 if (req.session.user) return res.redirect(next || '/');
73 renderPage(req, res, 'pages/auth-register', {
74 pageTitle: 'Register',
75 bodyClass: 'on-special',
76 error: null,
77 username: '',
78 email: '',
79 next,
80 });
81});
82
83router.post('/register', registerLimiter, (req, res) => {
84 const { username, email, password } = req.body;
85 const next = safeNext(req.body.next) || '';
86
87 // Validation
88 if (!username || !email || !password) {
89 return renderPage(req, res, 'pages/auth-register', {
90 pageTitle: 'Register',
91 bodyClass: 'on-special',
92 error: 'All fields required',
93 username: username || '',
94 email: email || '',
95 next,
96 });
97 }
98
99 if (!/^[a-z0-9_-]{3,32}$/i.test(username)) {
100 return renderPage(req, res, 'pages/auth-register', {
101 pageTitle: 'Register',
102 bodyClass: 'on-special',
103 error: 'Username: 3-32 characters, letters/numbers/underscore/dash only',
104 username, email, next,
105 });
106 }
107
108 if (password.length < 8) {
109 return renderPage(req, res, 'pages/auth-register', {
110 pageTitle: 'Register',
111 bodyClass: 'on-special',
112 error: 'Password must be at least 8 characters',
113 username, email, next,
114 });
115 }
116
117 // Check uniqueness
118 const existing = db.prepare(
119 'SELECT id FROM users WHERE username = ? OR email = ?'
120 ).get(username, email);
121
122 if (existing) {
123 return renderPage(req, res, 'pages/auth-register', {
124 pageTitle: 'Register',
125 bodyClass: 'on-special',
126 error: 'Username or email already taken',
127 username, email, next,
128 });
129 }
130
131 // Create user
132 const userId = uuid();
133 const hash = bcrypt.hashSync(password, 10);
134
135 // First user becomes god
136 const userCount = db.prepare('SELECT COUNT(*) as c FROM users').get().c;
137 const role = userCount === 0 ? 'god' : 'member';
138
139 db.prepare(`
140 INSERT INTO users (id, username, email, password_hash, role, theme, palette)
141 VALUES (?, ?, ?, ?, ?, 'dark', 'sage')
142 `).run(userId, username, email, hash, role);
143
144 // First user gets a personal site auto-created
145 if (userCount === 0) {
146 const siteId = uuid();
147 const siteSlug = username.toLowerCase();
148 db.prepare(`
149 INSERT INTO sites (id, slug, title, description, owner_id, palette, accent, language)
150 VALUES (?, ?, ?, ?, ?, 'sage', '#c2410c', 'nl')
151 `).run(siteId, siteSlug, username + "'s Site", 'Welcome to my site', userId);
152
153 db.prepare(`
154 INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')
155 `).run(siteId, userId);
156 }
157
158 req.session.user = {
159 id: userId, username, email, role,
160 palette: 'sage', theme: 'dark',
161 };
162
163 res.redirect(next || '/');
164});
165
166// ==================== PASSWORD RESET (request) ====================
167router.get('/reset-request', (req, res) => {
168 if (req.session.user) return res.redirect('/');
169 renderPage(req, res, 'pages/auth-reset-request', {
170 pageTitle: 'Reset password',
171 bodyClass: 'on-special',
172 error: null,
173 sent: false,
174 devResetUrl: null,
175 });
176});
177
178router.post('/reset-request', registerLimiter, (req, res) => {
179 const email = (req.body.email || '').trim().toLowerCase();
180 let devResetUrl = null;
181
182 if (email) {
183 const user = db.prepare('SELECT id, email FROM users WHERE LOWER(email) = ?').get(email);
184 if (user) {
185 // Generate token (32-byte hex, ~10 min expiry)
186 const token = crypto.randomBytes(32).toString('hex');
187 const expires = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // 30 min
188 db.prepare('UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?')
189 .run(token, expires, user.id);
190
191 // No email sender yet — log the reset URL so dev can use it.
192 const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
193 const host = req.headers['x-forwarded-host'] || req.get('host');
194 const url = `${proto}://${host}/auth/reset/${token}`;
195 console.log(`[password-reset] ${email} -> ${url}`);
196 if (process.env.NODE_ENV !== 'production') devResetUrl = url;
197 }
198 }
199
200 // Anti-enumeration: same response regardless of whether the email exists.
201 renderPage(req, res, 'pages/auth-reset-request', {
202 pageTitle: 'Reset password',
203 bodyClass: 'on-special',
204 error: null,
205 sent: true,
206 devResetUrl,
207 });
208});
209
210// ==================== PASSWORD RESET (apply) ====================
211router.get('/reset/:token', (req, res) => {
212 const row = db.prepare(`
213 SELECT id, username FROM users
214 WHERE reset_token = ? AND reset_token_expires > datetime('now')
215 `).get(req.params.token);
216
217 if (!row) {
218 return renderPage(req, res, 'pages/auth-reset', {
219 pageTitle: 'Reset password',
220 bodyClass: 'on-special',
221 error: 'This reset link is invalid or has expired.',
222 token: null,
223 username: null,
224 });
225 }
226
227 renderPage(req, res, 'pages/auth-reset', {
228 pageTitle: 'Reset password',
229 bodyClass: 'on-special',
230 error: null,
231 token: req.params.token,
232 username: row.username,
233 });
234});
235
236router.post('/reset/:token', (req, res) => {
237 const { new_password, confirm } = req.body;
238 const row = db.prepare(`
239 SELECT id, username FROM users
240 WHERE reset_token = ? AND reset_token_expires > datetime('now')
241 `).get(req.params.token);
242
243 const renderError = (msg) => renderPage(req, res, 'pages/auth-reset', {
244 pageTitle: 'Reset password',
245 bodyClass: 'on-special',
246 error: msg,
247 token: row ? req.params.token : null,
248 username: row ? row.username : null,
249 });
250
251 if (!row) return renderError('This reset link is invalid or has expired.');
252 if (!new_password || new_password.length < 8) {
253 return renderError('Password must be at least 8 characters');
254 }
255 if (new_password !== confirm) {
256 return renderError('Passwords do not match');
257 }
258
259 const hash = bcrypt.hashSync(new_password, 10);
260 db.prepare(`
261 UPDATE users
262 SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL,
263 updated_at = CURRENT_TIMESTAMP
264 WHERE id = ?
265 `).run(hash, row.id);
266
267 // Force login (don't auto-log them in — let them prove ownership)
268 res.redirect('/auth/login?success=' + encodeURIComponent('Password reset — please log in.'));
269});
270
271// ==================== LOGOUT ====================
272router.get('/logout', (req, res) => {
273 req.session.destroy(() => res.redirect('/'));
274});
275
276router.post('/logout', (req, res) => {
277 req.session.destroy(() => res.redirect('/'));
278});
279
280export default router;
Note: See TracBrowser for help on using the repository browser.