Files
root 44406b72db 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>
2026-07-07 23:15:37 +08:00

647 lines
20 KiB
Go

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
}