Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

auth.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

auth.tsxBlame501 lines · 1 contributor
06d5ffeClaude1/**
2 * Auth routes — register, login, logout (web + API).
3 */
4
5import { Hono } from "hono";
7298a17Claude6import { setCookie, deleteCookie, getCookie } from "hono/cookie";
7import { and, eq, isNull } from "drizzle-orm";
06d5ffeClaude8import { db } from "../db";
7298a17Claude9import {
10 users,
11 sessions,
12 organizations,
13 userTotp,
14 userRecoveryCodes,
15} from "../db/schema";
06d5ffeClaude16import {
17 hashPassword,
18 verifyPassword,
19 generateSessionToken,
20 sessionCookieOptions,
21 sessionExpiry,
22} from "../lib/auth";
7298a17Claude23import { verifyTotpCode, hashRecoveryCode } from "../lib/totp";
06d5ffeClaude24import { Layout } from "../views/layout";
25import type { AuthEnv } from "../middleware/auth";
26
27const auth = new Hono<AuthEnv>();
28
29// --- Web UI ---
30
31auth.get("/register", (c) => {
32 const error = c.req.query("error");
33 return c.html(
34 <Layout title="Register">
35 <div class="auth-container">
36 <h2>Create account</h2>
37 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
38 <form method="POST" action="/register">
39 <div class="form-group">
40 <label for="username">Username</label>
41 <input
42 type="text"
43 id="username"
44 name="username"
45 required
46 pattern="^[a-zA-Z0-9_-]+$"
47 minLength={2}
48 maxLength={39}
49 placeholder="your-username"
50 autocomplete="username"
51 />
52 </div>
53 <div class="form-group">
54 <label for="email">Email</label>
55 <input
56 type="email"
57 id="email"
58 name="email"
59 required
60 placeholder="you@example.com"
61 autocomplete="email"
62 />
63 </div>
64 <div class="form-group">
65 <label for="password">Password</label>
66 <input
67 type="password"
68 id="password"
69 name="password"
70 required
71 minLength={8}
72 placeholder="Min 8 characters"
73 autocomplete="new-password"
74 />
75 </div>
76 <button type="submit" class="btn btn-primary">
77 Create account
78 </button>
79 </form>
80 <p class="auth-switch">
81 Already have an account? <a href="/login">Sign in</a>
82 </p>
83 </div>
84 </Layout>
85 );
86});
87
88auth.post("/register", async (c) => {
89 const body = await c.req.parseBody();
90 const username = String(body.username || "").trim();
91 const email = String(body.email || "").trim();
92 const password = String(body.password || "");
93
94 if (!username || !email || !password) {
95 return c.redirect("/register?error=All+fields+are+required");
96 }
97
98 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
99 return c.redirect(
100 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
101 );
102 }
103
104 if (password.length < 8) {
105 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
106 }
107
108 // Check existing
109 const [existingUser] = await db
110 .select()
111 .from(users)
112 .where(eq(users.username, username))
113 .limit(1);
114 if (existingUser) {
115 return c.redirect("/register?error=Username+already+taken");
116 }
117
7437605Claude118 // B2: usernames share the URL namespace with org slugs; refuse collisions.
119 const [existingOrg] = await db
120 .select({ id: organizations.id })
121 .from(organizations)
122 .where(eq(organizations.slug, username.toLowerCase()))
123 .limit(1);
124 if (existingOrg) {
125 return c.redirect("/register?error=Username+already+taken");
126 }
127
06d5ffeClaude128 const [existingEmail] = await db
129 .select()
130 .from(users)
131 .where(eq(users.email, email))
132 .limit(1);
133 if (existingEmail) {
134 return c.redirect("/register?error=Email+already+registered");
135 }
136
137 const passwordHash = await hashPassword(password);
138
139 const [user] = await db
140 .insert(users)
141 .values({ username, email, passwordHash })
142 .returning();
143
144 // Create session
145 const token = generateSessionToken();
146 await db.insert(sessions).values({
147 userId: user.id,
148 token,
149 expiresAt: sessionExpiry(),
150 });
151
152 setCookie(c, "session", token, sessionCookieOptions());
153
154 const redirect = c.req.query("redirect") || "/";
155 return c.redirect(redirect);
156});
157
158auth.get("/login", (c) => {
159 const error = c.req.query("error");
160 const redirect = c.req.query("redirect") || "";
161 return c.html(
162 <Layout title="Sign in">
163 <div class="auth-container">
164 <h2>Sign in</h2>
165 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
166 <form
167 method="POST"
168 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
169 >
170 <div class="form-group">
171 <label for="username">Username or email</label>
172 <input
173 type="text"
174 id="username"
175 name="username"
176 required
177 placeholder="username or email"
178 autocomplete="username"
179 />
180 </div>
181 <div class="form-group">
182 <label for="password">Password</label>
183 <input
184 type="password"
185 id="password"
186 name="password"
187 required
188 placeholder="Password"
189 autocomplete="current-password"
190 />
191 </div>
192 <button type="submit" class="btn btn-primary">
193 Sign in
194 </button>
195 </form>
196 <p class="auth-switch">
197 New to gluecron? <a href="/register">Create an account</a>
198 </p>
199 </div>
200 </Layout>
201 );
202});
203
204auth.post("/login", async (c) => {
205 const body = await c.req.parseBody();
206 const identifier = String(body.username || "").trim();
207 const password = String(body.password || "");
208 const redirect = c.req.query("redirect") || "/";
209
210 if (!identifier || !password) {
211 return c.redirect("/login?error=All+fields+are+required");
212 }
213
214 // Find user by username or email
215 const isEmail = identifier.includes("@");
216 const [user] = await db
217 .select()
218 .from(users)
219 .where(
220 isEmail
221 ? eq(users.email, identifier)
222 : eq(users.username, identifier)
223 )
224 .limit(1);
225
226 if (!user) {
227 return c.redirect("/login?error=Invalid+credentials");
228 }
229
230 const valid = await verifyPassword(password, user.passwordHash);
231 if (!valid) {
232 return c.redirect("/login?error=Invalid+credentials");
233 }
234
7298a17Claude235 // B4: if the user has TOTP enabled, issue a pending-2fa session and
236 // redirect to the code prompt.
237 const [totp] = await db
238 .select({ enabledAt: userTotp.enabledAt })
239 .from(userTotp)
240 .where(eq(userTotp.userId, user.id))
241 .limit(1);
242 const needs2fa = !!(totp && totp.enabledAt);
243
06d5ffeClaude244 const token = generateSessionToken();
245 await db.insert(sessions).values({
246 userId: user.id,
247 token,
248 expiresAt: sessionExpiry(),
7298a17Claude249 requires2fa: needs2fa,
06d5ffeClaude250 });
251
252 setCookie(c, "session", token, sessionCookieOptions());
7298a17Claude253 if (needs2fa) {
254 return c.redirect(
255 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
256 );
257 }
06d5ffeClaude258 return c.redirect(redirect);
259});
260
7298a17Claude261// --- 2FA verify (B4) ---
262auth.get("/login/2fa", async (c) => {
263 const token = getCookie(c, "session");
264 if (!token) return c.redirect("/login");
265 const error = c.req.query("error");
266 const redirect = c.req.query("redirect") || "/";
267 return c.html(
268 <Layout title="Two-factor authentication">
269 <div class="auth-container">
270 <h2>Enter your code</h2>
271 <p
272 class="auth-switch"
273 style="margin-bottom: 16px; margin-top: 0"
274 >
275 Open your authenticator app and enter the 6-digit code. Lost your
276 device? Paste a recovery code instead.
277 </p>
278 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
279 <form
280 method="POST"
281 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
282 >
283 <div class="form-group">
284 <label for="code">Code</label>
285 <input
286 type="text"
287 id="code"
288 name="code"
289 required
290 autocomplete="one-time-code"
291 inputmode="numeric"
292 maxLength={24}
293 placeholder="123456 or xxxx-xxxx-xxxx"
294 />
295 </div>
296 <button type="submit" class="btn btn-primary">
297 Verify
298 </button>
299 </form>
300 <p class="auth-switch">
301 <a href="/logout">Cancel</a>
302 </p>
303 </div>
304 </Layout>
305 );
306});
307
308auth.post("/login/2fa", async (c) => {
309 const token = getCookie(c, "session");
310 if (!token) return c.redirect("/login");
311 const body = await c.req.parseBody();
312 const code = String(body.code || "").trim();
313 const redirect = c.req.query("redirect") || "/";
314
315 if (!code) {
316 return c.redirect(
317 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
318 );
319 }
320
321 try {
322 const [session] = await db
323 .select()
324 .from(sessions)
325 .where(eq(sessions.token, token))
326 .limit(1);
327 if (
328 !session ||
329 new Date(session.expiresAt) < new Date() ||
330 !session.requires2fa
331 ) {
332 return c.redirect("/login");
333 }
334
335 const [totp] = await db
336 .select()
337 .from(userTotp)
338 .where(eq(userTotp.userId, session.userId))
339 .limit(1);
340 if (!totp || !totp.enabledAt) {
341 // User doesn't have 2FA actually enabled — clear the flag and let
342 // them in. This can only happen if 2FA was disabled in another
343 // session between password check and code prompt.
344 await db
345 .update(sessions)
346 .set({ requires2fa: false })
347 .where(eq(sessions.token, token));
348 return c.redirect(redirect);
349 }
350
351 // Try TOTP code first.
352 const isSix = /^\d{6}$/.test(code);
353 let ok = false;
354 if (isSix) {
355 ok = await verifyTotpCode(totp.secret, code);
356 }
357 // Fall through to recovery code.
358 if (!ok) {
359 const hash = await hashRecoveryCode(code);
360 const [rec] = await db
361 .select()
362 .from(userRecoveryCodes)
363 .where(
364 and(
365 eq(userRecoveryCodes.userId, session.userId),
366 eq(userRecoveryCodes.codeHash, hash),
367 isNull(userRecoveryCodes.usedAt)
368 )
369 )
370 .limit(1);
371 if (rec) {
372 await db
373 .update(userRecoveryCodes)
374 .set({ usedAt: new Date() })
375 .where(eq(userRecoveryCodes.id, rec.id));
376 ok = true;
377 }
378 }
379
380 if (!ok) {
381 return c.redirect(
382 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
383 );
384 }
385
386 await db
387 .update(sessions)
388 .set({ requires2fa: false })
389 .where(eq(sessions.token, token));
390 await db
391 .update(userTotp)
392 .set({ lastUsedAt: new Date() })
393 .where(eq(userTotp.userId, session.userId));
394
395 return c.redirect(redirect);
396 } catch (err) {
397 console.error("[auth] 2fa verify:", err);
398 return c.redirect(
399 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
400 );
401 }
402});
403
06d5ffeClaude404auth.get("/logout", async (c) => {
405 deleteCookie(c, "session", { path: "/" });
406 return c.redirect("/");
407});
408
409// --- API ---
410
411auth.post("/api/auth/register", async (c) => {
412 const body = await c.req.json<{
413 username: string;
414 email: string;
415 password: string;
416 }>();
417
418 if (!body.username || !body.email || !body.password) {
419 return c.json({ error: "username, email, and password are required" }, 400);
420 }
421
422 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
423 return c.json({ error: "Invalid username" }, 400);
424 }
425
426 if (body.password.length < 8) {
427 return c.json({ error: "Password must be at least 8 characters" }, 400);
428 }
429
430 const [existing] = await db
431 .select()
432 .from(users)
433 .where(eq(users.username, body.username))
434 .limit(1);
435 if (existing) {
436 return c.json({ error: "Username already taken" }, 409);
437 }
438
439 const passwordHash = await hashPassword(body.password);
440 const [user] = await db
441 .insert(users)
442 .values({
443 username: body.username,
444 email: body.email,
445 passwordHash,
446 })
447 .returning();
448
449 const token = generateSessionToken();
450 await db.insert(sessions).values({
451 userId: user.id,
452 token,
453 expiresAt: sessionExpiry(),
454 });
455
456 return c.json(
457 {
458 user: { id: user.id, username: user.username, email: user.email },
459 token,
460 },
461 201
462 );
463});
464
465auth.post("/api/auth/login", async (c) => {
466 const body = await c.req.json<{ username: string; password: string }>();
467
468 if (!body.username || !body.password) {
469 return c.json({ error: "username and password are required" }, 400);
470 }
471
472 const isEmail = body.username.includes("@");
473 const [user] = await db
474 .select()
475 .from(users)
476 .where(
477 isEmail
478 ? eq(users.email, body.username)
479 : eq(users.username, body.username)
480 )
481 .limit(1);
482
483 if (!user) return c.json({ error: "Invalid credentials" }, 401);
484
485 const valid = await verifyPassword(body.password, user.passwordHash);
486 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
487
488 const token = generateSessionToken();
489 await db.insert(sessions).values({
490 userId: user.id,
491 token,
492 expiresAt: sessionExpiry(),
493 });
494
495 return c.json({
496 user: { id: user.id, username: user.username, email: user.email },
497 token,
498 });
499});
500
501export default auth;