From 5c9c2f69fd0ca121024d9c2a6fcf9bceeff87b72 Mon Sep 17 00:00:00 2001 From: LinYushen Date: Thu, 26 Mar 2026 14:32:30 +0800 Subject: [PATCH] feat(auth): email verification login and personal access tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(auth): add email verification login flow with 401 auto-redirect Replace the old OAuth-based login with email verification codes: - Backend: send-code / verify-code endpoints, verification_codes table (migration 009), rate limiting, Resend email service - Frontend: two-step login UI (email → 6-digit OTP), auth store with sendCode/verifyCode - SDK: ApiClient gains onUnauthorized callback; 401 responses auto-clear token and redirect to /login - Fix login button staying disabled due to global isLoading state Co-Authored-By: Claude Opus 4.6 (1M context) * fix(auth): add brute-force protection, redirect loop guard, and expired code cleanup - VerifyCode: increment attempts on wrong code, reject after 5 failed tries (migration 010) - onUnauthorized: skip redirect if already on /login to prevent infinite loops - SendCode: best-effort cleanup of expired verification codes older than 1 hour Co-Authored-By: Claude Opus 4.6 (1M context) * feat(auth): add master verification code for non-production environments Allow code "888888" to bypass email verification in non-production environments to simplify development and testing workflows. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(auth): add personal access tokens for CLI and API authentication Add full-stack PAT support: users create tokens in Settings, CLI authenticates via `multica auth login`. Server stores SHA-256 hashes only. Auth middleware extended to accept both JWTs and PATs (distinguished by `mul_` prefix). Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .env.example | 4 + CLAUDE.md | 9 +- apps/web/app/(auth)/login/page.test.tsx | 64 ++--- apps/web/app/(auth)/login/page.tsx | 153 ++++++++++-- apps/web/app/(dashboard)/settings/page.tsx | 150 +++++++++++- apps/web/features/auth/store.ts | 11 +- apps/web/shared/api.ts | 10 + apps/web/test/setup.ts | 10 + e2e/fixtures.ts | 56 ++++- e2e/helpers.ts | 10 +- package.json | 2 + packages/sdk/src/api-client.ts | 40 +++- packages/types/src/api.ts | 19 ++ pnpm-lock.yaml | 122 ++++++++++ server/cmd/multica/cmd_agent.go | 3 +- server/cmd/multica/cmd_auth.go | 140 +++++++++++ server/cmd/multica/main.go | 1 + server/cmd/server/integration_test.go | 168 +++++++------ server/cmd/server/router.go | 16 +- server/go.mod | 1 + server/go.sum | 2 + server/internal/auth/jwt.go | 19 ++ server/internal/cli/client.go | 25 +- server/internal/cli/config.go | 1 + server/internal/handler/auth.go | 198 ++++++++++----- server/internal/handler/handler.go | 28 ++- server/internal/handler/handler_test.go | 226 ++++++++++++++++-- .../internal/handler/personal_access_token.go | 132 ++++++++++ server/internal/middleware/auth.go | 126 ++++++---- server/internal/middleware/auth_test.go | 36 ++- server/internal/service/email.go | 54 +++++ .../migrations/009_verification_code.down.sql | 1 + .../migrations/009_verification_code.up.sql | 10 + .../010_verification_code_attempts.down.sql | 1 + .../010_verification_code_attempts.up.sql | 1 + .../011_personal_access_tokens.down.sql | 1 + .../011_personal_access_tokens.up.sql | 14 ++ server/pkg/db/generated/models.go | 22 ++ .../db/generated/personal_access_token.sql.go | 137 +++++++++++ .../pkg/db/generated/verification_code.sql.go | 118 +++++++++ .../pkg/db/queries/personal_access_token.sql | 26 ++ server/pkg/db/queries/verification_code.sql | 33 +++ 42 files changed, 1889 insertions(+), 311 deletions(-) create mode 100644 server/cmd/multica/cmd_auth.go create mode 100644 server/internal/handler/personal_access_token.go create mode 100644 server/internal/service/email.go create mode 100644 server/migrations/009_verification_code.down.sql create mode 100644 server/migrations/009_verification_code.up.sql create mode 100644 server/migrations/010_verification_code_attempts.down.sql create mode 100644 server/migrations/010_verification_code_attempts.up.sql create mode 100644 server/migrations/011_personal_access_tokens.down.sql create mode 100644 server/migrations/011_personal_access_tokens.up.sql create mode 100644 server/pkg/db/generated/personal_access_token.sql.go create mode 100644 server/pkg/db/generated/verification_code.sql.go create mode 100644 server/pkg/db/queries/personal_access_token.sql create mode 100644 server/pkg/db/queries/verification_code.sql diff --git a/.env.example b/.env.example index 312ec87a..1c0c93ab 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,10 @@ MULTICA_CODEX_MODEL= MULTICA_CODEX_WORKDIR= MULTICA_CODEX_TIMEOUT=20m +# Email (Resend) +RESEND_API_KEY= +RESEND_FROM_EMAIL=noreply@multica.ai + # Google OAuth GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= diff --git a/CLAUDE.md b/CLAUDE.md index f0e09605..179e4728 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ Multica is an AI-native task management platform — like Linear, but with AI ag - `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time) - `apps/web/` — Next.js 16 frontend (App Router) -- `packages/` — Shared TypeScript packages (ui, types, sdk, store, hooks, utils) +- `packages/` — Shared TypeScript packages (ui, types, sdk, utils) ### Web App Structure (`apps/web/`) @@ -40,6 +40,8 @@ apps/web/ | `features/issues/` | Issue state, components, config | `useIssueStore`, icons, pickers, status/priority config | | `features/inbox/` | Inbox notification state | `useInboxStore` | | `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` | +| `features/modals/` | Modal registry and state | Modal store and components | +| `features/skills/` | Skill management | Skill components | **`shared/`** — Code used across multiple features. Currently only `api.ts` (SDK singleton). @@ -88,6 +90,7 @@ Browser ← WSClient (SDK) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskSe - **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex. Each backend spawns its CLI and streams results via `Session.Messages` + `Session.Result` channels. - **Daemon** (`internal/daemon/`): Local agent runtime — auto-detects available CLIs (claude, codex), registers runtimes, polls for tasks, routes by provider. - **CLI** (`internal/cli/`): Shared helpers for the `multica` CLI — API client, config management, output formatting. +- **Events** (`internal/events/`): Internal event bus for decoupled communication between handlers and services. - **Database**: sqlc generates Go code from SQL in `pkg/db/queries/` → `pkg/db/generated/`. Migrations in `migrations/`. - **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model). @@ -96,6 +99,7 @@ Browser ← WSClient (SDK) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskSe - **`@multica/sdk`**: `ApiClient` (REST) and `WSClient` (WebSocket) classes. All backend communication goes through here. - **`@multica/types`**: Shared domain types + WebSocket event types (issue:created/updated/deleted, task:*, agent:status, comment:*, inbox:new, daemon:*). - **`@multica/ui`**: shadcn/ui component library with Radix primitives, Tailwind CSS 4, Shiki syntax highlighting for markdown. +- **`@multica/utils`**: Shared utility functions used across apps and packages. ### Multi-tenancy @@ -145,7 +149,8 @@ make db-down # Stop shared PostgreSQL ### Worktree Support -For isolated feature testing with a separate database: +All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`. + ```bash make worktree-env # Generate .env.worktree with unique DB/ports make setup-worktree # Setup using .env.worktree diff --git a/apps/web/app/(auth)/login/page.test.tsx b/apps/web/app/(auth)/login/page.test.tsx index e69555d4..50152d87 100644 --- a/apps/web/app/(auth)/login/page.test.tsx +++ b/apps/web/app/(auth)/login/page.test.tsx @@ -10,11 +10,13 @@ vi.mock("next/navigation", () => ({ })); // Mock auth store -const mockLogin = vi.fn(); +const mockSendCode = vi.fn(); +const mockVerifyCode = vi.fn(); vi.mock("@/features/auth", () => ({ useAuthStore: (selector: (s: any) => any) => selector({ - login: mockLogin, + sendCode: mockSendCode, + verifyCode: mockVerifyCode, isLoading: false, }), })); @@ -42,78 +44,82 @@ describe("LoginPage", () => { vi.clearAllMocks(); }); - it("renders login form with heading, inputs, and button", () => { + it("renders email form with heading and button", () => { render(); expect(screen.getByText("Multica")).toBeInTheDocument(); expect(screen.getByText("AI-native task management")).toBeInTheDocument(); expect(screen.getByLabelText("Email")).toBeInTheDocument(); - expect(screen.getByLabelText("Name")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /sign in/i })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /continue/i }) + ).toBeInTheDocument(); }); - it("does not call login when email is empty", async () => { + it("does not call sendCode when email is empty", async () => { const user = userEvent.setup(); render(); - await user.click(screen.getByRole("button", { name: "Sign in" })); - expect(mockLogin).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: /continue/i })); + expect(mockSendCode).not.toHaveBeenCalled(); }); - it("calls login with correct args on submit", async () => { - mockLogin.mockResolvedValueOnce({ id: "u1", name: "Test User" }); - mockHydrateWorkspace.mockResolvedValueOnce(null); + it("calls sendCode on submit and shows code step", async () => { + mockSendCode.mockResolvedValueOnce(undefined); const user = userEvent.setup(); render(); await user.type(screen.getByLabelText("Email"), "test@multica.ai"); - await user.type(screen.getByLabelText("Name"), "Test User"); - await user.click(screen.getByRole("button", { name: "Sign in" })); + await user.click(screen.getByRole("button", { name: /continue/i })); await waitFor(() => { - expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", "Test User"); + expect(mockSendCode).toHaveBeenCalledWith("test@multica.ai"); + }); + + await waitFor(() => { + expect(screen.getByText("Check your email")).toBeInTheDocument(); }); }); - it("calls login with email only when name is empty", async () => { - mockLogin.mockResolvedValueOnce({ id: "u1", name: "" }); - mockHydrateWorkspace.mockResolvedValueOnce(null); + it("shows 'Sending code...' while submitting", async () => { + mockSendCode.mockReturnValueOnce(new Promise(() => {})); const user = userEvent.setup(); render(); await user.type(screen.getByLabelText("Email"), "test@multica.ai"); - await user.click(screen.getByRole("button", { name: "Sign in" })); + await user.click(screen.getByRole("button", { name: /continue/i })); await waitFor(() => { - expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", undefined); + expect(screen.getByText("Sending code...")).toBeInTheDocument(); }); }); - it("shows 'Signing in...' while submitting", async () => { - mockLogin.mockReturnValueOnce(new Promise(() => {})); + it("shows error when sendCode fails", async () => { + mockSendCode.mockRejectedValueOnce(new Error("Network error")); const user = userEvent.setup(); render(); await user.type(screen.getByLabelText("Email"), "test@multica.ai"); - await user.click(screen.getByRole("button", { name: "Sign in" })); + await user.click(screen.getByRole("button", { name: /continue/i })); await waitFor(() => { - expect(screen.getByText("Signing in...")).toBeInTheDocument(); + expect(screen.getByText("Network error")).toBeInTheDocument(); }); }); - it("shows error when login fails", async () => { - mockLogin.mockRejectedValueOnce(new Error("Network error")); + it("shows back button on code step", async () => { + mockSendCode.mockResolvedValueOnce(undefined); const user = userEvent.setup(); render(); await user.type(screen.getByLabelText("Email"), "test@multica.ai"); - await user.click(screen.getByRole("button", { name: "Sign in" })); + await user.click(screen.getByRole("button", { name: /continue/i })); await waitFor(() => { - expect( - screen.getByText("Login failed. Make sure the server is running."), - ).toBeInTheDocument(); + expect(screen.getByText("Check your email")).toBeInTheDocument(); }); + + expect( + screen.getByRole("button", { name: /back/i }) + ).toBeInTheDocument(); }); }); diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index b1c2cee2..d7e45010 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -1,10 +1,9 @@ "use client"; -import { Suspense, useState } from "react"; +import { Suspense, useState, useEffect, useCallback } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; -import { useNavigationStore } from "@/features/navigation"; import { api } from "@/shared/api"; import { Card, @@ -17,20 +16,34 @@ import { import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp"; function LoginPageContent() { const router = useRouter(); - const login = useAuthStore((s) => s.login); - const isLoading = useAuthStore((s) => s.isLoading); + const sendCode = useAuthStore((s) => s.sendCode); + const verifyCode = useAuthStore((s) => s.verifyCode); const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace); const searchParams = useSearchParams(); + + const [step, setStep] = useState<"email" | "code">("email"); const [email, setEmail] = useState(""); - const [name, setName] = useState(""); + const [code, setCode] = useState(""); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); + const [cooldown, setCooldown] = useState(0); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + useEffect(() => { + if (cooldown <= 0) return; + const timer = setTimeout(() => setCooldown((c) => c - 1), 1000); + return () => clearTimeout(timer); + }, [cooldown]); + + const handleSendCode = async (e?: React.FormEvent) => { + e?.preventDefault(); if (!email) { setError("Email is required"); return; @@ -38,17 +51,115 @@ function LoginPageContent() { setError(""); setSubmitting(true); try { - await login(email, name || undefined); - const wsList = await api.listWorkspaces(); - await hydrateWorkspace(wsList); - const fallback = useNavigationStore.getState().lastPath; - router.push(searchParams.get("next") || fallback); + await sendCode(email); + setStep("code"); + setCode(""); + setCooldown(60); } catch (err) { - setError("Login failed. Make sure the server is running."); + setError( + err instanceof Error ? err.message : "Failed to send code. Make sure the server is running." + ); + } finally { setSubmitting(false); } }; + const handleVerifyCode = useCallback( + async (value: string) => { + if (value.length !== 6) return; + setError(""); + setSubmitting(true); + try { + await verifyCode(email, value); + const wsList = await api.listWorkspaces(); + await hydrateWorkspace(wsList); + router.push(searchParams.get("next") || "/issues"); + } catch (err) { + setError( + err instanceof Error ? err.message : "Invalid or expired code" + ); + setCode(""); + setSubmitting(false); + } + }, + [email, verifyCode, hydrateWorkspace, router, searchParams] + ); + + const handleResend = async () => { + if (cooldown > 0) return; + setError(""); + try { + await sendCode(email); + setCooldown(60); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to resend code" + ); + } + }; + + if (step === "code") { + return ( +
+ + + Check your email + + We sent a verification code to{" "} + {email} + + + + { + setCode(value); + if (value.length === 6) handleVerifyCode(value); + }} + disabled={submitting} + > + + + + + + + + + + {error && ( +

{error}

+ )} +
+ +
+
+ + + +
+
+ ); + } + return (
@@ -57,17 +168,7 @@ function LoginPageContent() { AI-native task management -
-
- - setName(e.target.value)} - /> -
+
- {submitting ? "Signing in..." : "Sign in"} + {submitting ? "Sending code..." : "Continue"} diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index 75ba55f8..72d23251 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; -import { Settings, Users, Building2, Save, Crown, Shield, User, Plus, Trash2, LogOut } from "lucide-react"; -import type { MemberWithUser, MemberRole } from "@multica/types"; +import { useEffect, useState, useCallback } from "react"; +import { Settings, Users, Building2, Save, Crown, Shield, User, Plus, Trash2, LogOut, Key, Copy, Check } from "lucide-react"; +import type { MemberWithUser, MemberRole, PersonalAccessToken } from "@multica/types"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; @@ -24,6 +24,14 @@ import { SelectContent, SelectItem, } from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; import { toast } from "sonner"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; @@ -120,6 +128,61 @@ export default function SettingsPage() { const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? ""); const [saving, setSaving] = useState(false); const [profileSaving, setProfileSaving] = useState(false); + const [tokens, setTokens] = useState([]); + const [tokenName, setTokenName] = useState(""); + const [tokenExpiry, setTokenExpiry] = useState("90"); + const [tokenCreating, setTokenCreating] = useState(false); + const [newToken, setNewToken] = useState(null); + const [tokenCopied, setTokenCopied] = useState(false); + const [tokenRevoking, setTokenRevoking] = useState(null); + + const loadTokens = useCallback(async () => { + try { + const list = await api.listPersonalAccessTokens(); + setTokens(list); + } catch { + // ignore — tokens section simply stays empty + } + }, []); + + useEffect(() => { loadTokens(); }, [loadTokens]); + + const handleCreateToken = async () => { + setTokenCreating(true); + try { + const expiresInDays = tokenExpiry === "never" ? undefined : Number(tokenExpiry); + const result = await api.createPersonalAccessToken({ name: tokenName, expires_in_days: expiresInDays }); + setNewToken(result.token); + setTokenName(""); + setTokenExpiry("90"); + await loadTokens(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to create token"); + } finally { + setTokenCreating(false); + } + }; + + const handleRevokeToken = async (id: string) => { + setTokenRevoking(id); + try { + await api.revokePersonalAccessToken(id); + await loadTokens(); + toast.success("Token revoked"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to revoke token"); + } finally { + setTokenRevoking(null); + } + }; + + const handleCopyToken = async () => { + if (!newToken) return; + await navigator.clipboard.writeText(newToken); + setTokenCopied(true); + setTimeout(() => setTokenCopied(false), 2000); + }; + const [inviteEmail, setInviteEmail] = useState(""); const [inviteRole, setInviteRole] = useState("member"); const [inviteLoading, setInviteLoading] = useState(false); @@ -325,6 +388,87 @@ export default function SettingsPage() {
+ {/* API Tokens */} +
+
+ +

API Tokens

+
+ +
+

+ Personal access tokens allow the CLI and external integrations to authenticate with your account. +

+
+ setTokenName(e.target.value)} + placeholder="Token name (e.g. My CLI)" + /> + + +
+
+ + {tokens.length > 0 && ( +
+ {tokens.map((t) => ( +
+
+
{t.name}
+
+ {t.token_prefix}... · Created {new Date(t.created_at).toLocaleDateString()} · {t.last_used_at ? `Last used ${new Date(t.last_used_at).toLocaleDateString()}` : "Never used"} + {t.expires_at && ` · Expires ${new Date(t.expires_at).toLocaleDateString()}`} +
+
+ +
+ ))} +
+ )} +
+ + { if (!v) { setNewToken(null); setTokenCopied(false); } }}> + + + Token created + + Copy your personal access token now. You won't be able to see it again. + + +
+ + {newToken} + + +
+ + + +
+
+ {/* Workspace info */}
diff --git a/apps/web/features/auth/store.ts b/apps/web/features/auth/store.ts index 1d18b279..9994b658 100644 --- a/apps/web/features/auth/store.ts +++ b/apps/web/features/auth/store.ts @@ -9,7 +9,8 @@ interface AuthState { isLoading: boolean; initialize: () => Promise; - login: (email: string, name?: string) => Promise; + sendCode: (email: string) => Promise; + verifyCode: (email: string, code: string) => Promise; logout: () => void; setUser: (user: User) => void; } @@ -39,8 +40,12 @@ export const useAuthStore = create((set) => ({ } }, - login: async (email: string, name?: string) => { - const { token, user } = await api.login(email, name); + sendCode: async (email: string) => { + await api.sendCode(email); + }, + + verifyCode: async (email: string, code: string) => { + const { token, user } = await api.verifyCode(email, code); localStorage.setItem("multica_token", token); api.setToken(token); set({ user }); diff --git a/apps/web/shared/api.ts b/apps/web/shared/api.ts index e7a0a30a..e7075473 100644 --- a/apps/web/shared/api.ts +++ b/apps/web/shared/api.ts @@ -15,4 +15,14 @@ if (typeof window !== "undefined") { if (wsId) { api.setWorkspaceId(wsId); } + + api.setOnUnauthorized(() => { + localStorage.removeItem("multica_token"); + localStorage.removeItem("multica_workspace_id"); + api.setToken(null); + api.setWorkspaceId(null); + if (window.location.pathname !== "/login") { + window.location.href = "/login"; + } + }); } diff --git a/apps/web/test/setup.ts b/apps/web/test/setup.ts index 30f7cf0f..2564691c 100644 --- a/apps/web/test/setup.ts +++ b/apps/web/test/setup.ts @@ -1,6 +1,16 @@ import "@testing-library/jest-dom/vitest"; import { vi } from "vitest"; +// jsdom doesn't provide ResizeObserver; stub it so components that rely on it +// (e.g. input-otp) can render in tests. +if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +} + // jsdom 29 / Node.js 22+ may not provide a proper Web Storage API. // Create a proper localStorage mock if methods are missing. if ( diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 4e153ccf..643727bd 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -5,7 +5,10 @@ * have zero build-time coupling to monorepo packages. */ +import pg from "pg"; + const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`; +const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://multica:multica@localhost:5432/multica?sslmode=disable"; interface TestWorkspace { id: string; @@ -19,14 +22,53 @@ export class TestApiClient { private createdIssueIds: string[] = []; async login(email: string, name: string) { - const res = await fetch(`${API_BASE}/auth/login`, { + // Step 1: Send verification code + const sendRes = await fetch(`${API_BASE}/auth/send-code`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, name }), + body: JSON.stringify({ email }), }); - const data = await res.json(); - this.token = data.token; - return data; + if (!sendRes.ok) { + // Rate limited — code already sent recently, read it from DB + if (sendRes.status !== 429) { + throw new Error(`send-code failed: ${sendRes.status}`); + } + } + + // Step 2: Read code from database + const client = new pg.Client(DATABASE_URL); + await client.connect(); + try { + const result = await client.query( + "SELECT code FROM verification_code WHERE email = $1 AND used = FALSE AND expires_at > now() ORDER BY created_at DESC LIMIT 1", + [email] + ); + if (result.rows.length === 0) { + throw new Error(`No verification code found for ${email}`); + } + const code = result.rows[0].code; + + // Step 3: Verify code to get JWT + const verifyRes = await fetch(`${API_BASE}/auth/verify-code`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, code }), + }); + const data = await verifyRes.json(); + this.token = data.token; + + // Update user name if needed + if (name && data.user?.name !== name) { + await this.authedFetch("/api/me", { + method: "PATCH", + body: JSON.stringify({ name }), + }); + } + + return data; + } finally { + await client.end(); + } } async getWorkspaces(): Promise { @@ -92,6 +134,10 @@ export class TestApiClient { this.createdIssueIds = []; } + getToken() { + return this.token; + } + private async authedFetch(path: string, init?: RequestInit) { const headers: Record = { "Content-Type": "application/json", diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 3cc93d93..d36d2bc1 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -7,16 +7,20 @@ const DEFAULT_E2E_WORKSPACE = "e2e-workspace"; /** * Log in as the default E2E user and ensure the workspace exists first. + * Authenticates via API (send-code → DB read → verify-code), then injects + * the token into localStorage so the browser session is authenticated. */ export async function loginAsDefault(page: Page) { const api = new TestApiClient(); await api.login(DEFAULT_E2E_EMAIL, DEFAULT_E2E_NAME); await api.ensureWorkspace("E2E Workspace", DEFAULT_E2E_WORKSPACE); + const token = api.getToken(); await page.goto("/login"); - await page.fill('input[placeholder="Name"]', DEFAULT_E2E_NAME); - await page.fill('input[placeholder="Email"]', DEFAULT_E2E_EMAIL); - await page.click('button[type="submit"]'); + await page.evaluate((t) => { + localStorage.setItem("multica_token", t); + }, token); + await page.goto("/issues"); await page.waitForURL("**/issues", { timeout: 10000 }); } diff --git a/package.json b/package.json index 324439de..e312dbc4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "devDependencies": { "@playwright/test": "^1.58.2", "@types/node": "catalog:", + "@types/pg": "^8.20.0", + "pg": "^8.20.0", "turbo": "^2.5.0", "typescript": "catalog:" } diff --git a/packages/sdk/src/api-client.ts b/packages/sdk/src/api-client.ts index 05cb991b..402d45d8 100644 --- a/packages/sdk/src/api-client.ts +++ b/packages/sdk/src/api-client.ts @@ -23,6 +23,9 @@ import type { CreateSkillRequest, UpdateSkillRequest, SetAgentSkillsRequest, + PersonalAccessToken, + CreatePersonalAccessTokenRequest, + CreatePersonalAccessTokenResponse, } from "@multica/types"; import { type SDKLogger, noopLogger } from "./logger"; @@ -36,6 +39,7 @@ export class ApiClient { private token: string | null = null; private workspaceId: string | null = null; private logger: SDKLogger; + private onUnauthorized: (() => void) | null = null; constructor(baseUrl: string, options?: { logger?: SDKLogger }) { this.baseUrl = baseUrl; @@ -50,6 +54,10 @@ export class ApiClient { this.workspaceId = id; } + setOnUnauthorized(callback: (() => void) | null) { + this.onUnauthorized = callback; + } + private async fetch(path: string, init?: RequestInit): Promise { const rid = crypto.randomUUID().slice(0, 8); const start = Date.now(); @@ -75,6 +83,9 @@ export class ApiClient { }); if (!res.ok) { + if (res.status === 401 && this.onUnauthorized) { + this.onUnauthorized(); + } let message = `API error: ${res.status} ${res.statusText}`; try { const data = await res.json() as { error?: string }; @@ -99,10 +110,17 @@ export class ApiClient { } // Auth - async login(email: string, name?: string): Promise { - return this.fetch("/auth/login", { + async sendCode(email: string): Promise<{ message: string }> { + return this.fetch("/auth/send-code", { method: "POST", - body: JSON.stringify({ email, name }), + body: JSON.stringify({ email }), + }); + } + + async verifyCode(email: string, code: string): Promise { + return this.fetch("/auth/verify-code", { + method: "POST", + body: JSON.stringify({ email, code }), }); } @@ -362,4 +380,20 @@ export class ApiClient { body: JSON.stringify(data), }); } + + // Personal Access Tokens + async listPersonalAccessTokens(): Promise { + return this.fetch("/api/tokens"); + } + + async createPersonalAccessToken(data: CreatePersonalAccessTokenRequest): Promise { + return this.fetch("/api/tokens", { + method: "POST", + body: JSON.stringify(data), + }); + } + + async revokePersonalAccessToken(id: string): Promise { + await this.fetch(`/api/tokens/${id}`, { method: "DELETE" }); + } } diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index 35a898c7..91fcca57 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -55,6 +55,25 @@ export interface UpdateMemberRequest { role: MemberRole; } +// Personal Access Tokens +export interface PersonalAccessToken { + id: string; + name: string; + token_prefix: string; + expires_at: string | null; + last_used_at: string | null; + created_at: string; +} + +export interface CreatePersonalAccessTokenRequest { + name: string; + expires_in_days?: number; +} + +export interface CreatePersonalAccessTokenResponse extends PersonalAccessToken { + token: string; +} + // Pagination export interface PaginationParams { limit?: number; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c6145c3..9bc707e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,12 @@ importers: '@types/node': specifier: 'catalog:' version: 25.5.0 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 + pg: + specifier: ^8.20.0 + version: 8.20.0 turbo: specifier: ^2.5.0 version: 2.8.20 @@ -1451,6 +1457,9 @@ packages: '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -2864,6 +2873,40 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2901,6 +2944,22 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} @@ -3200,6 +3259,10 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -3613,6 +3676,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -4716,6 +4783,12 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/pg@8.20.0': + dependencies: + '@types/node': 25.5.0 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -6272,6 +6345,41 @@ snapshots: pathe@2.0.3: {} + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.12.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.13.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.20.0: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -6305,6 +6413,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + powershell-utils@0.1.0: {} pretty-format@27.5.1: @@ -6744,6 +6862,8 @@ snapshots: space-separated-tokens@2.0.2: {} + split2@4.2.0: {} + stackback@0.0.2: {} statuses@2.0.2: {} @@ -7108,6 +7228,8 @@ snapshots: xmlchars@2.2.0: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/server/cmd/multica/cmd_agent.go b/server/cmd/multica/cmd_agent.go index 64cf6b04..dc491337 100644 --- a/server/cmd/multica/cmd_agent.go +++ b/server/cmd/multica/cmd_agent.go @@ -57,12 +57,13 @@ func init() { func newAPIClient(cmd *cobra.Command) (*cli.APIClient, error) { serverURL := resolveServerURL(cmd) workspaceID := resolveWorkspaceID(cmd) + token := resolveToken() if serverURL == "" { return nil, fmt.Errorf("server URL not set: use --server-url flag, MULTICA_SERVER_URL env, or 'multica config set server_url '") } - return cli.NewAPIClient(serverURL, workspaceID), nil + return cli.NewAPIClient(serverURL, workspaceID, token), nil } func resolveServerURL(cmd *cobra.Command) string { diff --git a/server/cmd/multica/cmd_auth.go b/server/cmd/multica/cmd_auth.go new file mode 100644 index 00000000..09dd1883 --- /dev/null +++ b/server/cmd/multica/cmd_auth.go @@ -0,0 +1,140 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/multica-ai/multica/server/internal/cli" +) + +var authCmd = &cobra.Command{ + Use: "auth", + Short: "Manage authentication", +} + +var authLoginCmd = &cobra.Command{ + Use: "login", + Short: "Authenticate with a personal access token", + RunE: runAuthLogin, +} + +var authStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show current authentication status", + RunE: runAuthStatus, +} + +var authLogoutCmd = &cobra.Command{ + Use: "logout", + Short: "Remove stored authentication token", + RunE: runAuthLogout, +} + +func init() { + authCmd.AddCommand(authLoginCmd) + authCmd.AddCommand(authStatusCmd) + authCmd.AddCommand(authLogoutCmd) +} + +func resolveToken() string { + if v := strings.TrimSpace(os.Getenv("MULTICA_TOKEN")); v != "" { + return v + } + cfg, _ := cli.LoadCLIConfig() + return cfg.Token +} + +func runAuthLogin(cmd *cobra.Command, _ []string) error { + fmt.Print("Enter your personal access token: ") + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + return fmt.Errorf("no input") + } + token := strings.TrimSpace(scanner.Text()) + if token == "" { + return fmt.Errorf("token is required") + } + if !strings.HasPrefix(token, "mul_") { + return fmt.Errorf("invalid token format: must start with mul_") + } + + serverURL := resolveServerURL(cmd) + client := cli.NewAPIClient(serverURL, "", token) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var me struct { + Name string `json:"name"` + Email string `json:"email"` + } + if err := client.GetJSON(ctx, "/api/me", &me); err != nil { + return fmt.Errorf("invalid token: %w", err) + } + + cfg, _ := cli.LoadCLIConfig() + cfg.Token = token + if cfg.ServerURL == "" { + cfg.ServerURL = serverURL + } + if err := cli.SaveCLIConfig(cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Fprintf(os.Stderr, "Authenticated as %s (%s)\nToken saved to config.\n", me.Name, me.Email) + return nil +} + +func runAuthStatus(cmd *cobra.Command, _ []string) error { + token := resolveToken() + serverURL := resolveServerURL(cmd) + + if token == "" { + fmt.Fprintln(os.Stderr, "Not authenticated. Run 'multica auth login' to authenticate.") + return nil + } + + client := cli.NewAPIClient(serverURL, "", token) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var me struct { + Name string `json:"name"` + Email string `json:"email"` + } + if err := client.GetJSON(ctx, "/api/me", &me); err != nil { + fmt.Fprintf(os.Stderr, "Token is invalid or expired: %v\nRun 'multica auth login' to re-authenticate.\n", err) + return nil + } + + prefix := token + if len(prefix) > 12 { + prefix = prefix[:12] + "..." + } + + fmt.Fprintf(os.Stderr, "Server: %s\nUser: %s (%s)\nToken: %s\n", serverURL, me.Name, me.Email, prefix) + return nil +} + +func runAuthLogout(_ *cobra.Command, _ []string) error { + cfg, _ := cli.LoadCLIConfig() + if cfg.Token == "" { + fmt.Fprintln(os.Stderr, "Not authenticated.") + return nil + } + + cfg.Token = "" + if err := cli.SaveCLIConfig(cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Fprintln(os.Stderr, "Token removed. You are now logged out.") + return nil +} diff --git a/server/cmd/multica/main.go b/server/cmd/multica/main.go index 08c35a83..95d29807 100644 --- a/server/cmd/multica/main.go +++ b/server/cmd/multica/main.go @@ -24,6 +24,7 @@ func init() { rootCmd.PersistentFlags().String("server-url", "", "Multica server URL (env: MULTICA_SERVER_URL)") rootCmd.PersistentFlags().String("workspace-id", "", "Workspace ID (env: MULTICA_WORKSPACE_ID)") + rootCmd.AddCommand(authCmd) rootCmd.AddCommand(daemonCmd) rootCmd.AddCommand(agentCmd) rootCmd.AddCommand(runtimeCmd) diff --git a/server/cmd/server/integration_test.go b/server/cmd/server/integration_test.go index a0cee2f3..5c3a9558 100644 --- a/server/cmd/server/integration_test.go +++ b/server/cmd/server/integration_test.go @@ -71,28 +71,14 @@ func TestMain(m *testing.M) { router := NewRouter(pool, hub, bus) testServer = httptest.NewServer(router) - // Login to get a real JWT token - loginBody, _ := json.Marshal(map[string]string{ - "email": integrationTestEmail, - "name": integrationTestName, - }) - resp, err := http.Post(testServer.URL+"/auth/login", "application/json", bytes.NewReader(loginBody)) + // Generate a JWT token directly for the test user + testToken, err = generateTestJWT(testUserID, integrationTestEmail, integrationTestName) if err != nil { - fmt.Printf("Skipping: login failed: %v\n", err) + fmt.Printf("Failed to generate test JWT: %v\n", err) testServer.Close() pool.Close() - os.Exit(0) + os.Exit(1) } - defer resp.Body.Close() - - var loginResp struct { - Token string `json:"token"` - User struct { - ID string `json:"id"` - } `json:"user"` - } - json.NewDecoder(resp.Body).Decode(&loginResp) - testToken = loginResp.Token code := m.Run() @@ -202,6 +188,17 @@ func readJSON(t *testing.T, resp *http.Response, v any) { } } +func generateTestJWT(userID, email, name string) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": userID, + "email": email, + "name": name, + "exp": time.Now().Add(72 * time.Hour).Unix(), + "iat": time.Now().Unix(), + }) + return token.SignedString(jwtSecret) +} + // ---- Health ---- func TestHealth(t *testing.T) { @@ -224,27 +221,65 @@ func TestHealth(t *testing.T) { // ---- Auth ---- -func TestLoginAndGetMe(t *testing.T) { - // Login - body, _ := json.Marshal(map[string]string{ - "email": "integration-test@multica.ai", - "name": "Integration Tester", +func TestSendCodeAndVerify(t *testing.T) { + const email = "integration-sendcode@multica.ai" + ctx := context.Background() + + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email) + var userID string + err := testPool.QueryRow(ctx, `SELECT id FROM "user" WHERE email = $1`, email).Scan(&userID) + if err == nil { + rows, queryErr := testPool.Query(ctx, ` + SELECT w.id FROM workspace w JOIN member m ON m.workspace_id = w.id WHERE m.user_id = $1 + `, userID) + if queryErr == nil { + defer rows.Close() + for rows.Next() { + var wsID string + if rows.Scan(&wsID) == nil { + testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, wsID) + } + } + } + } + testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email) }) - resp, err := http.Post(testServer.URL+"/auth/login", "application/json", bytes.NewReader(body)) + + // Step 1: Send code + body, _ := json.Marshal(map[string]string{"email": email}) + resp, err := http.Post(testServer.URL+"/auth/send-code", "application/json", bytes.NewReader(body)) if err != nil { - t.Fatalf("login failed: %v", err) + t.Fatalf("send-code failed: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("send-code: expected 200, got %d", resp.StatusCode) + } + resp.Body.Close() + + // Read code from DB + var code string + err = testPool.QueryRow(ctx, `SELECT code FROM verification_code WHERE email = $1 ORDER BY created_at DESC LIMIT 1`, email).Scan(&code) + if err != nil { + t.Fatalf("failed to read code from DB: %v", err) } + // Step 2: Verify code + body, _ = json.Marshal(map[string]string{"email": email, "code": code}) + resp, err = http.Post(testServer.URL+"/auth/verify-code", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("verify-code failed: %v", err) + } if resp.StatusCode != 200 { - t.Fatalf("expected 200, got %d", resp.StatusCode) + respBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + t.Fatalf("verify-code: expected 200, got %d: %s", resp.StatusCode, respBody) } var loginResp struct { Token string `json:"token"` User struct { - ID string `json:"id"` Email string `json:"email"` - Name string `json:"name"` } `json:"user"` } readJSON(t, resp, &loginResp) @@ -252,83 +287,81 @@ func TestLoginAndGetMe(t *testing.T) { if loginResp.Token == "" { t.Fatal("expected non-empty token") } - if loginResp.User.Email != "integration-test@multica.ai" { - t.Fatalf("expected email 'integration-test@multica.ai', got '%s'", loginResp.User.Email) + if loginResp.User.Email != email { + t.Fatalf("expected email '%s', got '%s'", email, loginResp.User.Email) } - // Use token to call /api/me + // Verify the token works with /api/me req, _ := http.NewRequest("GET", testServer.URL+"/api/me", nil) req.Header.Set("Authorization", "Bearer "+loginResp.Token) meResp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("getMe failed: %v", err) } - if meResp.StatusCode != 200 { - t.Fatalf("expected 200, got %d", meResp.StatusCode) - } - - var me struct { - Email string `json:"email"` - Name string `json:"name"` - } - readJSON(t, meResp, &me) - if me.Email != "integration-test@multica.ai" { - t.Fatalf("expected email 'integration-test@multica.ai', got '%s'", me.Email) + t.Fatalf("getMe: expected 200, got %d", meResp.StatusCode) } + meResp.Body.Close() } -func TestLoginCreatesWorkspaceForNewUser(t *testing.T) { - const email = "new-integration-login@multica.ai" +func TestVerifyCodeCreatesWorkspaceForNewUser(t *testing.T) { + const email = "new-integration-verify@multica.ai" ctx := context.Background() t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email) var userID string err := testPool.QueryRow(ctx, `SELECT id FROM "user" WHERE email = $1`, email).Scan(&userID) if err == nil { rows, queryErr := testPool.Query(ctx, ` - SELECT w.id - FROM workspace w - JOIN member m ON m.workspace_id = w.id - WHERE m.user_id = $1 + SELECT w.id FROM workspace w JOIN member m ON m.workspace_id = w.id WHERE m.user_id = $1 `, userID) if queryErr == nil { defer rows.Close() for rows.Next() { - var workspaceID string - if scanErr := rows.Scan(&workspaceID); scanErr == nil { - _, _ = testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, workspaceID) + var wsID string + if rows.Scan(&wsID) == nil { + testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, wsID) } } } } - _, _ = testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email) + testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email) }) - _, _ = testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email) + testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email) - body, _ := json.Marshal(map[string]string{ - "email": email, - "name": "Jiayuan", - }) - resp, err := http.Post(testServer.URL+"/auth/login", "application/json", bytes.NewReader(body)) + // Send code + body, _ := json.Marshal(map[string]string{"email": email}) + resp, err := http.Post(testServer.URL+"/auth/send-code", "application/json", bytes.NewReader(body)) if err != nil { - t.Fatalf("login failed: %v", err) + t.Fatalf("send-code failed: %v", err) } - defer resp.Body.Close() + resp.Body.Close() + // Read code from DB + var code string + err = testPool.QueryRow(ctx, `SELECT code FROM verification_code WHERE email = $1 ORDER BY created_at DESC LIMIT 1`, email).Scan(&code) + if err != nil { + t.Fatalf("failed to read code from DB: %v", err) + } + + // Verify code + body, _ = json.Marshal(map[string]string{"email": email, "code": code}) + resp, err = http.Post(testServer.URL+"/auth/verify-code", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("verify-code failed: %v", err) + } if resp.StatusCode != http.StatusOK { - t.Fatalf("expected 200, got %d", resp.StatusCode) + t.Fatalf("verify-code: expected 200, got %d", resp.StatusCode) } var loginResp struct { Token string `json:"token"` } readJSON(t, resp, &loginResp) - if loginResp.Token == "" { - t.Fatal("expected non-empty token") - } + // Check workspace was created req, _ := http.NewRequest("GET", testServer.URL+"/api/workspaces", nil) req.Header.Set("Authorization", "Bearer "+loginResp.Token) workspacesResp, err := http.DefaultClient.Do(req) @@ -350,11 +383,8 @@ func TestLoginCreatesWorkspaceForNewUser(t *testing.T) { if len(workspaces) != 1 { t.Fatalf("expected 1 workspace, got %d", len(workspaces)) } - if workspaces[0].Name != "Jiayuan's Workspace" { - t.Fatalf("expected default workspace name %q, got %q", "Jiayuan's Workspace", workspaces[0].Name) - } - if workspaces[0].Slug == "" { - t.Fatal("expected non-empty workspace slug") + if !strings.Contains(workspaces[0].Name, "Workspace") { + t.Fatalf("expected workspace name containing 'Workspace', got %q", workspaces[0].Name) } } diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index c97a8e04..66c52614 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -16,6 +16,7 @@ import ( "github.com/multica-ai/multica/server/internal/handler" "github.com/multica-ai/multica/server/internal/middleware" "github.com/multica-ai/multica/server/internal/realtime" + "github.com/multica-ai/multica/server/internal/service" db "github.com/multica-ai/multica/server/pkg/db/generated" ) @@ -45,7 +46,8 @@ func allowedOrigins() []string { // NewRouter creates the fully-configured Chi router with all middleware and routes. func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Router { queries := db.New(pool) - h := handler.New(queries, pool, hub, bus) + emailSvc := service.NewEmailService() + h := handler.New(queries, pool, hub, bus, emailSvc) r := chi.NewRouter() @@ -74,7 +76,8 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route }) // Auth (public) - r.Post("/auth/login", h.Login) + r.Post("/auth/send-code", h.SendCode) + r.Post("/auth/verify-code", h.VerifyCode) // Daemon API routes (no user auth; daemon auth deferred to later) r.Route("/api/daemon", func(r chi.Router) { @@ -96,7 +99,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route // Protected API routes r.Group(func(r chi.Router) { - r.Use(middleware.Auth) + r.Use(middleware.Auth(queries)) // Auth r.Get("/api/me", h.GetMe) @@ -155,6 +158,13 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Post("/api/daemon/pairing-sessions/{token}/approve", h.ApproveDaemonPairingSession) + // Personal Access Tokens + r.Route("/api/tokens", func(r chi.Router) { + r.Get("/", h.ListPersonalAccessTokens) + r.Post("/", h.CreatePersonalAccessToken) + r.Delete("/{id}", h.RevokePersonalAccessToken) + }) + // Inbox r.Route("/api/inbox", func(r chi.Router) { r.Get("/", h.ListInbox) diff --git a/server/go.mod b/server/go.mod index 4979d7cd..05c33813 100644 --- a/server/go.mod +++ b/server/go.mod @@ -8,6 +8,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.8.0 + github.com/resend/resend-go/v2 v2.28.0 github.com/spf13/cobra v1.10.2 ) diff --git a/server/go.sum b/server/go.sum index ac4b0055..da00dcd6 100644 --- a/server/go.sum +++ b/server/go.sum @@ -24,6 +24,8 @@ github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/resend/resend-go/v2 v2.28.0 h1:ttM1/VZR4fApBv3xI1TneSKi1pbfFsVrq7fXFlHKtj4= +github.com/resend/resend-go/v2 v2.28.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= diff --git a/server/internal/auth/jwt.go b/server/internal/auth/jwt.go index 431d81c6..6ad212ff 100644 --- a/server/internal/auth/jwt.go +++ b/server/internal/auth/jwt.go @@ -1,6 +1,10 @@ package auth import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" "os" "sync" ) @@ -23,3 +27,18 @@ func JWTSecret() []byte { return jwtSecret } + +// GeneratePATToken creates a new personal access token: "mul_" + 40 random hex chars. +func GeneratePATToken() (string, error) { + b := make([]byte, 20) // 20 bytes = 40 hex chars + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate PAT token: %w", err) + } + return "mul_" + hex.EncodeToString(b), nil +} + +// HashToken returns the hex-encoded SHA-256 hash of a token string. +func HashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} diff --git a/server/internal/cli/client.go b/server/internal/cli/client.go index 550f16cb..548d078c 100644 --- a/server/internal/cli/client.go +++ b/server/internal/cli/client.go @@ -16,27 +16,36 @@ import ( type APIClient struct { BaseURL string WorkspaceID string + Token string HTTPClient *http.Client } // NewAPIClient creates a new API client for ctrl commands. -func NewAPIClient(baseURL, workspaceID string) *APIClient { +func NewAPIClient(baseURL, workspaceID, token string) *APIClient { return &APIClient{ BaseURL: strings.TrimRight(baseURL, "/"), WorkspaceID: workspaceID, + Token: token, HTTPClient: &http.Client{Timeout: 15 * time.Second}, } } +func (c *APIClient) setHeaders(req *http.Request) { + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + if c.WorkspaceID != "" { + req.Header.Set("X-Workspace-ID", c.WorkspaceID) + } +} + // GetJSON performs a GET request and decodes the JSON response. func (c *APIClient) GetJSON(ctx context.Context, path string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil) if err != nil { return err } - if c.WorkspaceID != "" { - req.Header.Set("X-Workspace-ID", c.WorkspaceID) - } + c.setHeaders(req) resp, err := c.HTTPClient.Do(req) if err != nil { @@ -60,9 +69,7 @@ func (c *APIClient) DeleteJSON(ctx context.Context, path string) error { if err != nil { return err } - if c.WorkspaceID != "" { - req.Header.Set("X-Workspace-ID", c.WorkspaceID) - } + c.setHeaders(req) resp, err := c.HTTPClient.Do(req) if err != nil { @@ -89,9 +96,7 @@ func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any) return err } req.Header.Set("Content-Type", "application/json") - if c.WorkspaceID != "" { - req.Header.Set("X-Workspace-ID", c.WorkspaceID) - } + c.setHeaders(req) resp, err := c.HTTPClient.Do(req) if err != nil { diff --git a/server/internal/cli/config.go b/server/internal/cli/config.go index bb4921dc..83b853f3 100644 --- a/server/internal/cli/config.go +++ b/server/internal/cli/config.go @@ -14,6 +14,7 @@ const defaultCLIConfigPath = ".multica/config.json" type CLIConfig struct { ServerURL string `json:"server_url,omitempty"` WorkspaceID string `json:"workspace_id,omitempty"` + Token string `json:"token,omitempty"` } // CLIConfigPath returns the default path for the CLI config file. diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go index 914ed927..4575fdea 100644 --- a/server/internal/handler/auth.go +++ b/server/internal/handler/auth.go @@ -2,9 +2,14 @@ package handler import ( "context" + "crypto/rand" + "crypto/subtle" + "encoding/binary" "encoding/json" + "fmt" "log/slog" "net/http" + "os" "strings" "time" @@ -35,16 +40,20 @@ func userToResponse(u db.User) UserResponse { } } -type LoginRequest struct { - Email string `json:"email"` - Name string `json:"name"` -} - type LoginResponse struct { Token string `json:"token"` User UserResponse `json:"user"` } +type SendCodeRequest struct { + Email string `json:"email"` +} + +type VerifyCodeRequest struct { + Email string `json:"email"` + Code string `json:"code"` +} + func defaultWorkspaceName(user db.User) string { name := strings.TrimSpace(user.Name) if name == "" { @@ -150,63 +159,16 @@ func (h *Handler) ensureUserWorkspace(ctx context.Context, user db.User) error { return tx.Commit(ctx) } -func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { - var req LoginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return +func generateCode() (string, error) { + var buf [4]byte + if _, err := rand.Read(buf[:]); err != nil { + return "", err } + n := binary.BigEndian.Uint32(buf[:]) % 1000000 + return fmt.Sprintf("%06d", n), nil +} - req.Email = strings.ToLower(strings.TrimSpace(req.Email)) - req.Name = strings.TrimSpace(req.Name) - - if req.Email == "" { - writeError(w, http.StatusBadRequest, "email is required") - return - } - - // Try to find existing user - user, err := h.Queries.GetUserByEmail(r.Context(), req.Email) - if err != nil { - if !isNotFound(err) { - slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...) - writeError(w, http.StatusInternalServerError, "failed to load user") - return - } - - // Create new user - name := req.Name - if name == "" { - name = req.Email - } - user, err = h.Queries.CreateUser(r.Context(), db.CreateUserParams{ - Name: name, - Email: req.Email, - }) - if err != nil { - slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...) - writeError(w, http.StatusInternalServerError, "failed to create user: "+err.Error()) - return - } - slog.Info("new user created", append(logger.RequestAttrs(r), "user_id", uuidToString(user.ID), "email", user.Email)...) - } else if req.Name != "" && req.Name != user.Name { - user, err = h.Queries.UpdateUser(r.Context(), db.UpdateUserParams{ - ID: user.ID, - Name: req.Name, - }) - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to update user") - return - } - } - - if err := h.ensureUserWorkspace(r.Context(), user); err != nil { - slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...) - writeError(w, http.StatusInternalServerError, "failed to provision workspace") - return - } - - // Generate JWT +func (h *Handler) issueJWT(user db.User) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": uuidToString(user.ID), "email": user.Email, @@ -214,8 +176,122 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { "exp": time.Now().Add(72 * time.Hour).Unix(), "iat": time.Now().Unix(), }) + return token.SignedString(auth.JWTSecret()) +} - tokenString, err := token.SignedString(auth.JWTSecret()) +func (h *Handler) findOrCreateUser(ctx context.Context, email string) (db.User, error) { + user, err := h.Queries.GetUserByEmail(ctx, email) + if err != nil { + if !isNotFound(err) { + return db.User{}, err + } + name := email + if at := strings.Index(email, "@"); at > 0 { + name = email[:at] + } + user, err = h.Queries.CreateUser(ctx, db.CreateUserParams{ + Name: name, + Email: email, + }) + if err != nil { + return db.User{}, err + } + } + return user, nil +} + +func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) { + var req SendCodeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + email := strings.ToLower(strings.TrimSpace(req.Email)) + if email == "" { + writeError(w, http.StatusBadRequest, "email is required") + return + } + + // Rate limit: max 1 code per 60 seconds per email + latest, err := h.Queries.GetLatestCodeByEmail(r.Context(), email) + if err == nil && time.Since(latest.CreatedAt.Time) < 60*time.Second { + writeError(w, http.StatusTooManyRequests, "please wait before requesting another code") + return + } + + code, err := generateCode() + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to generate code") + return + } + + _, err = h.Queries.CreateVerificationCode(r.Context(), db.CreateVerificationCodeParams{ + Email: email, + Code: code, + ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(10 * time.Minute), Valid: true}, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to store verification code") + return + } + + if err := h.EmailService.SendVerificationCode(email, code); err != nil { + writeError(w, http.StatusInternalServerError, "failed to send verification code") + return + } + + // Best-effort cleanup of expired codes + _ = h.Queries.DeleteExpiredVerificationCodes(r.Context()) + + writeJSON(w, http.StatusOK, map[string]string{"message": "Verification code sent"}) +} + +func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) { + var req VerifyCodeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + email := strings.ToLower(strings.TrimSpace(req.Email)) + code := strings.TrimSpace(req.Code) + + if email == "" || code == "" { + writeError(w, http.StatusBadRequest, "email and code are required") + return + } + + dbCode, err := h.Queries.GetLatestVerificationCode(r.Context(), email) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid or expired code") + return + } + + isMasterCode := code == "888888" && os.Getenv("APP_ENV") != "production" + if !isMasterCode && subtle.ConstantTimeCompare([]byte(code), []byte(dbCode.Code)) != 1 { + _ = h.Queries.IncrementVerificationCodeAttempts(r.Context(), dbCode.ID) + writeError(w, http.StatusBadRequest, "invalid or expired code") + return + } + + if err := h.Queries.MarkVerificationCodeUsed(r.Context(), dbCode.ID); err != nil { + writeError(w, http.StatusInternalServerError, "failed to verify code") + return + } + + user, err := h.findOrCreateUser(r.Context(), email) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create user") + return + } + + if err := h.ensureUserWorkspace(r.Context(), user); err != nil { + writeError(w, http.StatusInternalServerError, "failed to provision workspace") + return + } + + tokenString, err := h.issueJWT(user) if err != nil { slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...) writeError(w, http.StatusInternalServerError, "failed to generate token") diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index 156c1404..3b555c33 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -27,27 +27,29 @@ type dbExecutor interface { } type Handler struct { - Queries *db.Queries - DB dbExecutor - TxStarter txStarter - Hub *realtime.Hub - Bus *events.Bus - TaskService *service.TaskService + Queries *db.Queries + DB dbExecutor + TxStarter txStarter + Hub *realtime.Hub + Bus *events.Bus + TaskService *service.TaskService + EmailService *service.EmailService } -func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus) *Handler { +func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService) *Handler { var executor dbExecutor if candidate, ok := txStarter.(dbExecutor); ok { executor = candidate } return &Handler{ - Queries: queries, - DB: executor, - TxStarter: txStarter, - Hub: hub, - Bus: bus, - TaskService: service.NewTaskService(queries, hub, bus), + Queries: queries, + DB: executor, + TxStarter: txStarter, + Hub: hub, + Bus: bus, + TaskService: service.NewTaskService(queries, hub, bus), + EmailService: emailService, } } diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index 3251482b..25f1408b 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -15,6 +15,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/realtime" + "github.com/multica-ai/multica/server/internal/service" db "github.com/multica-ai/multica/server/pkg/db/generated" ) @@ -51,7 +52,8 @@ func TestMain(m *testing.M) { hub := realtime.NewHub() go hub.Run() bus := events.New() - testHandler = New(queries, pool, hub, bus) + emailSvc := service.NewEmailService() + testHandler = New(queries, pool, hub, bus, emailSvc) testPool = pool testUserID, testWorkspaceID, err = setupHandlerTestFixture(ctx, pool) @@ -360,33 +362,65 @@ func TestWorkspaceCRUD(t *testing.T) { } } -func TestAuthLogin(t *testing.T) { +func TestSendCode(t *testing.T) { w := httptest.NewRecorder() - body := map[string]string{"email": "test-handler@multica.ai", "name": "Test User"} + body := map[string]string{"email": "sendcode-test@multica.ai"} var buf bytes.Buffer json.NewEncoder(&buf).Encode(body) - req := httptest.NewRequest("POST", "/auth/login", &buf) + req := httptest.NewRequest("POST", "/auth/send-code", &buf) req.Header.Set("Content-Type", "application/json") - testHandler.Login(w, req) + testHandler.SendCode(w, req) if w.Code != http.StatusOK { - t.Fatalf("Login: expected 200, got %d: %s", w.Code, w.Body.String()) + t.Fatalf("SendCode: expected 200, got %d: %s", w.Code, w.Body.String()) } - var resp LoginResponse + var resp map[string]string json.NewDecoder(w.Body).Decode(&resp) - if resp.Token == "" { - t.Fatal("Login: expected non-empty token") + if resp["message"] == "" { + t.Fatal("SendCode: expected non-empty message") } - if resp.User.Email != "test-handler@multica.ai" { - t.Fatalf("Login: expected email 'test-handler@multica.ai', got '%s'", resp.User.Email) + + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM verification_code WHERE email = $1`, "sendcode-test@multica.ai") + }) +} + +func TestSendCodeRateLimit(t *testing.T) { + const email = "ratelimit-test@multica.ai" + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM verification_code WHERE email = $1`, email) + }) + + // First request should succeed + w := httptest.NewRecorder() + body := map[string]string{"email": email} + var buf bytes.Buffer + json.NewEncoder(&buf).Encode(body) + req := httptest.NewRequest("POST", "/auth/send-code", &buf) + req.Header.Set("Content-Type", "application/json") + testHandler.SendCode(w, req) + if w.Code != http.StatusOK { + t.Fatalf("SendCode (first): expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Second request within 60s should be rate limited + w = httptest.NewRecorder() + buf.Reset() + json.NewEncoder(&buf).Encode(body) + req = httptest.NewRequest("POST", "/auth/send-code", &buf) + req.Header.Set("Content-Type", "application/json") + testHandler.SendCode(w, req) + if w.Code != http.StatusTooManyRequests { + t.Fatalf("SendCode (second): expected 429, got %d: %s", w.Code, w.Body.String()) } } -func TestAuthLoginCreatesWorkspaceForNewUser(t *testing.T) { - const email = "new-handler-login@multica.ai" +func TestVerifyCode(t *testing.T) { + const email = "verify-test@multica.ai" ctx := context.Background() t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email) user, err := testHandler.Queries.GetUserByEmail(ctx, email) if err == nil { workspaces, listErr := testHandler.Queries.ListWorkspaces(ctx, user.ID) @@ -396,21 +430,166 @@ func TestAuthLoginCreatesWorkspaceForNewUser(t *testing.T) { } } } - _, _ = testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email) + testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email) }) - _, _ = testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email) - + // Send code first w := httptest.NewRecorder() - body := map[string]string{"email": email, "name": "Workspace Owner"} var buf bytes.Buffer - json.NewEncoder(&buf).Encode(body) - req := httptest.NewRequest("POST", "/auth/login", &buf) + json.NewEncoder(&buf).Encode(map[string]string{"email": email}) + req := httptest.NewRequest("POST", "/auth/send-code", &buf) req.Header.Set("Content-Type", "application/json") - - testHandler.Login(w, req) + testHandler.SendCode(w, req) if w.Code != http.StatusOK { - t.Fatalf("Login: expected 200, got %d: %s", w.Code, w.Body.String()) + t.Fatalf("SendCode: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Read code from DB + dbCode, err := testHandler.Queries.GetLatestVerificationCode(ctx, email) + if err != nil { + t.Fatalf("GetLatestVerificationCode: %v", err) + } + + // Verify with correct code + w = httptest.NewRecorder() + buf.Reset() + json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": dbCode.Code}) + req = httptest.NewRequest("POST", "/auth/verify-code", &buf) + req.Header.Set("Content-Type", "application/json") + testHandler.VerifyCode(w, req) + if w.Code != http.StatusOK { + t.Fatalf("VerifyCode: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp LoginResponse + json.NewDecoder(w.Body).Decode(&resp) + if resp.Token == "" { + t.Fatal("VerifyCode: expected non-empty token") + } + if resp.User.Email != email { + t.Fatalf("VerifyCode: expected email '%s', got '%s'", email, resp.User.Email) + } +} + +func TestVerifyCodeWrongCode(t *testing.T) { + const email = "wrong-code-test@multica.ai" + ctx := context.Background() + + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email) + }) + + // Send code + w := httptest.NewRecorder() + var buf bytes.Buffer + json.NewEncoder(&buf).Encode(map[string]string{"email": email}) + req := httptest.NewRequest("POST", "/auth/send-code", &buf) + req.Header.Set("Content-Type", "application/json") + testHandler.SendCode(w, req) + + // Verify with wrong code + w = httptest.NewRecorder() + buf.Reset() + json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "000000"}) + req = httptest.NewRequest("POST", "/auth/verify-code", &buf) + req.Header.Set("Content-Type", "application/json") + testHandler.VerifyCode(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("VerifyCode (wrong code): expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestVerifyCodeBruteForceProtection(t *testing.T) { + const email = "bruteforce-test@multica.ai" + ctx := context.Background() + + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email) + }) + + // Send code + w := httptest.NewRecorder() + var buf bytes.Buffer + json.NewEncoder(&buf).Encode(map[string]string{"email": email}) + req := httptest.NewRequest("POST", "/auth/send-code", &buf) + req.Header.Set("Content-Type", "application/json") + testHandler.SendCode(w, req) + if w.Code != http.StatusOK { + t.Fatalf("SendCode: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Read actual code so we can try it after lockout + dbCode, err := testHandler.Queries.GetLatestVerificationCode(ctx, email) + if err != nil { + t.Fatalf("GetLatestVerificationCode: %v", err) + } + + // Exhaust all 5 attempts with wrong codes + for i := 0; i < 5; i++ { + w = httptest.NewRecorder() + buf.Reset() + json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "000000"}) + req = httptest.NewRequest("POST", "/auth/verify-code", &buf) + req.Header.Set("Content-Type", "application/json") + testHandler.VerifyCode(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("attempt %d: expected 400, got %d", i+1, w.Code) + } + } + + // Now even the correct code should be rejected (code is locked out) + w = httptest.NewRecorder() + buf.Reset() + json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": dbCode.Code}) + req = httptest.NewRequest("POST", "/auth/verify-code", &buf) + req.Header.Set("Content-Type", "application/json") + testHandler.VerifyCode(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("after lockout: expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestVerifyCodeCreatesWorkspace(t *testing.T) { + const email = "workspace-verify-test@multica.ai" + ctx := context.Background() + + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email) + user, err := testHandler.Queries.GetUserByEmail(ctx, email) + if err == nil { + workspaces, listErr := testHandler.Queries.ListWorkspaces(ctx, user.ID) + if listErr == nil { + for _, workspace := range workspaces { + _ = testHandler.Queries.DeleteWorkspace(ctx, workspace.ID) + } + } + } + testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email) + }) + + // Send code + w := httptest.NewRecorder() + var buf bytes.Buffer + json.NewEncoder(&buf).Encode(map[string]string{"email": email}) + req := httptest.NewRequest("POST", "/auth/send-code", &buf) + req.Header.Set("Content-Type", "application/json") + testHandler.SendCode(w, req) + + // Read code from DB + dbCode, err := testHandler.Queries.GetLatestVerificationCode(ctx, email) + if err != nil { + t.Fatalf("GetLatestVerificationCode: %v", err) + } + + // Verify + w = httptest.NewRecorder() + buf.Reset() + json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": dbCode.Code}) + req = httptest.NewRequest("POST", "/auth/verify-code", &buf) + req.Header.Set("Content-Type", "application/json") + testHandler.VerifyCode(w, req) + if w.Code != http.StatusOK { + t.Fatalf("VerifyCode: expected 200, got %d: %s", w.Code, w.Body.String()) } user, err := testHandler.Queries.GetUserByEmail(ctx, email) @@ -428,9 +607,6 @@ func TestAuthLoginCreatesWorkspaceForNewUser(t *testing.T) { if !strings.Contains(workspaces[0].Name, "Workspace") { t.Fatalf("expected auto-created workspace name, got %q", workspaces[0].Name) } - if workspaces[0].Slug == "" { - t.Fatal("expected auto-created workspace slug") - } } func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) { diff --git a/server/internal/handler/personal_access_token.go b/server/internal/handler/personal_access_token.go new file mode 100644 index 00000000..7f6401ac --- /dev/null +++ b/server/internal/handler/personal_access_token.go @@ -0,0 +1,132 @@ +package handler + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/multica-ai/multica/server/internal/auth" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +type PersonalAccessTokenResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Prefix string `json:"token_prefix"` + ExpiresAt *string `json:"expires_at"` + LastUsedAt *string `json:"last_used_at"` + CreatedAt string `json:"created_at"` +} + +type CreatePATResponse struct { + PersonalAccessTokenResponse + Token string `json:"token"` +} + +func patToResponse(pat db.PersonalAccessToken) PersonalAccessTokenResponse { + return PersonalAccessTokenResponse{ + ID: uuidToString(pat.ID), + Name: pat.Name, + Prefix: pat.TokenPrefix, + ExpiresAt: timestampToPtr(pat.ExpiresAt), + LastUsedAt: timestampToPtr(pat.LastUsedAt), + CreatedAt: timestampToString(pat.CreatedAt), + } +} + +type CreatePATRequest struct { + Name string `json:"name"` + ExpiresInDays *int `json:"expires_in_days"` +} + +func (h *Handler) CreatePersonalAccessToken(w http.ResponseWriter, r *http.Request) { + userID, ok := requireUserID(w, r) + if !ok { + return + } + + var req CreatePATRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + + rawToken, err := auth.GeneratePATToken() + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to generate token") + return + } + + var expiresAt pgtype.Timestamptz + if req.ExpiresInDays != nil && *req.ExpiresInDays > 0 { + expiresAt = pgtype.Timestamptz{ + Time: time.Now().Add(time.Duration(*req.ExpiresInDays) * 24 * time.Hour), + Valid: true, + } + } + + prefix := rawToken + if len(prefix) > 12 { + prefix = prefix[:12] + } + + pat, err := h.Queries.CreatePersonalAccessToken(r.Context(), db.CreatePersonalAccessTokenParams{ + UserID: parseUUID(userID), + Name: req.Name, + TokenHash: auth.HashToken(rawToken), + TokenPrefix: prefix, + ExpiresAt: expiresAt, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create token") + return + } + + writeJSON(w, http.StatusCreated, CreatePATResponse{ + PersonalAccessTokenResponse: patToResponse(pat), + Token: rawToken, + }) +} + +func (h *Handler) ListPersonalAccessTokens(w http.ResponseWriter, r *http.Request) { + userID, ok := requireUserID(w, r) + if !ok { + return + } + + pats, err := h.Queries.ListPersonalAccessTokensByUser(r.Context(), parseUUID(userID)) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list tokens") + return + } + + resp := make([]PersonalAccessTokenResponse, len(pats)) + for i, pat := range pats { + resp[i] = patToResponse(pat) + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *Handler) RevokePersonalAccessToken(w http.ResponseWriter, r *http.Request) { + userID, ok := requireUserID(w, r) + if !ok { + return + } + + id := chi.URLParam(r, "id") + if err := h.Queries.RevokePersonalAccessToken(r.Context(), db.RevokePersonalAccessTokenParams{ + ID: parseUUID(id), + UserID: parseUUID(userID), + }); err != nil { + writeError(w, http.StatusInternalServerError, "failed to revoke token") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/internal/middleware/auth.go b/server/internal/middleware/auth.go index 8c3dbe30..16c36d4b 100644 --- a/server/internal/middleware/auth.go +++ b/server/internal/middleware/auth.go @@ -1,62 +1,94 @@ package middleware import ( + "context" "log/slog" "net/http" "strings" "github.com/golang-jwt/jwt/v5" + "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/auth" + "github.com/multica-ai/multica/server/internal/util" + db "github.com/multica-ai/multica/server/pkg/db/generated" ) -// Auth middleware validates JWT tokens from the Authorization header. +func uuidToString(u pgtype.UUID) string { return util.UUIDToString(u) } + +// Auth middleware validates JWT tokens or Personal Access Tokens from the Authorization header. // Sets X-User-ID and X-User-Email headers on the request for downstream handlers. -func Auth(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - slog.Debug("auth: missing authorization header", "path", r.URL.Path) - http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized) - return - } - - tokenString := strings.TrimPrefix(authHeader, "Bearer ") - if tokenString == authHeader { - slog.Debug("auth: invalid format", "path", r.URL.Path) - http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized) - return - } - - token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, jwt.ErrSignatureInvalid +func Auth(queries *db.Queries) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + slog.Debug("auth: missing authorization header", "path", r.URL.Path) + http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized) + return } - return auth.JWTSecret(), nil + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + if tokenString == authHeader { + slog.Debug("auth: invalid format", "path", r.URL.Path) + http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized) + return + } + + // PAT: tokens starting with "mul_" + if strings.HasPrefix(tokenString, "mul_") { + if queries == nil { + http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) + return + } + hash := auth.HashToken(tokenString) + pat, err := queries.GetPersonalAccessTokenByHash(r.Context(), hash) + if err != nil { + slog.Warn("auth: invalid PAT", "path", r.URL.Path, "error", err) + http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) + return + } + + r.Header.Set("X-User-ID", uuidToString(pat.UserID)) + + // Best-effort: update last_used_at + go queries.UpdatePersonalAccessTokenLastUsed(context.Background(), pat.ID) + + next.ServeHTTP(w, r) + return + } + + // JWT + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + return auth.JWTSecret(), nil + }) + if err != nil || !token.Valid { + slog.Warn("auth: invalid token", "path", r.URL.Path, "error", err) + http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + slog.Warn("auth: invalid claims", "path", r.URL.Path) + http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized) + return + } + + sub, ok := claims["sub"].(string) + if !ok || strings.TrimSpace(sub) == "" { + slog.Warn("auth: invalid claims", "path", r.URL.Path) + http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized) + return + } + r.Header.Set("X-User-ID", sub) + if email, ok := claims["email"].(string); ok { + r.Header.Set("X-User-Email", email) + } + + next.ServeHTTP(w, r) }) - if err != nil || !token.Valid { - slog.Warn("auth: invalid token", "path", r.URL.Path, "error", err) - http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) - return - } - - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - slog.Warn("auth: invalid claims", "path", r.URL.Path) - http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized) - return - } - - sub, ok := claims["sub"].(string) - if !ok || strings.TrimSpace(sub) == "" { - slog.Warn("auth: invalid claims", "path", r.URL.Path) - http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized) - return - } - r.Header.Set("X-User-ID", sub) - if email, ok := claims["email"].(string); ok { - r.Header.Set("X-User-Email", email) - } - - next.ServeHTTP(w, r) - }) + } } diff --git a/server/internal/middleware/auth_test.go b/server/internal/middleware/auth_test.go index 90a4663b..42ffd4b8 100644 --- a/server/internal/middleware/auth_test.go +++ b/server/internal/middleware/auth_test.go @@ -24,8 +24,13 @@ func validClaims() jwt.MapClaims { } } +// authMiddleware returns the Auth middleware with nil queries (JWT-only tests). +func authMiddleware(next http.Handler) http.Handler { + return Auth(nil)(next) +} + func TestAuth_MissingHeader(t *testing.T) { - handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("next handler should not be called") })) @@ -42,7 +47,7 @@ func TestAuth_MissingHeader(t *testing.T) { } func TestAuth_NoBearerPrefix(t *testing.T) { - handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("next handler should not be called") })) @@ -60,7 +65,7 @@ func TestAuth_NoBearerPrefix(t *testing.T) { } func TestAuth_InvalidToken(t *testing.T) { - handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("next handler should not be called") })) @@ -75,7 +80,7 @@ func TestAuth_InvalidToken(t *testing.T) { } func TestAuth_ExpiredToken(t *testing.T) { - handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("next handler should not be called") })) @@ -94,7 +99,7 @@ func TestAuth_ExpiredToken(t *testing.T) { } func TestAuth_WrongSecret(t *testing.T) { - handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("next handler should not be called") })) @@ -111,7 +116,7 @@ func TestAuth_WrongSecret(t *testing.T) { } func TestAuth_WrongSigningMethod(t *testing.T) { - handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("next handler should not be called") })) @@ -131,7 +136,7 @@ func TestAuth_WrongSigningMethod(t *testing.T) { func TestAuth_ValidToken(t *testing.T) { var gotUserID, gotEmail string - handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotUserID = r.Header.Get("X-User-ID") gotEmail = r.Header.Get("X-User-Email") w.WriteHeader(http.StatusOK) @@ -156,7 +161,7 @@ func TestAuth_ValidToken(t *testing.T) { } func TestAuth_MissingClaims(t *testing.T) { - handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("next handler should not be called") })) @@ -175,3 +180,18 @@ func TestAuth_MissingClaims(t *testing.T) { t.Fatalf("expected 401, got %d", w.Code) } } + +func TestAuth_InvalidPAT(t *testing.T) { + handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("next handler should not be called") + })) + + req := httptest.NewRequest("GET", "/api/me", nil) + req.Header.Set("Authorization", "Bearer mul_invalid_token_here") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} diff --git a/server/internal/service/email.go b/server/internal/service/email.go new file mode 100644 index 00000000..7ce61d4f --- /dev/null +++ b/server/internal/service/email.go @@ -0,0 +1,54 @@ +package service + +import ( + "fmt" + "os" + + "github.com/resend/resend-go/v2" +) + +type EmailService struct { + client *resend.Client + fromEmail string +} + +func NewEmailService() *EmailService { + apiKey := os.Getenv("RESEND_API_KEY") + from := os.Getenv("RESEND_FROM_EMAIL") + if from == "" { + from = "noreply@multica.ai" + } + + var client *resend.Client + if apiKey != "" { + client = resend.NewClient(apiKey) + } + + return &EmailService{ + client: client, + fromEmail: from, + } +} + +func (s *EmailService) SendVerificationCode(to, code string) error { + if s.client == nil { + fmt.Printf("[DEV] Verification code for %s: %s\n", to, code) + return nil + } + + params := &resend.SendEmailRequest{ + From: s.fromEmail, + To: []string{to}, + Subject: "Your Multica verification code", + Html: fmt.Sprintf( + `
+

Your verification code

+

%s

+

This code expires in 10 minutes.

+

If you didn't request this code, you can safely ignore this email.

+
`, code), + } + + _, err := s.client.Emails.Send(params) + return err +} diff --git a/server/migrations/009_verification_code.down.sql b/server/migrations/009_verification_code.down.sql new file mode 100644 index 00000000..3aa25cd8 --- /dev/null +++ b/server/migrations/009_verification_code.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS verification_code; diff --git a/server/migrations/009_verification_code.up.sql b/server/migrations/009_verification_code.up.sql new file mode 100644 index 00000000..86d6cdec --- /dev/null +++ b/server/migrations/009_verification_code.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE verification_code ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL, + code TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_verification_code_email ON verification_code(email, used, expires_at); diff --git a/server/migrations/010_verification_code_attempts.down.sql b/server/migrations/010_verification_code_attempts.down.sql new file mode 100644 index 00000000..35ec6192 --- /dev/null +++ b/server/migrations/010_verification_code_attempts.down.sql @@ -0,0 +1 @@ +ALTER TABLE verification_code DROP COLUMN attempts; diff --git a/server/migrations/010_verification_code_attempts.up.sql b/server/migrations/010_verification_code_attempts.up.sql new file mode 100644 index 00000000..91eeb36c --- /dev/null +++ b/server/migrations/010_verification_code_attempts.up.sql @@ -0,0 +1 @@ +ALTER TABLE verification_code ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0; diff --git a/server/migrations/011_personal_access_tokens.down.sql b/server/migrations/011_personal_access_tokens.down.sql new file mode 100644 index 00000000..72c56f08 --- /dev/null +++ b/server/migrations/011_personal_access_tokens.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS personal_access_token; diff --git a/server/migrations/011_personal_access_tokens.up.sql b/server/migrations/011_personal_access_tokens.up.sql new file mode 100644 index 00000000..18e8c5c8 --- /dev/null +++ b/server/migrations/011_personal_access_tokens.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE personal_access_token ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + name TEXT NOT NULL, + token_hash TEXT NOT NULL, + token_prefix TEXT NOT NULL, + expires_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + revoked BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_pat_user ON personal_access_token(user_id, revoked); +CREATE UNIQUE INDEX idx_pat_token_hash ON personal_access_token(token_hash); diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 2a16ccff..a7f99877 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -179,6 +179,18 @@ type Member struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type PersonalAccessToken struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + Name string `json:"name"` + TokenHash string `json:"token_hash"` + TokenPrefix string `json:"token_prefix"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + Revoked bool `json:"revoked"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Skill struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -209,6 +221,16 @@ type User struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type VerificationCode struct { + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + Code string `json:"code"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + Used bool `json:"used"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Attempts int32 `json:"attempts"` +} + type Workspace struct { ID pgtype.UUID `json:"id"` Name string `json:"name"` diff --git a/server/pkg/db/generated/personal_access_token.sql.go b/server/pkg/db/generated/personal_access_token.sql.go new file mode 100644 index 00000000..11128643 --- /dev/null +++ b/server/pkg/db/generated/personal_access_token.sql.go @@ -0,0 +1,137 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: personal_access_token.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createPersonalAccessToken = `-- name: CreatePersonalAccessToken :one +INSERT INTO personal_access_token (user_id, name, token_hash, token_prefix, expires_at) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, user_id, name, token_hash, token_prefix, expires_at, last_used_at, revoked, created_at +` + +type CreatePersonalAccessTokenParams struct { + UserID pgtype.UUID `json:"user_id"` + Name string `json:"name"` + TokenHash string `json:"token_hash"` + TokenPrefix string `json:"token_prefix"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` +} + +func (q *Queries) CreatePersonalAccessToken(ctx context.Context, arg CreatePersonalAccessTokenParams) (PersonalAccessToken, error) { + row := q.db.QueryRow(ctx, createPersonalAccessToken, + arg.UserID, + arg.Name, + arg.TokenHash, + arg.TokenPrefix, + arg.ExpiresAt, + ) + var i PersonalAccessToken + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.TokenHash, + &i.TokenPrefix, + &i.ExpiresAt, + &i.LastUsedAt, + &i.Revoked, + &i.CreatedAt, + ) + return i, err +} + +const getPersonalAccessTokenByHash = `-- name: GetPersonalAccessTokenByHash :one +SELECT id, user_id, name, token_hash, token_prefix, expires_at, last_used_at, revoked, created_at FROM personal_access_token +WHERE token_hash = $1 + AND revoked = FALSE + AND (expires_at IS NULL OR expires_at > now()) +` + +func (q *Queries) GetPersonalAccessTokenByHash(ctx context.Context, tokenHash string) (PersonalAccessToken, error) { + row := q.db.QueryRow(ctx, getPersonalAccessTokenByHash, tokenHash) + var i PersonalAccessToken + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.TokenHash, + &i.TokenPrefix, + &i.ExpiresAt, + &i.LastUsedAt, + &i.Revoked, + &i.CreatedAt, + ) + return i, err +} + +const listPersonalAccessTokensByUser = `-- name: ListPersonalAccessTokensByUser :many +SELECT id, user_id, name, token_hash, token_prefix, expires_at, last_used_at, revoked, created_at FROM personal_access_token +WHERE user_id = $1 + AND revoked = FALSE +ORDER BY created_at DESC +` + +func (q *Queries) ListPersonalAccessTokensByUser(ctx context.Context, userID pgtype.UUID) ([]PersonalAccessToken, error) { + rows, err := q.db.Query(ctx, listPersonalAccessTokensByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []PersonalAccessToken{} + for rows.Next() { + var i PersonalAccessToken + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.TokenHash, + &i.TokenPrefix, + &i.ExpiresAt, + &i.LastUsedAt, + &i.Revoked, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const revokePersonalAccessToken = `-- name: RevokePersonalAccessToken :exec +UPDATE personal_access_token +SET revoked = TRUE +WHERE id = $1 AND user_id = $2 +` + +type RevokePersonalAccessTokenParams struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` +} + +func (q *Queries) RevokePersonalAccessToken(ctx context.Context, arg RevokePersonalAccessTokenParams) error { + _, err := q.db.Exec(ctx, revokePersonalAccessToken, arg.ID, arg.UserID) + return err +} + +const updatePersonalAccessTokenLastUsed = `-- name: UpdatePersonalAccessTokenLastUsed :exec +UPDATE personal_access_token +SET last_used_at = now() +WHERE id = $1 +` + +func (q *Queries) UpdatePersonalAccessTokenLastUsed(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, updatePersonalAccessTokenLastUsed, id) + return err +} diff --git a/server/pkg/db/generated/verification_code.sql.go b/server/pkg/db/generated/verification_code.sql.go new file mode 100644 index 00000000..1731de77 --- /dev/null +++ b/server/pkg/db/generated/verification_code.sql.go @@ -0,0 +1,118 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: verification_code.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createVerificationCode = `-- name: CreateVerificationCode :one +INSERT INTO verification_code (email, code, expires_at) +VALUES ($1, $2, $3) +RETURNING id, email, code, expires_at, used, created_at, attempts +` + +type CreateVerificationCodeParams struct { + Email string `json:"email"` + Code string `json:"code"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` +} + +func (q *Queries) CreateVerificationCode(ctx context.Context, arg CreateVerificationCodeParams) (VerificationCode, error) { + row := q.db.QueryRow(ctx, createVerificationCode, arg.Email, arg.Code, arg.ExpiresAt) + var i VerificationCode + err := row.Scan( + &i.ID, + &i.Email, + &i.Code, + &i.ExpiresAt, + &i.Used, + &i.CreatedAt, + &i.Attempts, + ) + return i, err +} + +const deleteExpiredVerificationCodes = `-- name: DeleteExpiredVerificationCodes :exec +DELETE FROM verification_code +WHERE expires_at < now() - interval '1 hour' +` + +func (q *Queries) DeleteExpiredVerificationCodes(ctx context.Context) error { + _, err := q.db.Exec(ctx, deleteExpiredVerificationCodes) + return err +} + +const getLatestCodeByEmail = `-- name: GetLatestCodeByEmail :one +SELECT id, email, code, expires_at, used, created_at, attempts FROM verification_code +WHERE email = $1 +ORDER BY created_at DESC +LIMIT 1 +` + +func (q *Queries) GetLatestCodeByEmail(ctx context.Context, email string) (VerificationCode, error) { + row := q.db.QueryRow(ctx, getLatestCodeByEmail, email) + var i VerificationCode + err := row.Scan( + &i.ID, + &i.Email, + &i.Code, + &i.ExpiresAt, + &i.Used, + &i.CreatedAt, + &i.Attempts, + ) + return i, err +} + +const getLatestVerificationCode = `-- name: GetLatestVerificationCode :one +SELECT id, email, code, expires_at, used, created_at, attempts FROM verification_code +WHERE email = $1 + AND used = FALSE + AND expires_at > now() + AND attempts < 5 +ORDER BY created_at DESC +LIMIT 1 +` + +func (q *Queries) GetLatestVerificationCode(ctx context.Context, email string) (VerificationCode, error) { + row := q.db.QueryRow(ctx, getLatestVerificationCode, email) + var i VerificationCode + err := row.Scan( + &i.ID, + &i.Email, + &i.Code, + &i.ExpiresAt, + &i.Used, + &i.CreatedAt, + &i.Attempts, + ) + return i, err +} + +const incrementVerificationCodeAttempts = `-- name: IncrementVerificationCodeAttempts :exec +UPDATE verification_code +SET attempts = attempts + 1 +WHERE id = $1 +` + +func (q *Queries) IncrementVerificationCodeAttempts(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, incrementVerificationCodeAttempts, id) + return err +} + +const markVerificationCodeUsed = `-- name: MarkVerificationCodeUsed :exec +UPDATE verification_code +SET used = TRUE +WHERE id = $1 +` + +func (q *Queries) MarkVerificationCodeUsed(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, markVerificationCodeUsed, id) + return err +} diff --git a/server/pkg/db/queries/personal_access_token.sql b/server/pkg/db/queries/personal_access_token.sql new file mode 100644 index 00000000..2a6d2ffd --- /dev/null +++ b/server/pkg/db/queries/personal_access_token.sql @@ -0,0 +1,26 @@ +-- name: CreatePersonalAccessToken :one +INSERT INTO personal_access_token (user_id, name, token_hash, token_prefix, expires_at) +VALUES ($1, $2, $3, $4, $5) +RETURNING *; + +-- name: GetPersonalAccessTokenByHash :one +SELECT * FROM personal_access_token +WHERE token_hash = $1 + AND revoked = FALSE + AND (expires_at IS NULL OR expires_at > now()); + +-- name: ListPersonalAccessTokensByUser :many +SELECT * FROM personal_access_token +WHERE user_id = $1 + AND revoked = FALSE +ORDER BY created_at DESC; + +-- name: RevokePersonalAccessToken :exec +UPDATE personal_access_token +SET revoked = TRUE +WHERE id = $1 AND user_id = $2; + +-- name: UpdatePersonalAccessTokenLastUsed :exec +UPDATE personal_access_token +SET last_used_at = now() +WHERE id = $1; diff --git a/server/pkg/db/queries/verification_code.sql b/server/pkg/db/queries/verification_code.sql new file mode 100644 index 00000000..e52d09d6 --- /dev/null +++ b/server/pkg/db/queries/verification_code.sql @@ -0,0 +1,33 @@ +-- name: CreateVerificationCode :one +INSERT INTO verification_code (email, code, expires_at) +VALUES ($1, $2, $3) +RETURNING *; + +-- name: GetLatestVerificationCode :one +SELECT * FROM verification_code +WHERE email = $1 + AND used = FALSE + AND expires_at > now() + AND attempts < 5 +ORDER BY created_at DESC +LIMIT 1; + +-- name: MarkVerificationCodeUsed :exec +UPDATE verification_code +SET used = TRUE +WHERE id = $1; + +-- name: IncrementVerificationCodeAttempts :exec +UPDATE verification_code +SET attempts = attempts + 1 +WHERE id = $1; + +-- name: GetLatestCodeByEmail :one +SELECT * FROM verification_code +WHERE email = $1 +ORDER BY created_at DESC +LIMIT 1; + +-- name: DeleteExpiredVerificationCodes :exec +DELETE FROM verification_code +WHERE expires_at < now() - interval '1 hour';