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:
parent
a997bcfec0
commit
5c9c2f69fd
42 changed files with 1889 additions and 311 deletions
|
|
@ -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 <url>'")
|
||||
}
|
||||
|
||||
return cli.NewAPIClient(serverURL, workspaceID), nil
|
||||
return cli.NewAPIClient(serverURL, workspaceID, token), nil
|
||||
}
|
||||
|
||||
func resolveServerURL(cmd *cobra.Command) string {
|
||||
|
|
|
|||
140
server/cmd/multica/cmd_auth.go
Normal file
140
server/cmd/multica/cmd_auth.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue