feat(auth): email verification login and personal access tokens

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
LinYushen 2026-03-26 14:32:30 +08:00 committed by GitHub
parent a997bcfec0
commit 5c9c2f69fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1889 additions and 311 deletions

View file

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

View file

@ -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)