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 }