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

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

View 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
}

View file

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