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:
2026-07-07 23:15:37 +08:00
commit 44406b72db
349 changed files with 73265 additions and 0 deletions
@@ -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)
}
+76
View File
@@ -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()
}
+133
View File
@@ -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
}
+646
View File
@@ -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)
}
}
+139
View File
@@ -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()
}
+196
View File
@@ -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
}
+158
View File
@@ -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
}