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

- 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:
Admin
2026-03-24 22:01:51 +05:00
parent 920ac0d41b
commit 4a7009989c
12 changed files with 495 additions and 650 deletions

View File

@@ -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;
}
}

View File

@@ -67,6 +67,8 @@ export interface User {
email_verified?: boolean;
verification_token?: string;
verification_token_exp?: string;
oauth_provider?: string;
oauth_id?: string;
}
// ─── 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, '\\"')}"`);
}
/**
* 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.
* @deprecated Email verification removed — kept only for migration safety.
*/
export async function getUserByVerificationToken(token: string): Promise<User | null> {
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.
* 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> {
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 });
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);
if (!ok) {
log.warn('pocketbase', 'loginUser: wrong password', { username });
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 });
return user;
}