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

@ -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"`

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

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