fix(auth): apply promo codes to oauth signups

This commit is contained in:
wucm667
2026-06-11 18:55:27 +08:00
parent e34ad2b194
commit f8c80bf038
10 changed files with 516 additions and 19 deletions
@@ -143,6 +143,7 @@ func (h *AuthHandler) DingTalkOAuthStart(c *gin.Context) {
intent := normalizeOAuthIntent(c.Query("intent"))
setDingTalkCookie(c, dingTalkOAuthIntentCookieName, encodeCookieValue(intent), dingTalkOAuthCookieMaxAgeSec, secureCookie)
captureOAuthPromoCode(c, secureCookie)
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
clearOAuthPendingSessionCookie(c, secureCookie)
@@ -317,6 +318,7 @@ func (h *AuthHandler) DingTalkOAuthCallback(c *gin.Context) {
clearDingTalkCookie(c, dingTalkOAuthStateCookieName, secureCookie)
clearDingTalkCookie(c, dingTalkOAuthRedirectCookie, secureCookie)
clearDingTalkCookie(c, dingTalkOAuthIntentCookieName, secureCookie)
clearOAuthPromoCodeCookie(c, secureCookie)
}()
expectedState, err := readCookieDecoded(c, dingTalkOAuthStateCookieName)
@@ -779,7 +781,15 @@ func (h *AuthHandler) CompleteDingTalkOAuthRegistration(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "dingtalk")
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
c.Request.Context(),
email,
username,
req.InvitationCode,
req.AffCode,
pendingOAuthPromoCode(session),
"dingtalk",
)
if err != nil {
response.ErrorFrom(c, err)
return
+10 -1
View File
@@ -78,6 +78,7 @@ func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) {
emailOAuthSetCookie(c, emailOAuthStateCookieName, encodeCookieValue(state), secureCookie)
emailOAuthSetCookie(c, emailOAuthRedirectCookie, encodeCookieValue(redirectTo), secureCookie)
emailOAuthSetCookie(c, emailOAuthProviderCookie, encodeCookieValue(provider), secureCookie)
captureOAuthPromoCode(c, secureCookie)
if affCode := strings.TrimSpace(firstNonEmpty(c.Query("aff_code"), c.Query("aff"))); affCode != "" {
emailOAuthSetCookie(c, emailOAuthAffiliateCookie, encodeCookieValue(affCode), secureCookie)
} else {
@@ -119,6 +120,7 @@ func (h *AuthHandler) emailOAuthCallback(c *gin.Context, provider string) {
emailOAuthClearCookie(c, emailOAuthRedirectCookie, secureCookie)
emailOAuthClearCookie(c, emailOAuthProviderCookie, secureCookie)
emailOAuthClearCookie(c, emailOAuthAffiliateCookie, secureCookie)
clearOAuthPromoCodeCookie(c, secureCookie)
}()
expectedState, err := readCookieDecoded(c, emailOAuthStateCookieName)
if err != nil || expectedState == "" || expectedState != state {
@@ -181,7 +183,13 @@ func (h *AuthHandler) emailOAuthCallbackWithProfile(
return
}
tokenPair, user, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithInvitation(c.Request.Context(), input, "", affiliateCode)
tokenPair, user, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithSignupCodes(
c.Request.Context(),
input,
"",
affiliateCode,
readOAuthPromoCode(c),
)
if err != nil {
if errors.Is(err, service.ErrOAuthInvitationRequired) {
if pendingErr := h.createEmailOAuthRegistrationPendingSession(c, provider, frontendCallback, redirectTo, profile); pendingErr != nil {
@@ -427,6 +435,7 @@ func (h *AuthHandler) completeEmailOAuthRegistration(c *gin.Context, provider st
response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_BIND_APPLY_FAILED", "failed to consume pending oauth session").WithCause(err))
return
}
h.authService.ApplyOAuthSignupPromoCode(c.Request.Context(), user.ID, pendingOAuthPromoCode(session))
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
clearCookies()
writeOAuthTokenPairResponse(c, tokenPair)
@@ -184,6 +184,49 @@ func TestEmailOAuthCallbackCreatesPasswordRegistrationSessionForNewEmail(t *test
require.Equal(t, "aff-user@example.com", completion["resolved_email"])
}
func TestEmailOAuthStartPreservesPromoCodeInPendingSession(t *testing.T) {
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
settingValues: map[string]string{
service.SettingKeyGitHubOAuthEnabled: "true",
service.SettingKeyGitHubOAuthClientID: "github-client",
service.SettingKeyGitHubOAuthClientSecret: "github-secret",
service.SettingKeyGitHubOAuthRedirectURL: "https://app.example/api/v1/auth/oauth/github/callback",
},
})
ctx := context.Background()
startRecorder := httptest.NewRecorder()
startCtx, _ := gin.CreateTestContext(startRecorder)
startCtx.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/start?promo_code=WELCOME2024", nil)
handler.GitHubOAuthStart(startCtx)
require.Equal(t, http.StatusFound, startRecorder.Code)
promoCookie := findCookie(startRecorder.Result().Cookies(), oauthPromoCodeCookieName)
require.NotNil(t, promoCookie)
require.Equal(t, "WELCOME2024", decodeCookieValueForTest(t, promoCookie.Value))
callbackRecorder := httptest.NewRecorder()
callbackCtx, _ := gin.CreateTestContext(callbackRecorder)
callbackReq := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/callback", nil)
callbackReq.AddCookie(promoCookie)
callbackCtx.Request = callbackReq
handler.emailOAuthCallbackWithProfile(callbackCtx, "github", config.EmailOAuthProviderConfig{
FrontendRedirectURL: "/auth/oauth/callback",
}, "/auth/oauth/callback", "/dashboard", &emailOAuthProfile{
Subject: "github-promo-user",
Email: "promo-user@example.com",
EmailVerified: true,
Username: "promo-user",
})
require.Equal(t, http.StatusFound, callbackRecorder.Code)
session, err := client.PendingAuthSession.Query().Only(ctx)
require.NoError(t, err)
require.Equal(t, "WELCOME2024", pendingOAuthPromoCode(session))
}
func TestCompleteEmailOAuthRegistrationUsesAffiliateCodeFromPendingSession(t *testing.T) {
affiliateRepo := newOAuthEmailAffiliateRepoStub(map[string]int64{"AFF456": 2002})
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
+20 -2
View File
@@ -110,6 +110,7 @@ func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
setCookie(c, linuxDoOAuthRedirectCookie, encodeCookieValue(redirectTo), linuxDoOAuthCookieMaxAgeSec, secureCookie)
intent := normalizeOAuthIntent(c.Query("intent"))
setCookie(c, linuxDoOAuthIntentCookieName, encodeCookieValue(intent), linuxDoOAuthCookieMaxAgeSec, secureCookie)
captureOAuthPromoCode(c, secureCookie)
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
clearOAuthPendingSessionCookie(c, secureCookie)
if intent == oauthIntentBindCurrentUser {
@@ -182,6 +183,7 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
clearCookie(c, linuxDoOAuthRedirectCookie, secureCookie)
clearCookie(c, linuxDoOAuthIntentCookieName, secureCookie)
clearCookie(c, linuxDoOAuthBindUserCookieName, secureCookie)
clearOAuthPromoCodeCookie(c, secureCookie)
}()
expectedState, err := readCookieDecoded(c, linuxDoOAuthStateCookieName)
@@ -329,7 +331,15 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "", "", "linuxdo")
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
c.Request.Context(),
email,
username,
"",
"",
readOAuthPromoCode(c),
"linuxdo",
)
if err == nil {
if err := applyPendingOAuthBinding(
c.Request.Context(),
@@ -561,7 +571,15 @@ func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "linuxdo")
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
c.Request.Context(),
email,
username,
req.InvitationCode,
req.AffCode,
pendingOAuthPromoCode(session),
"linuxdo",
)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -32,10 +32,12 @@ const (
oauthPendingBrowserCookieName = "oauth_pending_browser_session"
oauthPendingSessionCookiePath = "/api/v1/auth/oauth"
oauthPendingSessionCookieName = "oauth_pending_session"
oauthPromoCodeCookieName = "oauth_promo_code"
oauthPendingCookieMaxAgeSec = 10 * 60
oauthPendingChoiceStep = "choose_account_action_required"
oauthCompletionResponseKey = "completion_response"
oauthPromoCodeStateKey = "promo_code"
)
var pendingOAuthCreateAccountPreCommitHook func(context.Context, *dbent.PendingAuthSession) error
@@ -161,6 +163,53 @@ func readOAuthPendingSessionCookie(c *gin.Context) (string, error) {
return readCookieDecoded(c, oauthPendingSessionCookieName)
}
func captureOAuthPromoCode(c *gin.Context, secure bool) {
promoCode := strings.TrimSpace(c.Query("promo_code"))
if promoCode == "" {
clearOAuthPromoCodeCookie(c, secure)
return
}
http.SetCookie(c.Writer, &http.Cookie{
Name: oauthPromoCodeCookieName,
Value: encodeCookieValue(promoCode),
Path: oauthPendingBrowserCookiePath,
MaxAge: oauthPendingCookieMaxAgeSec,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
func clearOAuthPromoCodeCookie(c *gin.Context, secure bool) {
http.SetCookie(c.Writer, &http.Cookie{
Name: oauthPromoCodeCookieName,
Value: "",
Path: oauthPendingBrowserCookiePath,
MaxAge: -1,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
func readOAuthPromoCode(c *gin.Context) string {
if c == nil {
return ""
}
promoCode, err := readCookieDecoded(c, oauthPromoCodeCookieName)
if err != nil {
return ""
}
return strings.TrimSpace(promoCode)
}
func pendingOAuthPromoCode(session *dbent.PendingAuthSession) string {
if session == nil {
return ""
}
return pendingSessionStringValue(session.LocalFlowState, oauthPromoCodeStateKey)
}
func redirectToFrontendCallback(c *gin.Context, frontendCallback string) {
u, err := url.Parse(frontendCallback)
if err != nil {
@@ -183,6 +232,13 @@ func (h *AuthHandler) createOAuthPendingSession(c *gin.Context, payload oauthPen
return err
}
localFlowState := map[string]any{
oauthCompletionResponseKey: payload.CompletionResponse,
}
if promoCode := readOAuthPromoCode(c); promoCode != "" {
localFlowState[oauthPromoCodeStateKey] = promoCode
}
session, err := svc.CreatePendingSession(c.Request.Context(), service.CreatePendingAuthSessionInput{
Intent: strings.TrimSpace(payload.Intent),
Identity: payload.Identity,
@@ -191,9 +247,7 @@ func (h *AuthHandler) createOAuthPendingSession(c *gin.Context, payload oauthPen
RedirectTo: strings.TrimSpace(payload.RedirectTo),
BrowserSessionKey: strings.TrimSpace(payload.BrowserSessionKey),
UpstreamIdentityClaims: payload.UpstreamIdentityClaims,
LocalFlowState: map[string]any{
oauthCompletionResponseKey: payload.CompletionResponse,
},
LocalFlowState: localFlowState,
})
if err != nil {
slog.Error("pending auth session create failed",
@@ -1820,6 +1874,7 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
return
}
h.authService.ApplyOAuthSignupPromoCode(c.Request.Context(), user.ID, pendingOAuthPromoCode(session))
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
// createPendingOAuthAccount = 注册新账户,需要把钉钉昵称同步到 users.username 作为初始值
h.maybeSyncDingTalkAfterRegistration(c.Request.Context(), session, user.ID)
@@ -8,6 +8,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -1049,6 +1050,201 @@ func TestCreateOIDCOAuthAccountCreatesUserBindsIdentityAndConsumesSession(t *tes
require.NotNil(t, storedSession.ConsumedAt)
}
func TestCreateOIDCOAuthAccountAppliesPromoCodeFromPendingSession(t *testing.T) {
promoRepo := newOAuthPendingFlowPromoRepoStub("WELCOME2024", 25)
emailCache := &oauthPendingFlowEmailCacheStub{
verificationCodes: map[string]*service.VerificationCodeData{
"promo@example.com": {
Code: "246810",
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
},
},
}
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
emailVerifyEnabled: true,
emailCache: emailCache,
promoRepo: promoRepo,
settingValues: map[string]string{
service.SettingKeyPromoCodeEnabled: "true",
},
})
ctx := context.Background()
session, err := client.PendingAuthSession.Create().
SetSessionToken("promo-create-account-session-token").
SetIntent(oauthIntentLogin).
SetProviderType("oidc").
SetProviderKey("https://issuer.example").
SetProviderSubject("oidc-promo-123").
SetBrowserSessionKey("promo-create-account-browser-key").
SetUpstreamIdentityClaims(map[string]any{"username": "promo_user"}).
SetLocalFlowState(map[string]any{oauthPromoCodeStateKey: "WELCOME2024"}).
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
Save(ctx)
require.NoError(t, err)
body := bytes.NewBufferString(`{"email":"promo@example.com","verify_code":"246810","password":"secret-123"}`)
recorder := httptest.NewRecorder()
ginCtx, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", body)
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue(session.BrowserSessionKey)})
ginCtx.Request = req
handler.CreateOIDCOAuthAccount(ginCtx)
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, []string{"WELCOME2024"}, promoRepo.applyCalls)
createdUser, err := client.User.Query().Where(dbuser.EmailEQ("promo@example.com")).Only(ctx)
require.NoError(t, err)
require.Equal(t, 25.0, createdUser.Balance)
require.Len(t, promoRepo.usages, 1)
require.Equal(t, createdUser.ID, promoRepo.usages[0].UserID)
}
func TestCreateOIDCOAuthAccountWithoutPromoCodeDoesNotApplyPromo(t *testing.T) {
promoRepo := newOAuthPendingFlowPromoRepoStub("WELCOME2024", 25)
emailCache := &oauthPendingFlowEmailCacheStub{
verificationCodes: map[string]*service.VerificationCodeData{
"no-promo@example.com": {
Code: "246810",
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
},
},
}
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
emailVerifyEnabled: true,
emailCache: emailCache,
promoRepo: promoRepo,
settingValues: map[string]string{
service.SettingKeyPromoCodeEnabled: "true",
},
})
ctx := context.Background()
session, err := client.PendingAuthSession.Create().
SetSessionToken("no-promo-create-account-session-token").
SetIntent(oauthIntentLogin).
SetProviderType("oidc").
SetProviderKey("https://issuer.example").
SetProviderSubject("oidc-no-promo-123").
SetBrowserSessionKey("no-promo-create-account-browser-key").
SetUpstreamIdentityClaims(map[string]any{"username": "no_promo_user"}).
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
Save(ctx)
require.NoError(t, err)
body := bytes.NewBufferString(`{"email":"no-promo@example.com","verify_code":"246810","password":"secret-123"}`)
recorder := httptest.NewRecorder()
ginCtx, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", body)
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue(session.BrowserSessionKey)})
ginCtx.Request = req
handler.CreateOIDCOAuthAccount(ginCtx)
require.Equal(t, http.StatusOK, recorder.Code)
require.Empty(t, promoRepo.applyCalls)
createdUser, err := client.User.Query().Where(dbuser.EmailEQ("no-promo@example.com")).Only(ctx)
require.NoError(t, err)
require.Zero(t, createdUser.Balance)
}
func TestCreateOIDCOAuthAccountDoesNotApplyPromoWhenDisabled(t *testing.T) {
promoRepo := newOAuthPendingFlowPromoRepoStub("WELCOME2024", 25)
emailCache := &oauthPendingFlowEmailCacheStub{
verificationCodes: map[string]*service.VerificationCodeData{
"promo-disabled@example.com": {
Code: "246810",
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
},
},
}
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
emailVerifyEnabled: true,
emailCache: emailCache,
promoRepo: promoRepo,
settingValues: map[string]string{
service.SettingKeyPromoCodeEnabled: "false",
},
})
ctx := context.Background()
session, err := client.PendingAuthSession.Create().
SetSessionToken("promo-disabled-session-token").
SetIntent(oauthIntentLogin).
SetProviderType("oidc").
SetProviderKey("https://issuer.example").
SetProviderSubject("oidc-promo-disabled-123").
SetBrowserSessionKey("promo-disabled-browser-key").
SetUpstreamIdentityClaims(map[string]any{"username": "promo_disabled_user"}).
SetLocalFlowState(map[string]any{oauthPromoCodeStateKey: "WELCOME2024"}).
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
Save(ctx)
require.NoError(t, err)
body := bytes.NewBufferString(`{"email":"promo-disabled@example.com","verify_code":"246810","password":"secret-123"}`)
recorder := httptest.NewRecorder()
ginCtx, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", body)
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue(session.BrowserSessionKey)})
ginCtx.Request = req
handler.CreateOIDCOAuthAccount(ginCtx)
require.Equal(t, http.StatusOK, recorder.Code)
require.Empty(t, promoRepo.applyCalls)
createdUser, err := client.User.Query().Where(dbuser.EmailEQ("promo-disabled@example.com")).Only(ctx)
require.NoError(t, err)
require.Zero(t, createdUser.Balance)
}
func TestOAuthExistingUserLoginDoesNotApplyPromoCode(t *testing.T) {
promoRepo := newOAuthPendingFlowPromoRepoStub("WELCOME2024", 25)
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
promoRepo: promoRepo,
settingValues: map[string]string{
service.SettingKeyPromoCodeEnabled: "true",
},
})
ctx := context.Background()
existingUser, err := client.User.Create().
SetEmail("existing-promo@example.com").
SetUsername("existing").
SetPasswordHash("hash").
SetRole(service.RoleUser).
SetStatus(service.StatusActive).
SetBalance(7).
Save(ctx)
require.NoError(t, err)
_, loggedInUser, err := handler.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
ctx,
existingUser.Email,
existingUser.Username,
"",
"",
"WELCOME2024",
"oidc",
)
require.NoError(t, err)
require.Equal(t, existingUser.ID, loggedInUser.ID)
require.Empty(t, promoRepo.applyCalls)
reloadedUser, err := client.User.Get(ctx, existingUser.ID)
require.NoError(t, err)
require.Equal(t, 7.0, reloadedUser.Balance)
}
func TestCreateOIDCOAuthAccountExistingEmailReturnsChoicePendingSessionState(t *testing.T) {
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, false, "owner@example.com", "135790")
ctx := context.Background()
@@ -2120,6 +2316,7 @@ type oauthPendingFlowTestHandlerOptions struct {
emailVerifyEnabled bool
emailCache service.EmailCache
settingValues map[string]string
promoRepo service.PromoCodeRepository
defaultSubAssigner service.DefaultSubscriptionAssigner
affiliateService *service.AffiliateService
affiliateFactory func(*dbent.Client, *service.SettingService) *service.AffiliateService
@@ -2212,6 +2409,10 @@ CREATE TABLE IF NOT EXISTS user_affiliates (
options: options.userRepoOptions,
}
redeemRepo := &oauthPendingFlowRedeemCodeRepo{client: client}
var promoService *service.PromoService
if options.promoRepo != nil {
promoService = service.NewPromoService(options.promoRepo, userRepo, nil, client, nil)
}
var emailService *service.EmailService
if options.emailCache != nil {
emailService = service.NewEmailService(&oauthPendingFlowSettingRepoStub{
@@ -2230,7 +2431,7 @@ CREATE TABLE IF NOT EXISTS user_affiliates (
emailService,
nil,
nil,
nil,
promoService,
options.defaultSubAssigner,
affiliateService,
nil,
@@ -2250,10 +2451,11 @@ CREATE TABLE IF NOT EXISTS user_affiliates (
}
return &AuthHandler{
authService: authSvc,
userService: userSvc,
settingSvc: settingSvc,
totpService: totpSvc,
authService: authSvc,
userService: userSvc,
settingSvc: settingSvc,
promoService: promoService,
totpService: totpSvc,
}, client
}
@@ -2272,6 +2474,84 @@ type oauthPendingFlowSettingRepoStub struct {
values map[string]string
}
type oauthPendingFlowPromoRepoStub struct {
promo *service.PromoCode
applyCalls []string
usages []service.PromoCodeUsage
}
func newOAuthPendingFlowPromoRepoStub(code string, bonusAmount float64) *oauthPendingFlowPromoRepoStub {
return &oauthPendingFlowPromoRepoStub{
promo: &service.PromoCode{
ID: 1,
Code: code,
BonusAmount: bonusAmount,
Status: service.PromoCodeStatusActive,
},
}
}
func (r *oauthPendingFlowPromoRepoStub) Create(context.Context, *service.PromoCode) error {
panic("unexpected Create call")
}
func (r *oauthPendingFlowPromoRepoStub) GetByID(context.Context, int64) (*service.PromoCode, error) {
panic("unexpected GetByID call")
}
func (r *oauthPendingFlowPromoRepoStub) GetByCode(_ context.Context, code string) (*service.PromoCode, error) {
if r.promo == nil || !strings.EqualFold(strings.TrimSpace(code), r.promo.Code) {
return nil, service.ErrPromoCodeNotFound
}
clone := *r.promo
return &clone, nil
}
func (r *oauthPendingFlowPromoRepoStub) GetByCodeForUpdate(ctx context.Context, code string) (*service.PromoCode, error) {
promoCode, err := r.GetByCode(ctx, code)
if err != nil {
return nil, err
}
r.applyCalls = append(r.applyCalls, strings.TrimSpace(code))
return promoCode, nil
}
func (r *oauthPendingFlowPromoRepoStub) Update(context.Context, *service.PromoCode) error {
panic("unexpected Update call")
}
func (r *oauthPendingFlowPromoRepoStub) Delete(context.Context, int64) error {
panic("unexpected Delete call")
}
func (r *oauthPendingFlowPromoRepoStub) List(context.Context, pagination.PaginationParams) ([]service.PromoCode, *pagination.PaginationResult, error) {
panic("unexpected List call")
}
func (r *oauthPendingFlowPromoRepoStub) ListWithFilters(context.Context, pagination.PaginationParams, string, string) ([]service.PromoCode, *pagination.PaginationResult, error) {
panic("unexpected ListWithFilters call")
}
func (r *oauthPendingFlowPromoRepoStub) CreateUsage(_ context.Context, usage *service.PromoCodeUsage) error {
r.usages = append(r.usages, *usage)
return nil
}
func (r *oauthPendingFlowPromoRepoStub) GetUsageByPromoCodeAndUser(context.Context, int64, int64) (*service.PromoCodeUsage, error) {
return nil, nil
}
func (r *oauthPendingFlowPromoRepoStub) ListUsagesByPromoCode(context.Context, int64, pagination.PaginationParams) ([]service.PromoCodeUsage, *pagination.PaginationResult, error) {
panic("unexpected ListUsagesByPromoCode call")
}
func (r *oauthPendingFlowPromoRepoStub) IncrementUsedCount(context.Context, int64) error {
if r.promo != nil {
r.promo.UsedCount++
}
return nil
}
func (s *oauthPendingFlowSettingRepoStub) Get(context.Context, string) (*service.Setting, error) {
return nil, service.ErrSettingNotFound
}
@@ -2813,8 +3093,12 @@ func (r *oauthPendingFlowUserRepo) ListWithFilters(context.Context, pagination.P
panic("unexpected ListWithFilters call")
}
func (r *oauthPendingFlowUserRepo) UpdateBalance(context.Context, int64, float64) error {
panic("unexpected UpdateBalance call")
func (r *oauthPendingFlowUserRepo) UpdateBalance(ctx context.Context, userID int64, amount float64) error {
client := r.client
if tx := dbent.TxFromContext(ctx); tx != nil {
client = tx.Client()
}
return client.User.UpdateOneID(userID).AddBalance(amount).Exec(ctx)
}
func (r *oauthPendingFlowUserRepo) DeductBalance(context.Context, int64, float64) error {
+18 -2
View File
@@ -143,6 +143,7 @@ func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) {
oidcSetCookie(c, oidcOAuthRedirectCookie, encodeCookieValue(redirectTo), oidcOAuthCookieMaxAgeSec, secureCookie)
intent := normalizeOAuthIntent(c.Query("intent"))
oidcSetCookie(c, oidcOAuthIntentCookieName, encodeCookieValue(intent), oidcOAuthCookieMaxAgeSec, secureCookie)
captureOAuthPromoCode(c, secureCookie)
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
clearOAuthPendingSessionCookie(c, secureCookie)
if intent == oauthIntentBindCurrentUser {
@@ -226,6 +227,7 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
oidcClearCookie(c, oidcOAuthNonceCookie, secureCookie)
oidcClearCookie(c, oidcOAuthIntentCookieName, secureCookie)
oidcClearCookie(c, oidcOAuthBindUserCookieName, secureCookie)
clearOAuthPromoCodeCookie(c, secureCookie)
}()
expectedState, err := readCookieDecoded(c, oidcOAuthStateCookieName)
@@ -685,7 +687,15 @@ func (h *AuthHandler) CompleteOIDCOAuthRegistration(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "oidc")
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
c.Request.Context(),
email,
username,
req.InvitationCode,
req.AffCode,
pendingOAuthPromoCode(session),
"oidc",
)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -1258,7 +1268,13 @@ func (h *AuthHandler) tryOIDCVerifiedEmailFastPath(
AvatarURL: pendingSessionStringValue(upstreamClaims, "suggested_avatar_url"),
UpstreamMetadata: upstreamMetadata,
}
tokenPair, _, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithInvitation(ctx, input, "", "")
tokenPair, _, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithSignupCodes(
ctx,
input,
"",
"",
readOAuthPromoCode(c),
)
if err != nil {
log.Printf("[OIDC OAuth] verified-email fast path skipped: reason=%s", infraerrors.Reason(err))
return false
+11 -1
View File
@@ -125,6 +125,7 @@ func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) {
wechatSetCookie(c, wechatOAuthRedirectCookieName, encodeCookieValue(redirectTo), wechatOAuthCookieMaxAgeSec, secureCookie)
wechatSetCookie(c, wechatOAuthIntentCookieName, encodeCookieValue(intent), wechatOAuthCookieMaxAgeSec, secureCookie)
wechatSetCookie(c, wechatOAuthModeCookieName, encodeCookieValue(cfg.mode), wechatOAuthCookieMaxAgeSec, secureCookie)
captureOAuthPromoCode(c, secureCookie)
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
clearOAuthPendingSessionCookie(c, secureCookie)
if intent == oauthIntentBindCurrentUser {
@@ -171,6 +172,7 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
wechatClearCookie(c, wechatOAuthIntentCookieName, secureCookie)
wechatClearCookie(c, wechatOAuthModeCookieName, secureCookie)
wechatClearCookie(c, wechatOAuthBindUserCookieName, secureCookie)
clearOAuthPromoCodeCookie(c, secureCookie)
}()
expectedState, err := readCookieDecoded(c, wechatOAuthStateCookieName)
@@ -548,7 +550,15 @@ func (h *AuthHandler) CompleteWeChatOAuthRegistration(c *gin.Context) {
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "wechat")
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
c.Request.Context(),
email,
username,
req.InvitationCode,
req.AffCode,
pendingOAuthPromoCode(session),
"wechat",
)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -26,7 +26,7 @@ type EmailOAuthIdentityInput struct {
}
func (s *AuthService) LoginOrRegisterVerifiedEmailOAuth(ctx context.Context, input EmailOAuthIdentityInput) (*TokenPair, *User, error) {
return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, "", "")
return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, "", "", "")
}
func (s *AuthService) LoginOrRegisterVerifiedEmailOAuthWithInvitation(
@@ -35,7 +35,17 @@ func (s *AuthService) LoginOrRegisterVerifiedEmailOAuthWithInvitation(
invitationCode string,
affiliateCode string,
) (*TokenPair, *User, error) {
return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, invitationCode, affiliateCode)
return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, invitationCode, affiliateCode, "")
}
func (s *AuthService) LoginOrRegisterVerifiedEmailOAuthWithSignupCodes(
ctx context.Context,
input EmailOAuthIdentityInput,
invitationCode string,
affiliateCode string,
promoCode string,
) (*TokenPair, *User, error) {
return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, invitationCode, affiliateCode, promoCode)
}
func (s *AuthService) loginOrRegisterVerifiedEmailOAuth(
@@ -43,6 +53,7 @@ func (s *AuthService) loginOrRegisterVerifiedEmailOAuth(
input EmailOAuthIdentityInput,
invitationCode string,
affiliateCode string,
promoCode string,
) (*TokenPair, *User, error) {
if s == nil || s.userRepo == nil || s.entClient == nil {
return nil, nil, ErrServiceUnavailable
@@ -131,6 +142,8 @@ func (s *AuthService) loginOrRegisterVerifiedEmailOAuth(
if err := s.ApplyProviderDefaultSettingsOnFirstBind(ctx, user.ID, providerType); err != nil {
logger.LegacyPrintf("service.auth", "[Auth] Failed to apply %s first bind defaults: %v", providerType, err)
}
} else {
user = s.applyOAuthSignupPromoCode(ctx, user, promoCode)
}
s.RecordSuccessfulLogin(ctx, user.ID)
+39
View File
@@ -587,6 +587,17 @@ func (s *AuthService) canBypassRegistrationDisabledForOAuth(ctx context.Context,
// affiliateCode 用于邀请返利绑定,仅在新用户注册时使用。
// signupSource 标识来源渠道("dingtalk"/"linuxdo"/"wechat"/"oidc" 等),仅用于豁免检查。
func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username, invitationCode, affiliateCode, signupSource string) (*TokenPair, *User, error) {
return s.loginOrRegisterOAuthWithTokenPair(ctx, email, username, invitationCode, affiliateCode, "", signupSource)
}
// LoginOrRegisterOAuthWithTokenPairAndPromoCode behaves like
// LoginOrRegisterOAuthWithTokenPair and applies promoCode only when a new user
// is created.
func (s *AuthService) LoginOrRegisterOAuthWithTokenPairAndPromoCode(ctx context.Context, email, username, invitationCode, affiliateCode, promoCode, signupSource string) (*TokenPair, *User, error) {
return s.loginOrRegisterOAuthWithTokenPair(ctx, email, username, invitationCode, affiliateCode, promoCode, signupSource)
}
func (s *AuthService) loginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username, invitationCode, affiliateCode, promoCode, signupSource string) (*TokenPair, *User, error) {
// 检查 refreshTokenCache 是否可用
if s.refreshTokenCache == nil {
return nil, nil, errors.New("refresh token cache not configured")
@@ -606,6 +617,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
}
user, err := s.userRepo.GetByEmail(ctx, email)
created := false
if err != nil {
if errors.Is(err, ErrUserNotFound) {
// OAuth 首次登录视为注册
@@ -691,6 +703,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
return nil, nil, ErrServiceUnavailable
}
user = newUser
created = true
s.postAuthUserBootstrap(ctx, user, signupSource, false)
s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
// snapshot user × platform quotafail-open
@@ -711,6 +724,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
}
} else {
user = newUser
created = true
s.postAuthUserBootstrap(ctx, user, signupSource, false)
s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
// snapshot user × platform quotafail-open
@@ -739,6 +753,9 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
logger.LegacyPrintf("service.auth", "[Auth] Failed to update username after oauth login: %v", err)
}
}
if created {
user = s.applyOAuthSignupPromoCode(ctx, user, promoCode)
}
tokenPair, err := s.GenerateTokenPair(ctx, user, "")
if err != nil {
return nil, nil, fmt.Errorf("generate token pair: %w", err)
@@ -746,6 +763,28 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
return tokenPair, user, nil
}
func (s *AuthService) ApplyOAuthSignupPromoCode(ctx context.Context, userID int64, promoCode string) {
if userID <= 0 {
return
}
s.applyOAuthSignupPromoCode(ctx, &User{ID: userID}, promoCode)
}
func (s *AuthService) applyOAuthSignupPromoCode(ctx context.Context, user *User, promoCode string) *User {
promoCode = strings.TrimSpace(promoCode)
if user == nil || user.ID <= 0 || promoCode == "" || s.promoService == nil || s.settingService == nil || !s.settingService.IsPromoCodeEnabled(ctx) {
return user
}
if err := s.promoService.ApplyPromoCode(ctx, user.ID, promoCode); err != nil {
logger.LegacyPrintf("service.auth", "[Auth] Failed to apply promo code for oauth user %d: %v", user.ID, err)
return user
}
if updatedUser, err := s.userRepo.GetByID(ctx, user.ID); err == nil {
return updatedUser
}
return user
}
func (s *AuthService) assignSubscriptions(ctx context.Context, userID int64, items []DefaultSubscriptionSetting, notes string) {
if s.settingService == nil || s.defaultSubAssigner == nil || userID <= 0 {
return