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

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

View file

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