44406b72db
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>
647 lines
20 KiB
Go
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
|
|
}
|