feat: 增加 GitHub 和 Google 邮箱快捷登录

This commit is contained in:
lyen1688
2026-05-06 16:06:11 +08:00
parent a1106e8167
commit af550fa64e
35 changed files with 2656 additions and 74 deletions

View File

@@ -169,6 +169,16 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
OIDCConnectUserInfoEmailPath: settings.OIDCConnectUserInfoEmailPath,
OIDCConnectUserInfoIDPath: settings.OIDCConnectUserInfoIDPath,
OIDCConnectUserInfoUsernamePath: settings.OIDCConnectUserInfoUsernamePath,
GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
GitHubOAuthClientID: settings.GitHubOAuthClientID,
GitHubOAuthClientSecretConfigured: settings.GitHubOAuthClientSecretConfigured,
GitHubOAuthRedirectURL: settings.GitHubOAuthRedirectURL,
GitHubOAuthFrontendRedirectURL: settings.GitHubOAuthFrontendRedirectURL,
GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
GoogleOAuthClientID: settings.GoogleOAuthClientID,
GoogleOAuthClientSecretConfigured: settings.GoogleOAuthClientSecretConfigured,
GoogleOAuthRedirectURL: settings.GoogleOAuthRedirectURL,
GoogleOAuthFrontendRedirectURL: settings.GoogleOAuthFrontendRedirectURL,
SiteName: settings.SiteName,
SiteLogo: settings.SiteLogo,
SiteSubtitle: settings.SiteSubtitle,
@@ -368,6 +378,17 @@ type UpdateSettingsRequest struct {
OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"`
OIDCConnectUserInfoUsernamePath string `json:"oidc_connect_userinfo_username_path"`
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
GitHubOAuthClientID string `json:"github_oauth_client_id"`
GitHubOAuthClientSecret string `json:"github_oauth_client_secret"`
GitHubOAuthRedirectURL string `json:"github_oauth_redirect_url"`
GitHubOAuthFrontendRedirectURL string `json:"github_oauth_frontend_redirect_url"`
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
GoogleOAuthClientID string `json:"google_oauth_client_id"`
GoogleOAuthClientSecret string `json:"google_oauth_client_secret"`
GoogleOAuthRedirectURL string `json:"google_oauth_redirect_url"`
GoogleOAuthFrontendRedirectURL string `json:"google_oauth_frontend_redirect_url"`
// OEM设置
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
@@ -413,6 +434,16 @@ type UpdateSettingsRequest struct {
AuthSourceDefaultWeChatSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_wechat_subscriptions"`
AuthSourceDefaultWeChatGrantOnSignup *bool `json:"auth_source_default_wechat_grant_on_signup"`
AuthSourceDefaultWeChatGrantOnFirstBind *bool `json:"auth_source_default_wechat_grant_on_first_bind"`
AuthSourceDefaultGitHubBalance *float64 `json:"auth_source_default_github_balance"`
AuthSourceDefaultGitHubConcurrency *int `json:"auth_source_default_github_concurrency"`
AuthSourceDefaultGitHubSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_github_subscriptions"`
AuthSourceDefaultGitHubGrantOnSignup *bool `json:"auth_source_default_github_grant_on_signup"`
AuthSourceDefaultGitHubGrantOnFirstBind *bool `json:"auth_source_default_github_grant_on_first_bind"`
AuthSourceDefaultGoogleBalance *float64 `json:"auth_source_default_google_balance"`
AuthSourceDefaultGoogleConcurrency *int `json:"auth_source_default_google_concurrency"`
AuthSourceDefaultGoogleSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_google_subscriptions"`
AuthSourceDefaultGoogleGrantOnSignup *bool `json:"auth_source_default_google_grant_on_signup"`
AuthSourceDefaultGoogleGrantOnFirstBind *bool `json:"auth_source_default_google_grant_on_first_bind"`
ForceEmailOnThirdPartySignup *bool `json:"force_email_on_third_party_signup"`
// Model fallback configuration
@@ -1200,6 +1231,16 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
OIDCConnectUserInfoEmailPath: req.OIDCConnectUserInfoEmailPath,
OIDCConnectUserInfoIDPath: req.OIDCConnectUserInfoIDPath,
OIDCConnectUserInfoUsernamePath: req.OIDCConnectUserInfoUsernamePath,
GitHubOAuthEnabled: req.GitHubOAuthEnabled,
GitHubOAuthClientID: req.GitHubOAuthClientID,
GitHubOAuthClientSecret: req.GitHubOAuthClientSecret,
GitHubOAuthRedirectURL: req.GitHubOAuthRedirectURL,
GitHubOAuthFrontendRedirectURL: req.GitHubOAuthFrontendRedirectURL,
GoogleOAuthEnabled: req.GoogleOAuthEnabled,
GoogleOAuthClientID: req.GoogleOAuthClientID,
GoogleOAuthClientSecret: req.GoogleOAuthClientSecret,
GoogleOAuthRedirectURL: req.GoogleOAuthRedirectURL,
GoogleOAuthFrontendRedirectURL: req.GoogleOAuthFrontendRedirectURL,
SiteName: req.SiteName,
SiteLogo: req.SiteLogo,
SiteSubtitle: req.SiteSubtitle,
@@ -1396,6 +1437,20 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultWeChatGrantOnSignup, previousAuthSourceDefaults.WeChat.GrantOnSignup),
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultWeChatGrantOnFirstBind, previousAuthSourceDefaults.WeChat.GrantOnFirstBind),
},
GitHub: service.ProviderDefaultGrantSettings{
Balance: float64ValueOrDefault(req.AuthSourceDefaultGitHubBalance, previousAuthSourceDefaults.GitHub.Balance),
Concurrency: intValueOrDefault(req.AuthSourceDefaultGitHubConcurrency, previousAuthSourceDefaults.GitHub.Concurrency),
Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultGitHubSubscriptions, previousAuthSourceDefaults.GitHub.Subscriptions),
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultGitHubGrantOnSignup, previousAuthSourceDefaults.GitHub.GrantOnSignup),
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultGitHubGrantOnFirstBind, previousAuthSourceDefaults.GitHub.GrantOnFirstBind),
},
Google: service.ProviderDefaultGrantSettings{
Balance: float64ValueOrDefault(req.AuthSourceDefaultGoogleBalance, previousAuthSourceDefaults.Google.Balance),
Concurrency: intValueOrDefault(req.AuthSourceDefaultGoogleConcurrency, previousAuthSourceDefaults.Google.Concurrency),
Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultGoogleSubscriptions, previousAuthSourceDefaults.Google.Subscriptions),
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultGoogleGrantOnSignup, previousAuthSourceDefaults.Google.GrantOnSignup),
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultGoogleGrantOnFirstBind, previousAuthSourceDefaults.Google.GrantOnFirstBind),
},
ForceEmailOnThirdPartySignup: boolValueOrDefault(req.ForceEmailOnThirdPartySignup, previousAuthSourceDefaults.ForceEmailOnThirdPartySignup),
}
if err := h.settingService.UpdateSettingsWithAuthSourceDefaults(c.Request.Context(), settings, authSourceDefaults); err != nil {
@@ -1538,6 +1593,16 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
OIDCConnectUserInfoEmailPath: updatedSettings.OIDCConnectUserInfoEmailPath,
OIDCConnectUserInfoIDPath: updatedSettings.OIDCConnectUserInfoIDPath,
OIDCConnectUserInfoUsernamePath: updatedSettings.OIDCConnectUserInfoUsernamePath,
GitHubOAuthEnabled: updatedSettings.GitHubOAuthEnabled,
GitHubOAuthClientID: updatedSettings.GitHubOAuthClientID,
GitHubOAuthClientSecretConfigured: updatedSettings.GitHubOAuthClientSecretConfigured,
GitHubOAuthRedirectURL: updatedSettings.GitHubOAuthRedirectURL,
GitHubOAuthFrontendRedirectURL: updatedSettings.GitHubOAuthFrontendRedirectURL,
GoogleOAuthEnabled: updatedSettings.GoogleOAuthEnabled,
GoogleOAuthClientID: updatedSettings.GoogleOAuthClientID,
GoogleOAuthClientSecretConfigured: updatedSettings.GoogleOAuthClientSecretConfigured,
GoogleOAuthRedirectURL: updatedSettings.GoogleOAuthRedirectURL,
GoogleOAuthFrontendRedirectURL: updatedSettings.GoogleOAuthFrontendRedirectURL,
SiteName: updatedSettings.SiteName,
SiteLogo: updatedSettings.SiteLogo,
SiteSubtitle: updatedSettings.SiteSubtitle,
@@ -2027,6 +2092,8 @@ func appendAuthSourceDefaultChanges(changed []string, before *service.AuthSource
{name: "linuxdo", before: before.LinuxDo, after: after.LinuxDo},
{name: "oidc", before: before.OIDC, after: after.OIDC},
{name: "wechat", before: before.WeChat, after: after.WeChat},
{name: "github", before: before.GitHub, after: after.GitHub},
{name: "google", before: before.Google, after: after.Google},
}
for _, field := range fields {
if field.before.Balance != field.after.Balance {
@@ -2141,6 +2208,16 @@ func systemSettingsResponseData(settings dto.SystemSettings, authSourceDefaults
data["auth_source_default_wechat_subscriptions"] = authSourceDefaults.WeChat.Subscriptions
data["auth_source_default_wechat_grant_on_signup"] = authSourceDefaults.WeChat.GrantOnSignup
data["auth_source_default_wechat_grant_on_first_bind"] = authSourceDefaults.WeChat.GrantOnFirstBind
data["auth_source_default_github_balance"] = authSourceDefaults.GitHub.Balance
data["auth_source_default_github_concurrency"] = authSourceDefaults.GitHub.Concurrency
data["auth_source_default_github_subscriptions"] = authSourceDefaults.GitHub.Subscriptions
data["auth_source_default_github_grant_on_signup"] = authSourceDefaults.GitHub.GrantOnSignup
data["auth_source_default_github_grant_on_first_bind"] = authSourceDefaults.GitHub.GrantOnFirstBind
data["auth_source_default_google_balance"] = authSourceDefaults.Google.Balance
data["auth_source_default_google_concurrency"] = authSourceDefaults.Google.Concurrency
data["auth_source_default_google_subscriptions"] = authSourceDefaults.Google.Subscriptions
data["auth_source_default_google_grant_on_signup"] = authSourceDefaults.Google.GrantOnSignup
data["auth_source_default_google_grant_on_first_bind"] = authSourceDefaults.Google.GrantOnFirstBind
data["force_email_on_third_party_signup"] = authSourceDefaults.ForceEmailOnThirdPartySignup
return data

View File

@@ -0,0 +1,549 @@
package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/imroc/req/v3"
"github.com/tidwall/gjson"
)
const (
emailOAuthCookiePath = "/api/v1/auth/oauth"
emailOAuthStateCookieName = "email_oauth_state"
emailOAuthRedirectCookie = "email_oauth_redirect"
emailOAuthProviderCookie = "email_oauth_provider"
emailOAuthAffiliateCookie = "email_oauth_affiliate"
emailOAuthCookieMaxAgeSec = 10 * 60
emailOAuthDefaultRedirect = "/dashboard"
)
type emailOAuthTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope,omitempty"`
}
type emailOAuthProfile struct {
Subject string
Email string
EmailVerified bool
Username string
DisplayName string
AvatarURL string
Metadata map[string]any
}
func (h *AuthHandler) GitHubOAuthStart(c *gin.Context) { h.emailOAuthStart(c, "github") }
func (h *AuthHandler) GoogleOAuthStart(c *gin.Context) { h.emailOAuthStart(c, "google") }
func (h *AuthHandler) GitHubOAuthCallback(c *gin.Context) { h.emailOAuthCallback(c, "github") }
func (h *AuthHandler) GoogleOAuthCallback(c *gin.Context) { h.emailOAuthCallback(c, "google") }
func (h *AuthHandler) CompleteGitHubOAuthRegistration(c *gin.Context) {
h.completeEmailOAuthRegistration(c, "github")
}
func (h *AuthHandler) CompleteGoogleOAuthRegistration(c *gin.Context) {
h.completeEmailOAuthRegistration(c, "google")
}
func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) {
cfg, err := h.getEmailOAuthConfig(c.Request.Context(), provider)
if err != nil {
response.ErrorFrom(c, err)
return
}
state, err := oauth.GenerateState()
if err != nil {
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err))
return
}
redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect"))
if redirectTo == "" {
redirectTo = emailOAuthDefaultRedirect
}
secureCookie := isRequestHTTPS(c)
emailOAuthSetCookie(c, emailOAuthStateCookieName, encodeCookieValue(state), secureCookie)
emailOAuthSetCookie(c, emailOAuthRedirectCookie, encodeCookieValue(redirectTo), secureCookie)
emailOAuthSetCookie(c, emailOAuthProviderCookie, encodeCookieValue(provider), secureCookie)
if affCode := strings.TrimSpace(firstNonEmpty(c.Query("aff_code"), c.Query("aff"))); affCode != "" {
emailOAuthSetCookie(c, emailOAuthAffiliateCookie, encodeCookieValue(affCode), secureCookie)
} else {
emailOAuthClearCookie(c, emailOAuthAffiliateCookie, secureCookie)
}
authURL, err := buildEmailOAuthAuthorizeURL(cfg, state)
if err != nil {
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err))
return
}
c.Redirect(http.StatusFound, authURL)
}
func (h *AuthHandler) emailOAuthCallback(c *gin.Context, provider string) {
cfg, cfgErr := h.getEmailOAuthConfig(c.Request.Context(), provider)
if cfgErr != nil {
response.ErrorFrom(c, cfgErr)
return
}
frontendCallback := strings.TrimSpace(cfg.FrontendRedirectURL)
if frontendCallback == "" {
frontendCallback = "/auth/oauth/callback"
}
if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" {
redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description"))
return
}
code := strings.TrimSpace(c.Query("code"))
state := strings.TrimSpace(c.Query("state"))
if code == "" || state == "" {
redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "")
return
}
secureCookie := isRequestHTTPS(c)
defer func() {
emailOAuthClearCookie(c, emailOAuthStateCookieName, secureCookie)
emailOAuthClearCookie(c, emailOAuthRedirectCookie, secureCookie)
emailOAuthClearCookie(c, emailOAuthProviderCookie, secureCookie)
emailOAuthClearCookie(c, emailOAuthAffiliateCookie, secureCookie)
}()
expectedState, err := readCookieDecoded(c, emailOAuthStateCookieName)
if err != nil || expectedState == "" || expectedState != state {
redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "")
return
}
expectedProvider, _ := readCookieDecoded(c, emailOAuthProviderCookie)
if !strings.EqualFold(strings.TrimSpace(expectedProvider), provider) {
redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth provider", "")
return
}
redirectTo, _ := readCookieDecoded(c, emailOAuthRedirectCookie)
redirectTo = sanitizeFrontendRedirectPath(redirectTo)
if redirectTo == "" {
redirectTo = emailOAuthDefaultRedirect
}
tokenResp, err := exchangeEmailOAuthCode(c.Request.Context(), cfg, code)
if err != nil {
redirectOAuthError(c, frontendCallback, "token_exchange_failed", "failed to exchange oauth code", singleLine(err.Error()))
return
}
profile, err := fetchEmailOAuthProfile(c.Request.Context(), provider, cfg, tokenResp)
if err != nil {
redirectOAuthError(c, frontendCallback, "userinfo_failed", "failed to fetch verified email", singleLine(err.Error()))
return
}
h.emailOAuthCallbackWithProfile(c, provider, cfg, frontendCallback, redirectTo, profile)
}
func (h *AuthHandler) emailOAuthCallbackWithProfile(
c *gin.Context,
provider string,
cfg config.EmailOAuthProviderConfig,
frontendCallback string,
redirectTo string,
profile *emailOAuthProfile,
) {
input := service.EmailOAuthIdentityInput{
ProviderType: provider,
ProviderKey: provider,
ProviderSubject: profile.Subject,
Email: profile.Email,
EmailVerified: profile.EmailVerified,
Username: profile.Username,
DisplayName: profile.DisplayName,
AvatarURL: profile.AvatarURL,
UpstreamMetadata: profile.Metadata,
}
affiliateCode := h.emailOAuthAffiliateCode(c)
tokenPair, user, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithInvitation(c.Request.Context(), input, "", affiliateCode)
if err != nil {
if errors.Is(err, service.ErrOAuthInvitationRequired) {
if pendingErr := h.createEmailOAuthInvitationPendingSession(c, provider, frontendCallback, redirectTo, profile); pendingErr != nil {
redirectOAuthError(c, frontendCallback, infraerrors.Reason(pendingErr), infraerrors.Message(pendingErr), "")
return
}
redirectToFrontendCallback(c, frontendCallback)
return
}
redirectOAuthError(c, frontendCallback, infraerrors.Reason(err), infraerrors.Message(err), "")
return
}
if err := h.ensureBackendModeAllowsUser(c.Request.Context(), user); err != nil {
redirectOAuthError(c, frontendCallback, "login_blocked", infraerrors.Reason(err), infraerrors.Message(err))
return
}
fragment := url.Values{}
fragment.Set("access_token", tokenPair.AccessToken)
fragment.Set("refresh_token", tokenPair.RefreshToken)
fragment.Set("expires_in", fmt.Sprintf("%d", tokenPair.ExpiresIn))
fragment.Set("token_type", "Bearer")
fragment.Set("redirect", redirectTo)
redirectWithFragment(c, frontendCallback, fragment)
}
func (h *AuthHandler) emailOAuthAffiliateCode(c *gin.Context) string {
if c == nil {
return ""
}
if code, err := readCookieDecoded(c, emailOAuthAffiliateCookie); err == nil {
return strings.TrimSpace(code)
}
return ""
}
func (h *AuthHandler) createEmailOAuthInvitationPendingSession(
c *gin.Context,
provider string,
frontendCallback string,
redirectTo string,
profile *emailOAuthProfile,
) error {
if h == nil || profile == nil {
return infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
}
browserSessionKey, err := generateOAuthPendingBrowserSession()
if err != nil {
return infraerrors.InternalServer("PENDING_AUTH_SESSION_CREATE_FAILED", "failed to create pending auth session").WithCause(err)
}
setOAuthPendingBrowserCookie(c, browserSessionKey, isRequestHTTPS(c))
email := strings.TrimSpace(strings.ToLower(profile.Email))
username := strings.TrimSpace(profile.Username)
affiliateCode := h.emailOAuthAffiliateCode(c)
upstreamClaims := map[string]any{
"email": email,
"email_verified": profile.EmailVerified,
"username": username,
"provider": provider,
"provider_key": provider,
"provider_subject": strings.TrimSpace(profile.Subject),
}
if strings.TrimSpace(profile.DisplayName) != "" {
upstreamClaims["suggested_display_name"] = strings.TrimSpace(profile.DisplayName)
}
if strings.TrimSpace(profile.AvatarURL) != "" {
upstreamClaims["suggested_avatar_url"] = strings.TrimSpace(profile.AvatarURL)
}
if affiliateCode != "" {
upstreamClaims["aff_code"] = affiliateCode
}
for key, value := range profile.Metadata {
if _, exists := upstreamClaims[key]; !exists {
upstreamClaims[key] = value
}
}
completionResponse := map[string]any{
"step": oauthPendingChoiceStep,
"error": "invitation_required",
"choice_reason": "invitation_required",
"adoption_required": false,
"create_account_allowed": true,
"existing_account_bindable": false,
"force_email_on_signup": true,
"email": email,
"resolved_email": email,
"provider": provider,
"redirect": redirectTo,
}
if strings.TrimSpace(frontendCallback) != "" {
completionResponse["frontend_callback"] = strings.TrimSpace(frontendCallback)
}
return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
Intent: oauthIntentLogin,
Identity: service.PendingAuthIdentityKey{ProviderType: provider, ProviderKey: provider, ProviderSubject: strings.TrimSpace(profile.Subject)},
ResolvedEmail: email,
RedirectTo: redirectTo,
BrowserSessionKey: browserSessionKey,
UpstreamIdentityClaims: upstreamClaims,
CompletionResponse: completionResponse,
})
}
type completeEmailOAuthRequest struct {
InvitationCode string `json:"invitation_code" binding:"required"`
AffCode string `json:"aff_code,omitempty"`
}
func (h *AuthHandler) completeEmailOAuthRegistration(c *gin.Context, provider string) {
var req completeEmailOAuthRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
_, session, clearCookies, err := readPendingOAuthBrowserSession(c, h)
if err != nil {
response.ErrorFrom(c, err)
return
}
if err := ensurePendingOAuthCompleteRegistrationSession(session); err != nil {
response.ErrorFrom(c, err)
return
}
if !strings.EqualFold(strings.TrimSpace(session.ProviderType), provider) {
response.BadRequest(c, "Pending oauth session provider mismatch")
return
}
if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil {
response.ErrorFrom(c, err)
return
}
affiliateCode := strings.TrimSpace(req.AffCode)
if affiliateCode == "" {
affiliateCode = pendingSessionStringValue(session.UpstreamIdentityClaims, "aff_code")
}
tokenPair, user, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithInvitation(
c.Request.Context(),
service.EmailOAuthIdentityInput{
ProviderType: strings.TrimSpace(session.ProviderType),
ProviderKey: strings.TrimSpace(session.ProviderKey),
ProviderSubject: strings.TrimSpace(session.ProviderSubject),
Email: strings.TrimSpace(session.ResolvedEmail),
EmailVerified: true,
Username: pendingSessionStringValue(session.UpstreamIdentityClaims, "username"),
DisplayName: pendingSessionStringValue(session.UpstreamIdentityClaims, "suggested_display_name"),
AvatarURL: pendingSessionStringValue(session.UpstreamIdentityClaims, "suggested_avatar_url"),
UpstreamMetadata: clonePendingMap(session.UpstreamIdentityClaims),
},
strings.TrimSpace(req.InvitationCode),
affiliateCode,
)
if err != nil {
response.ErrorFrom(c, err)
return
}
client := h.entClient()
if client == nil {
response.ErrorFrom(c, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready"))
return
}
tx, err := client.Tx(c.Request.Context())
if err != nil {
response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_BIND_APPLY_FAILED", "failed to consume pending oauth session").WithCause(err))
return
}
defer func() { _ = tx.Rollback() }()
if err := consumePendingOAuthBrowserSessionTx(c.Request.Context(), tx, session); err != nil {
_ = tx.Rollback()
clearCookies()
response.ErrorFrom(c, err)
return
}
if err := tx.Commit(); err != nil {
response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_BIND_APPLY_FAILED", "failed to consume pending oauth session").WithCause(err))
return
}
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
clearCookies()
writeOAuthTokenPairResponse(c, tokenPair)
}
func (h *AuthHandler) getEmailOAuthConfig(ctx context.Context, provider string) (config.EmailOAuthProviderConfig, error) {
if h != nil && h.settingSvc != nil {
return h.settingSvc.GetEmailOAuthProviderConfig(ctx, provider)
}
return config.EmailOAuthProviderConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
}
func buildEmailOAuthAuthorizeURL(cfg config.EmailOAuthProviderConfig, state string) (string, error) {
u, err := url.Parse(cfg.AuthorizeURL)
if err != nil {
return "", fmt.Errorf("parse authorize_url: %w", err)
}
q := u.Query()
q.Set("response_type", "code")
q.Set("client_id", cfg.ClientID)
q.Set("redirect_uri", cfg.RedirectURL)
q.Set("state", state)
if strings.TrimSpace(cfg.Scopes) != "" {
q.Set("scope", cfg.Scopes)
}
u.RawQuery = q.Encode()
return u.String(), nil
}
func exchangeEmailOAuthCode(ctx context.Context, cfg config.EmailOAuthProviderConfig, code string) (*emailOAuthTokenResponse, error) {
resp, err := req.C().
R().
SetContext(ctx).
SetHeader("Accept", "application/json").
SetFormData(map[string]string{
"grant_type": "authorization_code",
"client_id": cfg.ClientID,
"client_secret": cfg.ClientSecret,
"code": code,
"redirect_uri": cfg.RedirectURL,
}).
Post(cfg.TokenURL)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("token endpoint status %d: %s", resp.StatusCode, truncateLogValue(resp.String(), 1024))
}
var tokenResp emailOAuthTokenResponse
if err := json.Unmarshal(resp.Bytes(), &tokenResp); err != nil {
return nil, err
}
if strings.TrimSpace(tokenResp.AccessToken) == "" {
return nil, errors.New("missing access_token")
}
return &tokenResp, nil
}
func fetchEmailOAuthProfile(ctx context.Context, provider string, cfg config.EmailOAuthProviderConfig, token *emailOAuthTokenResponse) (*emailOAuthProfile, error) {
resp, err := req.C().
R().
SetContext(ctx).
SetBearerAuthToken(token.AccessToken).
SetHeader("Accept", "application/json").
Get(cfg.UserInfoURL)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("userinfo endpoint status %d: %s", resp.StatusCode, truncateLogValue(resp.String(), 1024))
}
switch strings.ToLower(strings.TrimSpace(provider)) {
case "github":
return parseGitHubOAuthProfile(ctx, cfg, token, resp.String())
case "google":
return parseGoogleOAuthProfile(resp.String())
default:
return nil, errors.New("unsupported oauth provider")
}
}
func parseGitHubOAuthProfile(ctx context.Context, cfg config.EmailOAuthProviderConfig, token *emailOAuthTokenResponse, body string) (*emailOAuthProfile, error) {
subject := strings.TrimSpace(gjson.Get(body, "id").String())
if subject == "" {
return nil, errors.New("github user id is missing")
}
email := strings.TrimSpace(gjson.Get(body, "email").String())
emailVerified := false
if email != "" {
emailVerified = true
}
if strings.TrimSpace(cfg.EmailsURL) != "" {
if verifiedEmail, err := fetchGitHubPrimaryVerifiedEmail(ctx, cfg.EmailsURL, token.AccessToken); err == nil && verifiedEmail != "" {
email = verifiedEmail
emailVerified = true
} else if email == "" && err != nil {
return nil, err
}
}
if email == "" || !emailVerified {
return nil, errors.New("github verified email is missing")
}
login := strings.TrimSpace(gjson.Get(body, "login").String())
name := strings.TrimSpace(gjson.Get(body, "name").String())
return &emailOAuthProfile{
Subject: subject,
Email: email,
EmailVerified: true,
Username: firstNonEmpty(login, name, "github_"+subject),
DisplayName: firstNonEmpty(name, login),
AvatarURL: strings.TrimSpace(gjson.Get(body, "avatar_url").String()),
Metadata: map[string]any{
"login": login,
},
}, nil
}
func fetchGitHubPrimaryVerifiedEmail(ctx context.Context, emailsURL string, accessToken string) (string, error) {
resp, err := req.C().
R().
SetContext(ctx).
SetBearerAuthToken(accessToken).
SetHeader("Accept", "application/json").
Get(emailsURL)
if err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("github emails endpoint status %d: %s", resp.StatusCode, truncateLogValue(resp.String(), 1024))
}
items := gjson.Parse(resp.String()).Array()
for _, item := range items {
if item.Get("primary").Bool() && item.Get("verified").Bool() {
if email := strings.TrimSpace(item.Get("email").String()); email != "" {
return email, nil
}
}
}
for _, item := range items {
if item.Get("verified").Bool() {
if email := strings.TrimSpace(item.Get("email").String()); email != "" {
return email, nil
}
}
}
return "", errors.New("github verified email is missing")
}
func parseGoogleOAuthProfile(body string) (*emailOAuthProfile, error) {
subject := strings.TrimSpace(gjson.Get(body, "sub").String())
email := strings.TrimSpace(gjson.Get(body, "email").String())
verified := gjson.Get(body, "email_verified").Bool()
if subject == "" {
return nil, errors.New("google subject is missing")
}
if email == "" || !verified {
return nil, errors.New("google verified email is missing")
}
name := strings.TrimSpace(gjson.Get(body, "name").String())
return &emailOAuthProfile{
Subject: subject,
Email: email,
EmailVerified: true,
Username: firstNonEmpty(strings.TrimSpace(gjson.Get(body, "given_name").String()), name, email),
DisplayName: name,
AvatarURL: strings.TrimSpace(gjson.Get(body, "picture").String()),
Metadata: map[string]any{
"email_verified": true,
},
}, nil
}
func emailOAuthSetCookie(c *gin.Context, name, value string, secure bool) {
http.SetCookie(c.Writer, &http.Cookie{
Name: name,
Value: value,
Path: emailOAuthCookiePath,
MaxAge: emailOAuthCookieMaxAgeSec,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
func emailOAuthClearCookie(c *gin.Context, name string, secure bool) {
http.SetCookie(c.Writer, &http.Cookie{
Name: name,
Value: "",
Path: emailOAuthCookiePath,
MaxAge: -1,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}

View File

@@ -0,0 +1,333 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/authidentity"
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestEmailOAuthCallbackRequiresPendingRegistrationWhenInvitationEnabled(t *testing.T) {
handler, client := newOAuthPendingFlowTestHandler(t, true)
ctx := context.Background()
state := "github-oauth-state"
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/callback?code=code-1&state="+url.QueryEscape(state), nil)
req.AddCookie(&http.Cookie{Name: emailOAuthStateCookieName, Value: encodeCookieValue(state)})
req.AddCookie(&http.Cookie{Name: emailOAuthRedirectCookie, Value: encodeCookieValue("/dashboard")})
req.AddCookie(&http.Cookie{Name: emailOAuthProviderCookie, Value: encodeCookieValue("github")})
c.Request = req
profile := &emailOAuthProfile{
Subject: "github-123",
Email: "fresh@example.com",
EmailVerified: true,
Username: "fresh",
DisplayName: "Fresh User",
AvatarURL: "https://cdn.example/fresh.png",
Metadata: map[string]any{
"login": "fresh",
},
}
handler.emailOAuthCallbackWithProfile(c, "github", config.EmailOAuthProviderConfig{
Enabled: true,
ClientID: "github-client",
ClientSecret: "github-secret",
RedirectURL: "https://app.example/api/v1/auth/oauth/github/callback",
FrontendRedirectURL: "/auth/oauth/callback",
}, "/auth/oauth/callback", "/dashboard", profile)
require.Equal(t, http.StatusFound, recorder.Code)
location := recorder.Header().Get("Location")
require.Contains(t, location, "/auth/oauth/callback")
require.NotContains(t, location, "access_token=")
userCount, err := client.User.Query().Where(dbuser.EmailEQ("fresh@example.com")).Count(ctx)
require.NoError(t, err)
require.Zero(t, userCount)
session, err := client.PendingAuthSession.Query().Only(ctx)
require.NoError(t, err)
require.Equal(t, "github", session.ProviderType)
require.Equal(t, "github", session.ProviderKey)
require.Equal(t, "github-123", session.ProviderSubject)
require.Equal(t, "fresh@example.com", session.ResolvedEmail)
require.Equal(t, "/dashboard", session.RedirectTo)
require.Nil(t, session.TargetUserID)
completion, ok := readCompletionResponse(session.LocalFlowState)
require.True(t, ok)
require.Equal(t, oauthPendingChoiceStep, completion["step"])
require.Equal(t, "invitation_required", completion["error"])
require.Equal(t, "fresh@example.com", completion["email"])
require.Equal(t, "fresh@example.com", completion["resolved_email"])
require.Equal(t, true, completion["create_account_allowed"])
require.NotEmpty(t, findSetCookieValue(recorder.Result().Cookies(), oauthPendingSessionCookieName))
require.NotEmpty(t, findSetCookieValue(recorder.Result().Cookies(), oauthPendingBrowserCookieName))
}
func TestEmailOAuthCallbackExistingEmailLogsInWhenInvitationEnabled(t *testing.T) {
handler, client := newOAuthPendingFlowTestHandler(t, true)
ctx := context.Background()
user, err := client.User.Create().
SetEmail("existing@example.com").
SetUsername("existing").
SetPasswordHash("hash").
SetRole(service.RoleUser).
SetStatus(service.StatusActive).
Save(ctx)
require.NoError(t, err)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/google/callback", nil)
handler.emailOAuthCallbackWithProfile(c, "google", config.EmailOAuthProviderConfig{
Enabled: true,
ClientID: "google-client",
ClientSecret: "google-secret",
RedirectURL: "https://app.example/api/v1/auth/oauth/google/callback",
FrontendRedirectURL: "/auth/oauth/callback",
}, "/auth/oauth/callback", "/dashboard", &emailOAuthProfile{
Subject: "google-123",
Email: "existing@example.com",
EmailVerified: true,
Username: "existing",
})
require.Equal(t, http.StatusFound, recorder.Code)
location := recorder.Header().Get("Location")
require.Contains(t, location, "access_token=")
require.Contains(t, location, "redirect=%252Fdashboard")
sessionCount, err := client.PendingAuthSession.Query().Count(ctx)
require.NoError(t, err)
require.Zero(t, sessionCount)
identityCount, err := client.AuthIdentity.Query().Where(
authidentity.ProviderTypeEQ("google"),
authidentity.ProviderSubjectEQ("google-123"),
).Count(ctx)
require.NoError(t, err)
require.Equal(t, 1, identityCount)
_ = user
}
func TestEmailOAuthCallbackAutoRegistrationAppliesAffiliateCode(t *testing.T) {
affiliateRepo := newOAuthEmailAffiliateRepoStub(map[string]int64{"AFF123": 1001})
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
settingValues: map[string]string{
service.SettingKeyAffiliateEnabled: "true",
},
affiliateFactory: func(_ *dbent.Client, settingSvc *service.SettingService) *service.AffiliateService {
return service.NewAffiliateService(affiliateRepo, settingSvc, nil, nil)
},
})
ctx := context.Background()
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/callback", nil)
req.AddCookie(&http.Cookie{Name: emailOAuthAffiliateCookie, Value: encodeCookieValue("AFF123")})
c.Request = req
handler.emailOAuthCallbackWithProfile(c, "github", config.EmailOAuthProviderConfig{
Enabled: true,
ClientID: "github-client",
ClientSecret: "github-secret",
RedirectURL: "https://app.example/api/v1/auth/oauth/github/callback",
FrontendRedirectURL: "/auth/oauth/callback",
}, "/auth/oauth/callback", "/dashboard", &emailOAuthProfile{
Subject: "github-aff-user",
Email: "aff-user@example.com",
EmailVerified: true,
Username: "aff-user",
})
require.Equal(t, http.StatusFound, recorder.Code)
require.Contains(t, recorder.Header().Get("Location"), "access_token=")
user, err := client.User.Query().Where(dbuser.EmailEQ("aff-user@example.com")).Only(ctx)
require.NoError(t, err)
require.Equal(t, []int64{user.ID, user.ID}, affiliateRepo.ensureUserIDs)
require.Equal(t, []oauthEmailAffiliateBindCall{{userID: user.ID, inviterID: 1001}}, affiliateRepo.bindCalls)
}
func TestCompleteEmailOAuthRegistrationUsesAffiliateCodeFromPendingSession(t *testing.T) {
affiliateRepo := newOAuthEmailAffiliateRepoStub(map[string]int64{"AFF456": 2002})
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
invitationEnabled: true,
settingValues: map[string]string{
service.SettingKeyAffiliateEnabled: "true",
},
affiliateFactory: func(_ *dbent.Client, settingSvc *service.SettingService) *service.AffiliateService {
return service.NewAffiliateService(affiliateRepo, settingSvc, nil, nil)
},
})
ctx := context.Background()
invitation, err := client.RedeemCode.Create().
SetCode("INVITE456").
SetType(service.RedeemTypeInvitation).
SetStatus(service.StatusUnused).
SetValue(0).
Save(ctx)
require.NoError(t, err)
session, err := client.PendingAuthSession.Create().
SetSessionToken("email-oauth-aff-session-token").
SetIntent(oauthIntentLogin).
SetProviderType("google").
SetProviderKey("google").
SetProviderSubject("google-aff-user").
SetResolvedEmail("pending-aff@example.com").
SetRedirectTo("/dashboard").
SetBrowserSessionKey("browser-aff-key").
SetUpstreamIdentityClaims(map[string]any{
"email": "pending-aff@example.com",
"email_verified": true,
"username": "pending-aff",
"provider": "google",
"provider_key": "google",
"provider_subject": "google-aff-user",
"aff_code": "AFF456",
}).
SetLocalFlowState(map[string]any{
"step": oauthPendingChoiceStep,
"error": "invitation_required",
}).
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
Save(ctx)
require.NoError(t, err)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/google/complete-registration", strings.NewReader(`{"invitation_code":"INVITE456"}`))
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("browser-aff-key")})
c.Request = req
handler.completeEmailOAuthRegistration(c, "google")
require.Equal(t, http.StatusOK, recorder.Code)
user, err := client.User.Query().Where(dbuser.EmailEQ("pending-aff@example.com")).Only(ctx)
require.NoError(t, err)
require.Equal(t, []oauthEmailAffiliateBindCall{{userID: user.ID, inviterID: 2002}}, affiliateRepo.bindCalls)
storedInvitation, err := client.RedeemCode.Query().Where(redeemcode.IDEQ(invitation.ID)).Only(ctx)
require.NoError(t, err)
require.NotNil(t, storedInvitation.UsedBy)
require.Equal(t, user.ID, *storedInvitation.UsedBy)
}
type oauthEmailAffiliateBindCall struct {
userID int64
inviterID int64
}
type oauthEmailAffiliateRepoStub struct {
codeOwners map[string]int64
ensureUserIDs []int64
bindCalls []oauthEmailAffiliateBindCall
}
func newOAuthEmailAffiliateRepoStub(codeOwners map[string]int64) *oauthEmailAffiliateRepoStub {
return &oauthEmailAffiliateRepoStub{codeOwners: codeOwners}
}
func (r *oauthEmailAffiliateRepoStub) EnsureUserAffiliate(_ context.Context, userID int64) (*service.AffiliateSummary, error) {
r.ensureUserIDs = append(r.ensureUserIDs, userID)
return &service.AffiliateSummary{UserID: userID, AffCode: "SELF"}, nil
}
func (r *oauthEmailAffiliateRepoStub) GetAffiliateByCode(_ context.Context, code string) (*service.AffiliateSummary, error) {
userID, ok := r.codeOwners[strings.ToUpper(strings.TrimSpace(code))]
if !ok {
return nil, service.ErrAffiliateProfileNotFound
}
return &service.AffiliateSummary{UserID: userID, AffCode: strings.ToUpper(strings.TrimSpace(code))}, nil
}
func (r *oauthEmailAffiliateRepoStub) BindInviter(_ context.Context, userID, inviterID int64) (bool, error) {
r.bindCalls = append(r.bindCalls, oauthEmailAffiliateBindCall{userID: userID, inviterID: inviterID})
return true, nil
}
func (r *oauthEmailAffiliateRepoStub) AccrueQuota(context.Context, int64, int64, float64, int, *int64) (bool, error) {
panic("unexpected AccrueQuota call")
}
func (r *oauthEmailAffiliateRepoStub) GetAccruedRebateFromInvitee(context.Context, int64, int64) (float64, error) {
panic("unexpected GetAccruedRebateFromInvitee call")
}
func (r *oauthEmailAffiliateRepoStub) ThawFrozenQuota(context.Context, int64) (float64, error) {
panic("unexpected ThawFrozenQuota call")
}
func (r *oauthEmailAffiliateRepoStub) TransferQuotaToBalance(context.Context, int64) (float64, float64, error) {
panic("unexpected TransferQuotaToBalance call")
}
func (r *oauthEmailAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]service.AffiliateInvitee, error) {
panic("unexpected ListInvitees call")
}
func (r *oauthEmailAffiliateRepoStub) UpdateUserAffCode(context.Context, int64, string) error {
panic("unexpected UpdateUserAffCode call")
}
func (r *oauthEmailAffiliateRepoStub) ResetUserAffCode(context.Context, int64) (string, error) {
panic("unexpected ResetUserAffCode call")
}
func (r *oauthEmailAffiliateRepoStub) SetUserRebateRate(context.Context, int64, *float64) error {
panic("unexpected SetUserRebateRate call")
}
func (r *oauthEmailAffiliateRepoStub) BatchSetUserRebateRate(context.Context, []int64, *float64) error {
panic("unexpected BatchSetUserRebateRate call")
}
func (r *oauthEmailAffiliateRepoStub) ListUsersWithCustomSettings(context.Context, service.AffiliateAdminFilter) ([]service.AffiliateAdminEntry, int64, error) {
panic("unexpected ListUsersWithCustomSettings call")
}
func (r *oauthEmailAffiliateRepoStub) ListAffiliateInviteRecords(context.Context, service.AffiliateRecordFilter) ([]service.AffiliateInviteRecord, int64, error) {
panic("unexpected ListAffiliateInviteRecords call")
}
func (r *oauthEmailAffiliateRepoStub) ListAffiliateRebateRecords(context.Context, service.AffiliateRecordFilter) ([]service.AffiliateRebateRecord, int64, error) {
panic("unexpected ListAffiliateRebateRecords call")
}
func (r *oauthEmailAffiliateRepoStub) ListAffiliateTransferRecords(context.Context, service.AffiliateRecordFilter) ([]service.AffiliateTransferRecord, int64, error) {
panic("unexpected ListAffiliateTransferRecords call")
}
func (r *oauthEmailAffiliateRepoStub) GetAffiliateUserOverview(context.Context, int64) (*service.AffiliateUserOverview, error) {
panic("unexpected GetAffiliateUserOverview call")
}
func findSetCookieValue(cookies []*http.Cookie, name string) string {
for _, cookie := range cookies {
if cookie != nil && strings.EqualFold(cookie.Name, name) && cookie.MaxAge >= 0 {
return cookie.Value
}
}
return ""
}

View File

@@ -2121,6 +2121,8 @@ type oauthPendingFlowTestHandlerOptions struct {
emailCache service.EmailCache
settingValues map[string]string
defaultSubAssigner service.DefaultSubscriptionAssigner
affiliateService *service.AffiliateService
affiliateFactory func(*dbent.Client, *service.SettingService) *service.AffiliateService
totpCache service.TotpCache
totpEncryptor service.SecretEncryptor
userRepoOptions oauthPendingFlowUserRepoOptions
@@ -2160,6 +2162,21 @@ CREATE TABLE IF NOT EXISTS user_avatars (
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)`)
require.NoError(t, err)
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS user_affiliates (
user_id INTEGER PRIMARY KEY,
aff_code TEXT NOT NULL UNIQUE,
aff_code_custom BOOLEAN NOT NULL DEFAULT false,
aff_rebate_rate_percent REAL NULL,
inviter_id INTEGER NULL,
aff_count INTEGER NOT NULL DEFAULT 0,
aff_quota REAL NOT NULL DEFAULT 0,
aff_frozen_quota REAL NOT NULL DEFAULT 0,
aff_history_quota REAL NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)`)
require.NoError(t, err)
drv := entsql.OpenDB(dialect.SQLite, db)
client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
@@ -2177,14 +2194,19 @@ CREATE TABLE IF NOT EXISTS user_avatars (
},
}
settingValues := map[string]string{
service.SettingKeyRegistrationEnabled: "true",
service.SettingKeyInvitationCodeEnabled: boolSettingValue(options.invitationEnabled),
service.SettingKeyEmailVerifyEnabled: boolSettingValue(options.emailVerifyEnabled),
service.SettingKeyRegistrationEnabled: "true",
service.SettingKeyInvitationCodeEnabled: boolSettingValue(options.invitationEnabled),
service.SettingKeyEmailVerifyEnabled: boolSettingValue(options.emailVerifyEnabled),
service.SettingKeyRegistrationEmailSuffixWhitelist: "[]",
}
for key, value := range options.settingValues {
settingValues[key] = value
}
settingSvc := service.NewSettingService(&oauthPendingFlowSettingRepoStub{values: settingValues}, cfg)
affiliateService := options.affiliateService
if affiliateService == nil && options.affiliateFactory != nil {
affiliateService = options.affiliateFactory(client, settingSvc)
}
userRepo := &oauthPendingFlowUserRepo{
client: client,
options: options.userRepoOptions,
@@ -2210,7 +2232,7 @@ CREATE TABLE IF NOT EXISTS user_avatars (
nil,
nil,
options.defaultSubAssigner,
nil,
affiliateService,
)
userSvc := service.NewUserService(userRepo, nil, nil, nil)
var totpSvc *service.TotpService

View File

@@ -91,6 +91,17 @@ type SystemSettings struct {
OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"`
OIDCConnectUserInfoUsernamePath string `json:"oidc_connect_userinfo_username_path"`
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
GitHubOAuthClientID string `json:"github_oauth_client_id"`
GitHubOAuthClientSecretConfigured bool `json:"github_oauth_client_secret_configured"`
GitHubOAuthRedirectURL string `json:"github_oauth_redirect_url"`
GitHubOAuthFrontendRedirectURL string `json:"github_oauth_frontend_redirect_url"`
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
GoogleOAuthClientID string `json:"google_oauth_client_id"`
GoogleOAuthClientSecretConfigured bool `json:"google_oauth_client_secret_configured"`
GoogleOAuthRedirectURL string `json:"google_oauth_redirect_url"`
GoogleOAuthFrontendRedirectURL string `json:"google_oauth_frontend_redirect_url"`
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"`
@@ -241,6 +252,8 @@ type PublicSettings struct {
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
SoraClientEnabled bool `json:"sora_client_enabled"`
BackendModeEnabled bool `json:"backend_mode_enabled"`
PaymentEnabled bool `json:"payment_enabled"`

View File

@@ -63,6 +63,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
BackendModeEnabled: settings.BackendModeEnabled,
PaymentEnabled: settings.PaymentEnabled,
Version: h.version,