feat(auth): replace email/password registration with OAuth2 (Google + GitHub)
Some checks failed
CI / Test backend (pull_request) Successful in 19s
Release / Test backend (push) Successful in 18s
CI / Check ui (pull_request) Successful in 41s
Release / Check ui (push) Successful in 21s
CI / Docker / backend (pull_request) Successful in 1m43s
CI / Docker / runner (pull_request) Successful in 1m28s
Release / Docker / backend (push) Successful in 1m40s
CI / Docker / caddy (pull_request) Successful in 6m45s
Release / Docker / runner (push) Successful in 1m48s
Release / Docker / caddy (push) Successful in 7m12s
CI / Docker / ui (pull_request) Successful in 1m20s
Release / Docker / ui (push) Successful in 1m19s
Release / Gitea Release (push) Failing after 2s
Some checks failed
CI / Test backend (pull_request) Successful in 19s
Release / Test backend (push) Successful in 18s
CI / Check ui (pull_request) Successful in 41s
Release / Check ui (push) Successful in 21s
CI / Docker / backend (pull_request) Successful in 1m43s
CI / Docker / runner (pull_request) Successful in 1m28s
Release / Docker / backend (push) Successful in 1m40s
CI / Docker / caddy (pull_request) Successful in 6m45s
Release / Docker / runner (push) Successful in 1m48s
Release / Docker / caddy (push) Successful in 7m12s
CI / Docker / ui (pull_request) Successful in 1m20s
Release / Docker / ui (push) Successful in 1m19s
Release / Gitea Release (push) Failing after 2s
- New /auth/[provider] route: generates state cookie, redirects to provider - New /auth/[provider]/callback: exchanges code, fetches profile, auto-creates or links account, sets auth cookie - pocketbase.ts: add oauth_provider/oauth_id to User; new getUserByOAuth(), createOAuthUser(), linkOAuthToUser() helpers; loginUser() drops email_verified gate - pb-init-v3.sh: add oauth_provider + oauth_id fields (schema + migration) - docker-compose.yml: GOOGLE/GITHUB client ID/secret env vars (replaces SMTP vars) - Login page: two OAuth buttons (Google, GitHub) — register form removed - /verify-email route and email.ts removed (provider handles email verification) - /api/auth/register returns 410 (OAuth-only from now on)
This commit is contained in:
@@ -267,13 +267,11 @@ services:
|
|||||||
PUBLIC_UMAMI_SCRIPT_URL: "${PUBLIC_UMAMI_SCRIPT_URL}"
|
PUBLIC_UMAMI_SCRIPT_URL: "${PUBLIC_UMAMI_SCRIPT_URL}"
|
||||||
# GlitchTip client + server-side error tracking
|
# GlitchTip client + server-side error tracking
|
||||||
PUBLIC_GLITCHTIP_DSN: "${PUBLIC_GLITCHTIP_DSN}"
|
PUBLIC_GLITCHTIP_DSN: "${PUBLIC_GLITCHTIP_DSN}"
|
||||||
# Email verification (Resend SMTP — shared with Fider/GlitchTip)
|
# OAuth2 providers
|
||||||
SMTP_HOST: "${FIDER_SMTP_HOST}"
|
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}"
|
||||||
SMTP_PORT: "${FIDER_SMTP_PORT}"
|
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"
|
||||||
SMTP_USER: "${FIDER_SMTP_USER}"
|
GITHUB_CLIENT_ID: "${GITHUB_CLIENT_ID}"
|
||||||
SMTP_PASSWORD: "${FIDER_SMTP_PASSWORD}"
|
GITHUB_CLIENT_SECRET: "${GITHUB_CLIENT_SECRET}"
|
||||||
SMTP_FROM: "noreply@libnovel.cc"
|
|
||||||
APP_URL: "${ORIGIN}"
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
|
|||||||
@@ -185,7 +185,9 @@ create "app_users" '{
|
|||||||
{"name":"email", "type":"text"},
|
{"name":"email", "type":"text"},
|
||||||
{"name":"email_verified", "type":"bool"},
|
{"name":"email_verified", "type":"bool"},
|
||||||
{"name":"verification_token", "type":"text"},
|
{"name":"verification_token", "type":"text"},
|
||||||
{"name":"verification_token_exp","type":"text"}
|
{"name":"verification_token_exp","type":"text"},
|
||||||
|
{"name":"oauth_provider", "type":"text"},
|
||||||
|
{"name":"oauth_id", "type":"text"}
|
||||||
]}'
|
]}'
|
||||||
|
|
||||||
create "user_sessions" '{
|
create "user_sessions" '{
|
||||||
@@ -254,5 +256,7 @@ add_field "app_users" "email" "text"
|
|||||||
add_field "app_users" "email_verified" "bool"
|
add_field "app_users" "email_verified" "bool"
|
||||||
add_field "app_users" "verification_token" "text"
|
add_field "app_users" "verification_token" "text"
|
||||||
add_field "app_users" "verification_token_exp" "text"
|
add_field "app_users" "verification_token_exp" "text"
|
||||||
|
add_field "app_users" "oauth_provider" "text"
|
||||||
|
add_field "app_users" "oauth_id" "text"
|
||||||
|
|
||||||
log "done"
|
log "done"
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
/**
|
|
||||||
* Minimal SMTP mailer for email verification.
|
|
||||||
*
|
|
||||||
* Uses Node's built-in `tls` module to connect to smtp.resend.com:465
|
|
||||||
* (implicit TLS / SMTPS) — no external dependencies required.
|
|
||||||
*
|
|
||||||
* Env vars (injected by docker-compose via Doppler):
|
|
||||||
* SMTP_HOST smtp.resend.com
|
|
||||||
* SMTP_PORT 465
|
|
||||||
* SMTP_USER resend
|
|
||||||
* SMTP_PASSWORD re_...
|
|
||||||
* SMTP_FROM noreply@libnovel.cc
|
|
||||||
* APP_URL https://libnovel.cc (used to build verification links)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { env } from '$env/dynamic/private';
|
|
||||||
import { log } from '$lib/server/logger';
|
|
||||||
import * as tls from 'node:tls';
|
|
||||||
|
|
||||||
const SMTP_HOST = env.SMTP_HOST ?? 'smtp.resend.com';
|
|
||||||
const SMTP_PORT = parseInt(env.SMTP_PORT ?? '465', 10);
|
|
||||||
const SMTP_USER = env.SMTP_USER ?? '';
|
|
||||||
const SMTP_PASSWORD = env.SMTP_PASSWORD ?? '';
|
|
||||||
const SMTP_FROM = env.SMTP_FROM ?? 'noreply@libnovel.cc';
|
|
||||||
export const APP_URL = (env.APP_URL ?? 'https://libnovel.cc').replace(/\/$/, '');
|
|
||||||
|
|
||||||
// ─── Low-level SMTP over implicit TLS ────────────────────────────────────────
|
|
||||||
|
|
||||||
function smtpEncode(s: string): string {
|
|
||||||
return Buffer.from(s).toString('base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a raw email via SMTP over implicit TLS (port 465).
|
|
||||||
* Returns true on success, throws on failure.
|
|
||||||
*/
|
|
||||||
async function sendSmtp(opts: {
|
|
||||||
to: string;
|
|
||||||
subject: string;
|
|
||||||
html: string;
|
|
||||||
text: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const socket = tls.connect(
|
|
||||||
{ host: SMTP_HOST, port: SMTP_PORT, rejectUnauthorized: true },
|
|
||||||
() => {
|
|
||||||
// TLS handshake complete — SMTP conversation begins
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
socket.setEncoding('utf8');
|
|
||||||
socket.setTimeout(15_000);
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy(new Error('SMTP connection timed out'));
|
|
||||||
});
|
|
||||||
|
|
||||||
let buf = '';
|
|
||||||
let step = 0;
|
|
||||||
|
|
||||||
const send = (cmd: string) => socket.write(cmd + '\r\n');
|
|
||||||
|
|
||||||
const boundary = `----=_Part_${Date.now()}`;
|
|
||||||
const multipart = [
|
|
||||||
`--${boundary}`,
|
|
||||||
'Content-Type: text/plain; charset=UTF-8',
|
|
||||||
'',
|
|
||||||
opts.text,
|
|
||||||
`--${boundary}`,
|
|
||||||
'Content-Type: text/html; charset=UTF-8',
|
|
||||||
'',
|
|
||||||
opts.html,
|
|
||||||
`--${boundary}--`
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
const message = [
|
|
||||||
`From: LibNovel <${SMTP_FROM}>`,
|
|
||||||
`To: ${opts.to}`,
|
|
||||||
`Subject: ${opts.subject}`,
|
|
||||||
'MIME-Version: 1.0',
|
|
||||||
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
|
||||||
'',
|
|
||||||
multipart
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
socket.on('data', (chunk: string) => {
|
|
||||||
buf += chunk;
|
|
||||||
// Process complete lines
|
|
||||||
const lines = buf.split('\r\n');
|
|
||||||
buf = lines.pop() ?? '';
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line) continue;
|
|
||||||
const code = parseInt(line.slice(0, 3), 10);
|
|
||||||
// Only act on the final response line (no continuation dash)
|
|
||||||
if (line[3] === '-') continue;
|
|
||||||
|
|
||||||
if (code >= 400) {
|
|
||||||
socket.destroy(new Error(`SMTP error: ${line}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (step) {
|
|
||||||
case 0: // 220 banner
|
|
||||||
send(`EHLO libnovel.cc`);
|
|
||||||
step++;
|
|
||||||
break;
|
|
||||||
case 1: // 250 EHLO
|
|
||||||
send('AUTH LOGIN');
|
|
||||||
step++;
|
|
||||||
break;
|
|
||||||
case 2: // 334 Username prompt
|
|
||||||
send(smtpEncode(SMTP_USER));
|
|
||||||
step++;
|
|
||||||
break;
|
|
||||||
case 3: // 334 Password prompt
|
|
||||||
send(smtpEncode(SMTP_PASSWORD));
|
|
||||||
step++;
|
|
||||||
break;
|
|
||||||
case 4: // 235 Auth success
|
|
||||||
send(`MAIL FROM:<${SMTP_FROM}>`);
|
|
||||||
step++;
|
|
||||||
break;
|
|
||||||
case 5: // 250 MAIL FROM ok
|
|
||||||
send(`RCPT TO:<${opts.to}>`);
|
|
||||||
step++;
|
|
||||||
break;
|
|
||||||
case 6: // 250 RCPT TO ok
|
|
||||||
send('DATA');
|
|
||||||
step++;
|
|
||||||
break;
|
|
||||||
case 7: // 354 Start data
|
|
||||||
send(message + '\r\n.');
|
|
||||||
step++;
|
|
||||||
break;
|
|
||||||
case 8: // 250 Message accepted
|
|
||||||
send('QUIT');
|
|
||||||
step++;
|
|
||||||
break;
|
|
||||||
case 9: // 221 Bye
|
|
||||||
socket.destroy();
|
|
||||||
resolve();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => reject(err));
|
|
||||||
socket.on('close', () => {
|
|
||||||
if (step < 9) reject(new Error('SMTP connection closed unexpectedly'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Email templates ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function sendVerificationEmail(to: string, token: string): Promise<void> {
|
|
||||||
const link = `${APP_URL}/verify-email?token=${token}`;
|
|
||||||
|
|
||||||
const html = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head><meta charset="UTF-8"></head>
|
|
||||||
<body style="font-family:sans-serif;background:#18181b;color:#f4f4f5;padding:32px;">
|
|
||||||
<div style="max-width:480px;margin:0 auto;">
|
|
||||||
<h1 style="color:#f59e0b;font-size:24px;margin-bottom:8px;">Verify your email</h1>
|
|
||||||
<p style="color:#a1a1aa;margin-bottom:24px;">
|
|
||||||
Thanks for signing up to LibNovel. Click the button below to verify your email address.
|
|
||||||
The link expires in 24 hours.
|
|
||||||
</p>
|
|
||||||
<a href="${link}"
|
|
||||||
style="display:inline-block;background:#f59e0b;color:#18181b;font-weight:600;
|
|
||||||
padding:12px 24px;border-radius:6px;text-decoration:none;font-size:15px;">
|
|
||||||
Verify email
|
|
||||||
</a>
|
|
||||||
<p style="margin-top:24px;color:#71717a;font-size:13px;">
|
|
||||||
Or copy this link:<br>
|
|
||||||
<a href="${link}" style="color:#f59e0b;word-break:break-all;">${link}</a>
|
|
||||||
</p>
|
|
||||||
<p style="margin-top:32px;color:#52525b;font-size:12px;">
|
|
||||||
If you didn't create a LibNovel account, you can safely ignore this email.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
const text = `Verify your LibNovel email address\n\nClick this link to verify your account (expires in 24 hours):\n${link}\n\nIf you didn't sign up, ignore this email.`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sendSmtp({ to, subject: 'Verify your LibNovel email', html, text });
|
|
||||||
log.info('email', 'verification email sent', { to });
|
|
||||||
} catch (err) {
|
|
||||||
log.error('email', 'failed to send verification email', { to, err: String(err) });
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -67,6 +67,8 @@ export interface User {
|
|||||||
email_verified?: boolean;
|
email_verified?: boolean;
|
||||||
verification_token?: string;
|
verification_token?: string;
|
||||||
verification_token_exp?: string;
|
verification_token_exp?: string;
|
||||||
|
oauth_provider?: string;
|
||||||
|
oauth_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
||||||
@@ -496,8 +498,75 @@ export async function getUserByEmail(email: string): Promise<User | null> {
|
|||||||
return listOne<User>('app_users', `email="${email.replace(/"/g, '\\"')}"`);
|
return listOne<User>('app_users', `email="${email.replace(/"/g, '\\"')}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a user by OAuth provider + provider user ID. Returns null if not found.
|
||||||
|
*/
|
||||||
|
export async function getUserByOAuth(provider: string, oauthId: string): Promise<User | null> {
|
||||||
|
return listOne<User>(
|
||||||
|
'app_users',
|
||||||
|
`oauth_provider="${provider.replace(/"/g, '\\"')}"&&oauth_id="${oauthId.replace(/"/g, '\\"')}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new user via OAuth (no password). email_verified is true since the
|
||||||
|
* provider already verified it. Throws on DB errors.
|
||||||
|
*/
|
||||||
|
export async function createOAuthUser(
|
||||||
|
username: string,
|
||||||
|
email: string,
|
||||||
|
provider: string,
|
||||||
|
oauthId: string,
|
||||||
|
avatarUrl?: string,
|
||||||
|
role = 'user'
|
||||||
|
): Promise<User> {
|
||||||
|
log.info('pocketbase', 'createOAuthUser', { username, email, provider });
|
||||||
|
const res = await pbPost('/api/collections/app_users/records', {
|
||||||
|
username,
|
||||||
|
password_hash: '',
|
||||||
|
role,
|
||||||
|
email,
|
||||||
|
email_verified: true,
|
||||||
|
oauth_provider: provider,
|
||||||
|
oauth_id: oauthId,
|
||||||
|
avatar_url: avatarUrl ?? '',
|
||||||
|
created: new Date().toISOString()
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
log.error('pocketbase', 'createOAuthUser: PocketBase rejected record', {
|
||||||
|
username,
|
||||||
|
status: res.status,
|
||||||
|
body
|
||||||
|
});
|
||||||
|
throw new Error(`Failed to create OAuth user: ${res.status} ${body}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<User>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link an OAuth provider to an existing user account.
|
||||||
|
*/
|
||||||
|
export async function linkOAuthToUser(
|
||||||
|
userId: string,
|
||||||
|
provider: string,
|
||||||
|
oauthId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await pbPatch(`/api/collections/app_users/records/${userId}`, {
|
||||||
|
oauth_provider: provider,
|
||||||
|
oauth_id: oauthId,
|
||||||
|
email_verified: true
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
log.error('pocketbase', 'linkOAuthToUser: PATCH failed', { userId, status: res.status, body });
|
||||||
|
throw new Error(`Failed to link OAuth: ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Look up a user by verification token. Returns null if not found.
|
* Look up a user by verification token. Returns null if not found.
|
||||||
|
* @deprecated Email verification removed — kept only for migration safety.
|
||||||
*/
|
*/
|
||||||
export async function getUserByVerificationToken(token: string): Promise<User | null> {
|
export async function getUserByVerificationToken(token: string): Promise<User | null> {
|
||||||
return listOne<User>('app_users', `verification_token="${token.replace(/"/g, '\\"')}"`);
|
return listOne<User>('app_users', `verification_token="${token.replace(/"/g, '\\"')}"`);
|
||||||
@@ -608,7 +677,7 @@ export async function changePassword(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify username + password. Returns the user on success, null on failure.
|
* Verify username + password. Returns the user on success, null on failure.
|
||||||
* Throws with message 'Email not verified' if the account exists but hasn't been verified.
|
* Only used for legacy accounts that still have a password_hash.
|
||||||
*/
|
*/
|
||||||
export async function loginUser(username: string, password: string): Promise<User | null> {
|
export async function loginUser(username: string, password: string): Promise<User | null> {
|
||||||
log.debug('pocketbase', 'loginUser: lookup', { username });
|
log.debug('pocketbase', 'loginUser: lookup', { username });
|
||||||
@@ -617,15 +686,15 @@ export async function loginUser(username: string, password: string): Promise<Use
|
|||||||
log.warn('pocketbase', 'loginUser: username not found', { username });
|
log.warn('pocketbase', 'loginUser: username not found', { username });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (!user.password_hash) {
|
||||||
|
log.warn('pocketbase', 'loginUser: account has no password (OAuth-only)', { username });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const ok = verifyPassword(password, user.password_hash);
|
const ok = verifyPassword(password, user.password_hash);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
log.warn('pocketbase', 'loginUser: wrong password', { username });
|
log.warn('pocketbase', 'loginUser: wrong password', { username });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!user.email_verified) {
|
|
||||||
log.warn('pocketbase', 'loginUser: email not verified', { username });
|
|
||||||
throw new Error('Email not verified');
|
|
||||||
}
|
|
||||||
log.info('pocketbase', 'loginUser: success', { username, role: user.role });
|
log.info('pocketbase', 'loginUser: success', { username, role: user.role });
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { getSettings } from '$lib/server/pocketbase';
|
|||||||
import { log } from '$lib/server/logger';
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
// Routes that are accessible without being logged in
|
// Routes that are accessible without being logged in
|
||||||
const PUBLIC_ROUTES = new Set(['/login', '/verify-email']);
|
const PUBLIC_ROUTES = new Set(['/login']);
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ locals, url }) => {
|
export const load: LayoutServerLoad = async ({ locals, url }) => {
|
||||||
if (!PUBLIC_ROUTES.has(url.pathname) && !locals.user) {
|
// Allow /auth/* (OAuth initiation + callbacks) without login
|
||||||
|
const isPublic = PUBLIC_ROUTES.has(url.pathname) || url.pathname.startsWith('/auth/');
|
||||||
|
if (!isPublic && !locals.user) {
|
||||||
redirect(302, `/login`);
|
redirect(302, `/login`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +1,12 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { createUser } from '$lib/server/pocketbase';
|
|
||||||
import { sendVerificationEmail } from '$lib/server/email';
|
|
||||||
import { log } from '$lib/server/logger';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/auth/register
|
* POST /api/auth/register
|
||||||
* Body: { username: string, email: string, password: string }
|
|
||||||
* Returns: { pending_verification: true, email: string }
|
|
||||||
*
|
*
|
||||||
* Account is created but NOT activated until the user clicks the verification
|
* Username/password registration has been replaced by OAuth2 (Google & GitHub).
|
||||||
* link sent to their email. The iOS app should show a "check your inbox" screen.
|
* This endpoint is no longer supported.
|
||||||
*/
|
*/
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async () => {
|
||||||
let body: { username?: string; email?: string; password?: string };
|
error(410, 'Username/password registration is no longer supported. Please sign in with Google or GitHub.');
|
||||||
try {
|
|
||||||
body = await request.json();
|
|
||||||
} catch {
|
|
||||||
error(400, 'Invalid JSON body');
|
|
||||||
}
|
|
||||||
|
|
||||||
const username = (body.username ?? '').trim();
|
|
||||||
const email = (body.email ?? '').trim().toLowerCase();
|
|
||||||
const password = body.password ?? '';
|
|
||||||
|
|
||||||
if (!username || !email || !password) {
|
|
||||||
error(400, 'Username, email and password are required');
|
|
||||||
}
|
|
||||||
if (username.length < 3 || username.length > 32) {
|
|
||||||
error(400, 'Username must be between 3 and 32 characters');
|
|
||||||
}
|
|
||||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
|
||||||
error(400, 'Username may only contain letters, numbers, underscores and hyphens');
|
|
||||||
}
|
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
||||||
error(400, 'Please enter a valid email address');
|
|
||||||
}
|
|
||||||
if (password.length < 8) {
|
|
||||||
error(400, 'Password must be at least 8 characters');
|
|
||||||
}
|
|
||||||
|
|
||||||
let user;
|
|
||||||
try {
|
|
||||||
user = await createUser(username, password, email);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const msg = e instanceof Error ? e.message : 'Registration failed.';
|
|
||||||
if (msg.includes('Username already taken')) {
|
|
||||||
error(409, 'That username is already taken');
|
|
||||||
}
|
|
||||||
if (msg.includes('Email already in use')) {
|
|
||||||
error(409, 'That email address is already registered');
|
|
||||||
}
|
|
||||||
log.error('api/auth/register', 'unexpected error', { username, err: String(e) });
|
|
||||||
error(500, 'An error occurred. Please try again.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send verification email (non-fatal)
|
|
||||||
try {
|
|
||||||
await sendVerificationEmail(email, user.verification_token!);
|
|
||||||
} catch (e) {
|
|
||||||
log.error('api/auth/register', 'failed to send verification email', { username, email, err: String(e) });
|
|
||||||
}
|
|
||||||
|
|
||||||
return json({ pending_verification: true, email });
|
|
||||||
};
|
};
|
||||||
|
|||||||
79
ui/src/routes/auth/[provider]/+server.ts
Normal file
79
ui/src/routes/auth/[provider]/+server.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* GET /auth/[provider]
|
||||||
|
*
|
||||||
|
* Initiates the OAuth2 authorization code flow.
|
||||||
|
* Generates a random `state` param (stored in a short-lived cookie) to
|
||||||
|
* prevent CSRF, then redirects the browser to the provider's auth URL.
|
||||||
|
*
|
||||||
|
* Supported providers: google, github
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { redirect, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
const PROVIDERS = {
|
||||||
|
google: {
|
||||||
|
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||||
|
scopes: 'openid email profile'
|
||||||
|
},
|
||||||
|
github: {
|
||||||
|
authUrl: 'https://github.com/login/oauth/authorize',
|
||||||
|
scopes: 'read:user user:email'
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type Provider = keyof typeof PROVIDERS;
|
||||||
|
|
||||||
|
function clientId(provider: Provider): string {
|
||||||
|
if (provider === 'google') return env.GOOGLE_CLIENT_ID ?? '';
|
||||||
|
if (provider === 'github') return env.GITHUB_CLIENT_ID ?? '';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectUri(provider: Provider, origin: string): string {
|
||||||
|
return `${origin}/auth/${provider}/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const provider = params.provider as Provider;
|
||||||
|
if (!(provider in PROVIDERS)) {
|
||||||
|
error(404, 'Unknown OAuth provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = clientId(provider);
|
||||||
|
if (!id) {
|
||||||
|
error(500, `OAuth provider "${provider}" is not configured`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate state token — stored in a 10-minute cookie
|
||||||
|
const state = randomBytes(16).toString('hex');
|
||||||
|
cookies.set(`oauth_state_${provider}`, state, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 10 // 10 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Where to send the user after successful auth (default: home)
|
||||||
|
const next = url.searchParams.get('next') ?? '/';
|
||||||
|
cookies.set(`oauth_next_${provider}`, next, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 10
|
||||||
|
});
|
||||||
|
|
||||||
|
const origin = url.origin;
|
||||||
|
const cfg = PROVIDERS[provider];
|
||||||
|
const params2 = new URLSearchParams({
|
||||||
|
client_id: id,
|
||||||
|
redirect_uri: redirectUri(provider, origin),
|
||||||
|
response_type: 'code',
|
||||||
|
scope: cfg.scopes,
|
||||||
|
state
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect(302, `${cfg.authUrl}?${params2.toString()}`);
|
||||||
|
};
|
||||||
246
ui/src/routes/auth/[provider]/callback/+server.ts
Normal file
246
ui/src/routes/auth/[provider]/callback/+server.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* GET /auth/[provider]/callback
|
||||||
|
*
|
||||||
|
* Handles the OAuth2 authorization code callback.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Validate state cookie (CSRF check).
|
||||||
|
* 2. Exchange code for access token with the provider.
|
||||||
|
* 3. Fetch the user's profile (email, name, avatar) from the provider.
|
||||||
|
* 4. Look up app_users by (oauth_provider, oauth_id).
|
||||||
|
* - If found: log in.
|
||||||
|
* - If not found but email matches an existing user: link the account.
|
||||||
|
* - If not found at all: auto-create a new account.
|
||||||
|
* 5. Set auth cookie, redirect to `next` (default: '/').
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { redirect, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
getUserByOAuth,
|
||||||
|
getUserByEmail,
|
||||||
|
createOAuthUser,
|
||||||
|
linkOAuthToUser
|
||||||
|
} from '$lib/server/pocketbase';
|
||||||
|
import { createAuthToken } from '../../../../hooks.server';
|
||||||
|
import { createUserSession, mergeSessionProgress } from '$lib/server/pocketbase';
|
||||||
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
|
type Provider = 'google' | 'github';
|
||||||
|
|
||||||
|
const AUTH_COOKIE = 'libnovel_auth';
|
||||||
|
const ONE_YEAR = 60 * 60 * 24 * 365;
|
||||||
|
|
||||||
|
// ─── Token exchange ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeCode(
|
||||||
|
provider: Provider,
|
||||||
|
code: string,
|
||||||
|
redirectUri: string
|
||||||
|
): Promise<string> {
|
||||||
|
const clientId = provider === 'google' ? env.GOOGLE_CLIENT_ID : env.GITHUB_CLIENT_ID;
|
||||||
|
const clientSecret =
|
||||||
|
provider === 'google' ? env.GOOGLE_CLIENT_SECRET : env.GITHUB_CLIENT_SECRET;
|
||||||
|
|
||||||
|
const tokenUrl =
|
||||||
|
provider === 'google'
|
||||||
|
? 'https://oauth2.googleapis.com/token'
|
||||||
|
: 'https://github.com/login/oauth/access_token';
|
||||||
|
|
||||||
|
const res = await fetch(tokenUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
code,
|
||||||
|
client_id: clientId ?? '',
|
||||||
|
client_secret: clientSecret ?? '',
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
grant_type: 'authorization_code'
|
||||||
|
}).toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
log.error('oauth', 'token exchange failed', { provider, status: res.status, body });
|
||||||
|
throw new Error(`Token exchange failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as TokenResponse;
|
||||||
|
if (data.error || !data.access_token) {
|
||||||
|
log.error('oauth', 'token response error', { provider, error: data.error });
|
||||||
|
throw new Error(data.error ?? 'No access_token in response');
|
||||||
|
}
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Profile fetching ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface OAuthProfile {
|
||||||
|
id: string; // provider's user ID (as string)
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGoogleProfile(accessToken: string): Promise<OAuthProfile> {
|
||||||
|
const res = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Google userinfo failed: ${res.status}`);
|
||||||
|
const d = await res.json();
|
||||||
|
return {
|
||||||
|
id: String(d.id),
|
||||||
|
email: d.email ?? '',
|
||||||
|
name: d.name ?? d.email ?? '',
|
||||||
|
avatarUrl: d.picture
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGitHubProfile(accessToken: string): Promise<OAuthProfile> {
|
||||||
|
const [userRes, emailRes] = await Promise.all([
|
||||||
|
fetch('https://api.github.com/user', {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/vnd.github+json' }
|
||||||
|
}),
|
||||||
|
fetch('https://api.github.com/user/emails', {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/vnd.github+json' }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!userRes.ok) throw new Error(`GitHub user API failed: ${userRes.status}`);
|
||||||
|
const user = await userRes.json();
|
||||||
|
|
||||||
|
// Primary verified email — required for account linking
|
||||||
|
let email = user.email ?? '';
|
||||||
|
if (emailRes.ok) {
|
||||||
|
const emails = (await emailRes.json()) as Array<{
|
||||||
|
email: string;
|
||||||
|
primary: boolean;
|
||||||
|
verified: boolean;
|
||||||
|
}>;
|
||||||
|
const primary = emails.find((e) => e.primary && e.verified);
|
||||||
|
if (primary) email = primary.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email) throw new Error('GitHub account has no verified primary email');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(user.id),
|
||||||
|
email,
|
||||||
|
name: user.name ?? user.login ?? email,
|
||||||
|
avatarUrl: user.avatar_url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Username derivation ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Derive a valid username from name/email. Sanitises to [a-zA-Z0-9_-], max 32 chars. */
|
||||||
|
function deriveUsername(name: string, email: string): string {
|
||||||
|
// Prefer the part before @ in the email for predictability
|
||||||
|
const base = (email.split('@')[0] ?? name)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_-]/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
.slice(0, 28);
|
||||||
|
// Append 4 random hex chars to avoid collisions without needing a DB round-trip
|
||||||
|
const suffix = randomBytes(2).toString('hex');
|
||||||
|
return `${base || 'user'}_${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Handler ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params, url, cookies, locals }) => {
|
||||||
|
const provider = params.provider as Provider;
|
||||||
|
if (provider !== 'google' && provider !== 'github') {
|
||||||
|
error(404, 'Unknown OAuth provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
const state = url.searchParams.get('state');
|
||||||
|
const storedState = cookies.get(`oauth_state_${provider}`);
|
||||||
|
const next = cookies.get(`oauth_next_${provider}`) ?? '/';
|
||||||
|
|
||||||
|
// Clear short-lived cookies
|
||||||
|
cookies.delete(`oauth_state_${provider}`, { path: '/' });
|
||||||
|
cookies.delete(`oauth_next_${provider}`, { path: '/' });
|
||||||
|
|
||||||
|
if (!code || !state || state !== storedState) {
|
||||||
|
log.warn('oauth', 'state mismatch or missing code', { provider });
|
||||||
|
redirect(302, '/login?error=oauth_state');
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri = `${url.origin}/auth/${provider}/callback`;
|
||||||
|
|
||||||
|
let profile: OAuthProfile;
|
||||||
|
try {
|
||||||
|
const accessToken = await exchangeCode(provider, code, redirectUri);
|
||||||
|
profile =
|
||||||
|
provider === 'google'
|
||||||
|
? await fetchGoogleProfile(accessToken)
|
||||||
|
: await fetchGitHubProfile(accessToken);
|
||||||
|
} catch (err) {
|
||||||
|
log.error('oauth', 'profile fetch failed', { provider, err: String(err) });
|
||||||
|
redirect(302, '/login?error=oauth_failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile.email) {
|
||||||
|
log.warn('oauth', 'no email in profile', { provider, id: profile.id });
|
||||||
|
redirect(302, '/login?error=oauth_no_email');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Find or create user ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let user = await getUserByOAuth(provider, profile.id);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Try to link by email (user may have registered via the other provider)
|
||||||
|
const existing = await getUserByEmail(profile.email);
|
||||||
|
if (existing) {
|
||||||
|
// Link this provider to the existing account
|
||||||
|
await linkOAuthToUser(existing.id, provider, profile.id);
|
||||||
|
user = existing;
|
||||||
|
log.info('oauth', 'linked provider to existing account', {
|
||||||
|
provider,
|
||||||
|
userId: existing.id
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Auto-create a new account
|
||||||
|
const username = deriveUsername(profile.name, profile.email);
|
||||||
|
user = await createOAuthUser(username, profile.email, provider, profile.id, profile.avatarUrl);
|
||||||
|
log.info('oauth', 'created new account via oauth', { provider, username });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Merge anonymous session progress ───────────────────────────────────────
|
||||||
|
mergeSessionProgress(locals.sessionId, user.id).catch((err) =>
|
||||||
|
log.warn('oauth', 'mergeSessionProgress failed (non-fatal)', { err: String(err) })
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Create session + auth cookie ──────────────────────────────────────────
|
||||||
|
const authSessionId = randomBytes(16).toString('hex');
|
||||||
|
const userAgent = '' ; // not available in RequestHandler — omit
|
||||||
|
const ip = '';
|
||||||
|
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
|
||||||
|
log.warn('oauth', 'createUserSession failed (non-fatal)', { err: String(err) })
|
||||||
|
);
|
||||||
|
|
||||||
|
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
||||||
|
cookies.set(AUTH_COOKIE, token, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: ONE_YEAR
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect(302, next);
|
||||||
|
};
|
||||||
@@ -1,140 +1,12 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { loginUser, createUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase';
|
|
||||||
import { sendVerificationEmail } from '$lib/server/email';
|
|
||||||
import { createAuthToken } from '../../hooks.server';
|
|
||||||
import { log } from '$lib/server/logger';
|
|
||||||
import { randomBytes } from 'node:crypto';
|
|
||||||
|
|
||||||
const AUTH_COOKIE = 'libnovel_auth';
|
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||||
const ONE_YEAR = 60 * 60 * 24 * 365;
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
|
||||||
// Already logged in — send to home
|
// Already logged in — send to home
|
||||||
if (locals.user) {
|
if (locals.user) {
|
||||||
redirect(302, '/');
|
redirect(302, '/');
|
||||||
}
|
}
|
||||||
return {};
|
// Surface provider error codes to the page (oauth_state, oauth_failed, etc.)
|
||||||
};
|
const error = url.searchParams.get('error') ?? undefined;
|
||||||
|
return { error };
|
||||||
export const actions: Actions = {
|
|
||||||
login: async ({ request, cookies, locals }) => {
|
|
||||||
const data = await request.formData();
|
|
||||||
const username = (data.get('username') as string | null)?.trim() ?? '';
|
|
||||||
const password = (data.get('password') as string | null) ?? '';
|
|
||||||
|
|
||||||
if (!username || !password) {
|
|
||||||
return fail(400, { action: 'login', error: 'Username and password are required.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
let user;
|
|
||||||
try {
|
|
||||||
user = await loginUser(username, password);
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : '';
|
|
||||||
if (msg === 'Email not verified') {
|
|
||||||
return fail(403, {
|
|
||||||
action: 'login',
|
|
||||||
error: 'Please verify your email before signing in. Check your inbox for the verification link.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
log.error('auth', 'login unexpected error', { username, err: String(err) });
|
|
||||||
return fail(500, { action: 'login', error: 'An error occurred. Please try again.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return fail(401, { action: 'login', error: 'Invalid username or password.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge any anonymous session progress into the user's account so that
|
|
||||||
// chapters read before logging in are preserved and portable across devices.
|
|
||||||
mergeSessionProgress(locals.sessionId, user.id).catch((err) =>
|
|
||||||
log.warn('auth', 'login: mergeSessionProgress failed (non-fatal)', { err: String(err) })
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create a unique auth session ID for this login
|
|
||||||
const authSessionId = randomBytes(16).toString('hex');
|
|
||||||
|
|
||||||
// Record the session in PocketBase (best-effort, non-fatal)
|
|
||||||
const userAgent = request.headers.get('user-agent') ?? '';
|
|
||||||
const ip =
|
|
||||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
|
||||||
request.headers.get('x-real-ip') ??
|
|
||||||
'';
|
|
||||||
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
|
|
||||||
log.warn('auth', 'login: createUserSession failed (non-fatal)', { err: String(err) })
|
|
||||||
);
|
|
||||||
|
|
||||||
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
|
||||||
cookies.set(AUTH_COOKIE, token, {
|
|
||||||
path: '/',
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: ONE_YEAR
|
|
||||||
});
|
|
||||||
|
|
||||||
redirect(302, '/');
|
|
||||||
},
|
|
||||||
|
|
||||||
register: async ({ request }) => {
|
|
||||||
const data = await request.formData();
|
|
||||||
const username = (data.get('username') as string | null)?.trim() ?? '';
|
|
||||||
const email = (data.get('email') as string | null)?.trim().toLowerCase() ?? '';
|
|
||||||
const password = (data.get('password') as string | null) ?? '';
|
|
||||||
const confirm = (data.get('confirm') as string | null) ?? '';
|
|
||||||
|
|
||||||
if (!username || !email || !password) {
|
|
||||||
return fail(400, { action: 'register', error: 'All fields are required.' });
|
|
||||||
}
|
|
||||||
if (username.length < 3 || username.length > 32) {
|
|
||||||
return fail(400, {
|
|
||||||
action: 'register',
|
|
||||||
error: 'Username must be between 3 and 32 characters.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
|
||||||
return fail(400, {
|
|
||||||
action: 'register',
|
|
||||||
error: 'Username may only contain letters, numbers, underscores and hyphens.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
||||||
return fail(400, { action: 'register', error: 'Please enter a valid email address.' });
|
|
||||||
}
|
|
||||||
if (password.length < 8) {
|
|
||||||
return fail(400, {
|
|
||||||
action: 'register',
|
|
||||||
error: 'Password must be at least 8 characters.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (password !== confirm) {
|
|
||||||
return fail(400, { action: 'register', error: 'Passwords do not match.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
let user;
|
|
||||||
try {
|
|
||||||
user = await createUser(username, password, email);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const msg = err instanceof Error ? err.message : 'Registration failed.';
|
|
||||||
if (msg.includes('Username already taken')) {
|
|
||||||
return fail(409, { action: 'register', error: 'That username is already taken.' });
|
|
||||||
}
|
|
||||||
if (msg.includes('Email already in use')) {
|
|
||||||
return fail(409, { action: 'register', error: 'That email address is already registered.' });
|
|
||||||
}
|
|
||||||
log.error('auth', 'register unexpected error', { username, err: String(err) });
|
|
||||||
return fail(500, { action: 'register', error: 'An error occurred. Please try again.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send verification email (non-fatal — user can re-request later)
|
|
||||||
try {
|
|
||||||
await sendVerificationEmail(email, user.verification_token!);
|
|
||||||
} catch (err) {
|
|
||||||
log.error('auth', 'register: failed to send verification email', { username, email, err: String(err) });
|
|
||||||
// Don't fail registration if email fails — user sees the pending screen
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return success state — do NOT log the user in yet
|
|
||||||
return { action: 'register', registered: true, email };
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ActionData } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
let { form }: { form: ActionData } = $props();
|
let { data }: { data: { error?: string } } = $props();
|
||||||
|
|
||||||
// Cast to access union members that TypeScript can't narrow statically
|
const errorMessages: Record<string, string> = {
|
||||||
const f = $derived(form as (typeof form) & { registered?: boolean; email?: string } | null);
|
oauth_state: 'Sign-in was cancelled or expired. Please try again.',
|
||||||
|
oauth_failed: 'Could not connect to the provider. Please try again.',
|
||||||
let mode: 'login' | 'register' = $state('login');
|
oauth_no_email: 'Your account has no verified email address. Please add one and retry.'
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -16,155 +17,71 @@
|
|||||||
<div class="flex items-center justify-center min-h-[60vh]">
|
<div class="flex items-center justify-center min-h-[60vh]">
|
||||||
<div class="w-full max-w-sm">
|
<div class="w-full max-w-sm">
|
||||||
|
|
||||||
<!-- Post-registration: check inbox -->
|
<div class="text-center mb-8">
|
||||||
{#if f?.registered}
|
<h1 class="text-2xl font-bold text-zinc-100 mb-2">Sign in to libnovel</h1>
|
||||||
<div class="text-center">
|
<p class="text-sm text-zinc-400">Choose a provider to continue</p>
|
||||||
<div class="mb-4 text-4xl">✉️</div>
|
|
||||||
<h2 class="text-lg font-semibold text-zinc-100 mb-2">Check your inbox</h2>
|
|
||||||
<p class="text-sm text-zinc-400 mb-6">
|
|
||||||
We sent a verification link to <span class="text-zinc-200 font-medium">{f?.email}</span>.
|
|
||||||
Click it to activate your account.
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-zinc-500">
|
|
||||||
Didn't receive it? Check your spam folder, or
|
|
||||||
<a href="/login" class="text-amber-400 hover:text-amber-300 transition-colors">try again</a>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- Tab switcher -->
|
|
||||||
<div class="flex mb-6 border-b border-zinc-700">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (mode = 'login')}
|
|
||||||
class="flex-1 pb-3 text-sm font-medium transition-colors
|
|
||||||
{mode === 'login'
|
|
||||||
? 'text-amber-400 border-b-2 border-amber-400 -mb-px'
|
|
||||||
: 'text-zinc-400 hover:text-zinc-100'}"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (mode = 'register')}
|
|
||||||
class="flex-1 pb-3 text-sm font-medium transition-colors
|
|
||||||
{mode === 'register'
|
|
||||||
? 'text-amber-400 border-b-2 border-amber-400 -mb-px'
|
|
||||||
: 'text-zinc-400 hover:text-zinc-100'}"
|
|
||||||
>
|
|
||||||
Create account
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if form?.error && (form?.action === mode || !form?.action)}
|
{#if data.error && errorMessages[data.error]}
|
||||||
<div class="mb-4 rounded bg-red-900/40 border border-red-700 px-4 py-3 text-sm text-red-300">
|
<div class="mb-6 rounded bg-red-900/40 border border-red-700 px-4 py-3 text-sm text-red-300">
|
||||||
{form.error}
|
{errorMessages[data.error]}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if mode === 'login'}
|
<div class="flex flex-col gap-3">
|
||||||
<form method="POST" action="?/login" class="flex flex-col gap-4">
|
<!-- Google -->
|
||||||
<div>
|
<a
|
||||||
<label for="login-username" class="block text-xs text-zinc-400 mb-1">Username</label>
|
href="/auth/google"
|
||||||
<input
|
class="flex items-center justify-center gap-3 w-full py-3 px-4 rounded-lg
|
||||||
id="login-username"
|
bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm font-medium
|
||||||
name="username"
|
hover:bg-zinc-700 hover:border-zinc-600 transition-colors"
|
||||||
type="text"
|
|
||||||
autocomplete="username"
|
|
||||||
required
|
|
||||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
|
||||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
|
||||||
placeholder="your_username"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="login-password" class="block text-xs text-zinc-400 mb-1">Password</label>
|
|
||||||
<input
|
|
||||||
id="login-password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autocomplete="current-password"
|
|
||||||
required
|
|
||||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
|
||||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="w-full py-2 rounded bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors"
|
|
||||||
>
|
>
|
||||||
Sign in
|
<svg class="w-5 h-5 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
</button>
|
<path
|
||||||
</form>
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
{:else}
|
fill="#4285F4"
|
||||||
<form method="POST" action="?/register" class="flex flex-col gap-4">
|
|
||||||
<div>
|
|
||||||
<label for="reg-username" class="block text-xs text-zinc-400 mb-1">Username</label>
|
|
||||||
<input
|
|
||||||
id="reg-username"
|
|
||||||
name="username"
|
|
||||||
type="text"
|
|
||||||
autocomplete="username"
|
|
||||||
required
|
|
||||||
minlength="3"
|
|
||||||
maxlength="32"
|
|
||||||
pattern="[a-zA-Z0-9_\-]+"
|
|
||||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
|
||||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
|
||||||
placeholder="your_username"
|
|
||||||
/>
|
/>
|
||||||
<p class="mt-1 text-xs text-zinc-500">3–32 characters: letters, numbers, _ or -</p>
|
<path
|
||||||
</div>
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
<div>
|
fill="#34A853"
|
||||||
<label for="reg-email" class="block text-xs text-zinc-400 mb-1">Email</label>
|
|
||||||
<input
|
|
||||||
id="reg-email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
autocomplete="email"
|
|
||||||
required
|
|
||||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
|
||||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
/>
|
||||||
<p class="mt-1 text-xs text-zinc-500">Used to verify your account — not shown publicly</p>
|
<path
|
||||||
</div>
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
<div>
|
fill="#FBBC05"
|
||||||
<label for="reg-password" class="block text-xs text-zinc-400 mb-1">Password</label>
|
|
||||||
<input
|
|
||||||
id="reg-password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
required
|
|
||||||
minlength="8"
|
|
||||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
|
||||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
/>
|
||||||
<p class="mt-1 text-xs text-zinc-500">At least 8 characters</p>
|
<path
|
||||||
</div>
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
<div>
|
fill="#EA4335"
|
||||||
<label for="reg-confirm" class="block text-xs text-zinc-400 mb-1">Confirm password</label>
|
|
||||||
<input
|
|
||||||
id="reg-confirm"
|
|
||||||
name="confirm"
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
required
|
|
||||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
|
||||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</svg>
|
||||||
<button
|
Continue with Google
|
||||||
type="submit"
|
</a>
|
||||||
class="w-full py-2 rounded bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors"
|
|
||||||
|
<!-- GitHub -->
|
||||||
|
<a
|
||||||
|
href="/auth/github"
|
||||||
|
class="flex items-center justify-center gap-3 w-full py-3 px-4 rounded-lg
|
||||||
|
bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm font-medium
|
||||||
|
hover:bg-zinc-700 hover:border-zinc-600 transition-colors"
|
||||||
>
|
>
|
||||||
Create account
|
<svg class="w-5 h-5 shrink-0 fill-zinc-100" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
</button>
|
<path
|
||||||
</form>
|
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483
|
||||||
{/if}
|
0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466
|
||||||
{/if}
|
-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832
|
||||||
|
.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688
|
||||||
|
-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0
|
||||||
|
0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028
|
||||||
|
1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012
|
||||||
|
2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Continue with GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-8 text-center text-xs text-zinc-500">
|
||||||
|
By signing in you agree to our terms of service.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
import type { PageServerLoad } from './$types';
|
|
||||||
import {
|
|
||||||
getUserByVerificationToken,
|
|
||||||
verifyUserEmail,
|
|
||||||
createUserSession
|
|
||||||
} from '$lib/server/pocketbase';
|
|
||||||
import { createAuthToken } from '../../hooks.server';
|
|
||||||
import { log } from '$lib/server/logger';
|
|
||||||
import { randomBytes } from 'node:crypto';
|
|
||||||
|
|
||||||
const AUTH_COOKIE = 'libnovel_auth';
|
|
||||||
const ONE_YEAR = 60 * 60 * 24 * 365;
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url, cookies, request }) => {
|
|
||||||
const token = url.searchParams.get('token') ?? '';
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return { success: false, error: 'Missing verification token.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
let user;
|
|
||||||
try {
|
|
||||||
user = await getUserByVerificationToken(token);
|
|
||||||
} catch (e) {
|
|
||||||
log.error('verify-email', 'lookup failed', { err: String(e) });
|
|
||||||
return { success: false, error: 'An error occurred. Please try again.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return { success: false, error: 'Invalid or expired verification link.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check expiry
|
|
||||||
if (user.verification_token_exp) {
|
|
||||||
const exp = new Date(user.verification_token_exp).getTime();
|
|
||||||
if (Date.now() > exp) {
|
|
||||||
return { success: false, error: 'This verification link has expired. Please register again.' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark email as verified
|
|
||||||
try {
|
|
||||||
await verifyUserEmail(user.id);
|
|
||||||
} catch (e) {
|
|
||||||
log.error('verify-email', 'verifyUserEmail failed', { userId: user.id, err: String(e) });
|
|
||||||
return { success: false, error: 'Failed to verify email. Please try again.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log the user in automatically
|
|
||||||
const authSessionId = randomBytes(16).toString('hex');
|
|
||||||
const userAgent = request.headers.get('user-agent') ?? '';
|
|
||||||
const ip =
|
|
||||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
|
||||||
request.headers.get('x-real-ip') ??
|
|
||||||
'';
|
|
||||||
|
|
||||||
createUserSession(user.id, authSessionId, userAgent, ip).catch((e) =>
|
|
||||||
log.warn('verify-email', 'createUserSession failed (non-fatal)', { err: String(e) })
|
|
||||||
);
|
|
||||||
|
|
||||||
const authToken = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
|
||||||
cookies.set(AUTH_COOKIE, authToken, {
|
|
||||||
path: '/',
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: ONE_YEAR
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info('verify-email', 'email verified, user logged in', { userId: user.id, username: user.username });
|
|
||||||
redirect(302, '/');
|
|
||||||
};
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { PageData } from './$types';
|
|
||||||
let { data }: { data: PageData } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Verify email — libnovel</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-center min-h-[60vh]">
|
|
||||||
<div class="w-full max-w-sm text-center">
|
|
||||||
{#if data.error}
|
|
||||||
<div class="mb-6 rounded bg-red-900/40 border border-red-700 px-4 py-3 text-sm text-red-300">
|
|
||||||
{data.error}
|
|
||||||
</div>
|
|
||||||
<a href="/login" class="text-sm text-amber-400 hover:text-amber-300 transition-colors">
|
|
||||||
Back to sign in
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
Reference in New Issue
Block a user