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.tsxBlame597 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>
2df1f8cClaude196 <div
197 style="margin: 16px 0; text-align: center; color: var(--text-muted); font-size: 12px"
198 >
199 — or —
200 </div>
201 <div style="text-align: center">
202 <button
203 type="button"
204 id="pk-signin-btn"
205 class="btn"
206 style="width: 100%"
207 >
208 Sign in with passkey
209 </button>
210 <div
211 id="pk-signin-status"
212 style="margin-top: 8px; color: var(--text-muted); font-size: 12px; min-height: 16px"
213 />
214 </div>
06d5ffeClaude215 <p class="auth-switch">
216 New to gluecron? <a href="/register">Create an account</a>
217 </p>
2df1f8cClaude218 <script
219 dangerouslySetInnerHTML={{
220 __html: /* js */ `
221 (function () {
222 const btn = document.getElementById('pk-signin-btn');
223 const status = document.getElementById('pk-signin-status');
224 const userInput = document.getElementById('username');
225 const redirect = ${JSON.stringify(redirect || "/")};
226 if (!btn) return;
227 function b64uToBuf(s) {
228 s = s.replace(/-/g,'+').replace(/_/g,'/');
229 while (s.length % 4) s += '=';
230 const bin = atob(s);
231 const buf = new Uint8Array(bin.length);
232 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
233 return buf.buffer;
234 }
235 function bufToB64u(buf) {
236 const bytes = new Uint8Array(buf);
237 let bin = '';
238 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
239 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
240 }
241 btn.addEventListener('click', async function () {
242 if (!window.PublicKeyCredential) {
243 status.textContent = 'Passkeys not supported in this browser.';
244 return;
245 }
246 status.textContent = 'Preparing…';
247 try {
248 const username = (userInput && userInput.value || '').trim();
249 const optsRes = await fetch('/api/passkeys/auth/options', {
250 method: 'POST',
251 headers: { 'content-type': 'application/json' },
252 body: JSON.stringify(username ? { username: username } : {})
253 });
254 if (!optsRes.ok) throw new Error('options failed');
255 const { options, sessionKey } = await optsRes.json();
256 options.challenge = b64uToBuf(options.challenge);
257 if (options.allowCredentials) {
258 options.allowCredentials = options.allowCredentials.map(function (c) {
259 return Object.assign({}, c, { id: b64uToBuf(c.id) });
260 });
261 }
262 status.textContent = 'Touch your authenticator…';
263 const cred = await navigator.credentials.get({ publicKey: options });
264 const resp = {
265 id: cred.id,
266 rawId: bufToB64u(cred.rawId),
267 type: cred.type,
268 response: {
269 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
270 authenticatorData: bufToB64u(cred.response.authenticatorData),
271 signature: bufToB64u(cred.response.signature),
272 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
273 },
274 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
275 };
276 const verifyRes = await fetch('/api/passkeys/auth/verify', {
277 method: 'POST',
278 headers: { 'content-type': 'application/json' },
279 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
280 });
281 if (!verifyRes.ok) {
282 const j = await verifyRes.json().catch(function () { return {}; });
283 throw new Error(j.error || 'verify failed');
284 }
285 status.textContent = 'Signed in. Redirecting…';
286 window.location.href = redirect;
287 } catch (e) {
288 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
289 }
290 });
291 })();
292 `,
293 }}
294 />
06d5ffeClaude295 </div>
296 </Layout>
297 );
298});
299
300auth.post("/login", async (c) => {
301 const body = await c.req.parseBody();
302 const identifier = String(body.username || "").trim();
303 const password = String(body.password || "");
304 const redirect = c.req.query("redirect") || "/";
305
306 if (!identifier || !password) {
307 return c.redirect("/login?error=All+fields+are+required");
308 }
309
310 // Find user by username or email
311 const isEmail = identifier.includes("@");
312 const [user] = await db
313 .select()
314 .from(users)
315 .where(
316 isEmail
317 ? eq(users.email, identifier)
318 : eq(users.username, identifier)
319 )
320 .limit(1);
321
322 if (!user) {
323 return c.redirect("/login?error=Invalid+credentials");
324 }
325
326 const valid = await verifyPassword(password, user.passwordHash);
327 if (!valid) {
328 return c.redirect("/login?error=Invalid+credentials");
329 }
330
7298a17Claude331 // B4: if the user has TOTP enabled, issue a pending-2fa session and
332 // redirect to the code prompt.
333 const [totp] = await db
334 .select({ enabledAt: userTotp.enabledAt })
335 .from(userTotp)
336 .where(eq(userTotp.userId, user.id))
337 .limit(1);
338 const needs2fa = !!(totp && totp.enabledAt);
339
06d5ffeClaude340 const token = generateSessionToken();
341 await db.insert(sessions).values({
342 userId: user.id,
343 token,
344 expiresAt: sessionExpiry(),
7298a17Claude345 requires2fa: needs2fa,
06d5ffeClaude346 });
347
348 setCookie(c, "session", token, sessionCookieOptions());
7298a17Claude349 if (needs2fa) {
350 return c.redirect(
351 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
352 );
353 }
06d5ffeClaude354 return c.redirect(redirect);
355});
356
7298a17Claude357// --- 2FA verify (B4) ---
358auth.get("/login/2fa", async (c) => {
359 const token = getCookie(c, "session");
360 if (!token) return c.redirect("/login");
361 const error = c.req.query("error");
362 const redirect = c.req.query("redirect") || "/";
363 return c.html(
364 <Layout title="Two-factor authentication">
365 <div class="auth-container">
366 <h2>Enter your code</h2>
367 <p
368 class="auth-switch"
369 style="margin-bottom: 16px; margin-top: 0"
370 >
371 Open your authenticator app and enter the 6-digit code. Lost your
372 device? Paste a recovery code instead.
373 </p>
374 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
375 <form
376 method="POST"
377 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
378 >
379 <div class="form-group">
380 <label for="code">Code</label>
381 <input
382 type="text"
383 id="code"
384 name="code"
385 required
386 autocomplete="one-time-code"
387 inputmode="numeric"
388 maxLength={24}
389 placeholder="123456 or xxxx-xxxx-xxxx"
390 />
391 </div>
392 <button type="submit" class="btn btn-primary">
393 Verify
394 </button>
395 </form>
396 <p class="auth-switch">
397 <a href="/logout">Cancel</a>
398 </p>
399 </div>
400 </Layout>
401 );
402});
403
404auth.post("/login/2fa", async (c) => {
405 const token = getCookie(c, "session");
406 if (!token) return c.redirect("/login");
407 const body = await c.req.parseBody();
408 const code = String(body.code || "").trim();
409 const redirect = c.req.query("redirect") || "/";
410
411 if (!code) {
412 return c.redirect(
413 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
414 );
415 }
416
417 try {
418 const [session] = await db
419 .select()
420 .from(sessions)
421 .where(eq(sessions.token, token))
422 .limit(1);
423 if (
424 !session ||
425 new Date(session.expiresAt) < new Date() ||
426 !session.requires2fa
427 ) {
428 return c.redirect("/login");
429 }
430
431 const [totp] = await db
432 .select()
433 .from(userTotp)
434 .where(eq(userTotp.userId, session.userId))
435 .limit(1);
436 if (!totp || !totp.enabledAt) {
437 // User doesn't have 2FA actually enabled — clear the flag and let
438 // them in. This can only happen if 2FA was disabled in another
439 // session between password check and code prompt.
440 await db
441 .update(sessions)
442 .set({ requires2fa: false })
443 .where(eq(sessions.token, token));
444 return c.redirect(redirect);
445 }
446
447 // Try TOTP code first.
448 const isSix = /^\d{6}$/.test(code);
449 let ok = false;
450 if (isSix) {
451 ok = await verifyTotpCode(totp.secret, code);
452 }
453 // Fall through to recovery code.
454 if (!ok) {
455 const hash = await hashRecoveryCode(code);
456 const [rec] = await db
457 .select()
458 .from(userRecoveryCodes)
459 .where(
460 and(
461 eq(userRecoveryCodes.userId, session.userId),
462 eq(userRecoveryCodes.codeHash, hash),
463 isNull(userRecoveryCodes.usedAt)
464 )
465 )
466 .limit(1);
467 if (rec) {
468 await db
469 .update(userRecoveryCodes)
470 .set({ usedAt: new Date() })
471 .where(eq(userRecoveryCodes.id, rec.id));
472 ok = true;
473 }
474 }
475
476 if (!ok) {
477 return c.redirect(
478 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
479 );
480 }
481
482 await db
483 .update(sessions)
484 .set({ requires2fa: false })
485 .where(eq(sessions.token, token));
486 await db
487 .update(userTotp)
488 .set({ lastUsedAt: new Date() })
489 .where(eq(userTotp.userId, session.userId));
490
491 return c.redirect(redirect);
492 } catch (err) {
493 console.error("[auth] 2fa verify:", err);
494 return c.redirect(
495 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
496 );
497 }
498});
499
06d5ffeClaude500auth.get("/logout", async (c) => {
501 deleteCookie(c, "session", { path: "/" });
502 return c.redirect("/");
503});
504
505// --- API ---
506
507auth.post("/api/auth/register", async (c) => {
508 const body = await c.req.json<{
509 username: string;
510 email: string;
511 password: string;
512 }>();
513
514 if (!body.username || !body.email || !body.password) {
515 return c.json({ error: "username, email, and password are required" }, 400);
516 }
517
518 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
519 return c.json({ error: "Invalid username" }, 400);
520 }
521
522 if (body.password.length < 8) {
523 return c.json({ error: "Password must be at least 8 characters" }, 400);
524 }
525
526 const [existing] = await db
527 .select()
528 .from(users)
529 .where(eq(users.username, body.username))
530 .limit(1);
531 if (existing) {
532 return c.json({ error: "Username already taken" }, 409);
533 }
534
535 const passwordHash = await hashPassword(body.password);
536 const [user] = await db
537 .insert(users)
538 .values({
539 username: body.username,
540 email: body.email,
541 passwordHash,
542 })
543 .returning();
544
545 const token = generateSessionToken();
546 await db.insert(sessions).values({
547 userId: user.id,
548 token,
549 expiresAt: sessionExpiry(),
550 });
551
552 return c.json(
553 {
554 user: { id: user.id, username: user.username, email: user.email },
555 token,
556 },
557 201
558 );
559});
560
561auth.post("/api/auth/login", async (c) => {
562 const body = await c.req.json<{ username: string; password: string }>();
563
564 if (!body.username || !body.password) {
565 return c.json({ error: "username and password are required" }, 400);
566 }
567
568 const isEmail = body.username.includes("@");
569 const [user] = await db
570 .select()
571 .from(users)
572 .where(
573 isEmail
574 ? eq(users.email, body.username)
575 : eq(users.username, body.username)
576 )
577 .limit(1);
578
579 if (!user) return c.json({ error: "Invalid credentials" }, 401);
580
581 const valid = await verifyPassword(body.password, user.passwordHash);
582 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
583
584 const token = generateSessionToken();
585 await db.insert(sessions).values({
586 userId: user.id,
587 token,
588 expiresAt: sessionExpiry(),
589 });
590
591 return c.json({
592 user: { id: user.id, username: user.username, email: user.email },
593 token,
594 });
595});
596
597export default auth;