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)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ require (
|
|||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/resend/resend-go/v2 v2.28.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
|
|||
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/resend/resend-go/v2 v2.28.0 h1:ttM1/VZR4fApBv3xI1TneSKi1pbfFsVrq7fXFlHKtj4=
|
||||
github.com/resend/resend-go/v2 v2.28.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
|
@ -23,3 +27,18 @@ func JWTSecret() []byte {
|
|||
|
||||
return jwtSecret
|
||||
}
|
||||
|
||||
// GeneratePATToken creates a new personal access token: "mul_" + 40 random hex chars.
|
||||
func GeneratePATToken() (string, error) {
|
||||
b := make([]byte, 20) // 20 bytes = 40 hex chars
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("generate PAT token: %w", err)
|
||||
}
|
||||
return "mul_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// HashToken returns the hex-encoded SHA-256 hash of a token string.
|
||||
func HashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -2,9 +2,14 @@ package handler
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -35,16 +40,20 @@ func userToResponse(u db.User) UserResponse {
|
|||
}
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
User UserResponse `json:"user"`
|
||||
}
|
||||
|
||||
type SendCodeRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type VerifyCodeRequest struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
func defaultWorkspaceName(user db.User) string {
|
||||
name := strings.TrimSpace(user.Name)
|
||||
if name == "" {
|
||||
|
|
@ -150,63 +159,16 @@ func (h *Handler) ensureUserWorkspace(ctx context.Context, user db.User) error {
|
|||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
func generateCode() (string, error) {
|
||||
var buf [4]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
n := binary.BigEndian.Uint32(buf[:]) % 1000000
|
||||
return fmt.Sprintf("%06d", n), nil
|
||||
}
|
||||
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
|
||||
if req.Email == "" {
|
||||
writeError(w, http.StatusBadRequest, "email is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Try to find existing user
|
||||
user, err := h.Queries.GetUserByEmail(r.Context(), req.Email)
|
||||
if err != nil {
|
||||
if !isNotFound(err) {
|
||||
slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
|
||||
// Create new user
|
||||
name := req.Name
|
||||
if name == "" {
|
||||
name = req.Email
|
||||
}
|
||||
user, err = h.Queries.CreateUser(r.Context(), db.CreateUserParams{
|
||||
Name: name,
|
||||
Email: req.Email,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create user: "+err.Error())
|
||||
return
|
||||
}
|
||||
slog.Info("new user created", append(logger.RequestAttrs(r), "user_id", uuidToString(user.ID), "email", user.Email)...)
|
||||
} else if req.Name != "" && req.Name != user.Name {
|
||||
user, err = h.Queries.UpdateUser(r.Context(), db.UpdateUserParams{
|
||||
ID: user.ID,
|
||||
Name: req.Name,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update user")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.ensureUserWorkspace(r.Context(), user); err != nil {
|
||||
slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to provision workspace")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
func (h *Handler) issueJWT(user db.User) (string, error) {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"sub": uuidToString(user.ID),
|
||||
"email": user.Email,
|
||||
|
|
@ -214,8 +176,122 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|||
"exp": time.Now().Add(72 * time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
return token.SignedString(auth.JWTSecret())
|
||||
}
|
||||
|
||||
tokenString, err := token.SignedString(auth.JWTSecret())
|
||||
func (h *Handler) findOrCreateUser(ctx context.Context, email string) (db.User, error) {
|
||||
user, err := h.Queries.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if !isNotFound(err) {
|
||||
return db.User{}, err
|
||||
}
|
||||
name := email
|
||||
if at := strings.Index(email, "@"); at > 0 {
|
||||
name = email[:at]
|
||||
}
|
||||
user, err = h.Queries.CreateUser(ctx, db.CreateUserParams{
|
||||
Name: name,
|
||||
Email: email,
|
||||
})
|
||||
if err != nil {
|
||||
return db.User{}, err
|
||||
}
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
|
||||
var req SendCodeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||
if email == "" {
|
||||
writeError(w, http.StatusBadRequest, "email is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limit: max 1 code per 60 seconds per email
|
||||
latest, err := h.Queries.GetLatestCodeByEmail(r.Context(), email)
|
||||
if err == nil && time.Since(latest.CreatedAt.Time) < 60*time.Second {
|
||||
writeError(w, http.StatusTooManyRequests, "please wait before requesting another code")
|
||||
return
|
||||
}
|
||||
|
||||
code, err := generateCode()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate code")
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.Queries.CreateVerificationCode(r.Context(), db.CreateVerificationCodeParams{
|
||||
Email: email,
|
||||
Code: code,
|
||||
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(10 * time.Minute), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to store verification code")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.EmailService.SendVerificationCode(email, code); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to send verification code")
|
||||
return
|
||||
}
|
||||
|
||||
// Best-effort cleanup of expired codes
|
||||
_ = h.Queries.DeleteExpiredVerificationCodes(r.Context())
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "Verification code sent"})
|
||||
}
|
||||
|
||||
func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) {
|
||||
var req VerifyCodeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||
code := strings.TrimSpace(req.Code)
|
||||
|
||||
if email == "" || code == "" {
|
||||
writeError(w, http.StatusBadRequest, "email and code are required")
|
||||
return
|
||||
}
|
||||
|
||||
dbCode, err := h.Queries.GetLatestVerificationCode(r.Context(), email)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid or expired code")
|
||||
return
|
||||
}
|
||||
|
||||
isMasterCode := code == "888888" && os.Getenv("APP_ENV") != "production"
|
||||
if !isMasterCode && subtle.ConstantTimeCompare([]byte(code), []byte(dbCode.Code)) != 1 {
|
||||
_ = h.Queries.IncrementVerificationCodeAttempts(r.Context(), dbCode.ID)
|
||||
writeError(w, http.StatusBadRequest, "invalid or expired code")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Queries.MarkVerificationCodeUsed(r.Context(), dbCode.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to verify code")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.findOrCreateUser(r.Context(), email)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create user")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.ensureUserWorkspace(r.Context(), user); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to provision workspace")
|
||||
return
|
||||
}
|
||||
|
||||
tokenString, err := h.issueJWT(user)
|
||||
if err != nil {
|
||||
slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
||||
|
|
|
|||
|
|
@ -27,27 +27,29 @@ type dbExecutor interface {
|
|||
}
|
||||
|
||||
type Handler struct {
|
||||
Queries *db.Queries
|
||||
DB dbExecutor
|
||||
TxStarter txStarter
|
||||
Hub *realtime.Hub
|
||||
Bus *events.Bus
|
||||
TaskService *service.TaskService
|
||||
Queries *db.Queries
|
||||
DB dbExecutor
|
||||
TxStarter txStarter
|
||||
Hub *realtime.Hub
|
||||
Bus *events.Bus
|
||||
TaskService *service.TaskService
|
||||
EmailService *service.EmailService
|
||||
}
|
||||
|
||||
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus) *Handler {
|
||||
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService) *Handler {
|
||||
var executor dbExecutor
|
||||
if candidate, ok := txStarter.(dbExecutor); ok {
|
||||
executor = candidate
|
||||
}
|
||||
|
||||
return &Handler{
|
||||
Queries: queries,
|
||||
DB: executor,
|
||||
TxStarter: txStarter,
|
||||
Hub: hub,
|
||||
Bus: bus,
|
||||
TaskService: service.NewTaskService(queries, hub, bus),
|
||||
Queries: queries,
|
||||
DB: executor,
|
||||
TxStarter: txStarter,
|
||||
Hub: hub,
|
||||
Bus: bus,
|
||||
TaskService: service.NewTaskService(queries, hub, bus),
|
||||
EmailService: emailService,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import (
|
|||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
"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"
|
||||
)
|
||||
|
||||
|
|
@ -51,7 +52,8 @@ func TestMain(m *testing.M) {
|
|||
hub := realtime.NewHub()
|
||||
go hub.Run()
|
||||
bus := events.New()
|
||||
testHandler = New(queries, pool, hub, bus)
|
||||
emailSvc := service.NewEmailService()
|
||||
testHandler = New(queries, pool, hub, bus, emailSvc)
|
||||
testPool = pool
|
||||
|
||||
testUserID, testWorkspaceID, err = setupHandlerTestFixture(ctx, pool)
|
||||
|
|
@ -360,33 +362,65 @@ func TestWorkspaceCRUD(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin(t *testing.T) {
|
||||
func TestSendCode(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
body := map[string]string{"email": "test-handler@multica.ai", "name": "Test User"}
|
||||
body := map[string]string{"email": "sendcode-test@multica.ai"}
|
||||
var buf bytes.Buffer
|
||||
json.NewEncoder(&buf).Encode(body)
|
||||
req := httptest.NewRequest("POST", "/auth/login", &buf)
|
||||
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testHandler.Login(w, req)
|
||||
testHandler.SendCode(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Login: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("SendCode: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp LoginResponse
|
||||
var resp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp.Token == "" {
|
||||
t.Fatal("Login: expected non-empty token")
|
||||
if resp["message"] == "" {
|
||||
t.Fatal("SendCode: expected non-empty message")
|
||||
}
|
||||
if resp.User.Email != "test-handler@multica.ai" {
|
||||
t.Fatalf("Login: expected email 'test-handler@multica.ai', got '%s'", resp.User.Email)
|
||||
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM verification_code WHERE email = $1`, "sendcode-test@multica.ai")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSendCodeRateLimit(t *testing.T) {
|
||||
const email = "ratelimit-test@multica.ai"
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM verification_code WHERE email = $1`, email)
|
||||
})
|
||||
|
||||
// First request should succeed
|
||||
w := httptest.NewRecorder()
|
||||
body := map[string]string{"email": email}
|
||||
var buf bytes.Buffer
|
||||
json.NewEncoder(&buf).Encode(body)
|
||||
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testHandler.SendCode(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("SendCode (first): expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Second request within 60s should be rate limited
|
||||
w = httptest.NewRecorder()
|
||||
buf.Reset()
|
||||
json.NewEncoder(&buf).Encode(body)
|
||||
req = httptest.NewRequest("POST", "/auth/send-code", &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testHandler.SendCode(w, req)
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("SendCode (second): expected 429, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginCreatesWorkspaceForNewUser(t *testing.T) {
|
||||
const email = "new-handler-login@multica.ai"
|
||||
func TestVerifyCode(t *testing.T) {
|
||||
const email = "verify-test@multica.ai"
|
||||
ctx := context.Background()
|
||||
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
|
||||
user, err := testHandler.Queries.GetUserByEmail(ctx, email)
|
||||
if err == nil {
|
||||
workspaces, listErr := testHandler.Queries.ListWorkspaces(ctx, user.ID)
|
||||
|
|
@ -396,21 +430,166 @@ func TestAuthLoginCreatesWorkspaceForNewUser(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
_, _ = 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)
|
||||
|
||||
// Send code first
|
||||
w := httptest.NewRecorder()
|
||||
body := map[string]string{"email": email, "name": "Workspace Owner"}
|
||||
var buf bytes.Buffer
|
||||
json.NewEncoder(&buf).Encode(body)
|
||||
req := httptest.NewRequest("POST", "/auth/login", &buf)
|
||||
json.NewEncoder(&buf).Encode(map[string]string{"email": email})
|
||||
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
testHandler.Login(w, req)
|
||||
testHandler.SendCode(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Login: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("SendCode: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Read code from DB
|
||||
dbCode, err := testHandler.Queries.GetLatestVerificationCode(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestVerificationCode: %v", err)
|
||||
}
|
||||
|
||||
// Verify with correct code
|
||||
w = httptest.NewRecorder()
|
||||
buf.Reset()
|
||||
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": dbCode.Code})
|
||||
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testHandler.VerifyCode(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("VerifyCode: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp LoginResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp.Token == "" {
|
||||
t.Fatal("VerifyCode: expected non-empty token")
|
||||
}
|
||||
if resp.User.Email != email {
|
||||
t.Fatalf("VerifyCode: expected email '%s', got '%s'", email, resp.User.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCodeWrongCode(t *testing.T) {
|
||||
const email = "wrong-code-test@multica.ai"
|
||||
ctx := context.Background()
|
||||
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
|
||||
})
|
||||
|
||||
// Send code
|
||||
w := httptest.NewRecorder()
|
||||
var buf bytes.Buffer
|
||||
json.NewEncoder(&buf).Encode(map[string]string{"email": email})
|
||||
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testHandler.SendCode(w, req)
|
||||
|
||||
// Verify with wrong code
|
||||
w = httptest.NewRecorder()
|
||||
buf.Reset()
|
||||
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "000000"})
|
||||
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testHandler.VerifyCode(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("VerifyCode (wrong code): expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCodeBruteForceProtection(t *testing.T) {
|
||||
const email = "bruteforce-test@multica.ai"
|
||||
ctx := context.Background()
|
||||
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
|
||||
})
|
||||
|
||||
// Send code
|
||||
w := httptest.NewRecorder()
|
||||
var buf bytes.Buffer
|
||||
json.NewEncoder(&buf).Encode(map[string]string{"email": email})
|
||||
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testHandler.SendCode(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("SendCode: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Read actual code so we can try it after lockout
|
||||
dbCode, err := testHandler.Queries.GetLatestVerificationCode(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestVerificationCode: %v", err)
|
||||
}
|
||||
|
||||
// Exhaust all 5 attempts with wrong codes
|
||||
for i := 0; i < 5; i++ {
|
||||
w = httptest.NewRecorder()
|
||||
buf.Reset()
|
||||
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "000000"})
|
||||
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testHandler.VerifyCode(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("attempt %d: expected 400, got %d", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Now even the correct code should be rejected (code is locked out)
|
||||
w = httptest.NewRecorder()
|
||||
buf.Reset()
|
||||
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": dbCode.Code})
|
||||
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testHandler.VerifyCode(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("after lockout: expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCodeCreatesWorkspace(t *testing.T) {
|
||||
const email = "workspace-verify-test@multica.ai"
|
||||
ctx := context.Background()
|
||||
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
|
||||
user, err := testHandler.Queries.GetUserByEmail(ctx, email)
|
||||
if err == nil {
|
||||
workspaces, listErr := testHandler.Queries.ListWorkspaces(ctx, user.ID)
|
||||
if listErr == nil {
|
||||
for _, workspace := range workspaces {
|
||||
_ = testHandler.Queries.DeleteWorkspace(ctx, workspace.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
|
||||
})
|
||||
|
||||
// Send code
|
||||
w := httptest.NewRecorder()
|
||||
var buf bytes.Buffer
|
||||
json.NewEncoder(&buf).Encode(map[string]string{"email": email})
|
||||
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testHandler.SendCode(w, req)
|
||||
|
||||
// Read code from DB
|
||||
dbCode, err := testHandler.Queries.GetLatestVerificationCode(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestVerificationCode: %v", err)
|
||||
}
|
||||
|
||||
// Verify
|
||||
w = httptest.NewRecorder()
|
||||
buf.Reset()
|
||||
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": dbCode.Code})
|
||||
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testHandler.VerifyCode(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("VerifyCode: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
user, err := testHandler.Queries.GetUserByEmail(ctx, email)
|
||||
|
|
@ -428,9 +607,6 @@ func TestAuthLoginCreatesWorkspaceForNewUser(t *testing.T) {
|
|||
if !strings.Contains(workspaces[0].Name, "Workspace") {
|
||||
t.Fatalf("expected auto-created workspace name, got %q", workspaces[0].Name)
|
||||
}
|
||||
if workspaces[0].Slug == "" {
|
||||
t.Fatal("expected auto-created workspace slug")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) {
|
||||
|
|
|
|||
132
server/internal/handler/personal_access_token.go
Normal file
132
server/internal/handler/personal_access_token.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/auth"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
type PersonalAccessTokenResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Prefix string `json:"token_prefix"`
|
||||
ExpiresAt *string `json:"expires_at"`
|
||||
LastUsedAt *string `json:"last_used_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type CreatePATResponse struct {
|
||||
PersonalAccessTokenResponse
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func patToResponse(pat db.PersonalAccessToken) PersonalAccessTokenResponse {
|
||||
return PersonalAccessTokenResponse{
|
||||
ID: uuidToString(pat.ID),
|
||||
Name: pat.Name,
|
||||
Prefix: pat.TokenPrefix,
|
||||
ExpiresAt: timestampToPtr(pat.ExpiresAt),
|
||||
LastUsedAt: timestampToPtr(pat.LastUsedAt),
|
||||
CreatedAt: timestampToString(pat.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
type CreatePATRequest struct {
|
||||
Name string `json:"name"`
|
||||
ExpiresInDays *int `json:"expires_in_days"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreatePersonalAccessToken(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req CreatePATRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
|
||||
rawToken, err := auth.GeneratePATToken()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
||||
return
|
||||
}
|
||||
|
||||
var expiresAt pgtype.Timestamptz
|
||||
if req.ExpiresInDays != nil && *req.ExpiresInDays > 0 {
|
||||
expiresAt = pgtype.Timestamptz{
|
||||
Time: time.Now().Add(time.Duration(*req.ExpiresInDays) * 24 * time.Hour),
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
prefix := rawToken
|
||||
if len(prefix) > 12 {
|
||||
prefix = prefix[:12]
|
||||
}
|
||||
|
||||
pat, err := h.Queries.CreatePersonalAccessToken(r.Context(), db.CreatePersonalAccessTokenParams{
|
||||
UserID: parseUUID(userID),
|
||||
Name: req.Name,
|
||||
TokenHash: auth.HashToken(rawToken),
|
||||
TokenPrefix: prefix,
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create token")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, CreatePATResponse{
|
||||
PersonalAccessTokenResponse: patToResponse(pat),
|
||||
Token: rawToken,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ListPersonalAccessTokens(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
pats, err := h.Queries.ListPersonalAccessTokensByUser(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list tokens")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]PersonalAccessTokenResponse, len(pats))
|
||||
for i, pat := range pats {
|
||||
resp[i] = patToResponse(pat)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) RevokePersonalAccessToken(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.Queries.RevokePersonalAccessToken(r.Context(), db.RevokePersonalAccessTokenParams{
|
||||
ID: parseUUID(id),
|
||||
UserID: parseUUID(userID),
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to revoke token")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
|
@ -1,62 +1,94 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/auth"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// Auth middleware validates JWT tokens from the Authorization header.
|
||||
func uuidToString(u pgtype.UUID) string { return util.UUIDToString(u) }
|
||||
|
||||
// Auth middleware validates JWT tokens or Personal Access Tokens from the Authorization header.
|
||||
// Sets X-User-ID and X-User-Email headers on the request for downstream handlers.
|
||||
func Auth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
slog.Debug("auth: missing authorization header", "path", r.URL.Path)
|
||||
http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if tokenString == authHeader {
|
||||
slog.Debug("auth: invalid format", "path", r.URL.Path)
|
||||
http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
func Auth(queries *db.Queries) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
slog.Debug("auth: missing authorization header", "path", r.URL.Path)
|
||||
http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
return auth.JWTSecret(), nil
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if tokenString == authHeader {
|
||||
slog.Debug("auth: invalid format", "path", r.URL.Path)
|
||||
http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// PAT: tokens starting with "mul_"
|
||||
if strings.HasPrefix(tokenString, "mul_") {
|
||||
if queries == nil {
|
||||
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
hash := auth.HashToken(tokenString)
|
||||
pat, err := queries.GetPersonalAccessTokenByHash(r.Context(), hash)
|
||||
if err != nil {
|
||||
slog.Warn("auth: invalid PAT", "path", r.URL.Path, "error", err)
|
||||
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
r.Header.Set("X-User-ID", uuidToString(pat.UserID))
|
||||
|
||||
// Best-effort: update last_used_at
|
||||
go queries.UpdatePersonalAccessTokenLastUsed(context.Background(), pat.ID)
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// JWT
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return auth.JWTSecret(), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
slog.Warn("auth: invalid token", "path", r.URL.Path, "error", err)
|
||||
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
slog.Warn("auth: invalid claims", "path", r.URL.Path)
|
||||
http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
sub, ok := claims["sub"].(string)
|
||||
if !ok || strings.TrimSpace(sub) == "" {
|
||||
slog.Warn("auth: invalid claims", "path", r.URL.Path)
|
||||
http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
r.Header.Set("X-User-ID", sub)
|
||||
if email, ok := claims["email"].(string); ok {
|
||||
r.Header.Set("X-User-Email", email)
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
slog.Warn("auth: invalid token", "path", r.URL.Path, "error", err)
|
||||
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
slog.Warn("auth: invalid claims", "path", r.URL.Path)
|
||||
http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
sub, ok := claims["sub"].(string)
|
||||
if !ok || strings.TrimSpace(sub) == "" {
|
||||
slog.Warn("auth: invalid claims", "path", r.URL.Path)
|
||||
http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
r.Header.Set("X-User-ID", sub)
|
||||
if email, ok := claims["email"].(string); ok {
|
||||
r.Header.Set("X-User-Email", email)
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,8 +24,13 @@ func validClaims() jwt.MapClaims {
|
|||
}
|
||||
}
|
||||
|
||||
// authMiddleware returns the Auth middleware with nil queries (JWT-only tests).
|
||||
func authMiddleware(next http.Handler) http.Handler {
|
||||
return Auth(nil)(next)
|
||||
}
|
||||
|
||||
func TestAuth_MissingHeader(t *testing.T) {
|
||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("next handler should not be called")
|
||||
}))
|
||||
|
||||
|
|
@ -42,7 +47,7 @@ func TestAuth_MissingHeader(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestAuth_NoBearerPrefix(t *testing.T) {
|
||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("next handler should not be called")
|
||||
}))
|
||||
|
||||
|
|
@ -60,7 +65,7 @@ func TestAuth_NoBearerPrefix(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestAuth_InvalidToken(t *testing.T) {
|
||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("next handler should not be called")
|
||||
}))
|
||||
|
||||
|
|
@ -75,7 +80,7 @@ func TestAuth_InvalidToken(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestAuth_ExpiredToken(t *testing.T) {
|
||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("next handler should not be called")
|
||||
}))
|
||||
|
||||
|
|
@ -94,7 +99,7 @@ func TestAuth_ExpiredToken(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestAuth_WrongSecret(t *testing.T) {
|
||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("next handler should not be called")
|
||||
}))
|
||||
|
||||
|
|
@ -111,7 +116,7 @@ func TestAuth_WrongSecret(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestAuth_WrongSigningMethod(t *testing.T) {
|
||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("next handler should not be called")
|
||||
}))
|
||||
|
||||
|
|
@ -131,7 +136,7 @@ func TestAuth_WrongSigningMethod(t *testing.T) {
|
|||
|
||||
func TestAuth_ValidToken(t *testing.T) {
|
||||
var gotUserID, gotEmail string
|
||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotUserID = r.Header.Get("X-User-ID")
|
||||
gotEmail = r.Header.Get("X-User-Email")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
|
@ -156,7 +161,7 @@ func TestAuth_ValidToken(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestAuth_MissingClaims(t *testing.T) {
|
||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("next handler should not be called")
|
||||
}))
|
||||
|
||||
|
|
@ -175,3 +180,18 @@ func TestAuth_MissingClaims(t *testing.T) {
|
|||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuth_InvalidPAT(t *testing.T) {
|
||||
handler := authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("next handler should not be called")
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer mul_invalid_token_here")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
server/internal/service/email.go
Normal file
54
server/internal/service/email.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/resend/resend-go/v2"
|
||||
)
|
||||
|
||||
type EmailService struct {
|
||||
client *resend.Client
|
||||
fromEmail string
|
||||
}
|
||||
|
||||
func NewEmailService() *EmailService {
|
||||
apiKey := os.Getenv("RESEND_API_KEY")
|
||||
from := os.Getenv("RESEND_FROM_EMAIL")
|
||||
if from == "" {
|
||||
from = "noreply@multica.ai"
|
||||
}
|
||||
|
||||
var client *resend.Client
|
||||
if apiKey != "" {
|
||||
client = resend.NewClient(apiKey)
|
||||
}
|
||||
|
||||
return &EmailService{
|
||||
client: client,
|
||||
fromEmail: from,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EmailService) SendVerificationCode(to, code string) error {
|
||||
if s.client == nil {
|
||||
fmt.Printf("[DEV] Verification code for %s: %s\n", to, code)
|
||||
return nil
|
||||
}
|
||||
|
||||
params := &resend.SendEmailRequest{
|
||||
From: s.fromEmail,
|
||||
To: []string{to},
|
||||
Subject: "Your Multica verification code",
|
||||
Html: fmt.Sprintf(
|
||||
`<div style="font-family: sans-serif; max-width: 400px; margin: 0 auto;">
|
||||
<h2>Your verification code</h2>
|
||||
<p style="font-size: 32px; font-weight: bold; letter-spacing: 8px; margin: 24px 0;">%s</p>
|
||||
<p>This code expires in 10 minutes.</p>
|
||||
<p style="color: #666; font-size: 14px;">If you didn't request this code, you can safely ignore this email.</p>
|
||||
</div>`, code),
|
||||
}
|
||||
|
||||
_, err := s.client.Emails.Send(params)
|
||||
return err
|
||||
}
|
||||
1
server/migrations/009_verification_code.down.sql
Normal file
1
server/migrations/009_verification_code.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS verification_code;
|
||||
10
server/migrations/009_verification_code.up.sql
Normal file
10
server/migrations/009_verification_code.up.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
CREATE TABLE verification_code (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_verification_code_email ON verification_code(email, used, expires_at);
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE verification_code DROP COLUMN attempts;
|
||||
1
server/migrations/010_verification_code_attempts.up.sql
Normal file
1
server/migrations/010_verification_code_attempts.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE verification_code ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0;
|
||||
1
server/migrations/011_personal_access_tokens.down.sql
Normal file
1
server/migrations/011_personal_access_tokens.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS personal_access_token;
|
||||
14
server/migrations/011_personal_access_tokens.up.sql
Normal file
14
server/migrations/011_personal_access_tokens.up.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
CREATE TABLE personal_access_token (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
token_prefix TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_pat_user ON personal_access_token(user_id, revoked);
|
||||
CREATE UNIQUE INDEX idx_pat_token_hash ON personal_access_token(token_hash);
|
||||
|
|
@ -179,6 +179,18 @@ type Member struct {
|
|||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type PersonalAccessToken struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
TokenHash string `json:"token_hash"`
|
||||
TokenPrefix string `json:"token_prefix"`
|
||||
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
|
||||
Revoked bool `json:"revoked"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Skill struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
|
|
@ -209,6 +221,16 @@ type User struct {
|
|||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type VerificationCode struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||
Used bool `json:"used"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
Attempts int32 `json:"attempts"`
|
||||
}
|
||||
|
||||
type Workspace struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
|
|
|||
137
server/pkg/db/generated/personal_access_token.sql.go
Normal file
137
server/pkg/db/generated/personal_access_token.sql.go
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: personal_access_token.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createPersonalAccessToken = `-- name: CreatePersonalAccessToken :one
|
||||
INSERT INTO personal_access_token (user_id, name, token_hash, token_prefix, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, user_id, name, token_hash, token_prefix, expires_at, last_used_at, revoked, created_at
|
||||
`
|
||||
|
||||
type CreatePersonalAccessTokenParams struct {
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
TokenHash string `json:"token_hash"`
|
||||
TokenPrefix string `json:"token_prefix"`
|
||||
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreatePersonalAccessToken(ctx context.Context, arg CreatePersonalAccessTokenParams) (PersonalAccessToken, error) {
|
||||
row := q.db.QueryRow(ctx, createPersonalAccessToken,
|
||||
arg.UserID,
|
||||
arg.Name,
|
||||
arg.TokenHash,
|
||||
arg.TokenPrefix,
|
||||
arg.ExpiresAt,
|
||||
)
|
||||
var i PersonalAccessToken
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.TokenHash,
|
||||
&i.TokenPrefix,
|
||||
&i.ExpiresAt,
|
||||
&i.LastUsedAt,
|
||||
&i.Revoked,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getPersonalAccessTokenByHash = `-- name: GetPersonalAccessTokenByHash :one
|
||||
SELECT id, user_id, name, token_hash, token_prefix, expires_at, last_used_at, revoked, created_at FROM personal_access_token
|
||||
WHERE token_hash = $1
|
||||
AND revoked = FALSE
|
||||
AND (expires_at IS NULL OR expires_at > now())
|
||||
`
|
||||
|
||||
func (q *Queries) GetPersonalAccessTokenByHash(ctx context.Context, tokenHash string) (PersonalAccessToken, error) {
|
||||
row := q.db.QueryRow(ctx, getPersonalAccessTokenByHash, tokenHash)
|
||||
var i PersonalAccessToken
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.TokenHash,
|
||||
&i.TokenPrefix,
|
||||
&i.ExpiresAt,
|
||||
&i.LastUsedAt,
|
||||
&i.Revoked,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listPersonalAccessTokensByUser = `-- name: ListPersonalAccessTokensByUser :many
|
||||
SELECT id, user_id, name, token_hash, token_prefix, expires_at, last_used_at, revoked, created_at FROM personal_access_token
|
||||
WHERE user_id = $1
|
||||
AND revoked = FALSE
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListPersonalAccessTokensByUser(ctx context.Context, userID pgtype.UUID) ([]PersonalAccessToken, error) {
|
||||
rows, err := q.db.Query(ctx, listPersonalAccessTokensByUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []PersonalAccessToken{}
|
||||
for rows.Next() {
|
||||
var i PersonalAccessToken
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.TokenHash,
|
||||
&i.TokenPrefix,
|
||||
&i.ExpiresAt,
|
||||
&i.LastUsedAt,
|
||||
&i.Revoked,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const revokePersonalAccessToken = `-- name: RevokePersonalAccessToken :exec
|
||||
UPDATE personal_access_token
|
||||
SET revoked = TRUE
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type RevokePersonalAccessTokenParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) RevokePersonalAccessToken(ctx context.Context, arg RevokePersonalAccessTokenParams) error {
|
||||
_, err := q.db.Exec(ctx, revokePersonalAccessToken, arg.ID, arg.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
const updatePersonalAccessTokenLastUsed = `-- name: UpdatePersonalAccessTokenLastUsed :exec
|
||||
UPDATE personal_access_token
|
||||
SET last_used_at = now()
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) UpdatePersonalAccessTokenLastUsed(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, updatePersonalAccessTokenLastUsed, id)
|
||||
return err
|
||||
}
|
||||
118
server/pkg/db/generated/verification_code.sql.go
Normal file
118
server/pkg/db/generated/verification_code.sql.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: verification_code.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createVerificationCode = `-- name: CreateVerificationCode :one
|
||||
INSERT INTO verification_code (email, code, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, code, expires_at, used, created_at, attempts
|
||||
`
|
||||
|
||||
type CreateVerificationCodeParams struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateVerificationCode(ctx context.Context, arg CreateVerificationCodeParams) (VerificationCode, error) {
|
||||
row := q.db.QueryRow(ctx, createVerificationCode, arg.Email, arg.Code, arg.ExpiresAt)
|
||||
var i VerificationCode
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.Code,
|
||||
&i.ExpiresAt,
|
||||
&i.Used,
|
||||
&i.CreatedAt,
|
||||
&i.Attempts,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteExpiredVerificationCodes = `-- name: DeleteExpiredVerificationCodes :exec
|
||||
DELETE FROM verification_code
|
||||
WHERE expires_at < now() - interval '1 hour'
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteExpiredVerificationCodes(ctx context.Context) error {
|
||||
_, err := q.db.Exec(ctx, deleteExpiredVerificationCodes)
|
||||
return err
|
||||
}
|
||||
|
||||
const getLatestCodeByEmail = `-- name: GetLatestCodeByEmail :one
|
||||
SELECT id, email, code, expires_at, used, created_at, attempts FROM verification_code
|
||||
WHERE email = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetLatestCodeByEmail(ctx context.Context, email string) (VerificationCode, error) {
|
||||
row := q.db.QueryRow(ctx, getLatestCodeByEmail, email)
|
||||
var i VerificationCode
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.Code,
|
||||
&i.ExpiresAt,
|
||||
&i.Used,
|
||||
&i.CreatedAt,
|
||||
&i.Attempts,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getLatestVerificationCode = `-- name: GetLatestVerificationCode :one
|
||||
SELECT id, email, code, expires_at, used, created_at, attempts FROM verification_code
|
||||
WHERE email = $1
|
||||
AND used = FALSE
|
||||
AND expires_at > now()
|
||||
AND attempts < 5
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetLatestVerificationCode(ctx context.Context, email string) (VerificationCode, error) {
|
||||
row := q.db.QueryRow(ctx, getLatestVerificationCode, email)
|
||||
var i VerificationCode
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.Code,
|
||||
&i.ExpiresAt,
|
||||
&i.Used,
|
||||
&i.CreatedAt,
|
||||
&i.Attempts,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const incrementVerificationCodeAttempts = `-- name: IncrementVerificationCodeAttempts :exec
|
||||
UPDATE verification_code
|
||||
SET attempts = attempts + 1
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) IncrementVerificationCodeAttempts(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, incrementVerificationCodeAttempts, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const markVerificationCodeUsed = `-- name: MarkVerificationCodeUsed :exec
|
||||
UPDATE verification_code
|
||||
SET used = TRUE
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) MarkVerificationCodeUsed(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, markVerificationCodeUsed, id)
|
||||
return err
|
||||
}
|
||||
26
server/pkg/db/queries/personal_access_token.sql
Normal file
26
server/pkg/db/queries/personal_access_token.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
-- name: CreatePersonalAccessToken :one
|
||||
INSERT INTO personal_access_token (user_id, name, token_hash, token_prefix, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetPersonalAccessTokenByHash :one
|
||||
SELECT * FROM personal_access_token
|
||||
WHERE token_hash = $1
|
||||
AND revoked = FALSE
|
||||
AND (expires_at IS NULL OR expires_at > now());
|
||||
|
||||
-- name: ListPersonalAccessTokensByUser :many
|
||||
SELECT * FROM personal_access_token
|
||||
WHERE user_id = $1
|
||||
AND revoked = FALSE
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: RevokePersonalAccessToken :exec
|
||||
UPDATE personal_access_token
|
||||
SET revoked = TRUE
|
||||
WHERE id = $1 AND user_id = $2;
|
||||
|
||||
-- name: UpdatePersonalAccessTokenLastUsed :exec
|
||||
UPDATE personal_access_token
|
||||
SET last_used_at = now()
|
||||
WHERE id = $1;
|
||||
33
server/pkg/db/queries/verification_code.sql
Normal file
33
server/pkg/db/queries/verification_code.sql
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
-- name: CreateVerificationCode :one
|
||||
INSERT INTO verification_code (email, code, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetLatestVerificationCode :one
|
||||
SELECT * FROM verification_code
|
||||
WHERE email = $1
|
||||
AND used = FALSE
|
||||
AND expires_at > now()
|
||||
AND attempts < 5
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: MarkVerificationCodeUsed :exec
|
||||
UPDATE verification_code
|
||||
SET used = TRUE
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: IncrementVerificationCodeAttempts :exec
|
||||
UPDATE verification_code
|
||||
SET attempts = attempts + 1
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetLatestCodeByEmail :one
|
||||
SELECT * FROM verification_code
|
||||
WHERE email = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: DeleteExpiredVerificationCodes :exec
|
||||
DELETE FROM verification_code
|
||||
WHERE expires_at < now() - interval '1 hour';
|
||||
Loading…
Add table
Add a link
Reference in a new issue