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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue