Initial commit: img-infinite-canvas AI design workbench MVP
Moteva-style AI design workbench replica. Home prompt creates a project; the project page provides an infinite canvas with node drag, zoom/pan, a design chat panel, history replay, image asset upload, and project save/regenerate. - Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage; sky-valley/pi agent runtime adapter. - Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n, shadcn/ui components, auth (OTP/Turnstile/Google/WeChat). - Deploy: Docker Compose and k3s manifests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"img_infinite_canvas/internal/domain/design"
|
||||
)
|
||||
|
||||
type CodeCache interface {
|
||||
Get(ctx context.Context, key string, target any) (bool, error)
|
||||
Set(ctx context.Context, key string, value any, ttl time.Duration) error
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
type CacheCodeStore struct {
|
||||
cache CodeCache
|
||||
ttl time.Duration
|
||||
prefix string
|
||||
}
|
||||
|
||||
type codeIndex struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func NewCacheCodeStore(cache CodeCache, ttl time.Duration) *CacheCodeStore {
|
||||
if ttl <= 0 {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
return &CacheCodeStore{
|
||||
cache: cache,
|
||||
ttl: ttl,
|
||||
prefix: "auth:code",
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CacheCodeStore) CreateVerificationCode(ctx context.Context, code VerificationCode) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if code.ExpiresAt.IsZero() {
|
||||
code.ExpiresAt = time.Now().Add(s.ttl)
|
||||
}
|
||||
key := s.activeKey(code.Region, code.Channel, code.Target, code.Purpose)
|
||||
ttl := s.remainingTTL(code)
|
||||
if err := s.cache.Set(ctx, key, code, ttl); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.cache.Set(ctx, s.idKey(code.ID), codeIndex{Key: key}, ttl)
|
||||
}
|
||||
|
||||
func (s *CacheCodeStore) FindActiveVerificationCode(ctx context.Context, region string, channel string, target string, purpose string) (VerificationCode, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return VerificationCode{}, err
|
||||
}
|
||||
key := s.activeKey(region, channel, target, purpose)
|
||||
var code VerificationCode
|
||||
found, err := s.cache.Get(ctx, key, &code)
|
||||
if err != nil {
|
||||
return VerificationCode{}, err
|
||||
}
|
||||
if !found || code.ID == "" {
|
||||
return VerificationCode{}, design.ErrNotFound
|
||||
}
|
||||
if code.ConsumedAt != nil || time.Now().After(code.ExpiresAt) {
|
||||
_ = s.cache.Delete(ctx, key)
|
||||
_ = s.cache.Delete(ctx, s.idKey(code.ID))
|
||||
return VerificationCode{}, design.ErrNotFound
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func (s *CacheCodeStore) MarkVerificationCodeConsumed(ctx context.Context, id string, _ time.Time) error {
|
||||
index, err := s.findIndex(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.cache.Delete(ctx, index.Key); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.cache.Delete(ctx, s.idKey(id))
|
||||
}
|
||||
|
||||
func (s *CacheCodeStore) IncrementVerificationCodeAttempts(ctx context.Context, id string) error {
|
||||
index, err := s.findIndex(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var code VerificationCode
|
||||
found, err := s.cache.Get(ctx, index.Key, &code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || code.ID == "" {
|
||||
_ = s.cache.Delete(ctx, s.idKey(id))
|
||||
return design.ErrNotFound
|
||||
}
|
||||
code.Attempts++
|
||||
return s.cache.Set(ctx, index.Key, code, s.remainingTTL(code))
|
||||
}
|
||||
|
||||
func (s *CacheCodeStore) findIndex(ctx context.Context, id string) (codeIndex, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return codeIndex{}, err
|
||||
}
|
||||
var index codeIndex
|
||||
found, err := s.cache.Get(ctx, s.idKey(id), &index)
|
||||
if err != nil {
|
||||
return codeIndex{}, err
|
||||
}
|
||||
if !found || index.Key == "" {
|
||||
return codeIndex{}, design.ErrNotFound
|
||||
}
|
||||
return index, nil
|
||||
}
|
||||
|
||||
func (s *CacheCodeStore) remainingTTL(code VerificationCode) time.Duration {
|
||||
if !code.ExpiresAt.IsZero() {
|
||||
ttl := time.Until(code.ExpiresAt)
|
||||
if ttl > 0 {
|
||||
return ttl
|
||||
}
|
||||
}
|
||||
return s.ttl
|
||||
}
|
||||
|
||||
func (s *CacheCodeStore) activeKey(region string, channel string, target string, purpose string) string {
|
||||
raw := strings.Join([]string{
|
||||
strings.TrimSpace(region),
|
||||
strings.TrimSpace(channel),
|
||||
strings.TrimSpace(target),
|
||||
strings.TrimSpace(purpose),
|
||||
}, "\x00")
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return s.prefix + ":active:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *CacheCodeStore) idKey(id string) string {
|
||||
return s.prefix + ":id:" + strings.TrimSpace(id)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
loginEmailFromName = "hello"
|
||||
loginEmailFromEmail = "hello@auth.moteva.local"
|
||||
loginEmailBrandName = "Moteva"
|
||||
)
|
||||
|
||||
type EmailTemplateOptions struct {
|
||||
FromName string
|
||||
FromEmail string
|
||||
BrandName string
|
||||
}
|
||||
|
||||
func buildLoginEmail(toEmail string, code string, opts EmailTemplateOptions) *EmailMessage {
|
||||
toEmail = normalizeEmail(toEmail)
|
||||
code = strings.TrimSpace(code)
|
||||
fromName := fallbackString(opts.FromName, loginEmailFromName)
|
||||
fromEmail := fallbackString(opts.FromEmail, loginEmailFromEmail)
|
||||
brandName := fallbackString(opts.BrandName, loginEmailBrandName)
|
||||
return &EmailMessage{
|
||||
FromName: fromName,
|
||||
FromEmail: fromEmail,
|
||||
ToEmail: toEmail,
|
||||
Subject: "Welcome to " + brandName + "!",
|
||||
Code: code,
|
||||
Text: fmt.Sprintf(
|
||||
"Hi,\n\nWelcome to us! Enter this code within the next 10 minutes to log in:\n\n%s\n\nPlease ignore this email if this wasn't you trying to create a %s account.\n\nThe %s Team",
|
||||
code,
|
||||
brandName,
|
||||
brandName,
|
||||
),
|
||||
HTML: loginEmailHTML(code, brandName),
|
||||
}
|
||||
}
|
||||
|
||||
func loginEmailHTML(code string, brandName string) string {
|
||||
escapedCode := html.EscapeString(code)
|
||||
escapedBrand := html.EscapeString(brandName)
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<body style="margin:0;padding:28px;background:#fff;font-family:Arial,Helvetica,sans-serif;color:#141922;">
|
||||
<main style="max-width:1160px;background:#f8f8f8;border-radius:36px;overflow:hidden;">
|
||||
<section style="padding:64px 40px 84px;">
|
||||
<div style="font-size:48px;font-weight:800;letter-spacing:-1px;margin-bottom:72px;">
|
||||
<span style="display:inline-grid;place-items:center;width:64px;height:64px;border-radius:50%;background:#050505;color:#fff;font-size:28px;margin-right:16px;vertical-align:middle;">M</span>
|
||||
<span style="vertical-align:middle;">` + escapedBrand + `</span>
|
||||
</div>
|
||||
<p style="font-size:30px;font-weight:800;margin:0 0 30px;">Hi,</p>
|
||||
<p style="font-size:30px;line-height:1.4;margin:0 0 110px;">Welcome to us! Enter this code within the next 10 minutes to log in:</p>
|
||||
<div style="text-align:center;font-size:96px;line-height:1;font-weight:900;text-decoration:underline;letter-spacing:2px;margin-bottom:110px;">` + escapedCode + `</div>
|
||||
<p style="font-size:29px;line-height:1.4;margin:0 0 32px;">Please ignore this email if this wasn't you trying to create a ` + escapedBrand + ` account.</p>
|
||||
<p style="font-size:30px;font-weight:900;margin:0;">The ` + escapedBrand + ` Team</p>
|
||||
</section>
|
||||
<footer style="background:#000;color:#fff;padding:44px 40px;">
|
||||
<div style="color:#50e6ff;font-size:25px;font-weight:900;letter-spacing:.4px;margin-bottom:28px;">THE DESIGN AGENT WHO CREATES BY YOUR SIDE.</div>
|
||||
<div style="font-size:22px;font-weight:800;line-height:1.35;">DIVE INTO THE AUTO-DESIGN AGENT<br/>CRAFTED FOR LIMITLESS CREATIVITY.</div>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
func fallbackString(value string, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"img_infinite_canvas/internal/domain/design"
|
||||
)
|
||||
|
||||
type MemoryStore struct {
|
||||
mu sync.RWMutex
|
||||
users map[string]User
|
||||
codes map[string]VerificationCode
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{
|
||||
users: make(map[string]User),
|
||||
codes: make(map[string]VerificationCode),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MemoryStore) FindUserByID(ctx context.Context, id string) (User, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
user, ok := s.users[strings.TrimSpace(id)]
|
||||
if !ok {
|
||||
return User{}, design.ErrNotFound
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) FindUserByPhone(ctx context.Context, countryCode string, phone string) (User, error) {
|
||||
return s.find(ctx, func(user User) bool {
|
||||
return user.CountryCode == normalizeCountryCode(countryCode) && user.Phone == normalizePhone(phone)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MemoryStore) FindUserByEmail(ctx context.Context, email string) (User, error) {
|
||||
email = normalizeEmail(email)
|
||||
return s.find(ctx, func(user User) bool {
|
||||
return normalizeEmail(user.Email) == email
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MemoryStore) FindUserByGoogleSubject(ctx context.Context, subject string) (User, error) {
|
||||
subject = strings.TrimSpace(subject)
|
||||
return s.find(ctx, func(user User) bool {
|
||||
return user.GoogleSubject == subject
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MemoryStore) FindUserByWechat(ctx context.Context, openID string, unionID string) (User, error) {
|
||||
openID = strings.TrimSpace(openID)
|
||||
unionID = strings.TrimSpace(unionID)
|
||||
return s.find(ctx, func(user User) bool {
|
||||
if unionID != "" && user.WechatUnionID == unionID {
|
||||
return true
|
||||
}
|
||||
return openID != "" && user.WechatOpenID == openID
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MemoryStore) UpsertUser(ctx context.Context, user User) (User, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
user.ID = normalizeUUID(user.ID)
|
||||
user.Email = normalizeEmail(user.Email)
|
||||
user.Phone = normalizePhone(user.Phone)
|
||||
user.CountryCode = normalizeCountryCode(user.CountryCode)
|
||||
user.Name = strings.TrimSpace(user.Name)
|
||||
user.AvatarURL = strings.TrimSpace(user.AvatarURL)
|
||||
user.Status = activeStatus(user.Status)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.users[user.ID] = user
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) UpdateUserProfile(ctx context.Context, userID string, patch ProfilePatch) (User, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
userID = normalizeUUID(userID)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
user, ok := s.users[userID]
|
||||
if !ok {
|
||||
return User{}, design.ErrNotFound
|
||||
}
|
||||
if patch.Name != nil {
|
||||
user.Name = strings.TrimSpace(*patch.Name)
|
||||
}
|
||||
if patch.AvatarURL != nil {
|
||||
user.AvatarURL = strings.TrimSpace(*patch.AvatarURL)
|
||||
}
|
||||
s.users[user.ID] = user
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) CreateVerificationCode(ctx context.Context, code VerificationCode) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.codes[code.ID] = code
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) FindActiveVerificationCode(ctx context.Context, region string, channel string, target string, purpose string) (VerificationCode, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return VerificationCode{}, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var latest VerificationCode
|
||||
for _, code := range s.codes {
|
||||
if code.Region != region || code.Channel != channel || code.Target != target || code.Purpose != purpose {
|
||||
continue
|
||||
}
|
||||
if code.ConsumedAt != nil {
|
||||
continue
|
||||
}
|
||||
if latest.ID == "" || code.ExpiresAt.After(latest.ExpiresAt) {
|
||||
latest = code
|
||||
}
|
||||
}
|
||||
if latest.ID == "" {
|
||||
return VerificationCode{}, design.ErrNotFound
|
||||
}
|
||||
return latest, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) MarkVerificationCodeConsumed(ctx context.Context, id string, consumedAt time.Time) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
code, ok := s.codes[id]
|
||||
if !ok {
|
||||
return design.ErrNotFound
|
||||
}
|
||||
code.ConsumedAt = &consumedAt
|
||||
s.codes[id] = code
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) IncrementVerificationCodeAttempts(ctx context.Context, id string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
code, ok := s.codes[id]
|
||||
if !ok {
|
||||
return design.ErrNotFound
|
||||
}
|
||||
code.Attempts++
|
||||
s.codes[id] = code
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) find(ctx context.Context, match func(User) bool) (User, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, user := range s.users {
|
||||
if match(user) {
|
||||
return user, nil
|
||||
}
|
||||
}
|
||||
return User{}, design.ErrNotFound
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"img_infinite_canvas/internal/domain/design"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type PostgresStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPostgresStore(ctx context.Context, dataSource string) (*PostgresStore, error) {
|
||||
pool, err := pgxpool.New(ctx, dataSource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &PostgresStore{pool: pool}, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) FindUserByID(ctx context.Context, id string) (User, error) {
|
||||
return s.findUser(ctx, `SELECT id, region, phone_country_code, phone, email, name, avatar_url, password_hash, wechat_open_id, wechat_union_id, google_subject, status FROM users WHERE id = $1`, toPGUUID(id))
|
||||
}
|
||||
|
||||
func (s *PostgresStore) FindUserByPhone(ctx context.Context, countryCode string, phone string) (User, error) {
|
||||
return s.findUser(ctx, `SELECT id, region, phone_country_code, phone, email, name, avatar_url, password_hash, wechat_open_id, wechat_union_id, google_subject, status FROM users WHERE phone_country_code = $1 AND phone = $2`, normalizeCountryCode(countryCode), normalizePhone(phone))
|
||||
}
|
||||
|
||||
func (s *PostgresStore) FindUserByEmail(ctx context.Context, email string) (User, error) {
|
||||
return s.findUser(ctx, `SELECT id, region, phone_country_code, phone, email, name, avatar_url, password_hash, wechat_open_id, wechat_union_id, google_subject, status FROM users WHERE LOWER(email) = LOWER($1)`, normalizeEmail(email))
|
||||
}
|
||||
|
||||
func (s *PostgresStore) FindUserByGoogleSubject(ctx context.Context, subject string) (User, error) {
|
||||
return s.findUser(ctx, `SELECT id, region, phone_country_code, phone, email, name, avatar_url, password_hash, wechat_open_id, wechat_union_id, google_subject, status FROM users WHERE google_subject = $1`, subject)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) FindUserByWechat(ctx context.Context, openID string, unionID string) (User, error) {
|
||||
if unionID != "" {
|
||||
return s.findUser(ctx, `
|
||||
SELECT id, region, phone_country_code, phone, email, name, avatar_url, password_hash, wechat_open_id, wechat_union_id, google_subject, status
|
||||
FROM users
|
||||
WHERE wechat_union_id = $1 OR wechat_open_id = $2
|
||||
ORDER BY CASE WHEN wechat_union_id = $1 THEN 0 ELSE 1 END
|
||||
LIMIT 1`, unionID, openID)
|
||||
}
|
||||
return s.findUser(ctx, `SELECT id, region, phone_country_code, phone, email, name, avatar_url, password_hash, wechat_open_id, wechat_union_id, google_subject, status FROM users WHERE wechat_open_id = $1`, openID)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UpsertUser(ctx context.Context, user User) (User, error) {
|
||||
now := time.Now()
|
||||
phone := normalizePhone(user.Phone)
|
||||
countryCode := ""
|
||||
if phone != "" {
|
||||
countryCode = normalizeCountryCode(user.CountryCode)
|
||||
}
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO users (id, region, phone_country_code, phone, email, name, avatar_url, password_hash, wechat_open_id, wechat_union_id, google_subject, status, phone_verified_at, email_verified_at, last_login_at, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
|
||||
CASE WHEN $4 <> '' THEN $13::timestamptz ELSE NULL::timestamptz END,
|
||||
CASE WHEN $5 <> '' THEN $13::timestamptz ELSE NULL::timestamptz END,
|
||||
$13, $13, $13)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
region = EXCLUDED.region,
|
||||
phone_country_code = CASE WHEN EXCLUDED.phone <> '' THEN EXCLUDED.phone_country_code ELSE users.phone_country_code END,
|
||||
phone = CASE WHEN EXCLUDED.phone <> '' THEN EXCLUDED.phone ELSE users.phone END,
|
||||
email = CASE WHEN EXCLUDED.email <> '' THEN EXCLUDED.email ELSE users.email END,
|
||||
name = CASE WHEN EXCLUDED.name <> '' THEN EXCLUDED.name ELSE users.name END,
|
||||
avatar_url = CASE WHEN EXCLUDED.avatar_url <> '' THEN EXCLUDED.avatar_url ELSE users.avatar_url END,
|
||||
password_hash = CASE WHEN EXCLUDED.password_hash <> '' THEN EXCLUDED.password_hash ELSE users.password_hash END,
|
||||
wechat_open_id = CASE WHEN EXCLUDED.wechat_open_id <> '' THEN EXCLUDED.wechat_open_id ELSE users.wechat_open_id END,
|
||||
wechat_union_id = CASE WHEN EXCLUDED.wechat_union_id <> '' THEN EXCLUDED.wechat_union_id ELSE users.wechat_union_id END,
|
||||
google_subject = CASE WHEN EXCLUDED.google_subject <> '' THEN EXCLUDED.google_subject ELSE users.google_subject END,
|
||||
status = EXCLUDED.status,
|
||||
phone_verified_at = CASE WHEN EXCLUDED.phone <> '' THEN EXCLUDED.phone_verified_at ELSE users.phone_verified_at END,
|
||||
email_verified_at = CASE WHEN EXCLUDED.email <> '' THEN EXCLUDED.email_verified_at ELSE users.email_verified_at END,
|
||||
last_login_at = EXCLUDED.last_login_at,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING id, region, phone_country_code, phone, email, name, avatar_url, password_hash, wechat_open_id, wechat_union_id, google_subject, status
|
||||
`, toPGUUID(user.ID), user.Region, countryCode, phone, normalizeEmail(user.Email), user.Name, user.AvatarURL, user.PasswordHash, user.WechatOpenID, user.WechatUnionID, user.GoogleSubject, activeStatus(user.Status), now)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UpdateUserProfile(ctx context.Context, userID string, patch ProfilePatch) (User, error) {
|
||||
name := pgtype.Text{}
|
||||
if patch.Name != nil {
|
||||
name = pgtype.Text{String: strings.TrimSpace(*patch.Name), Valid: true}
|
||||
}
|
||||
avatarURL := pgtype.Text{}
|
||||
if patch.AvatarURL != nil {
|
||||
avatarURL = pgtype.Text{String: strings.TrimSpace(*patch.AvatarURL), Valid: true}
|
||||
}
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
UPDATE users
|
||||
SET name = CASE WHEN $2::text IS NULL THEN name ELSE $2::text END,
|
||||
avatar_url = CASE WHEN $3::text IS NULL THEN avatar_url ELSE $3::text END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, region, phone_country_code, phone, email, name, avatar_url, password_hash, wechat_open_id, wechat_union_id, google_subject, status
|
||||
`, toPGUUID(userID), name, avatarURL)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateVerificationCode(ctx context.Context, code VerificationCode) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO auth_verification_codes (id, region, channel, target, purpose, code_hash, attempts, expires_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 0, $7, NOW())
|
||||
`, code.ID, code.Region, code.Channel, code.Target, code.Purpose, code.CodeHash, code.ExpiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) FindActiveVerificationCode(ctx context.Context, region string, channel string, target string, purpose string) (VerificationCode, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, region, channel, target, purpose, code_hash, attempts, expires_at, consumed_at
|
||||
FROM auth_verification_codes
|
||||
WHERE region = $1 AND channel = $2 AND target = $3 AND purpose = $4 AND consumed_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, region, channel, target, purpose)
|
||||
var code VerificationCode
|
||||
var consumedAt *time.Time
|
||||
if err := row.Scan(&code.ID, &code.Region, &code.Channel, &code.Target, &code.Purpose, &code.CodeHash, &code.Attempts, &code.ExpiresAt, &consumedAt); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return VerificationCode{}, design.ErrNotFound
|
||||
}
|
||||
return VerificationCode{}, err
|
||||
}
|
||||
code.ConsumedAt = consumedAt
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) MarkVerificationCodeConsumed(ctx context.Context, id string, consumedAt time.Time) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE auth_verification_codes SET consumed_at = $2 WHERE id = $1`, id, consumedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) IncrementVerificationCodeAttempts(ctx context.Context, id string) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE auth_verification_codes SET attempts = attempts + 1 WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) Close() error {
|
||||
if s == nil || s.pool == nil {
|
||||
return nil
|
||||
}
|
||||
s.pool.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) findUser(ctx context.Context, query string, args ...any) (User, error) {
|
||||
return scanUser(s.pool.QueryRow(ctx, query, args...))
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanUser(row rowScanner) (User, error) {
|
||||
var id pgtype.UUID
|
||||
var user User
|
||||
if err := row.Scan(&id, &user.Region, &user.CountryCode, &user.Phone, &user.Email, &user.Name, &user.AvatarURL, &user.PasswordHash, &user.WechatOpenID, &user.WechatUnionID, &user.GoogleSubject, &user.Status); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, design.ErrNotFound
|
||||
}
|
||||
return User{}, err
|
||||
}
|
||||
user.ID = fromPGUUID(id)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func toPGUUID(id string) pgtype.UUID {
|
||||
parsed, err := uuid.Parse(normalizeUUID(id))
|
||||
if err != nil {
|
||||
parsed = uuid.MustParse(design.DefaultUserID)
|
||||
}
|
||||
return pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
|
||||
func fromPGUUID(value pgtype.UUID) string {
|
||||
if !value.Valid {
|
||||
return design.DefaultUserID
|
||||
}
|
||||
return uuid.UUID(value.Bytes).String()
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type googleTokenInfo struct {
|
||||
Audience string `json:"aud"`
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified string `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
Error string `json:"error"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
|
||||
type wechatTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
OpenID string `json:"openid"`
|
||||
UnionID string `json:"unionid"`
|
||||
Scope string `json:"scope"`
|
||||
ErrCode int64 `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
func (s *Service) verifyGoogleIDToken(ctx context.Context, idToken string) (GoogleIdentity, error) {
|
||||
idToken = strings.TrimSpace(idToken)
|
||||
if idToken == "" {
|
||||
return GoogleIdentity{}, fmt.Errorf("%w: idToken is required", ErrInvalidCredentials)
|
||||
}
|
||||
endpoint := strings.TrimSpace(s.cfg.GoogleTokenInfo)
|
||||
if endpoint == "" || strings.TrimSpace(s.cfg.GoogleClientID) == "" {
|
||||
return GoogleIdentity{}, ErrProviderNotReady
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("id_token", idToken)
|
||||
requestURL := endpoint + "?" + values.Encode()
|
||||
timeout := s.cfg.GoogleTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 8 * time.Second
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return GoogleIdentity{}, err
|
||||
}
|
||||
client := &http.Client{Timeout: timeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return GoogleIdentity{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var info googleTokenInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return GoogleIdentity{}, err
|
||||
}
|
||||
if resp.StatusCode >= 400 || info.Error != "" {
|
||||
if info.ErrorDesc != "" {
|
||||
return GoogleIdentity{}, fmt.Errorf("%w: %s", ErrInvalidCredentials, info.ErrorDesc)
|
||||
}
|
||||
return GoogleIdentity{}, ErrInvalidCredentials
|
||||
}
|
||||
if info.Audience != strings.TrimSpace(s.cfg.GoogleClientID) {
|
||||
return GoogleIdentity{}, fmt.Errorf("%w: google audience mismatch", ErrInvalidCredentials)
|
||||
}
|
||||
if info.Subject == "" || info.Email == "" {
|
||||
return GoogleIdentity{}, fmt.Errorf("%w: google identity is incomplete", ErrInvalidCredentials)
|
||||
}
|
||||
if info.EmailVerified != "" && !strings.EqualFold(info.EmailVerified, "true") {
|
||||
return GoogleIdentity{}, fmt.Errorf("%w: google email is not verified", ErrInvalidCredentials)
|
||||
}
|
||||
return GoogleIdentity{
|
||||
Subject: info.Subject,
|
||||
Email: normalizeEmail(info.Email),
|
||||
Name: strings.TrimSpace(info.Name),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) exchangeWechatCodeHTTP(ctx context.Context, code string) (WechatIdentity, error) {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return WechatIdentity{}, fmt.Errorf("%w: code is required", ErrInvalidCredentials)
|
||||
}
|
||||
if strings.TrimSpace(s.cfg.WechatAppID) == "" || strings.TrimSpace(s.cfg.WechatAppSecret) == "" {
|
||||
return WechatIdentity{}, ErrProviderNotReady
|
||||
}
|
||||
endpoint := strings.TrimSpace(s.cfg.WechatTokenURL)
|
||||
if endpoint == "" {
|
||||
return WechatIdentity{}, ErrProviderNotReady
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("appid", s.cfg.WechatAppID)
|
||||
values.Set("secret", s.cfg.WechatAppSecret)
|
||||
values.Set("code", code)
|
||||
values.Set("grant_type", "authorization_code")
|
||||
requestURL := endpoint + "?" + values.Encode()
|
||||
timeout := s.cfg.WechatTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 8 * time.Second
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return WechatIdentity{}, err
|
||||
}
|
||||
client := &http.Client{Timeout: timeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return WechatIdentity{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var token wechatTokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
|
||||
return WechatIdentity{}, err
|
||||
}
|
||||
if resp.StatusCode >= 400 || token.ErrCode != 0 {
|
||||
if token.ErrMsg != "" {
|
||||
return WechatIdentity{}, fmt.Errorf("%w: %s", ErrInvalidCredentials, token.ErrMsg)
|
||||
}
|
||||
return WechatIdentity{}, ErrInvalidCredentials
|
||||
}
|
||||
if token.OpenID == "" {
|
||||
return WechatIdentity{}, fmt.Errorf("%w: wechat identity is incomplete", ErrInvalidCredentials)
|
||||
}
|
||||
return WechatIdentity{
|
||||
OpenID: strings.TrimSpace(token.OpenID),
|
||||
UnionID: strings.TrimSpace(token.UnionID),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"img_infinite_canvas/internal/domain/design"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Region string
|
||||
RegionMode string
|
||||
TokenSecret string
|
||||
TokenTTL time.Duration
|
||||
RefreshTokenTTL time.Duration
|
||||
CodeTTL time.Duration
|
||||
DevReturnCode bool
|
||||
EmailFromName string
|
||||
EmailFromEmail string
|
||||
BrandName string
|
||||
TurnstileRequired bool
|
||||
TurnstileSiteKey string
|
||||
GoogleClientID string
|
||||
GoogleTokenInfo string
|
||||
GoogleTimeout time.Duration
|
||||
WechatAppID string
|
||||
WechatAppSecret string
|
||||
WechatRedirect string
|
||||
WechatScope string
|
||||
WechatAuthURL string
|
||||
WechatTokenURL string
|
||||
WechatTimeout time.Duration
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
codeStore VerificationCodeStore
|
||||
emailSender EmailSender
|
||||
humanVerifier HumanVerifier
|
||||
cfg Config
|
||||
now func() time.Time
|
||||
wechatExchanger func(context.Context, string) (WechatIdentity, error)
|
||||
}
|
||||
|
||||
func NewService(store Store, cfg Config) *Service {
|
||||
return NewServiceWithCodeStore(store, store, cfg)
|
||||
}
|
||||
|
||||
func NewServiceWithCodeStore(store Store, codeStore VerificationCodeStore, cfg Config) *Service {
|
||||
return NewServiceWithCodeStoreAndEmail(store, codeStore, nil, cfg)
|
||||
}
|
||||
|
||||
func NewServiceWithCodeStoreAndEmail(store Store, codeStore VerificationCodeStore, emailSender EmailSender, cfg Config) *Service {
|
||||
return NewServiceWithCodeStoreEmailAndHumanVerifier(store, codeStore, emailSender, nil, cfg)
|
||||
}
|
||||
|
||||
func NewServiceWithCodeStoreEmailAndHumanVerifier(store Store, codeStore VerificationCodeStore, emailSender EmailSender, humanVerifier HumanVerifier, cfg Config) *Service {
|
||||
cfg.Region = normalizeRegion(cfg.Region)
|
||||
if cfg.Region == "" {
|
||||
cfg.Region = RegionChina
|
||||
}
|
||||
if cfg.TokenTTL <= 0 {
|
||||
cfg.TokenTTL = 7 * 24 * time.Hour
|
||||
}
|
||||
if cfg.RefreshTokenTTL <= 0 {
|
||||
cfg.RefreshTokenTTL = 30 * 24 * time.Hour
|
||||
}
|
||||
if cfg.CodeTTL <= 0 {
|
||||
cfg.CodeTTL = 10 * time.Minute
|
||||
}
|
||||
if strings.TrimSpace(cfg.EmailFromName) == "" {
|
||||
cfg.EmailFromName = loginEmailFromName
|
||||
}
|
||||
if strings.TrimSpace(cfg.EmailFromEmail) == "" {
|
||||
cfg.EmailFromEmail = loginEmailFromEmail
|
||||
}
|
||||
if strings.TrimSpace(cfg.BrandName) == "" {
|
||||
cfg.BrandName = loginEmailBrandName
|
||||
}
|
||||
if strings.TrimSpace(cfg.WechatScope) == "" {
|
||||
cfg.WechatScope = "snsapi_login"
|
||||
}
|
||||
if strings.TrimSpace(cfg.WechatAuthURL) == "" {
|
||||
cfg.WechatAuthURL = "https://open.weixin.qq.com/connect/qrconnect"
|
||||
}
|
||||
if strings.TrimSpace(cfg.WechatTokenURL) == "" {
|
||||
cfg.WechatTokenURL = "https://api.weixin.qq.com/sns/oauth2/access_token"
|
||||
}
|
||||
if strings.TrimSpace(cfg.GoogleTokenInfo) == "" {
|
||||
cfg.GoogleTokenInfo = "https://oauth2.googleapis.com/tokeninfo"
|
||||
}
|
||||
if codeStore == nil {
|
||||
codeStore = store
|
||||
}
|
||||
service := &Service{store: store, codeStore: codeStore, emailSender: emailSender, humanVerifier: humanVerifier, cfg: cfg, now: time.Now}
|
||||
service.wechatExchanger = service.exchangeWechatCodeHTTP
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) Options() Options {
|
||||
turnstile := TurnstileOptions{Enabled: s.cfg.TurnstileRequired, SiteKey: strings.TrimSpace(s.cfg.TurnstileSiteKey)}
|
||||
region := s.cfg.Region
|
||||
if region == RegionGlobal {
|
||||
return Options{Region: region, Methods: []Method{
|
||||
{ID: "google", Label: "Google", Provider: "google"},
|
||||
{ID: "email_code", Label: "Email code", Provider: "email"},
|
||||
}, Turnstile: turnstile}
|
||||
}
|
||||
return Options{Region: region, Methods: []Method{
|
||||
{ID: "wechat_qr", Label: "WeChat QR", Provider: "wechat"},
|
||||
{ID: "sms_code", Label: "SMS code", Provider: "sms"},
|
||||
{ID: "phone_password", Label: "Phone password", Provider: "password"},
|
||||
}, Turnstile: turnstile}
|
||||
}
|
||||
|
||||
func (s *Service) RequestChinaSMSCode(ctx context.Context, phone string, countryCode string, purpose string) (CodeResult, error) {
|
||||
if err := s.requireRegion(RegionChina); err != nil {
|
||||
return CodeResult{}, err
|
||||
}
|
||||
return s.createCode(ctx, RegionChina, ChannelSMS, normalizePhone(phone), normalizeCountryCode(countryCode), normalizePurpose(purpose))
|
||||
}
|
||||
|
||||
func (s *Service) LoginChinaSMSCode(ctx context.Context, phone string, countryCode string, code string) (Session, error) {
|
||||
if err := s.requireRegion(RegionChina); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
phone = normalizePhone(phone)
|
||||
countryCode = normalizeCountryCode(countryCode)
|
||||
target := phoneTarget(countryCode, phone)
|
||||
if err := s.verifyCode(ctx, RegionChina, ChannelSMS, target, PurposeLogin, code); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
user, err := s.store.FindUserByPhone(ctx, countryCode, phone)
|
||||
if err != nil {
|
||||
if !errors.Is(err, design.ErrNotFound) {
|
||||
return Session{}, err
|
||||
}
|
||||
user = User{ID: newID(), Region: RegionChina, Phone: phone, CountryCode: countryCode, Status: "active"}
|
||||
}
|
||||
user.Region = RegionChina
|
||||
user.Phone = phone
|
||||
user.CountryCode = countryCode
|
||||
user.Status = activeStatus(user.Status)
|
||||
return s.upsertAndSign(ctx, user)
|
||||
}
|
||||
|
||||
func (s *Service) LoginChinaPassword(ctx context.Context, phone string, countryCode string, password string) (Session, error) {
|
||||
if err := s.requireRegion(RegionChina); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
user, err := s.store.FindUserByPhone(ctx, normalizeCountryCode(countryCode), normalizePhone(phone))
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("%w: phone or password is wrong", ErrInvalidCredentials)
|
||||
}
|
||||
if strings.TrimSpace(user.PasswordHash) == "" {
|
||||
return Session{}, fmt.Errorf("%w: password login is not enabled for this account", ErrInvalidCredentials)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
return Session{}, fmt.Errorf("%w: phone or password is wrong", ErrInvalidCredentials)
|
||||
}
|
||||
user.Region = RegionChina
|
||||
return s.upsertAndSign(ctx, user)
|
||||
}
|
||||
|
||||
func (s *Service) WechatAuthURL(ctx context.Context) (string, string, int64, error) {
|
||||
if err := s.requireRegion(RegionChina); err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
if strings.TrimSpace(s.cfg.WechatAppID) == "" || strings.TrimSpace(s.cfg.WechatRedirect) == "" {
|
||||
return "", "", 0, ErrProviderNotReady
|
||||
}
|
||||
state, expiresIn, err := signState(s.cfg.TokenSecret, s.cfg.CodeTTL, s.now())
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("appid", s.cfg.WechatAppID)
|
||||
values.Set("redirect_uri", s.cfg.WechatRedirect)
|
||||
values.Set("response_type", "code")
|
||||
values.Set("scope", s.cfg.WechatScope)
|
||||
values.Set("state", state)
|
||||
authURL := strings.TrimRight(s.cfg.WechatAuthURL, "?") + "?" + values.Encode() + "#wechat_redirect"
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
return authURL, state, expiresIn, nil
|
||||
}
|
||||
|
||||
func (s *Service) LoginChinaWechat(ctx context.Context, code string, state string, bindingToken string, phone string, countryCode string, smsCode string) (Session, error) {
|
||||
if err := s.requireRegion(RegionChina); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
if strings.TrimSpace(bindingToken) != "" {
|
||||
return s.bindWechatPhone(ctx, bindingToken, phone, countryCode, smsCode)
|
||||
}
|
||||
if err := verifyState(ctx, state, s.cfg.TokenSecret, s.now()); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
exchanger := s.wechatExchanger
|
||||
if exchanger == nil {
|
||||
exchanger = s.exchangeWechatCodeHTTP
|
||||
}
|
||||
identity, err := exchanger(ctx, code)
|
||||
if err != nil {
|
||||
return Session{}, normalizeTokenErr(err)
|
||||
}
|
||||
return s.loginWechatIdentity(ctx, identity)
|
||||
}
|
||||
|
||||
func (s *Service) loginWechatIdentity(ctx context.Context, identity WechatIdentity) (Session, error) {
|
||||
user, err := s.store.FindUserByWechat(ctx, identity.OpenID, identity.UnionID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, design.ErrNotFound) {
|
||||
return Session{}, err
|
||||
}
|
||||
return s.newWechatBindingSession(identity)
|
||||
}
|
||||
if normalizePhone(user.Phone) == "" {
|
||||
return s.newWechatBindingSession(identity)
|
||||
}
|
||||
if err := ensureWechatIdentityCompatible(user, identity); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
user.Region = RegionChina
|
||||
user.WechatOpenID = identity.OpenID
|
||||
if identity.UnionID != "" {
|
||||
user.WechatUnionID = identity.UnionID
|
||||
}
|
||||
user.Status = activeStatus(user.Status)
|
||||
return s.upsertAndSign(ctx, user)
|
||||
}
|
||||
|
||||
func (s *Service) bindWechatPhone(ctx context.Context, bindingToken string, phone string, countryCode string, smsCode string) (Session, error) {
|
||||
identity, ok := parseWechatBindingToken(ctx, bindingToken, s.cfg.TokenSecret, s.now())
|
||||
if !ok {
|
||||
return Session{}, fmt.Errorf("%w: wechat binding token is invalid", ErrInvalidCredentials)
|
||||
}
|
||||
phone = normalizePhone(phone)
|
||||
countryCode = normalizeCountryCode(countryCode)
|
||||
if phone == "" {
|
||||
return Session{}, fmt.Errorf("%w: phone is required", ErrInvalidCredentials)
|
||||
}
|
||||
target := phoneTarget(countryCode, phone)
|
||||
if err := s.verifyCode(ctx, RegionChina, ChannelSMS, target, PurposeWechatBind, smsCode); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
user, err := s.userForWechatBinding(ctx, identity, countryCode, phone)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
user.Region = RegionChina
|
||||
user.Phone = phone
|
||||
user.CountryCode = countryCode
|
||||
user.WechatOpenID = identity.OpenID
|
||||
user.WechatUnionID = identity.UnionID
|
||||
user.Status = activeStatus(user.Status)
|
||||
return s.upsertAndSign(ctx, user)
|
||||
}
|
||||
|
||||
func (s *Service) userForWechatBinding(ctx context.Context, identity WechatIdentity, countryCode string, phone string) (User, error) {
|
||||
wechatUser, wechatErr := s.store.FindUserByWechat(ctx, identity.OpenID, identity.UnionID)
|
||||
if wechatErr != nil && !errors.Is(wechatErr, design.ErrNotFound) {
|
||||
return User{}, wechatErr
|
||||
}
|
||||
phoneUser, phoneErr := s.store.FindUserByPhone(ctx, countryCode, phone)
|
||||
if phoneErr != nil && !errors.Is(phoneErr, design.ErrNotFound) {
|
||||
return User{}, phoneErr
|
||||
}
|
||||
if phoneErr == nil {
|
||||
if wechatErr == nil && wechatUser.ID != phoneUser.ID {
|
||||
return User{}, fmt.Errorf("%w: wechat is already bound to another account", ErrInvalidCredentials)
|
||||
}
|
||||
if err := ensureWechatIdentityCompatible(phoneUser, identity); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return phoneUser, nil
|
||||
}
|
||||
if wechatErr == nil {
|
||||
if err := ensureWechatIdentityCompatible(wechatUser, identity); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return wechatUser, nil
|
||||
}
|
||||
return User{ID: newID(), Region: RegionChina, Status: "active"}, nil
|
||||
}
|
||||
|
||||
func (s *Service) newWechatBindingSession(identity WechatIdentity) (Session, error) {
|
||||
token, expiresIn, err := signWechatBindingToken(identity, s.cfg.TokenSecret, s.cfg.CodeTTL, s.now())
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
return Session{Status: SessionStatusPhoneBindingRequired, BindingToken: token, BindingExpiresIn: expiresIn}, nil
|
||||
}
|
||||
|
||||
func ensureWechatIdentityCompatible(user User, identity WechatIdentity) error {
|
||||
if strings.TrimSpace(user.WechatOpenID) != "" && strings.TrimSpace(user.WechatOpenID) != strings.TrimSpace(identity.OpenID) {
|
||||
return fmt.Errorf("%w: account is already bound to another wechat identity", ErrInvalidCredentials)
|
||||
}
|
||||
if strings.TrimSpace(user.WechatUnionID) != "" && strings.TrimSpace(identity.UnionID) != "" && strings.TrimSpace(user.WechatUnionID) != strings.TrimSpace(identity.UnionID) {
|
||||
return fmt.Errorf("%w: account is already bound to another wechat identity", ErrInvalidCredentials)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) RequestGlobalEmailCode(ctx context.Context, email string, purpose string) (CodeResult, error) {
|
||||
return s.RequestGlobalEmailCodeWithVerification(ctx, email, purpose, "")
|
||||
}
|
||||
|
||||
func (s *Service) RequestGlobalEmailCodeWithVerification(ctx context.Context, email string, purpose string, humanToken string) (CodeResult, error) {
|
||||
if err := s.requireRegion(RegionGlobal); err != nil {
|
||||
return CodeResult{}, err
|
||||
}
|
||||
if err := s.verifyHuman(ctx, humanToken); err != nil {
|
||||
return CodeResult{}, err
|
||||
}
|
||||
return s.createCode(ctx, RegionGlobal, ChannelEmail, normalizeEmail(email), "", normalizePurpose(purpose))
|
||||
}
|
||||
|
||||
func (s *Service) LoginGlobalEmailCode(ctx context.Context, email string, code string) (Session, error) {
|
||||
if err := s.requireRegion(RegionGlobal); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
email = normalizeEmail(email)
|
||||
if err := s.verifyCode(ctx, RegionGlobal, ChannelEmail, email, PurposeLogin, code); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
user, err := s.store.FindUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if !errors.Is(err, design.ErrNotFound) {
|
||||
return Session{}, err
|
||||
}
|
||||
user = User{ID: newID(), Region: RegionGlobal, Email: email, Status: "active"}
|
||||
}
|
||||
user.Region = RegionGlobal
|
||||
user.Email = email
|
||||
user.Status = activeStatus(user.Status)
|
||||
return s.upsertAndSign(ctx, user)
|
||||
}
|
||||
|
||||
func (s *Service) LoginGlobalGoogle(ctx context.Context, idToken string) (Session, error) {
|
||||
if err := s.requireRegion(RegionGlobal); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
identity, err := s.verifyGoogleIDToken(ctx, idToken)
|
||||
if err != nil {
|
||||
return Session{}, normalizeTokenErr(err)
|
||||
}
|
||||
user, err := s.store.FindUserByGoogleSubject(ctx, identity.Subject)
|
||||
if err != nil {
|
||||
if !errors.Is(err, design.ErrNotFound) {
|
||||
return Session{}, err
|
||||
}
|
||||
user = User{ID: newID(), Region: RegionGlobal, Status: "active"}
|
||||
}
|
||||
user.Region = RegionGlobal
|
||||
user.Email = identity.Email
|
||||
user.Name = identity.Name
|
||||
user.GoogleSubject = identity.Subject
|
||||
user.Status = activeStatus(user.Status)
|
||||
return s.upsertAndSign(ctx, user)
|
||||
}
|
||||
|
||||
func (s *Service) UserIDFromBearer(ctx context.Context, authorization string) (string, bool) {
|
||||
token, ok := bearerToken(authorization)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return parseToken(ctx, token, s.cfg.TokenSecret, s.now())
|
||||
}
|
||||
|
||||
func (s *Service) GetProfile(ctx context.Context, userID string) (User, error) {
|
||||
return s.store.FindUserByID(ctx, normalizeUUID(userID))
|
||||
}
|
||||
|
||||
func (s *Service) UpdateProfile(ctx context.Context, userID string, patch ProfilePatch) (User, error) {
|
||||
if patch.Name != nil {
|
||||
name := strings.TrimSpace(*patch.Name)
|
||||
if name == "" || len([]rune(name)) > 40 {
|
||||
return User{}, fmt.Errorf("%w: name must be 1-40 characters", ErrInvalidCredentials)
|
||||
}
|
||||
patch.Name = &name
|
||||
}
|
||||
if patch.AvatarURL != nil {
|
||||
avatarURL := strings.TrimSpace(*patch.AvatarURL)
|
||||
if avatarURL != "" && !validAvatarURL(avatarURL) {
|
||||
return User{}, fmt.Errorf("%w: avatar url is invalid", ErrInvalidCredentials)
|
||||
}
|
||||
patch.AvatarURL = &avatarURL
|
||||
}
|
||||
return s.store.UpdateUserProfile(ctx, normalizeUUID(userID), patch)
|
||||
}
|
||||
|
||||
func (s *Service) ListDevices(ctx context.Context, userID string) ([]Device, error) {
|
||||
if _, err := s.store.FindUserByID(ctx, normalizeUUID(userID)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []Device{
|
||||
{
|
||||
ID: "current",
|
||||
Online: true,
|
||||
Current: true,
|
||||
DeviceType: "desktop",
|
||||
System: "unknown",
|
||||
Browser: "unknown",
|
||||
Client: "PC-WEB",
|
||||
LastSeenAt: s.now().UTC(),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) RemoveOtherDevices(ctx context.Context, userID string) (int64, error) {
|
||||
if _, err := s.store.FindUserByID(ctx, normalizeUUID(userID)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, userID string) (int64, error) {
|
||||
if _, err := s.store.FindUserByID(ctx, normalizeUUID(userID)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *Service) Close() error {
|
||||
if s == nil || s.store == nil {
|
||||
return nil
|
||||
}
|
||||
return s.store.Close()
|
||||
}
|
||||
|
||||
func (s *Service) createCode(ctx context.Context, region string, channel string, target string, countryCode string, purpose string) (CodeResult, error) {
|
||||
if target == "" {
|
||||
return CodeResult{}, fmt.Errorf("%w: target is required", ErrInvalidCredentials)
|
||||
}
|
||||
if channel == ChannelSMS {
|
||||
target = phoneTarget(countryCode, target)
|
||||
}
|
||||
code, err := randomDigits(6)
|
||||
if err != nil {
|
||||
return CodeResult{}, err
|
||||
}
|
||||
expiresIn := int64(s.cfg.CodeTTL.Seconds())
|
||||
expiresAt := s.now().Add(s.cfg.CodeTTL)
|
||||
record := VerificationCode{
|
||||
ID: newID(),
|
||||
Region: region,
|
||||
Channel: channel,
|
||||
Target: target,
|
||||
Purpose: purpose,
|
||||
CodeHash: hashVerificationCode(code, target, s.cfg.TokenSecret),
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
if err := s.codeStore.CreateVerificationCode(ctx, record); err != nil {
|
||||
return CodeResult{}, err
|
||||
}
|
||||
result := CodeResult{Target: target, ExpiresIn: expiresIn}
|
||||
var email *EmailMessage
|
||||
if channel == ChannelEmail {
|
||||
email = buildLoginEmail(target, code, EmailTemplateOptions{
|
||||
FromName: s.cfg.EmailFromName,
|
||||
FromEmail: s.cfg.EmailFromEmail,
|
||||
BrandName: s.cfg.BrandName,
|
||||
})
|
||||
if s.emailSender == nil && !s.cfg.DevReturnCode {
|
||||
_ = s.codeStore.MarkVerificationCodeConsumed(ctx, record.ID, s.now())
|
||||
return CodeResult{}, ErrProviderNotReady
|
||||
}
|
||||
if s.emailSender != nil {
|
||||
if err := s.emailSender.SendLoginCode(ctx, *email); err != nil {
|
||||
_ = s.codeStore.MarkVerificationCodeConsumed(ctx, record.ID, s.now())
|
||||
return CodeResult{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.cfg.DevReturnCode {
|
||||
result.DevCode = code
|
||||
result.Email = email
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) verifyCode(ctx context.Context, region string, channel string, target string, purpose string, code string) error {
|
||||
record, err := s.codeStore.FindActiveVerificationCode(ctx, region, channel, target, purpose)
|
||||
if err != nil {
|
||||
return ErrCodeInvalid
|
||||
}
|
||||
if record.ExpiresAt.Before(s.now()) {
|
||||
return ErrCodeExpired
|
||||
}
|
||||
expected := hashVerificationCode(code, target, s.cfg.TokenSecret)
|
||||
if expected != record.CodeHash {
|
||||
_ = s.codeStore.IncrementVerificationCodeAttempts(ctx, record.ID)
|
||||
return ErrCodeInvalid
|
||||
}
|
||||
return s.codeStore.MarkVerificationCodeConsumed(ctx, record.ID, s.now())
|
||||
}
|
||||
|
||||
func (s *Service) verifyHuman(ctx context.Context, token string) error {
|
||||
if !s.cfg.TurnstileRequired {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return ErrHumanVerificationRequired
|
||||
}
|
||||
if s.humanVerifier == nil {
|
||||
return ErrProviderNotReady
|
||||
}
|
||||
return s.humanVerifier.Verify(ctx, token)
|
||||
}
|
||||
|
||||
func (s *Service) upsertAndSign(ctx context.Context, user User) (Session, error) {
|
||||
user.ID = normalizeUUID(user.ID)
|
||||
user.Status = activeStatus(user.Status)
|
||||
saved, err := s.store.UpsertUser(ctx, user)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
now := s.now()
|
||||
token, expiresIn, err := signToken(saved.ID, s.cfg.TokenSecret, s.cfg.TokenTTL, now)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
refreshToken, refreshExpiresIn, err := signRefreshToken(saved.ID, s.cfg.TokenSecret, s.cfg.RefreshTokenTTL, now)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
return Session{
|
||||
Status: SessionStatusAuthenticated,
|
||||
User: saved,
|
||||
AccessToken: token,
|
||||
ExpiresIn: expiresIn,
|
||||
RefreshToken: refreshToken,
|
||||
RefreshExpiresIn: refreshExpiresIn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) requireRegion(region string) error {
|
||||
if normalizeRegion(s.cfg.Region) != region {
|
||||
return fmt.Errorf("%w: current region is %s", ErrInvalidRegion, s.cfg.Region)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeRegion(region string) string {
|
||||
region = strings.ToLower(strings.TrimSpace(region))
|
||||
switch region {
|
||||
case RegionChina, "cn", "mainland":
|
||||
return RegionChina
|
||||
case RegionGlobal, "foreign", "oversea", "overseas", "intl", "international":
|
||||
return RegionGlobal
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePurpose(purpose string) string {
|
||||
purpose = strings.ToLower(strings.TrimSpace(purpose))
|
||||
switch purpose {
|
||||
case PurposeWechatBind:
|
||||
return PurposeWechatBind
|
||||
default:
|
||||
return PurposeLogin
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCountryCode(countryCode string) string {
|
||||
countryCode = strings.TrimSpace(countryCode)
|
||||
if countryCode == "" {
|
||||
return defaultCountryCode
|
||||
}
|
||||
if !strings.HasPrefix(countryCode, "+") {
|
||||
countryCode = "+" + countryCode
|
||||
}
|
||||
return countryCode
|
||||
}
|
||||
|
||||
var nonDigits = regexp.MustCompile(`\D+`)
|
||||
|
||||
func normalizePhone(phone string) string {
|
||||
return nonDigits.ReplaceAllString(strings.TrimSpace(phone), "")
|
||||
}
|
||||
|
||||
func normalizeEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
func validAvatarURL(value string) bool {
|
||||
if strings.HasPrefix(value, "/memory-assets/") {
|
||||
return true
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return parsed.Scheme == "http" || parsed.Scheme == "https"
|
||||
}
|
||||
|
||||
func phoneTarget(countryCode string, phone string) string {
|
||||
return normalizeCountryCode(countryCode) + ":" + normalizePhone(phone)
|
||||
}
|
||||
|
||||
func activeStatus(status string) string {
|
||||
status = strings.TrimSpace(status)
|
||||
if status == "" {
|
||||
return "active"
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func normalizeUUID(id string) string {
|
||||
id = strings.TrimSpace(id)
|
||||
if _, err := uuid.Parse(id); err == nil {
|
||||
return id
|
||||
}
|
||||
return newID()
|
||||
}
|
||||
|
||||
func newID() string {
|
||||
return uuid.NewString()
|
||||
}
|
||||
|
||||
func randomDigits(length int) (string, error) {
|
||||
if length <= 0 {
|
||||
length = 6
|
||||
}
|
||||
var builder strings.Builder
|
||||
for i := 0; i < length; i++ {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(10))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
builder.WriteByte(byte('0' + n.Int64()))
|
||||
}
|
||||
return builder.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type fakeEmailSender struct {
|
||||
messages []EmailMessage
|
||||
}
|
||||
|
||||
func (s *fakeEmailSender) SendLoginCode(_ context.Context, message EmailMessage) error {
|
||||
s.messages = append(s.messages, message)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeHumanVerifier struct {
|
||||
tokens []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (v *fakeHumanVerifier) Verify(_ context.Context, token string) error {
|
||||
v.tokens = append(v.tokens, token)
|
||||
return v.err
|
||||
}
|
||||
|
||||
func TestChinaSMSLoginCreatesUUIDUserAndBearerToken(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(NewMemoryStore(), Config{
|
||||
Region: RegionChina,
|
||||
TokenSecret: "test-secret",
|
||||
TokenTTL: time.Hour,
|
||||
CodeTTL: time.Minute,
|
||||
DevReturnCode: true,
|
||||
})
|
||||
|
||||
code, err := service.RequestChinaSMSCode(ctx, "13800138000", "+86", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := service.LoginChinaSMSCode(ctx, "13800138000", "+86", code.DevCode)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := uuid.Parse(session.User.ID); err != nil {
|
||||
t.Fatalf("expected uuid user id, got %q", session.User.ID)
|
||||
}
|
||||
if session.User.Region != RegionChina {
|
||||
t.Fatalf("expected china user, got %#v", session.User)
|
||||
}
|
||||
userID, ok := service.UserIDFromBearer(ctx, "Bearer "+session.AccessToken)
|
||||
if !ok || userID != session.User.ID {
|
||||
t.Fatalf("expected bearer token to resolve %s, got %s / %v", session.User.ID, userID, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualRegionRejectsOtherRegionLogin(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(NewMemoryStore(), Config{
|
||||
Region: RegionGlobal,
|
||||
TokenSecret: "test-secret",
|
||||
CodeTTL: time.Minute,
|
||||
DevReturnCode: true,
|
||||
})
|
||||
|
||||
_, err := service.RequestChinaSMSCode(ctx, "13800138000", "+86", "")
|
||||
if !errors.Is(err, ErrInvalidRegion) {
|
||||
t.Fatalf("expected invalid region, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalEmailCodeLogin(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(NewMemoryStore(), Config{
|
||||
Region: RegionGlobal,
|
||||
TokenSecret: "test-secret",
|
||||
CodeTTL: time.Minute,
|
||||
DevReturnCode: true,
|
||||
})
|
||||
|
||||
code, err := service.RequestGlobalEmailCode(ctx, "USER@example.com", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := service.LoginGlobalEmailCode(ctx, "user@example.com", code.DevCode)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session.User.Email != "user@example.com" || session.User.Region != RegionGlobal {
|
||||
t.Fatalf("unexpected session user %#v", session.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountProfileUpdatesPersistInStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(NewMemoryStore(), Config{
|
||||
Region: RegionGlobal,
|
||||
TokenSecret: "test-secret",
|
||||
CodeTTL: time.Minute,
|
||||
DevReturnCode: true,
|
||||
})
|
||||
|
||||
code, err := service.RequestGlobalEmailCode(ctx, "user@example.com", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := service.LoginGlobalEmailCode(ctx, "user@example.com", code.DevCode)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := "Liang Xu"
|
||||
avatarURL := "https://cdn.example.com/avatar.webp"
|
||||
user, err := service.UpdateProfile(ctx, session.User.ID, ProfilePatch{Name: &name, AvatarURL: &avatarURL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user.Name != name || user.AvatarURL != avatarURL {
|
||||
t.Fatalf("unexpected profile %#v", user)
|
||||
}
|
||||
profile, err := service.GetProfile(ctx, session.User.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if profile.Name != name || profile.AvatarURL != avatarURL {
|
||||
t.Fatalf("profile was not persisted %#v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDevicesExposeCurrentLocalDeviceOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(NewMemoryStore(), Config{
|
||||
Region: RegionGlobal,
|
||||
TokenSecret: "test-secret",
|
||||
CodeTTL: time.Minute,
|
||||
DevReturnCode: true,
|
||||
})
|
||||
|
||||
code, err := service.RequestGlobalEmailCode(ctx, "user@example.com", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := service.LoginGlobalEmailCode(ctx, "user@example.com", code.DevCode)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
devices, err := service.ListDevices(ctx, session.User.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(devices) != 1 || !devices[0].Current {
|
||||
t.Fatalf("expected one current device, got %#v", devices)
|
||||
}
|
||||
removed, err := service.RemoveOtherDevices(ctx, session.User.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed != 0 {
|
||||
t.Fatalf("expected no removable devices, got %d", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalEmailCodeSendsConfiguredEmail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sender := &fakeEmailSender{}
|
||||
service := NewServiceWithCodeStoreAndEmail(NewMemoryStore(), NewMemoryStore(), sender, Config{
|
||||
Region: RegionGlobal,
|
||||
TokenSecret: "test-secret",
|
||||
CodeTTL: 10 * time.Minute,
|
||||
DevReturnCode: true,
|
||||
EmailFromName: "Moteva Login",
|
||||
EmailFromEmail: "hello@example.com",
|
||||
BrandName: "Moteva",
|
||||
})
|
||||
|
||||
code, err := service.RequestGlobalEmailCode(ctx, "user@example.com", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code.ExpiresIn != 600 {
|
||||
t.Fatalf("expected 10 minute ttl, got %d", code.ExpiresIn)
|
||||
}
|
||||
if len(sender.messages) != 1 {
|
||||
t.Fatalf("expected one email, got %d", len(sender.messages))
|
||||
}
|
||||
message := sender.messages[0]
|
||||
if message.FromEmail != "hello@example.com" || message.ToEmail != "user@example.com" || message.Code != code.DevCode {
|
||||
t.Fatalf("unexpected email message %#v", message)
|
||||
}
|
||||
if code.Email == nil || code.Email.Subject != "Welcome to Moteva!" {
|
||||
t.Fatalf("expected dev email preview, got %#v", code.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalEmailCodeRequiresSenderOutsideDevMode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(NewMemoryStore(), Config{
|
||||
Region: RegionGlobal,
|
||||
TokenSecret: "test-secret",
|
||||
CodeTTL: 10 * time.Minute,
|
||||
DevReturnCode: false,
|
||||
})
|
||||
|
||||
_, err := service.RequestGlobalEmailCode(ctx, "user@example.com", "")
|
||||
if !errors.Is(err, ErrProviderNotReady) {
|
||||
t.Fatalf("expected provider not ready, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalEmailCodeRequiresHumanVerificationBeforeEmail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sender := &fakeEmailSender{}
|
||||
verifier := &fakeHumanVerifier{}
|
||||
service := NewServiceWithCodeStoreEmailAndHumanVerifier(NewMemoryStore(), NewMemoryStore(), sender, verifier, Config{
|
||||
Region: RegionGlobal,
|
||||
TokenSecret: "test-secret",
|
||||
CodeTTL: 10 * time.Minute,
|
||||
DevReturnCode: true,
|
||||
TurnstileRequired: true,
|
||||
})
|
||||
|
||||
_, err := service.RequestGlobalEmailCodeWithVerification(ctx, "user@example.com", "", "")
|
||||
if !errors.Is(err, ErrHumanVerificationRequired) {
|
||||
t.Fatalf("expected human verification required, got %v", err)
|
||||
}
|
||||
if len(sender.messages) != 0 {
|
||||
t.Fatalf("expected no email before human verification, got %d", len(sender.messages))
|
||||
}
|
||||
|
||||
code, err := service.RequestGlobalEmailCodeWithVerification(ctx, "user@example.com", "", "turnstile-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code.DevCode == "" || len(sender.messages) != 1 {
|
||||
t.Fatalf("expected verified email code, got code=%q messages=%d", code.DevCode, len(sender.messages))
|
||||
}
|
||||
if len(verifier.tokens) != 1 || verifier.tokens[0] != "turnstile-token" {
|
||||
t.Fatalf("expected verifier token, got %#v", verifier.tokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalEmailCodeStopsWhenHumanVerificationFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sender := &fakeEmailSender{}
|
||||
verifier := &fakeHumanVerifier{err: ErrHumanVerificationInvalid}
|
||||
service := NewServiceWithCodeStoreEmailAndHumanVerifier(NewMemoryStore(), NewMemoryStore(), sender, verifier, Config{
|
||||
Region: RegionGlobal,
|
||||
TokenSecret: "test-secret",
|
||||
CodeTTL: 10 * time.Minute,
|
||||
DevReturnCode: true,
|
||||
TurnstileRequired: true,
|
||||
})
|
||||
|
||||
_, err := service.RequestGlobalEmailCodeWithVerification(ctx, "user@example.com", "", "bad-token")
|
||||
if !errors.Is(err, ErrHumanVerificationInvalid) {
|
||||
t.Fatalf("expected human verification invalid, got %v", err)
|
||||
}
|
||||
if len(sender.messages) != 0 {
|
||||
t.Fatalf("expected no email after failed human verification, got %d", len(sender.messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChinaWechatLoginWithBoundPhoneAuthenticatesDirectly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryStore()
|
||||
identity := WechatIdentity{OpenID: "wechat-open-1", UnionID: "wechat-union-1"}
|
||||
if _, err := store.UpsertUser(ctx, User{
|
||||
ID: uuid.NewString(),
|
||||
Region: RegionChina,
|
||||
Phone: "13800138000",
|
||||
CountryCode: "+86",
|
||||
WechatOpenID: identity.OpenID,
|
||||
WechatUnionID: identity.UnionID,
|
||||
Status: "active",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(store, Config{
|
||||
Region: RegionChina,
|
||||
TokenSecret: "test-secret",
|
||||
TokenTTL: time.Hour,
|
||||
CodeTTL: time.Minute,
|
||||
})
|
||||
service.wechatExchanger = func(context.Context, string) (WechatIdentity, error) {
|
||||
return identity, nil
|
||||
}
|
||||
state, _, err := signState(service.cfg.TokenSecret, service.cfg.CodeTTL, service.now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
session, err := service.LoginChinaWechat(ctx, "qr-code", state, "", "", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session.Status != SessionStatusAuthenticated || session.AccessToken == "" {
|
||||
t.Fatalf("expected authenticated session, got %#v", session)
|
||||
}
|
||||
if session.User.Phone != "13800138000" || session.User.WechatOpenID != identity.OpenID {
|
||||
t.Fatalf("unexpected session user %#v", session.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChinaWechatFirstLoginRequiresPhoneBindingThenFutureLoginIsDirect(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
identity := WechatIdentity{OpenID: "wechat-open-2", UnionID: "wechat-union-2"}
|
||||
service := NewService(NewMemoryStore(), Config{
|
||||
Region: RegionChina,
|
||||
TokenSecret: "test-secret",
|
||||
TokenTTL: time.Hour,
|
||||
CodeTTL: time.Minute,
|
||||
DevReturnCode: true,
|
||||
})
|
||||
service.wechatExchanger = func(context.Context, string) (WechatIdentity, error) {
|
||||
return identity, nil
|
||||
}
|
||||
state, _, err := signState(service.cfg.TokenSecret, service.cfg.CodeTTL, service.now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pending, err := service.LoginChinaWechat(ctx, "qr-code", state, "", "", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pending.Status != SessionStatusPhoneBindingRequired {
|
||||
t.Fatalf("expected phone binding state, got %#v", pending)
|
||||
}
|
||||
if pending.AccessToken != "" || pending.BindingToken == "" || pending.BindingExpiresIn == 0 {
|
||||
t.Fatalf("unexpected pending session %#v", pending)
|
||||
}
|
||||
|
||||
code, err := service.RequestChinaSMSCode(ctx, "13800138001", "+86", PurposeWechatBind)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bound, err := service.LoginChinaWechat(ctx, "", "", pending.BindingToken, "13800138001", "+86", code.DevCode)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bound.Status != SessionStatusAuthenticated || bound.AccessToken == "" {
|
||||
t.Fatalf("expected authenticated bound session, got %#v", bound)
|
||||
}
|
||||
if bound.User.Phone != "13800138001" || bound.User.WechatUnionID != identity.UnionID {
|
||||
t.Fatalf("unexpected bound user %#v", bound.User)
|
||||
}
|
||||
|
||||
nextState, _, err := signState(service.cfg.TokenSecret, service.cfg.CodeTTL, service.now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
direct, err := service.LoginChinaWechat(ctx, "qr-code-again", nextState, "", "", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if direct.Status != SessionStatusAuthenticated || direct.User.ID != bound.User.ID {
|
||||
t.Fatalf("expected future qr login to be direct for %s, got %#v", bound.User.ID, direct)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SMTPOptions struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
UseTLS bool
|
||||
StartTLS bool
|
||||
InsecureSkipVerify bool
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type SMTPSender struct {
|
||||
opts SMTPOptions
|
||||
}
|
||||
|
||||
func NewSMTPSender(opts SMTPOptions) (*SMTPSender, error) {
|
||||
opts.Host = strings.TrimSpace(opts.Host)
|
||||
if opts.Host == "" {
|
||||
return nil, fmt.Errorf("%w: smtp host is required", ErrProviderNotReady)
|
||||
}
|
||||
if opts.Port <= 0 {
|
||||
opts.Port = 587
|
||||
}
|
||||
if opts.Timeout <= 0 {
|
||||
opts.Timeout = 10 * time.Second
|
||||
}
|
||||
return &SMTPSender{opts: opts}, nil
|
||||
}
|
||||
|
||||
func (s *SMTPSender) SendLoginCode(ctx context.Context, message EmailMessage) error {
|
||||
if s == nil {
|
||||
return ErrProviderNotReady
|
||||
}
|
||||
if strings.TrimSpace(message.FromEmail) == "" || strings.TrimSpace(message.ToEmail) == "" {
|
||||
return fmt.Errorf("%w: email sender and recipient are required", ErrInvalidCredentials)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, s.opts.Timeout)
|
||||
defer cancel()
|
||||
|
||||
client, err := s.smtpClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if s.opts.StartTLS && !s.opts.UseTLS {
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(s.tlsConfig()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(s.opts.Username) != "" {
|
||||
auth := smtp.PlainAuth("", s.opts.Username, s.opts.Password, s.opts.Host)
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := client.Mail(message.FromEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Rcpt(message.ToEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := writer.Write(buildSMTPMessage(message)); err != nil {
|
||||
_ = writer.Close()
|
||||
return err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func (s *SMTPSender) smtpClient(ctx context.Context) (*smtp.Client, error) {
|
||||
address := fmt.Sprintf("%s:%d", s.opts.Host, s.opts.Port)
|
||||
dialer := &net.Dialer{Timeout: s.opts.Timeout}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.opts.UseTLS {
|
||||
tlsConn := tls.Client(conn, s.tlsConfig())
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
conn = tlsConn
|
||||
}
|
||||
return smtp.NewClient(conn, s.opts.Host)
|
||||
}
|
||||
|
||||
func (s *SMTPSender) tlsConfig() *tls.Config {
|
||||
return &tls.Config{
|
||||
ServerName: s.opts.Host,
|
||||
InsecureSkipVerify: s.opts.InsecureSkipVerify,
|
||||
}
|
||||
}
|
||||
|
||||
func buildSMTPMessage(message EmailMessage) []byte {
|
||||
boundary := "moteva-auth-code"
|
||||
from := mail.Address{Name: message.FromName, Address: message.FromEmail}
|
||||
to := mail.Address{Address: message.ToEmail}
|
||||
var buffer bytes.Buffer
|
||||
buffer.WriteString("From: " + from.String() + "\r\n")
|
||||
buffer.WriteString("To: " + to.String() + "\r\n")
|
||||
buffer.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", message.Subject) + "\r\n")
|
||||
buffer.WriteString("MIME-Version: 1.0\r\n")
|
||||
buffer.WriteString("Content-Type: multipart/alternative; boundary=" + boundary + "\r\n\r\n")
|
||||
buffer.WriteString("--" + boundary + "\r\n")
|
||||
buffer.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||
buffer.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
|
||||
buffer.WriteString(message.Text + "\r\n\r\n")
|
||||
buffer.WriteString("--" + boundary + "\r\n")
|
||||
buffer.WriteString("Content-Type: text/html; charset=utf-8\r\n")
|
||||
buffer.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
|
||||
buffer.WriteString(message.HTML + "\r\n\r\n")
|
||||
buffer.WriteString("--" + boundary + "--\r\n")
|
||||
return buffer.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type tokenPayload struct {
|
||||
Subject string `json:"sub"`
|
||||
Type string `json:"typ,omitempty"`
|
||||
OpenID string `json:"openid,omitempty"`
|
||||
UnionID string `json:"unionid,omitempty"`
|
||||
Issued int64 `json:"iat"`
|
||||
Expires int64 `json:"exp"`
|
||||
}
|
||||
|
||||
func signToken(userID string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
|
||||
if ttl <= 0 {
|
||||
ttl = 7 * 24 * time.Hour
|
||||
}
|
||||
return signTypedToken(userID, "access", secret, ttl, now)
|
||||
}
|
||||
|
||||
func signRefreshToken(userID string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * 24 * time.Hour
|
||||
}
|
||||
return signTypedToken(userID, "refresh", secret, ttl, now)
|
||||
}
|
||||
|
||||
func signTypedToken(userID string, tokenType string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
|
||||
payload := tokenPayload{
|
||||
Subject: userID,
|
||||
Type: tokenType,
|
||||
Issued: now.Unix(),
|
||||
Expires: now.Add(ttl).Unix(),
|
||||
}
|
||||
token, err := signPayload(payload, secret)
|
||||
return token, int64(ttl.Seconds()), err
|
||||
}
|
||||
|
||||
func parseToken(ctx context.Context, token string, secret string, now time.Time) (string, bool) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", false
|
||||
}
|
||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
||||
if len(parts) != 2 {
|
||||
return "", false
|
||||
}
|
||||
expected := signBytes([]byte(parts[0]), secret)
|
||||
actual, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil || !hmac.Equal(expected, actual) {
|
||||
return "", false
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
var payload tokenPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return "", false
|
||||
}
|
||||
if payload.Subject == "" || payload.Expires <= now.Unix() {
|
||||
return "", false
|
||||
}
|
||||
if payload.Type != "" && payload.Type != "access" {
|
||||
return "", false
|
||||
}
|
||||
return payload.Subject, true
|
||||
}
|
||||
|
||||
func signWechatBindingToken(identity WechatIdentity, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
|
||||
if ttl <= 0 {
|
||||
ttl = 5 * time.Minute
|
||||
}
|
||||
if strings.TrimSpace(identity.OpenID) == "" {
|
||||
return "", 0, fmt.Errorf("%w: wechat identity is incomplete", ErrInvalidCredentials)
|
||||
}
|
||||
payload := tokenPayload{
|
||||
Type: "wechat_binding",
|
||||
OpenID: strings.TrimSpace(identity.OpenID),
|
||||
UnionID: strings.TrimSpace(identity.UnionID),
|
||||
Issued: now.Unix(),
|
||||
Expires: now.Add(ttl).Unix(),
|
||||
}
|
||||
token, err := signPayload(payload, secret)
|
||||
return token, int64(ttl.Seconds()), err
|
||||
}
|
||||
|
||||
func parseWechatBindingToken(ctx context.Context, token string, secret string, now time.Time) (WechatIdentity, bool) {
|
||||
payload, ok := parsePayload(ctx, token, secret)
|
||||
if !ok {
|
||||
return WechatIdentity{}, false
|
||||
}
|
||||
if payload.Type != "wechat_binding" || payload.OpenID == "" || payload.Expires <= now.Unix() {
|
||||
return WechatIdentity{}, false
|
||||
}
|
||||
return WechatIdentity{OpenID: payload.OpenID, UnionID: payload.UnionID}, true
|
||||
}
|
||||
|
||||
func signPayload(payload tokenPayload, secret string) (string, error) {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
encodedPayload := base64.RawURLEncoding.EncodeToString(data)
|
||||
signature := signBytes([]byte(encodedPayload), secret)
|
||||
return encodedPayload + "." + base64.RawURLEncoding.EncodeToString(signature), nil
|
||||
}
|
||||
|
||||
func parsePayload(ctx context.Context, token string, secret string) (tokenPayload, bool) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return tokenPayload{}, false
|
||||
}
|
||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
||||
if len(parts) != 2 {
|
||||
return tokenPayload{}, false
|
||||
}
|
||||
expected := signBytes([]byte(parts[0]), secret)
|
||||
actual, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil || !hmac.Equal(expected, actual) {
|
||||
return tokenPayload{}, false
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return tokenPayload{}, false
|
||||
}
|
||||
var payload tokenPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return tokenPayload{}, false
|
||||
}
|
||||
return payload, true
|
||||
}
|
||||
|
||||
func signState(secret string, ttl time.Duration, now time.Time) (string, int64, error) {
|
||||
stateID := newID()
|
||||
token, expiresIn, err := signToken(stateID, secret, ttl, now)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return token, expiresIn, nil
|
||||
}
|
||||
|
||||
func verifyState(ctx context.Context, state string, secret string, now time.Time) error {
|
||||
if strings.TrimSpace(state) == "" {
|
||||
return fmt.Errorf("%w: state is required", ErrInvalidCredentials)
|
||||
}
|
||||
if _, ok := parseToken(ctx, state, secret, now); !ok {
|
||||
return fmt.Errorf("%w: state is invalid", ErrInvalidCredentials)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hashVerificationCode(code string, target string, secret string) string {
|
||||
value := strings.TrimSpace(target) + ":" + strings.TrimSpace(code)
|
||||
return base64.RawURLEncoding.EncodeToString(signBytes([]byte(value), secret))
|
||||
}
|
||||
|
||||
func signBytes(data []byte, secret string) []byte {
|
||||
secret = strings.TrimSpace(secret)
|
||||
if secret == "" {
|
||||
secret = "local-dev-change-me"
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write(data)
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func bearerToken(header string) (string, bool) {
|
||||
header = strings.TrimSpace(header)
|
||||
if header == "" {
|
||||
return "", false
|
||||
}
|
||||
parts := strings.Fields(header)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
return "", false
|
||||
}
|
||||
return parts[1], true
|
||||
}
|
||||
|
||||
func normalizeTokenErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrInvalidCredentials, err)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const defaultTurnstileSiteVerifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||
|
||||
type TurnstileVerifierOptions struct {
|
||||
SecretKey string
|
||||
SiteVerifyURL string
|
||||
Timeout time.Duration
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
type TurnstileVerifier struct {
|
||||
secretKey string
|
||||
siteVerifyURL string
|
||||
timeout time.Duration
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type turnstileVerifyRequest struct {
|
||||
Secret string `json:"secret"`
|
||||
Response string `json:"response"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
}
|
||||
|
||||
type turnstileVerifyResponse struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorCodes []string `json:"error-codes"`
|
||||
}
|
||||
|
||||
func NewTurnstileVerifier(opts TurnstileVerifierOptions) (*TurnstileVerifier, error) {
|
||||
secretKey := strings.TrimSpace(opts.SecretKey)
|
||||
if secretKey == "" {
|
||||
return nil, ErrProviderNotReady
|
||||
}
|
||||
siteVerifyURL := strings.TrimSpace(opts.SiteVerifyURL)
|
||||
if siteVerifyURL == "" {
|
||||
siteVerifyURL = defaultTurnstileSiteVerifyURL
|
||||
}
|
||||
timeout := opts.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
client := opts.HTTPClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: timeout}
|
||||
}
|
||||
return &TurnstileVerifier{
|
||||
secretKey: secretKey,
|
||||
siteVerifyURL: siteVerifyURL,
|
||||
timeout: timeout,
|
||||
client: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (v *TurnstileVerifier) Verify(ctx context.Context, token string) error {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return ErrHumanVerificationRequired
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, v.timeout)
|
||||
defer cancel()
|
||||
|
||||
body, err := json.Marshal(turnstileVerifyRequest{
|
||||
Secret: v.secretKey,
|
||||
Response: token,
|
||||
IdempotencyKey: uuid.NewString(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, v.siteVerifyURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := v.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: turnstile verification request failed", ErrHumanVerificationInvalid)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result turnstileVerifyResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("%w: turnstile verification response is invalid", ErrHumanVerificationInvalid)
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || !result.Success {
|
||||
if len(result.ErrorCodes) > 0 {
|
||||
return fmt.Errorf("%w: %s", ErrHumanVerificationInvalid, strings.Join(result.ErrorCodes, ","))
|
||||
}
|
||||
return ErrHumanVerificationInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
RegionChina = "china"
|
||||
RegionGlobal = "global"
|
||||
|
||||
SessionStatusAuthenticated = "authenticated"
|
||||
SessionStatusPhoneBindingRequired = "phone_binding_required"
|
||||
|
||||
ChannelSMS = "sms"
|
||||
ChannelEmail = "email"
|
||||
|
||||
PurposeLogin = "login"
|
||||
PurposeWechatBind = "wechat_bind"
|
||||
defaultCountryCode = "+86"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidRegion = errors.New("invalid auth region")
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrCodeInvalid = errors.New("verification code is invalid")
|
||||
ErrCodeExpired = errors.New("verification code is expired")
|
||||
ErrProviderNotReady = errors.New("auth provider is not configured")
|
||||
ErrHumanVerificationRequired = errors.New("human verification is required")
|
||||
ErrHumanVerificationInvalid = errors.New("human verification failed")
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Region string
|
||||
Methods []Method
|
||||
Turnstile TurnstileOptions
|
||||
}
|
||||
|
||||
type TurnstileOptions struct {
|
||||
Enabled bool
|
||||
SiteKey string
|
||||
}
|
||||
|
||||
type Method struct {
|
||||
ID string
|
||||
Label string
|
||||
Provider string
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Region string
|
||||
Phone string
|
||||
CountryCode string
|
||||
Email string
|
||||
Name string
|
||||
AvatarURL string
|
||||
PasswordHash string
|
||||
WechatOpenID string
|
||||
WechatUnionID string
|
||||
GoogleSubject string
|
||||
Status string
|
||||
}
|
||||
|
||||
type ProfilePatch struct {
|
||||
Name *string
|
||||
AvatarURL *string
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
ID string
|
||||
Online bool
|
||||
Current bool
|
||||
DeviceType string
|
||||
System string
|
||||
Browser string
|
||||
Client string
|
||||
LastSeenAt time.Time
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Status string
|
||||
User User
|
||||
AccessToken string
|
||||
ExpiresIn int64
|
||||
RefreshToken string
|
||||
RefreshExpiresIn int64
|
||||
BindingToken string
|
||||
BindingExpiresIn int64
|
||||
}
|
||||
|
||||
type CodeResult struct {
|
||||
Target string
|
||||
ExpiresIn int64
|
||||
DevCode string
|
||||
Email *EmailMessage
|
||||
}
|
||||
|
||||
type EmailMessage struct {
|
||||
FromName string
|
||||
FromEmail string
|
||||
ToEmail string
|
||||
Subject string
|
||||
Code string
|
||||
Text string
|
||||
HTML string
|
||||
}
|
||||
|
||||
type EmailSender interface {
|
||||
SendLoginCode(ctx context.Context, message EmailMessage) error
|
||||
}
|
||||
|
||||
type HumanVerifier interface {
|
||||
Verify(ctx context.Context, token string) error
|
||||
}
|
||||
|
||||
type VerificationCode struct {
|
||||
ID string
|
||||
Region string
|
||||
Channel string
|
||||
Target string
|
||||
Purpose string
|
||||
CodeHash string
|
||||
Attempts int
|
||||
ExpiresAt time.Time
|
||||
ConsumedAt *time.Time
|
||||
}
|
||||
|
||||
type GoogleIdentity struct {
|
||||
Subject string
|
||||
Email string
|
||||
Name string
|
||||
}
|
||||
|
||||
type WechatIdentity struct {
|
||||
OpenID string
|
||||
UnionID string
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
FindUserByID(ctx context.Context, id string) (User, error)
|
||||
FindUserByPhone(ctx context.Context, countryCode string, phone string) (User, error)
|
||||
FindUserByEmail(ctx context.Context, email string) (User, error)
|
||||
FindUserByGoogleSubject(ctx context.Context, subject string) (User, error)
|
||||
FindUserByWechat(ctx context.Context, openID string, unionID string) (User, error)
|
||||
UpsertUser(ctx context.Context, user User) (User, error)
|
||||
UpdateUserProfile(ctx context.Context, userID string, patch ProfilePatch) (User, error)
|
||||
VerificationCodeStore
|
||||
Close() error
|
||||
}
|
||||
|
||||
type VerificationCodeStore interface {
|
||||
CreateVerificationCode(ctx context.Context, code VerificationCode) error
|
||||
FindActiveVerificationCode(ctx context.Context, region string, channel string, target string, purpose string) (VerificationCode, error)
|
||||
MarkVerificationCodeConsumed(ctx context.Context, id string, consumedAt time.Time) error
|
||||
IncrementVerificationCodeAttempts(ctx context.Context, id string) error
|
||||
}
|
||||
Reference in New Issue
Block a user