mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-29 00:10:31 +00:00
对 #4814 的审计跟进修复: - 域名尾随点绕过:user@gmail.com. 的域名不在 gmail 家族名单内,点号折叠与 googlemail 归一被整体跳过,别名刷号原样可复现。归一化入口统一去掉 FQDN 根点。 - 误拒合法用户:剥 "+后缀" 缺空串守卫,+alice@ 与 +bob@ 都折叠成 @domain, 该域后续 "+x@" 注册会永久 EMAIL_EXISTS 且无自助恢复。改为仅当 "+" 不在首位时剥离。 - 无界不可索引全表扫描:原实现按 LOWER(email) LIKE '%@domain' 把整域邮箱读进内存, 且挂在公开未鉴权的 send-verify-code 上。改为按去点邮箱 REPLACE(LOWER(TRIM(email)), '.', '') 做等值 + "local+%@domain" 前缀探针并带 LIMIT, 新增同表达式的部分索引(migrations/190)。TRIM 口径与既有精确匹配一致, 历史带首尾空白的行同样命中;LIKE 元字符转义,% 与 _ 不会扩大匹配面。 - 并发竞态:注册改走 CreateWithEmailAliasGuard,在邮箱唯一性锁上追加收件箱身份锁并在 锁内复查,避免同一收件箱的多个别名变体同时通过服务层前置查重。管理员建号仍走 Create,不受别名限制。 - 能力断言静默 fail-open:别名查重方法上提到 UserRepository 端口(编译期强制), 移除可选接口类型断言与静默降级分支。 - OAuth 邮箱注册的两条建号路径(同样发放注册赠额)纳入同一查重口径;邮箱换绑/绑定 不纳入,否则用户把邮箱改成自己收件箱的别名会被误拒。
490 lines
15 KiB
Go
490 lines
15 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/mail"
|
||
"strings"
|
||
"time"
|
||
|
||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
|
||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||
)
|
||
|
||
func normalizeOAuthSignupSource(signupSource string) string {
|
||
signupSource = strings.TrimSpace(strings.ToLower(signupSource))
|
||
switch signupSource {
|
||
case "", "email":
|
||
return "email"
|
||
case "linuxdo", "wechat", "oidc", "github", "google", "dingtalk":
|
||
return signupSource
|
||
default:
|
||
return "email"
|
||
}
|
||
}
|
||
|
||
// SendPendingOAuthVerifyCode sends a local verification code for pending OAuth
|
||
// account-creation flows without relying on the public registration gate.
|
||
func (s *AuthService) SendPendingOAuthVerifyCode(ctx context.Context, email string, locale ...string) (*SendVerifyCodeResult, error) {
|
||
email = strings.TrimSpace(strings.ToLower(email))
|
||
if email == "" {
|
||
return nil, ErrEmailVerifyRequired
|
||
}
|
||
if _, err := mail.ParseAddress(email); err != nil {
|
||
return nil, ErrEmailVerifyRequired
|
||
}
|
||
if isReservedEmail(email) {
|
||
return nil, ErrEmailReserved
|
||
}
|
||
if s == nil || s.emailService == nil {
|
||
return nil, ErrServiceUnavailable
|
||
}
|
||
|
||
siteName := "Sub2API"
|
||
if s.settingService != nil {
|
||
siteName = s.settingService.GetSiteName(ctx)
|
||
}
|
||
if err := s.emailService.SendVerifyCode(ctx, email, siteName, firstEmailLocale(locale)); err != nil {
|
||
return nil, err
|
||
}
|
||
return &SendVerifyCodeResult{
|
||
Countdown: int(verifyCodeCooldown / time.Second),
|
||
}, nil
|
||
}
|
||
|
||
func (s *AuthService) validateOAuthRegistrationInvitation(ctx context.Context, invitationCode string) (*RedeemCode, error) {
|
||
if s == nil || s.settingService == nil || !s.settingService.IsInvitationCodeEnabled(ctx) {
|
||
return nil, nil
|
||
}
|
||
if s.redeemRepo == nil && s.oauthEmailFlowClient(ctx) == nil {
|
||
return nil, ErrServiceUnavailable
|
||
}
|
||
|
||
invitationCode = strings.TrimSpace(invitationCode)
|
||
if invitationCode == "" {
|
||
return nil, ErrInvitationCodeRequired
|
||
}
|
||
|
||
redeemCode, err := s.loadOAuthRegistrationInvitation(ctx, invitationCode)
|
||
if err != nil {
|
||
return nil, ErrInvitationCodeInvalid
|
||
}
|
||
if redeemCode.Type != RedeemTypeInvitation || !redeemCode.CanUse() {
|
||
return nil, ErrInvitationCodeInvalid
|
||
}
|
||
return redeemCode, nil
|
||
}
|
||
|
||
// VerifyOAuthEmailCode verifies the locally entered email verification code for
|
||
// third-party signup and binding flows. This is intentionally independent from
|
||
// the global registration email verification toggle.
|
||
func (s *AuthService) VerifyOAuthEmailCode(ctx context.Context, email, verifyCode string) error {
|
||
email = strings.TrimSpace(strings.ToLower(email))
|
||
verifyCode = strings.TrimSpace(verifyCode)
|
||
|
||
if email == "" {
|
||
return ErrEmailVerifyRequired
|
||
}
|
||
if verifyCode == "" {
|
||
return ErrEmailVerifyRequired
|
||
}
|
||
if s == nil || s.emailService == nil {
|
||
return ErrServiceUnavailable
|
||
}
|
||
return s.emailService.VerifyCode(ctx, email, verifyCode)
|
||
}
|
||
|
||
// RegisterOAuthEmailAccount creates a local account from a third-party first
|
||
// login after the user has verified a local email address.
|
||
func (s *AuthService) RegisterOAuthEmailAccount(
|
||
ctx context.Context,
|
||
email string,
|
||
password string,
|
||
verifyCode string,
|
||
invitationCode string,
|
||
signupSource string,
|
||
) (*TokenPair, *User, error) {
|
||
if s == nil {
|
||
return nil, nil, ErrServiceUnavailable
|
||
}
|
||
if s.settingService == nil || (!s.settingService.IsRegistrationEnabled(ctx) && !s.canBypassRegistrationDisabledForOAuth(ctx, signupSource)) {
|
||
return nil, nil, ErrRegDisabled
|
||
}
|
||
|
||
email = strings.TrimSpace(strings.ToLower(email))
|
||
if isReservedEmail(email) {
|
||
return nil, nil, ErrEmailReserved
|
||
}
|
||
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
|
||
slog.Error("oauth email register: policy rejected", "email", email, "error", err.Error())
|
||
return nil, nil, err
|
||
}
|
||
if err := s.VerifyOAuthEmailCode(ctx, email, verifyCode); err != nil {
|
||
slog.Error("oauth email register: verify code failed", "email", email, "error", err.Error())
|
||
return nil, nil, err
|
||
}
|
||
|
||
if _, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode); err != nil {
|
||
slog.Error("oauth email register: invitation failed", "email", email, "error", err.Error())
|
||
return nil, nil, err
|
||
}
|
||
|
||
// 含 +别名 / Gmail 点号 / FQDN 根点变体归一化:该路径同样发放注册赠额,不能被单个收件箱刷号。
|
||
existsEmail, err := s.existsByEmailOrAlias(ctx, email)
|
||
if err != nil {
|
||
slog.Error("oauth email register: ExistsByEmail failed", "email", email, "error", err.Error())
|
||
return nil, nil, ErrServiceUnavailable
|
||
}
|
||
if existsEmail {
|
||
return nil, nil, ErrEmailExists
|
||
}
|
||
|
||
hashedPassword, err := s.HashPassword(password)
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("hash password: %w", err)
|
||
}
|
||
|
||
signupSource = normalizeOAuthSignupSource(signupSource)
|
||
grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
|
||
|
||
user := &User{
|
||
Email: email,
|
||
PasswordHash: hashedPassword,
|
||
Role: RoleUser,
|
||
Balance: grantPlan.Balance,
|
||
Concurrency: grantPlan.Concurrency,
|
||
Status: StatusActive,
|
||
SignupSource: signupSource,
|
||
}
|
||
|
||
if err := s.userRepo.CreateWithEmailAliasGuard(ctx, user); err != nil {
|
||
if errors.Is(err, ErrEmailExists) {
|
||
return nil, nil, ErrEmailExists
|
||
}
|
||
slog.Error("oauth email register: userRepo.Create failed", "email", email, "signup_source", signupSource, "error", err.Error())
|
||
return nil, nil, ErrServiceUnavailable
|
||
}
|
||
|
||
tokenPair, err := s.GenerateTokenPair(ctx, user, "")
|
||
if err != nil {
|
||
_ = s.RollbackOAuthEmailAccountCreation(ctx, user.ID, "")
|
||
return nil, nil, fmt.Errorf("generate token pair: %w", err)
|
||
}
|
||
return tokenPair, user, nil
|
||
}
|
||
|
||
// RegisterVerifiedOAuthEmailAccount creates a local account from an OAuth
|
||
// provider that has already returned a verified email address.
|
||
func (s *AuthService) RegisterVerifiedOAuthEmailAccount(
|
||
ctx context.Context,
|
||
email string,
|
||
password string,
|
||
invitationCode string,
|
||
signupSource string,
|
||
) (*TokenPair, *User, error) {
|
||
if s == nil {
|
||
return nil, nil, ErrServiceUnavailable
|
||
}
|
||
if s.settingService == nil || (!s.settingService.IsRegistrationEnabled(ctx) && !s.canBypassRegistrationDisabledForOAuth(ctx, signupSource)) {
|
||
return nil, nil, ErrRegDisabled
|
||
}
|
||
|
||
email = strings.TrimSpace(strings.ToLower(email))
|
||
if email == "" || len(email) > 255 {
|
||
return nil, nil, ErrEmailVerifyRequired
|
||
}
|
||
if _, err := mail.ParseAddress(email); err != nil {
|
||
return nil, nil, ErrEmailVerifyRequired
|
||
}
|
||
if isReservedEmail(email) {
|
||
return nil, nil, ErrEmailReserved
|
||
}
|
||
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if strings.TrimSpace(password) == "" {
|
||
return nil, nil, infraerrors.BadRequest("PASSWORD_REQUIRED", "password is required")
|
||
}
|
||
if _, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
// 与本地注册同口径:同一收件箱的别名变体不能各自建号(该路径也发放注册赠额)。
|
||
existsEmail, err := s.existsByEmailOrAlias(ctx, email)
|
||
if err != nil {
|
||
return nil, nil, ErrServiceUnavailable
|
||
}
|
||
if existsEmail {
|
||
return nil, nil, ErrEmailExists
|
||
}
|
||
|
||
hashedPassword, err := s.HashPassword(password)
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("hash password: %w", err)
|
||
}
|
||
|
||
signupSource = normalizeOAuthSignupSource(signupSource)
|
||
grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
|
||
var defaultRPMLimit int
|
||
if s.settingService != nil {
|
||
defaultRPMLimit = s.settingService.GetDefaultUserRPMLimit(ctx)
|
||
}
|
||
user := &User{
|
||
Email: email,
|
||
PasswordHash: hashedPassword,
|
||
Role: RoleUser,
|
||
Balance: grantPlan.Balance,
|
||
Concurrency: grantPlan.Concurrency,
|
||
RPMLimit: defaultRPMLimit,
|
||
Status: StatusActive,
|
||
SignupSource: signupSource,
|
||
}
|
||
|
||
if err := s.userRepo.CreateWithEmailAliasGuard(ctx, user); err != nil {
|
||
if errors.Is(err, ErrEmailExists) {
|
||
return nil, nil, ErrEmailExists
|
||
}
|
||
return nil, nil, ErrServiceUnavailable
|
||
}
|
||
|
||
tokenPair, err := s.GenerateTokenPair(ctx, user, "")
|
||
if err != nil {
|
||
_ = s.RollbackOAuthEmailAccountCreation(ctx, user.ID, "")
|
||
return nil, nil, fmt.Errorf("generate token pair: %w", err)
|
||
}
|
||
return tokenPair, user, nil
|
||
}
|
||
|
||
// FinalizeOAuthEmailAccount applies invitation usage and normal signup bootstrap
|
||
// only after the pending OAuth flow has fully reached its last reversible step.
|
||
func (s *AuthService) FinalizeOAuthEmailAccount(
|
||
ctx context.Context,
|
||
user *User,
|
||
invitationCode string,
|
||
signupSource string,
|
||
affiliateCode string,
|
||
) error {
|
||
if s == nil || user == nil || user.ID <= 0 {
|
||
return ErrServiceUnavailable
|
||
}
|
||
|
||
signupSource = normalizeOAuthSignupSource(signupSource)
|
||
invitationRedeemCode, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if invitationRedeemCode != nil {
|
||
if err := s.useOAuthRegistrationInvitation(ctx, invitationRedeemCode.ID, user.ID); err != nil {
|
||
return ErrInvitationCodeInvalid
|
||
}
|
||
}
|
||
|
||
s.updateOAuthSignupSource(ctx, user.ID, signupSource)
|
||
grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
|
||
s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
|
||
// snapshot user × platform quota(fail-open)
|
||
_ = s.snapshotPlatformQuotaDefaults(ctx, user.ID, &grantPlan)
|
||
s.bindOAuthAffiliate(ctx, user.ID, affiliateCode)
|
||
return nil
|
||
}
|
||
|
||
// RollbackOAuthEmailAccountCreation removes a partially-created local account
|
||
// and restores any invitation code already consumed by that account.
|
||
func (s *AuthService) RollbackOAuthEmailAccountCreation(ctx context.Context, userID int64, invitationCode string) error {
|
||
if s == nil || s.userRepo == nil || userID <= 0 {
|
||
return ErrServiceUnavailable
|
||
}
|
||
if err := s.restoreOAuthRegistrationInvitation(ctx, invitationCode, userID); err != nil {
|
||
return err
|
||
}
|
||
if err := s.userRepo.Delete(ctx, userID); err != nil {
|
||
return fmt.Errorf("delete created oauth user: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *AuthService) restoreOAuthRegistrationInvitation(ctx context.Context, invitationCode string, userID int64) error {
|
||
if s == nil || s.settingService == nil || !s.settingService.IsInvitationCodeEnabled(ctx) {
|
||
return nil
|
||
}
|
||
if s.redeemRepo == nil && s.oauthEmailFlowClient(ctx) == nil {
|
||
return ErrServiceUnavailable
|
||
}
|
||
|
||
invitationCode = strings.TrimSpace(invitationCode)
|
||
if invitationCode == "" || userID <= 0 {
|
||
return nil
|
||
}
|
||
|
||
redeemCode, err := s.loadOAuthRegistrationInvitation(ctx, invitationCode)
|
||
if err != nil {
|
||
if errors.Is(err, ErrRedeemCodeNotFound) {
|
||
return nil
|
||
}
|
||
return fmt.Errorf("load invitation code: %w", err)
|
||
}
|
||
if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUsed || redeemCode.UsedBy == nil || *redeemCode.UsedBy != userID {
|
||
return nil
|
||
}
|
||
|
||
redeemCode.Status = StatusUnused
|
||
redeemCode.UsedBy = nil
|
||
redeemCode.UsedAt = nil
|
||
if err := s.updateOAuthRegistrationInvitation(ctx, redeemCode); err != nil {
|
||
return fmt.Errorf("restore invitation code: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *AuthService) oauthEmailFlowClient(ctx context.Context) *dbent.Client {
|
||
if s == nil || s.entClient == nil {
|
||
return nil
|
||
}
|
||
if tx := dbent.TxFromContext(ctx); tx != nil {
|
||
return tx.Client()
|
||
}
|
||
return s.entClient
|
||
}
|
||
|
||
func (s *AuthService) loadOAuthRegistrationInvitation(ctx context.Context, invitationCode string) (*RedeemCode, error) {
|
||
if client := s.oauthEmailFlowClient(ctx); client != nil {
|
||
entity, err := client.RedeemCode.Query().Where(redeemcode.CodeEQ(invitationCode)).Only(ctx)
|
||
if err != nil {
|
||
if dbent.IsNotFound(err) {
|
||
return nil, ErrRedeemCodeNotFound
|
||
}
|
||
return nil, err
|
||
}
|
||
return &RedeemCode{
|
||
ID: entity.ID,
|
||
Code: entity.Code,
|
||
Type: entity.Type,
|
||
Value: entity.Value,
|
||
Status: entity.Status,
|
||
UsedBy: entity.UsedBy,
|
||
UsedAt: entity.UsedAt,
|
||
Notes: oauthEmailFlowStringValue(entity.Notes),
|
||
CreatedAt: entity.CreatedAt,
|
||
ExpiresAt: entity.ExpiresAt,
|
||
GroupID: entity.GroupID,
|
||
ValidityDays: entity.ValidityDays,
|
||
}, nil
|
||
}
|
||
return s.redeemRepo.GetByCode(ctx, invitationCode)
|
||
}
|
||
|
||
func (s *AuthService) useOAuthRegistrationInvitation(ctx context.Context, invitationID, userID int64) error {
|
||
if client := s.oauthEmailFlowClient(ctx); client != nil {
|
||
affected, err := client.RedeemCode.Update().
|
||
Where(
|
||
redeemcode.IDEQ(invitationID),
|
||
redeemcode.StatusEQ(StatusUnused),
|
||
redeemcode.Or(redeemcode.ExpiresAtIsNil(), redeemcode.ExpiresAtGT(time.Now().UTC())),
|
||
).
|
||
SetStatus(StatusUsed).
|
||
SetUsedBy(userID).
|
||
SetUsedAt(time.Now().UTC()).
|
||
Save(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if affected == 0 {
|
||
return ErrRedeemCodeUsed
|
||
}
|
||
return nil
|
||
}
|
||
return s.redeemRepo.Use(ctx, invitationID, userID)
|
||
}
|
||
|
||
func (s *AuthService) updateOAuthRegistrationInvitation(ctx context.Context, code *RedeemCode) error {
|
||
if code == nil {
|
||
return nil
|
||
}
|
||
if client := s.oauthEmailFlowClient(ctx); client != nil {
|
||
update := client.RedeemCode.UpdateOneID(code.ID).
|
||
SetCode(code.Code).
|
||
SetType(code.Type).
|
||
SetValue(code.Value).
|
||
SetStatus(code.Status).
|
||
SetNotes(code.Notes).
|
||
SetValidityDays(code.ValidityDays)
|
||
if code.ExpiresAt != nil {
|
||
update = update.SetExpiresAt(*code.ExpiresAt)
|
||
} else {
|
||
update = update.ClearExpiresAt()
|
||
}
|
||
if code.UsedBy != nil {
|
||
update = update.SetUsedBy(*code.UsedBy)
|
||
} else {
|
||
update = update.ClearUsedBy()
|
||
}
|
||
if code.UsedAt != nil {
|
||
update = update.SetUsedAt(*code.UsedAt)
|
||
} else {
|
||
update = update.ClearUsedAt()
|
||
}
|
||
if code.GroupID != nil {
|
||
update = update.SetGroupID(*code.GroupID)
|
||
} else {
|
||
update = update.ClearGroupID()
|
||
}
|
||
_, err := update.Save(ctx)
|
||
return err
|
||
}
|
||
return s.redeemRepo.Update(ctx, code)
|
||
}
|
||
|
||
func (s *AuthService) updateOAuthSignupSource(ctx context.Context, userID int64, signupSource string) {
|
||
client := s.oauthEmailFlowClient(ctx)
|
||
if client == nil || userID <= 0 || strings.TrimSpace(signupSource) == "" {
|
||
return
|
||
}
|
||
_ = client.User.UpdateOneID(userID).SetSignupSource(signupSource).Exec(ctx)
|
||
}
|
||
|
||
func oauthEmailFlowStringValue(value *string) string {
|
||
if value == nil {
|
||
return ""
|
||
}
|
||
return *value
|
||
}
|
||
|
||
// ValidatePasswordCredentials checks the local password without completing the
|
||
// login flow. This is used by pending third-party account adoption flows before
|
||
// the external identity has been bound.
|
||
func (s *AuthService) ValidatePasswordCredentials(ctx context.Context, email, password string) (*User, error) {
|
||
if s == nil {
|
||
return nil, ErrServiceUnavailable
|
||
}
|
||
|
||
user, err := s.userRepo.GetByEmail(ctx, strings.TrimSpace(strings.ToLower(email)))
|
||
if err != nil {
|
||
if errors.Is(err, ErrUserNotFound) {
|
||
return nil, ErrInvalidCredentials
|
||
}
|
||
return nil, ErrServiceUnavailable
|
||
}
|
||
if !user.IsActive() {
|
||
return nil, ErrUserNotActive
|
||
}
|
||
if !s.CheckPassword(password, user.PasswordHash) {
|
||
return nil, ErrInvalidCredentials
|
||
}
|
||
return user, nil
|
||
}
|
||
|
||
// RecordSuccessfulLogin updates last-login activity after a non-standard login
|
||
// flow finishes with a real session.
|
||
func (s *AuthService) RecordSuccessfulLogin(ctx context.Context, userID int64) {
|
||
if s != nil && s.userRepo != nil && userID > 0 {
|
||
user, err := s.userRepo.GetByID(ctx, userID)
|
||
if err == nil && user != nil && !isReservedEmail(user.Email) {
|
||
s.backfillEmailIdentityOnSuccessfulLogin(ctx, user)
|
||
}
|
||
}
|
||
s.touchUserLogin(ctx, userID)
|
||
}
|