feat: 增加 GitHub 和 Google 邮箱快捷登录
This commit is contained in:
parent
a1106e8167
commit
af550fa64e
@ -16,6 +16,8 @@ import (
|
|||||||
|
|
||||||
var authProviderTypes = map[string]struct{}{
|
var authProviderTypes = map[string]struct{}{
|
||||||
"email": {},
|
"email": {},
|
||||||
|
"github": {},
|
||||||
|
"google": {},
|
||||||
"linuxdo": {},
|
"linuxdo": {},
|
||||||
"oidc": {},
|
"oidc": {},
|
||||||
"wechat": {},
|
"wechat": {},
|
||||||
|
|||||||
@ -77,10 +77,10 @@ func (User) Fields() []ent.Field {
|
|||||||
field.String("signup_source").
|
field.String("signup_source").
|
||||||
Validate(func(value string) error {
|
Validate(func(value string) error {
|
||||||
switch value {
|
switch value {
|
||||||
case "email", "linuxdo", "wechat", "oidc":
|
case "email", "linuxdo", "wechat", "oidc", "github", "google":
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("must be one of email, linuxdo, wechat, oidc")
|
return fmt.Errorf("must be one of email, linuxdo, wechat, oidc, github, google")
|
||||||
}
|
}
|
||||||
}).
|
}).
|
||||||
Default("email"),
|
Default("email"),
|
||||||
|
|||||||
@ -72,6 +72,8 @@ type Config struct {
|
|||||||
LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"`
|
LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"`
|
||||||
WeChat WeChatConnectConfig `mapstructure:"wechat_connect"`
|
WeChat WeChatConnectConfig `mapstructure:"wechat_connect"`
|
||||||
OIDC OIDCConnectConfig `mapstructure:"oidc_connect"`
|
OIDC OIDCConnectConfig `mapstructure:"oidc_connect"`
|
||||||
|
GitHubOAuth EmailOAuthProviderConfig `mapstructure:"github_oauth"`
|
||||||
|
GoogleOAuth EmailOAuthProviderConfig `mapstructure:"google_oauth"`
|
||||||
Default DefaultConfig `mapstructure:"default"`
|
Default DefaultConfig `mapstructure:"default"`
|
||||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||||
Pricing PricingConfig `mapstructure:"pricing"`
|
Pricing PricingConfig `mapstructure:"pricing"`
|
||||||
@ -240,6 +242,19 @@ type OIDCConnectConfig struct {
|
|||||||
UserInfoUsernamePath string `mapstructure:"userinfo_username_path"`
|
UserInfoUsernamePath string `mapstructure:"userinfo_username_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EmailOAuthProviderConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
ClientID string `mapstructure:"client_id"`
|
||||||
|
ClientSecret string `mapstructure:"client_secret"`
|
||||||
|
AuthorizeURL string `mapstructure:"authorize_url"`
|
||||||
|
TokenURL string `mapstructure:"token_url"`
|
||||||
|
UserInfoURL string `mapstructure:"userinfo_url"`
|
||||||
|
EmailsURL string `mapstructure:"emails_url"`
|
||||||
|
Scopes string `mapstructure:"scopes"`
|
||||||
|
RedirectURL string `mapstructure:"redirect_url"`
|
||||||
|
FrontendRedirectURL string `mapstructure:"frontend_redirect_url"`
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultWeChatConnectMode = "open"
|
defaultWeChatConnectMode = "open"
|
||||||
defaultWeChatConnectScopes = "snsapi_login"
|
defaultWeChatConnectScopes = "snsapi_login"
|
||||||
|
|||||||
@ -169,6 +169,16 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
|||||||
OIDCConnectUserInfoEmailPath: settings.OIDCConnectUserInfoEmailPath,
|
OIDCConnectUserInfoEmailPath: settings.OIDCConnectUserInfoEmailPath,
|
||||||
OIDCConnectUserInfoIDPath: settings.OIDCConnectUserInfoIDPath,
|
OIDCConnectUserInfoIDPath: settings.OIDCConnectUserInfoIDPath,
|
||||||
OIDCConnectUserInfoUsernamePath: settings.OIDCConnectUserInfoUsernamePath,
|
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,
|
SiteName: settings.SiteName,
|
||||||
SiteLogo: settings.SiteLogo,
|
SiteLogo: settings.SiteLogo,
|
||||||
SiteSubtitle: settings.SiteSubtitle,
|
SiteSubtitle: settings.SiteSubtitle,
|
||||||
@ -368,6 +378,17 @@ type UpdateSettingsRequest struct {
|
|||||||
OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"`
|
OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"`
|
||||||
OIDCConnectUserInfoUsernamePath string `json:"oidc_connect_userinfo_username_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设置
|
// OEM设置
|
||||||
SiteName string `json:"site_name"`
|
SiteName string `json:"site_name"`
|
||||||
SiteLogo string `json:"site_logo"`
|
SiteLogo string `json:"site_logo"`
|
||||||
@ -413,6 +434,16 @@ type UpdateSettingsRequest struct {
|
|||||||
AuthSourceDefaultWeChatSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_wechat_subscriptions"`
|
AuthSourceDefaultWeChatSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_wechat_subscriptions"`
|
||||||
AuthSourceDefaultWeChatGrantOnSignup *bool `json:"auth_source_default_wechat_grant_on_signup"`
|
AuthSourceDefaultWeChatGrantOnSignup *bool `json:"auth_source_default_wechat_grant_on_signup"`
|
||||||
AuthSourceDefaultWeChatGrantOnFirstBind *bool `json:"auth_source_default_wechat_grant_on_first_bind"`
|
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"`
|
ForceEmailOnThirdPartySignup *bool `json:"force_email_on_third_party_signup"`
|
||||||
|
|
||||||
// Model fallback configuration
|
// Model fallback configuration
|
||||||
@ -1200,6 +1231,16 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
OIDCConnectUserInfoEmailPath: req.OIDCConnectUserInfoEmailPath,
|
OIDCConnectUserInfoEmailPath: req.OIDCConnectUserInfoEmailPath,
|
||||||
OIDCConnectUserInfoIDPath: req.OIDCConnectUserInfoIDPath,
|
OIDCConnectUserInfoIDPath: req.OIDCConnectUserInfoIDPath,
|
||||||
OIDCConnectUserInfoUsernamePath: req.OIDCConnectUserInfoUsernamePath,
|
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,
|
SiteName: req.SiteName,
|
||||||
SiteLogo: req.SiteLogo,
|
SiteLogo: req.SiteLogo,
|
||||||
SiteSubtitle: req.SiteSubtitle,
|
SiteSubtitle: req.SiteSubtitle,
|
||||||
@ -1396,6 +1437,20 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultWeChatGrantOnSignup, previousAuthSourceDefaults.WeChat.GrantOnSignup),
|
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultWeChatGrantOnSignup, previousAuthSourceDefaults.WeChat.GrantOnSignup),
|
||||||
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultWeChatGrantOnFirstBind, previousAuthSourceDefaults.WeChat.GrantOnFirstBind),
|
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),
|
ForceEmailOnThirdPartySignup: boolValueOrDefault(req.ForceEmailOnThirdPartySignup, previousAuthSourceDefaults.ForceEmailOnThirdPartySignup),
|
||||||
}
|
}
|
||||||
if err := h.settingService.UpdateSettingsWithAuthSourceDefaults(c.Request.Context(), settings, authSourceDefaults); err != nil {
|
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,
|
OIDCConnectUserInfoEmailPath: updatedSettings.OIDCConnectUserInfoEmailPath,
|
||||||
OIDCConnectUserInfoIDPath: updatedSettings.OIDCConnectUserInfoIDPath,
|
OIDCConnectUserInfoIDPath: updatedSettings.OIDCConnectUserInfoIDPath,
|
||||||
OIDCConnectUserInfoUsernamePath: updatedSettings.OIDCConnectUserInfoUsernamePath,
|
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,
|
SiteName: updatedSettings.SiteName,
|
||||||
SiteLogo: updatedSettings.SiteLogo,
|
SiteLogo: updatedSettings.SiteLogo,
|
||||||
SiteSubtitle: updatedSettings.SiteSubtitle,
|
SiteSubtitle: updatedSettings.SiteSubtitle,
|
||||||
@ -2027,6 +2092,8 @@ func appendAuthSourceDefaultChanges(changed []string, before *service.AuthSource
|
|||||||
{name: "linuxdo", before: before.LinuxDo, after: after.LinuxDo},
|
{name: "linuxdo", before: before.LinuxDo, after: after.LinuxDo},
|
||||||
{name: "oidc", before: before.OIDC, after: after.OIDC},
|
{name: "oidc", before: before.OIDC, after: after.OIDC},
|
||||||
{name: "wechat", before: before.WeChat, after: after.WeChat},
|
{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 {
|
for _, field := range fields {
|
||||||
if field.before.Balance != field.after.Balance {
|
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_subscriptions"] = authSourceDefaults.WeChat.Subscriptions
|
||||||
data["auth_source_default_wechat_grant_on_signup"] = authSourceDefaults.WeChat.GrantOnSignup
|
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_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
|
data["force_email_on_third_party_signup"] = authSourceDefaults.ForceEmailOnThirdPartySignup
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|||||||
549
backend/internal/handler/auth_email_oauth.go
Normal file
549
backend/internal/handler/auth_email_oauth.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
333
backend/internal/handler/auth_email_oauth_test.go
Normal file
333
backend/internal/handler/auth_email_oauth_test.go
Normal 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 ""
|
||||||
|
}
|
||||||
@ -2121,6 +2121,8 @@ type oauthPendingFlowTestHandlerOptions struct {
|
|||||||
emailCache service.EmailCache
|
emailCache service.EmailCache
|
||||||
settingValues map[string]string
|
settingValues map[string]string
|
||||||
defaultSubAssigner service.DefaultSubscriptionAssigner
|
defaultSubAssigner service.DefaultSubscriptionAssigner
|
||||||
|
affiliateService *service.AffiliateService
|
||||||
|
affiliateFactory func(*dbent.Client, *service.SettingService) *service.AffiliateService
|
||||||
totpCache service.TotpCache
|
totpCache service.TotpCache
|
||||||
totpEncryptor service.SecretEncryptor
|
totpEncryptor service.SecretEncryptor
|
||||||
userRepoOptions oauthPendingFlowUserRepoOptions
|
userRepoOptions oauthPendingFlowUserRepoOptions
|
||||||
@ -2160,6 +2162,21 @@ CREATE TABLE IF NOT EXISTS user_avatars (
|
|||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
)`)
|
)`)
|
||||||
require.NoError(t, err)
|
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)
|
drv := entsql.OpenDB(dialect.SQLite, db)
|
||||||
client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
|
client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
|
||||||
@ -2177,14 +2194,19 @@ CREATE TABLE IF NOT EXISTS user_avatars (
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
settingValues := map[string]string{
|
settingValues := map[string]string{
|
||||||
service.SettingKeyRegistrationEnabled: "true",
|
service.SettingKeyRegistrationEnabled: "true",
|
||||||
service.SettingKeyInvitationCodeEnabled: boolSettingValue(options.invitationEnabled),
|
service.SettingKeyInvitationCodeEnabled: boolSettingValue(options.invitationEnabled),
|
||||||
service.SettingKeyEmailVerifyEnabled: boolSettingValue(options.emailVerifyEnabled),
|
service.SettingKeyEmailVerifyEnabled: boolSettingValue(options.emailVerifyEnabled),
|
||||||
|
service.SettingKeyRegistrationEmailSuffixWhitelist: "[]",
|
||||||
}
|
}
|
||||||
for key, value := range options.settingValues {
|
for key, value := range options.settingValues {
|
||||||
settingValues[key] = value
|
settingValues[key] = value
|
||||||
}
|
}
|
||||||
settingSvc := service.NewSettingService(&oauthPendingFlowSettingRepoStub{values: settingValues}, cfg)
|
settingSvc := service.NewSettingService(&oauthPendingFlowSettingRepoStub{values: settingValues}, cfg)
|
||||||
|
affiliateService := options.affiliateService
|
||||||
|
if affiliateService == nil && options.affiliateFactory != nil {
|
||||||
|
affiliateService = options.affiliateFactory(client, settingSvc)
|
||||||
|
}
|
||||||
userRepo := &oauthPendingFlowUserRepo{
|
userRepo := &oauthPendingFlowUserRepo{
|
||||||
client: client,
|
client: client,
|
||||||
options: options.userRepoOptions,
|
options: options.userRepoOptions,
|
||||||
@ -2210,7 +2232,7 @@ CREATE TABLE IF NOT EXISTS user_avatars (
|
|||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
options.defaultSubAssigner,
|
options.defaultSubAssigner,
|
||||||
nil,
|
affiliateService,
|
||||||
)
|
)
|
||||||
userSvc := service.NewUserService(userRepo, nil, nil, nil)
|
userSvc := service.NewUserService(userRepo, nil, nil, nil)
|
||||||
var totpSvc *service.TotpService
|
var totpSvc *service.TotpService
|
||||||
|
|||||||
@ -91,6 +91,17 @@ type SystemSettings struct {
|
|||||||
OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"`
|
OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"`
|
||||||
OIDCConnectUserInfoUsernamePath string `json:"oidc_connect_userinfo_username_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"`
|
SiteName string `json:"site_name"`
|
||||||
SiteLogo string `json:"site_logo"`
|
SiteLogo string `json:"site_logo"`
|
||||||
SiteSubtitle string `json:"site_subtitle"`
|
SiteSubtitle string `json:"site_subtitle"`
|
||||||
@ -241,6 +252,8 @@ type PublicSettings struct {
|
|||||||
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
|
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
|
||||||
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
|
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
|
||||||
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
|
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"`
|
SoraClientEnabled bool `json:"sora_client_enabled"`
|
||||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||||
PaymentEnabled bool `json:"payment_enabled"`
|
PaymentEnabled bool `json:"payment_enabled"`
|
||||||
|
|||||||
@ -63,6 +63,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
|||||||
WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
|
WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
|
||||||
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
|
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
|
||||||
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
|
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
|
||||||
|
GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
|
||||||
|
GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
|
||||||
BackendModeEnabled: settings.BackendModeEnabled,
|
BackendModeEnabled: settings.BackendModeEnabled,
|
||||||
PaymentEnabled: settings.PaymentEnabled,
|
PaymentEnabled: settings.PaymentEnabled,
|
||||||
Version: h.version,
|
Version: h.version,
|
||||||
|
|||||||
@ -40,6 +40,8 @@ func backendModeAllowsAuthPath(path string) bool {
|
|||||||
"/auth/oauth/wechat/callback",
|
"/auth/oauth/wechat/callback",
|
||||||
"/auth/oauth/wechat/payment/callback",
|
"/auth/oauth/wechat/payment/callback",
|
||||||
"/auth/oauth/oidc/callback",
|
"/auth/oauth/oidc/callback",
|
||||||
|
"/auth/oauth/github/callback",
|
||||||
|
"/auth/oauth/google/callback",
|
||||||
"/auth/oauth/linuxdo/complete-registration",
|
"/auth/oauth/linuxdo/complete-registration",
|
||||||
"/auth/oauth/wechat/complete-registration",
|
"/auth/oauth/wechat/complete-registration",
|
||||||
"/auth/oauth/oidc/complete-registration",
|
"/auth/oauth/oidc/complete-registration",
|
||||||
|
|||||||
@ -246,6 +246,30 @@ func TestBackendModeAuthGuard(t *testing.T) {
|
|||||||
path: "/api/v1/auth/oauth/oidc/callback",
|
path: "/api/v1/auth/oauth/oidc/callback",
|
||||||
wantStatus: http.StatusOK,
|
wantStatus: http.StatusOK,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "enabled_blocks_github_oauth_start",
|
||||||
|
enabled: "true",
|
||||||
|
path: "/api/v1/auth/oauth/github/start",
|
||||||
|
wantStatus: http.StatusForbidden,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled_allows_github_oauth_callback",
|
||||||
|
enabled: "true",
|
||||||
|
path: "/api/v1/auth/oauth/github/callback",
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled_blocks_google_oauth_start",
|
||||||
|
enabled: "true",
|
||||||
|
path: "/api/v1/auth/oauth/google/start",
|
||||||
|
wantStatus: http.StatusForbidden,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "enabled_allows_google_oauth_callback",
|
||||||
|
enabled: "true",
|
||||||
|
path: "/api/v1/auth/oauth/google/callback",
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "enabled_allows_oauth_pending_exchange",
|
name: "enabled_allows_oauth_pending_exchange",
|
||||||
enabled: "true",
|
enabled: "true",
|
||||||
|
|||||||
@ -63,6 +63,22 @@ func RegisterAuthRoutes(
|
|||||||
FailureMode: middleware.RateLimitFailClose,
|
FailureMode: middleware.RateLimitFailClose,
|
||||||
}), h.Auth.ResetPassword)
|
}), h.Auth.ResetPassword)
|
||||||
auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart)
|
auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart)
|
||||||
|
auth.GET("/oauth/github/start", h.Auth.GitHubOAuthStart)
|
||||||
|
auth.GET("/oauth/github/callback", h.Auth.GitHubOAuthCallback)
|
||||||
|
auth.POST("/oauth/github/complete-registration",
|
||||||
|
rateLimiter.LimitWithOptions("oauth-github-complete", 10, time.Minute, middleware.RateLimitOptions{
|
||||||
|
FailureMode: middleware.RateLimitFailClose,
|
||||||
|
}),
|
||||||
|
h.Auth.CompleteGitHubOAuthRegistration,
|
||||||
|
)
|
||||||
|
auth.GET("/oauth/google/start", h.Auth.GoogleOAuthStart)
|
||||||
|
auth.GET("/oauth/google/callback", h.Auth.GoogleOAuthCallback)
|
||||||
|
auth.POST("/oauth/google/complete-registration",
|
||||||
|
rateLimiter.LimitWithOptions("oauth-google-complete", 10, time.Minute, middleware.RateLimitOptions{
|
||||||
|
FailureMode: middleware.RateLimitFailClose,
|
||||||
|
}),
|
||||||
|
h.Auth.CompleteGoogleOAuthRegistration,
|
||||||
|
)
|
||||||
auth.GET("/oauth/linuxdo/bind/start", func(c *gin.Context) {
|
auth.GET("/oauth/linuxdo/bind/start", func(c *gin.Context) {
|
||||||
query := c.Request.URL.Query()
|
query := c.Request.URL.Query()
|
||||||
query.Set("intent", "bind_current_user")
|
query.Set("intent", "bind_current_user")
|
||||||
|
|||||||
274
backend/internal/service/auth_email_oauth_auto.go
Normal file
274
backend/internal/service/auth_email_oauth_auto.go
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||||
|
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
||||||
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmailOAuthIdentityInput struct {
|
||||||
|
ProviderType string
|
||||||
|
ProviderKey string
|
||||||
|
ProviderSubject string
|
||||||
|
Email string
|
||||||
|
EmailVerified bool
|
||||||
|
Username string
|
||||||
|
DisplayName string
|
||||||
|
AvatarURL string
|
||||||
|
UpstreamMetadata map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) LoginOrRegisterVerifiedEmailOAuth(ctx context.Context, input EmailOAuthIdentityInput) (*TokenPair, *User, error) {
|
||||||
|
return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) LoginOrRegisterVerifiedEmailOAuthWithInvitation(
|
||||||
|
ctx context.Context,
|
||||||
|
input EmailOAuthIdentityInput,
|
||||||
|
invitationCode string,
|
||||||
|
affiliateCode string,
|
||||||
|
) (*TokenPair, *User, error) {
|
||||||
|
return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, invitationCode, affiliateCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) loginOrRegisterVerifiedEmailOAuth(
|
||||||
|
ctx context.Context,
|
||||||
|
input EmailOAuthIdentityInput,
|
||||||
|
invitationCode string,
|
||||||
|
affiliateCode string,
|
||||||
|
) (*TokenPair, *User, error) {
|
||||||
|
if s == nil || s.userRepo == nil || s.entClient == nil {
|
||||||
|
return nil, nil, ErrServiceUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
providerType := normalizeOAuthSignupSource(input.ProviderType)
|
||||||
|
if providerType != "github" && providerType != "google" {
|
||||||
|
return nil, nil, infraerrors.BadRequest("OAUTH_PROVIDER_INVALID", "oauth provider is invalid")
|
||||||
|
}
|
||||||
|
providerKey := strings.TrimSpace(input.ProviderKey)
|
||||||
|
if providerKey == "" {
|
||||||
|
providerKey = providerType
|
||||||
|
}
|
||||||
|
providerSubject := strings.TrimSpace(input.ProviderSubject)
|
||||||
|
if providerSubject == "" {
|
||||||
|
return nil, nil, infraerrors.BadRequest("OAUTH_SUBJECT_MISSING", "oauth subject is missing")
|
||||||
|
}
|
||||||
|
if !input.EmailVerified {
|
||||||
|
return nil, nil, infraerrors.Forbidden("OAUTH_EMAIL_NOT_VERIFIED", "oauth email is not verified")
|
||||||
|
}
|
||||||
|
|
||||||
|
email := strings.TrimSpace(strings.ToLower(input.Email))
|
||||||
|
if email == "" || len(email) > 255 {
|
||||||
|
return nil, nil, infraerrors.BadRequest("INVALID_EMAIL", "invalid email")
|
||||||
|
}
|
||||||
|
if _, err := mail.ParseAddress(email); err != nil {
|
||||||
|
return nil, nil, infraerrors.BadRequest("INVALID_EMAIL", "invalid email")
|
||||||
|
}
|
||||||
|
if isReservedEmail(email) {
|
||||||
|
return nil, nil, ErrEmailReserved
|
||||||
|
}
|
||||||
|
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
identityUser, err := s.findEmailOAuthIdentityOwner(ctx, providerType, providerKey, providerSubject)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if identityUser != nil && !strings.EqualFold(strings.TrimSpace(identityUser.Email), email) {
|
||||||
|
return nil, nil, infraerrors.Conflict("AUTH_IDENTITY_EMAIL_MISMATCH", "oauth identity belongs to a different email")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := identityUser
|
||||||
|
created := false
|
||||||
|
if user == nil {
|
||||||
|
user, err = s.userRepo.GetByEmail(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrUserNotFound) {
|
||||||
|
user, err = s.createEmailOAuthUser(ctx, email, input.Username, providerType, invitationCode, affiliateCode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
created = true
|
||||||
|
} else {
|
||||||
|
logger.LegacyPrintf("service.auth", "[Auth] Database error during %s oauth login: %v", providerType, err)
|
||||||
|
return nil, nil, ErrServiceUnavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.IsActive() {
|
||||||
|
return nil, nil, ErrUserNotActive
|
||||||
|
}
|
||||||
|
if err := s.ensureEmailOAuthIdentity(ctx, user.ID, EmailOAuthIdentityInput{
|
||||||
|
ProviderType: providerType,
|
||||||
|
ProviderKey: providerKey,
|
||||||
|
ProviderSubject: providerSubject,
|
||||||
|
Email: email,
|
||||||
|
EmailVerified: input.EmailVerified,
|
||||||
|
Username: input.Username,
|
||||||
|
DisplayName: input.DisplayName,
|
||||||
|
AvatarURL: input.AvatarURL,
|
||||||
|
UpstreamMetadata: input.UpstreamMetadata,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Username == "" && strings.TrimSpace(input.Username) != "" {
|
||||||
|
user.Username = strings.TrimSpace(input.Username)
|
||||||
|
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||||
|
logger.LegacyPrintf("service.auth", "[Auth] Failed to update username after %s oauth login: %v", providerType, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !created {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.RecordSuccessfulLogin(ctx, user.ID)
|
||||||
|
|
||||||
|
tokenPair, err := s.GenerateTokenPair(ctx, user, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("generate token pair: %w", err)
|
||||||
|
}
|
||||||
|
return tokenPair, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) createEmailOAuthUser(ctx context.Context, email, username, providerType, invitationCode, affiliateCode string) (*User, error) {
|
||||||
|
if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
|
||||||
|
return nil, ErrRegDisabled
|
||||||
|
}
|
||||||
|
invitationRedeemCode, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrInvitationCodeRequired) {
|
||||||
|
return nil, ErrOAuthInvitationRequired
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
randomPassword, err := randomHexString(32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrServiceUnavailable
|
||||||
|
}
|
||||||
|
hashedPassword, err := s.HashPassword(randomPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hash password: %w", err)
|
||||||
|
}
|
||||||
|
grantPlan := s.resolveSignupGrantPlan(ctx, providerType)
|
||||||
|
var defaultRPMLimit int
|
||||||
|
if s.settingService != nil {
|
||||||
|
defaultRPMLimit = s.settingService.GetDefaultUserRPMLimit(ctx)
|
||||||
|
}
|
||||||
|
user := &User{
|
||||||
|
Email: email,
|
||||||
|
Username: strings.TrimSpace(username),
|
||||||
|
PasswordHash: hashedPassword,
|
||||||
|
Role: RoleUser,
|
||||||
|
Balance: grantPlan.Balance,
|
||||||
|
Concurrency: grantPlan.Concurrency,
|
||||||
|
RPMLimit: defaultRPMLimit,
|
||||||
|
Status: StatusActive,
|
||||||
|
SignupSource: providerType,
|
||||||
|
}
|
||||||
|
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||||
|
if errors.Is(err, ErrEmailExists) {
|
||||||
|
existing, loadErr := s.userRepo.GetByEmail(ctx, email)
|
||||||
|
if loadErr != nil {
|
||||||
|
return nil, ErrServiceUnavailable
|
||||||
|
}
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
return nil, ErrServiceUnavailable
|
||||||
|
}
|
||||||
|
s.postAuthUserBootstrap(ctx, user, providerType, false)
|
||||||
|
s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
|
||||||
|
s.bindOAuthAffiliate(ctx, user.ID, affiliateCode)
|
||||||
|
if invitationRedeemCode != nil {
|
||||||
|
if err := s.useOAuthRegistrationInvitation(ctx, invitationRedeemCode.ID, user.ID); err != nil {
|
||||||
|
_ = s.RollbackOAuthEmailAccountCreation(ctx, user.ID, invitationCode)
|
||||||
|
return nil, ErrInvitationCodeInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) findEmailOAuthIdentityOwner(ctx context.Context, providerType, providerKey, providerSubject string) (*User, error) {
|
||||||
|
identity, err := s.entClient.AuthIdentity.Query().
|
||||||
|
Where(
|
||||||
|
authidentity.ProviderTypeEQ(providerType),
|
||||||
|
authidentity.ProviderKeyEQ(providerKey),
|
||||||
|
authidentity.ProviderSubjectEQ(providerSubject),
|
||||||
|
).
|
||||||
|
Only(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if dbent.IsNotFound(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, infraerrors.InternalServer("AUTH_IDENTITY_LOOKUP_FAILED", "failed to inspect auth identity ownership").WithCause(err)
|
||||||
|
}
|
||||||
|
user, err := s.userRepo.GetByID(ctx, identity.UserID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrUserNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, ErrServiceUnavailable
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) ensureEmailOAuthIdentity(ctx context.Context, userID int64, input EmailOAuthIdentityInput) error {
|
||||||
|
metadata := map[string]any{
|
||||||
|
"email": strings.TrimSpace(strings.ToLower(input.Email)),
|
||||||
|
"email_verified": input.EmailVerified,
|
||||||
|
}
|
||||||
|
for key, value := range input.UpstreamMetadata {
|
||||||
|
metadata[key] = value
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.Username) != "" {
|
||||||
|
metadata["username"] = strings.TrimSpace(input.Username)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.DisplayName) != "" {
|
||||||
|
metadata["display_name"] = strings.TrimSpace(input.DisplayName)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.AvatarURL) != "" {
|
||||||
|
metadata["avatar_url"] = strings.TrimSpace(input.AvatarURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
providerType := normalizeOAuthSignupSource(input.ProviderType)
|
||||||
|
providerKey := strings.TrimSpace(input.ProviderKey)
|
||||||
|
providerSubject := strings.TrimSpace(input.ProviderSubject)
|
||||||
|
identity, err := s.entClient.AuthIdentity.Query().
|
||||||
|
Where(
|
||||||
|
authidentity.ProviderTypeEQ(providerType),
|
||||||
|
authidentity.ProviderKeyEQ(providerKey),
|
||||||
|
authidentity.ProviderSubjectEQ(providerSubject),
|
||||||
|
).
|
||||||
|
Only(ctx)
|
||||||
|
if err != nil && !dbent.IsNotFound(err) {
|
||||||
|
return infraerrors.InternalServer("AUTH_IDENTITY_LOOKUP_FAILED", "failed to inspect auth identity ownership").WithCause(err)
|
||||||
|
}
|
||||||
|
if identity != nil {
|
||||||
|
if identity.UserID != userID {
|
||||||
|
return infraerrors.Conflict("AUTH_IDENTITY_OWNERSHIP_CONFLICT", "auth identity already belongs to another user")
|
||||||
|
}
|
||||||
|
_, err = s.entClient.AuthIdentity.UpdateOneID(identity.ID).
|
||||||
|
SetMetadata(metadata).
|
||||||
|
Save(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.entClient.AuthIdentity.Create().
|
||||||
|
SetUserID(userID).
|
||||||
|
SetProviderType(providerType).
|
||||||
|
SetProviderKey(providerKey).
|
||||||
|
SetProviderSubject(providerSubject).
|
||||||
|
SetMetadata(metadata).
|
||||||
|
Save(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@ -17,7 +17,7 @@ func normalizeOAuthSignupSource(signupSource string) string {
|
|||||||
switch signupSource {
|
switch signupSource {
|
||||||
case "", "email":
|
case "", "email":
|
||||||
return "email"
|
return "email"
|
||||||
case "linuxdo", "wechat", "oidc":
|
case "linuxdo", "wechat", "oidc", "github", "google":
|
||||||
return signupSource
|
return signupSource
|
||||||
default:
|
default:
|
||||||
return "email"
|
return "email"
|
||||||
|
|||||||
@ -775,6 +775,10 @@ func authSourceSignupSettings(defaults *AuthSourceDefaultSettings, signupSource
|
|||||||
return defaults.OIDC, true
|
return defaults.OIDC, true
|
||||||
case "wechat":
|
case "wechat":
|
||||||
return defaults.WeChat, true
|
return defaults.WeChat, true
|
||||||
|
case "github":
|
||||||
|
return defaults.GitHub, true
|
||||||
|
case "google":
|
||||||
|
return defaults.Google, true
|
||||||
default:
|
default:
|
||||||
return ProviderDefaultGrantSettings{}, false
|
return ProviderDefaultGrantSettings{}, false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -173,6 +173,18 @@ const (
|
|||||||
SettingKeyOIDCConnectUserInfoIDPath = "oidc_connect_userinfo_id_path"
|
SettingKeyOIDCConnectUserInfoIDPath = "oidc_connect_userinfo_id_path"
|
||||||
SettingKeyOIDCConnectUserInfoUsernamePath = "oidc_connect_userinfo_username_path"
|
SettingKeyOIDCConnectUserInfoUsernamePath = "oidc_connect_userinfo_username_path"
|
||||||
|
|
||||||
|
// GitHub / Google 邮箱快捷登录设置
|
||||||
|
SettingKeyGitHubOAuthEnabled = "github_oauth_enabled"
|
||||||
|
SettingKeyGitHubOAuthClientID = "github_oauth_client_id"
|
||||||
|
SettingKeyGitHubOAuthClientSecret = "github_oauth_client_secret"
|
||||||
|
SettingKeyGitHubOAuthRedirectURL = "github_oauth_redirect_url"
|
||||||
|
SettingKeyGitHubOAuthFrontendRedirectURL = "github_oauth_frontend_redirect_url"
|
||||||
|
SettingKeyGoogleOAuthEnabled = "google_oauth_enabled"
|
||||||
|
SettingKeyGoogleOAuthClientID = "google_oauth_client_id"
|
||||||
|
SettingKeyGoogleOAuthClientSecret = "google_oauth_client_secret"
|
||||||
|
SettingKeyGoogleOAuthRedirectURL = "google_oauth_redirect_url"
|
||||||
|
SettingKeyGoogleOAuthFrontendRedirectURL = "google_oauth_frontend_redirect_url"
|
||||||
|
|
||||||
// OEM设置
|
// OEM设置
|
||||||
SettingKeySiteName = "site_name" // 网站名称
|
SettingKeySiteName = "site_name" // 网站名称
|
||||||
SettingKeySiteLogo = "site_logo" // 网站Logo (base64)
|
SettingKeySiteLogo = "site_logo" // 网站Logo (base64)
|
||||||
@ -216,6 +228,16 @@ const (
|
|||||||
SettingKeyAuthSourceDefaultWeChatSubscriptions = "auth_source_default_wechat_subscriptions"
|
SettingKeyAuthSourceDefaultWeChatSubscriptions = "auth_source_default_wechat_subscriptions"
|
||||||
SettingKeyAuthSourceDefaultWeChatGrantOnSignup = "auth_source_default_wechat_grant_on_signup"
|
SettingKeyAuthSourceDefaultWeChatGrantOnSignup = "auth_source_default_wechat_grant_on_signup"
|
||||||
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind = "auth_source_default_wechat_grant_on_first_bind"
|
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind = "auth_source_default_wechat_grant_on_first_bind"
|
||||||
|
SettingKeyAuthSourceDefaultGitHubBalance = "auth_source_default_github_balance"
|
||||||
|
SettingKeyAuthSourceDefaultGitHubConcurrency = "auth_source_default_github_concurrency"
|
||||||
|
SettingKeyAuthSourceDefaultGitHubSubscriptions = "auth_source_default_github_subscriptions"
|
||||||
|
SettingKeyAuthSourceDefaultGitHubGrantOnSignup = "auth_source_default_github_grant_on_signup"
|
||||||
|
SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind = "auth_source_default_github_grant_on_first_bind"
|
||||||
|
SettingKeyAuthSourceDefaultGoogleBalance = "auth_source_default_google_balance"
|
||||||
|
SettingKeyAuthSourceDefaultGoogleConcurrency = "auth_source_default_google_concurrency"
|
||||||
|
SettingKeyAuthSourceDefaultGoogleSubscriptions = "auth_source_default_google_subscriptions"
|
||||||
|
SettingKeyAuthSourceDefaultGoogleGrantOnSignup = "auth_source_default_google_grant_on_signup"
|
||||||
|
SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind = "auth_source_default_google_grant_on_first_bind"
|
||||||
SettingKeyForceEmailOnThirdPartySignup = "force_email_on_third_party_signup"
|
SettingKeyForceEmailOnThirdPartySignup = "force_email_on_third_party_signup"
|
||||||
|
|
||||||
// 管理员 API Key
|
// 管理员 API Key
|
||||||
|
|||||||
@ -129,6 +129,8 @@ type AuthSourceDefaultSettings struct {
|
|||||||
LinuxDo ProviderDefaultGrantSettings
|
LinuxDo ProviderDefaultGrantSettings
|
||||||
OIDC ProviderDefaultGrantSettings
|
OIDC ProviderDefaultGrantSettings
|
||||||
WeChat ProviderDefaultGrantSettings
|
WeChat ProviderDefaultGrantSettings
|
||||||
|
GitHub ProviderDefaultGrantSettings
|
||||||
|
Google ProviderDefaultGrantSettings
|
||||||
ForceEmailOnThirdPartySignup bool
|
ForceEmailOnThirdPartySignup bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,6 +171,20 @@ var (
|
|||||||
grantOnSignup: SettingKeyAuthSourceDefaultWeChatGrantOnSignup,
|
grantOnSignup: SettingKeyAuthSourceDefaultWeChatGrantOnSignup,
|
||||||
grantOnFirstBind: SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind,
|
grantOnFirstBind: SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind,
|
||||||
}
|
}
|
||||||
|
gitHubAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
||||||
|
balance: SettingKeyAuthSourceDefaultGitHubBalance,
|
||||||
|
concurrency: SettingKeyAuthSourceDefaultGitHubConcurrency,
|
||||||
|
subscriptions: SettingKeyAuthSourceDefaultGitHubSubscriptions,
|
||||||
|
grantOnSignup: SettingKeyAuthSourceDefaultGitHubGrantOnSignup,
|
||||||
|
grantOnFirstBind: SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind,
|
||||||
|
}
|
||||||
|
googleAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
||||||
|
balance: SettingKeyAuthSourceDefaultGoogleBalance,
|
||||||
|
concurrency: SettingKeyAuthSourceDefaultGoogleConcurrency,
|
||||||
|
subscriptions: SettingKeyAuthSourceDefaultGoogleSubscriptions,
|
||||||
|
grantOnSignup: SettingKeyAuthSourceDefaultGoogleGrantOnSignup,
|
||||||
|
grantOnFirstBind: SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -177,6 +193,17 @@ const (
|
|||||||
defaultWeChatConnectMode = "open"
|
defaultWeChatConnectMode = "open"
|
||||||
defaultWeChatConnectScopes = "snsapi_login"
|
defaultWeChatConnectScopes = "snsapi_login"
|
||||||
defaultWeChatConnectFrontend = "/auth/wechat/callback"
|
defaultWeChatConnectFrontend = "/auth/wechat/callback"
|
||||||
|
defaultGitHubOAuthAuthorize = "https://github.com/login/oauth/authorize"
|
||||||
|
defaultGitHubOAuthToken = "https://github.com/login/oauth/access_token"
|
||||||
|
defaultGitHubOAuthUserInfo = "https://api.github.com/user"
|
||||||
|
defaultGitHubOAuthEmails = "https://api.github.com/user/emails"
|
||||||
|
defaultGitHubOAuthScopes = "read:user user:email"
|
||||||
|
defaultGitHubOAuthFrontend = "/auth/oauth/callback"
|
||||||
|
defaultGoogleOAuthAuthorize = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||||
|
defaultGoogleOAuthToken = "https://oauth2.googleapis.com/token"
|
||||||
|
defaultGoogleOAuthUserInfo = "https://openidconnect.googleapis.com/v1/userinfo"
|
||||||
|
defaultGoogleOAuthScopes = "openid email profile"
|
||||||
|
defaultGoogleOAuthFrontend = "/auth/oauth/callback"
|
||||||
)
|
)
|
||||||
|
|
||||||
func normalizeWeChatConnectModeSetting(raw string) string {
|
func normalizeWeChatConnectModeSetting(raw string) string {
|
||||||
@ -448,6 +475,12 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
SettingPaymentEnabled,
|
SettingPaymentEnabled,
|
||||||
SettingKeyOIDCConnectEnabled,
|
SettingKeyOIDCConnectEnabled,
|
||||||
SettingKeyOIDCConnectProviderName,
|
SettingKeyOIDCConnectProviderName,
|
||||||
|
SettingKeyGitHubOAuthEnabled,
|
||||||
|
SettingKeyGitHubOAuthClientID,
|
||||||
|
SettingKeyGitHubOAuthClientSecret,
|
||||||
|
SettingKeyGoogleOAuthEnabled,
|
||||||
|
SettingKeyGoogleOAuthClientID,
|
||||||
|
SettingKeyGoogleOAuthClientSecret,
|
||||||
SettingKeyBalanceLowNotifyEnabled,
|
SettingKeyBalanceLowNotifyEnabled,
|
||||||
SettingKeyBalanceLowNotifyThreshold,
|
SettingKeyBalanceLowNotifyThreshold,
|
||||||
SettingKeyBalanceLowNotifyRechargeURL,
|
SettingKeyBalanceLowNotifyRechargeURL,
|
||||||
@ -482,6 +515,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
if oidcProviderName == "" {
|
if oidcProviderName == "" {
|
||||||
oidcProviderName = "OIDC"
|
oidcProviderName = "OIDC"
|
||||||
}
|
}
|
||||||
|
gitHubEnabled := s.emailOAuthPublicEnabled(settings, "github")
|
||||||
|
googleEnabled := s.emailOAuthPublicEnabled(settings, "google")
|
||||||
weChatEnabled, weChatOpenEnabled, weChatMPEnabled, weChatMobileEnabled := s.weChatOAuthCapabilitiesFromSettings(settings)
|
weChatEnabled, weChatOpenEnabled, weChatMPEnabled, weChatMobileEnabled := s.weChatOAuthCapabilitiesFromSettings(settings)
|
||||||
|
|
||||||
// Password reset requires email verification to be enabled
|
// Password reset requires email verification to be enabled
|
||||||
@ -534,6 +569,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
PaymentEnabled: settings[SettingPaymentEnabled] == "true",
|
PaymentEnabled: settings[SettingPaymentEnabled] == "true",
|
||||||
OIDCOAuthEnabled: oidcEnabled,
|
OIDCOAuthEnabled: oidcEnabled,
|
||||||
OIDCOAuthProviderName: oidcProviderName,
|
OIDCOAuthProviderName: oidcProviderName,
|
||||||
|
GitHubOAuthEnabled: gitHubEnabled,
|
||||||
|
GoogleOAuthEnabled: googleEnabled,
|
||||||
BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true",
|
BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true",
|
||||||
AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true",
|
AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true",
|
||||||
BalanceLowNotifyThreshold: balanceLowNotifyThreshold,
|
BalanceLowNotifyThreshold: balanceLowNotifyThreshold,
|
||||||
@ -677,6 +714,8 @@ type PublicSettingsInjectionPayload struct {
|
|||||||
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
|
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
|
||||||
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
|
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
|
||||||
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
|
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
|
||||||
|
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
|
||||||
|
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
|
||||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||||
PaymentEnabled bool `json:"payment_enabled"`
|
PaymentEnabled bool `json:"payment_enabled"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
@ -733,6 +772,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
|||||||
WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
|
WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
|
||||||
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
|
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
|
||||||
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
|
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
|
||||||
|
GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
|
||||||
|
GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
|
||||||
BackendModeEnabled: settings.BackendModeEnabled,
|
BackendModeEnabled: settings.BackendModeEnabled,
|
||||||
PaymentEnabled: settings.PaymentEnabled,
|
PaymentEnabled: settings.PaymentEnabled,
|
||||||
Version: s.version,
|
Version: s.version,
|
||||||
@ -806,6 +847,98 @@ func (s *SettingService) weChatOAuthCapabilitiesFromSettings(settings map[string
|
|||||||
return openReady || mpReady, openReady, mpReady, mobileReady
|
return openReady || mpReady, openReady, mpReady, mobileReady
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) emailOAuthBaseConfig(provider string) config.EmailOAuthProviderConfig {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||||
|
case "github":
|
||||||
|
cfg := config.EmailOAuthProviderConfig{
|
||||||
|
AuthorizeURL: defaultGitHubOAuthAuthorize,
|
||||||
|
TokenURL: defaultGitHubOAuthToken,
|
||||||
|
UserInfoURL: defaultGitHubOAuthUserInfo,
|
||||||
|
EmailsURL: defaultGitHubOAuthEmails,
|
||||||
|
Scopes: defaultGitHubOAuthScopes,
|
||||||
|
FrontendRedirectURL: defaultGitHubOAuthFrontend,
|
||||||
|
}
|
||||||
|
if s != nil && s.cfg != nil {
|
||||||
|
cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GitHubOAuth)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
case "google":
|
||||||
|
cfg := config.EmailOAuthProviderConfig{
|
||||||
|
AuthorizeURL: defaultGoogleOAuthAuthorize,
|
||||||
|
TokenURL: defaultGoogleOAuthToken,
|
||||||
|
UserInfoURL: defaultGoogleOAuthUserInfo,
|
||||||
|
Scopes: defaultGoogleOAuthScopes,
|
||||||
|
FrontendRedirectURL: defaultGoogleOAuthFrontend,
|
||||||
|
}
|
||||||
|
if s != nil && s.cfg != nil {
|
||||||
|
cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GoogleOAuth)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
default:
|
||||||
|
return config.EmailOAuthProviderConfig{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeEmailOAuthBaseConfig(base, override config.EmailOAuthProviderConfig) config.EmailOAuthProviderConfig {
|
||||||
|
base.Enabled = override.Enabled
|
||||||
|
if strings.TrimSpace(override.ClientID) != "" {
|
||||||
|
base.ClientID = strings.TrimSpace(override.ClientID)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(override.ClientSecret) != "" {
|
||||||
|
base.ClientSecret = strings.TrimSpace(override.ClientSecret)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(override.AuthorizeURL) != "" {
|
||||||
|
base.AuthorizeURL = strings.TrimSpace(override.AuthorizeURL)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(override.TokenURL) != "" {
|
||||||
|
base.TokenURL = strings.TrimSpace(override.TokenURL)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(override.UserInfoURL) != "" {
|
||||||
|
base.UserInfoURL = strings.TrimSpace(override.UserInfoURL)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(override.EmailsURL) != "" {
|
||||||
|
base.EmailsURL = strings.TrimSpace(override.EmailsURL)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(override.Scopes) != "" {
|
||||||
|
base.Scopes = strings.TrimSpace(override.Scopes)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(override.RedirectURL) != "" {
|
||||||
|
base.RedirectURL = strings.TrimSpace(override.RedirectURL)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(override.FrontendRedirectURL) != "" {
|
||||||
|
base.FrontendRedirectURL = strings.TrimSpace(override.FrontendRedirectURL)
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) emailOAuthPublicEnabled(settings map[string]string, provider string) bool {
|
||||||
|
cfg := s.effectiveEmailOAuthConfig(settings, provider)
|
||||||
|
return cfg.Enabled && strings.TrimSpace(cfg.ClientID) != "" && strings.TrimSpace(cfg.ClientSecret) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) effectiveEmailOAuthConfig(settings map[string]string, provider string) config.EmailOAuthProviderConfig {
|
||||||
|
cfg := s.emailOAuthBaseConfig(provider)
|
||||||
|
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||||
|
case "github":
|
||||||
|
if raw, ok := settings[SettingKeyGitHubOAuthEnabled]; ok {
|
||||||
|
cfg.Enabled = raw == "true"
|
||||||
|
}
|
||||||
|
cfg.ClientID = firstNonEmpty(settings[SettingKeyGitHubOAuthClientID], cfg.ClientID)
|
||||||
|
cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGitHubOAuthClientSecret], cfg.ClientSecret)
|
||||||
|
cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthRedirectURL], cfg.RedirectURL)
|
||||||
|
cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGitHubOAuthFrontend)
|
||||||
|
case "google":
|
||||||
|
if raw, ok := settings[SettingKeyGoogleOAuthEnabled]; ok {
|
||||||
|
cfg.Enabled = raw == "true"
|
||||||
|
}
|
||||||
|
cfg.ClientID = firstNonEmpty(settings[SettingKeyGoogleOAuthClientID], cfg.ClientID)
|
||||||
|
cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGoogleOAuthClientSecret], cfg.ClientSecret)
|
||||||
|
cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthRedirectURL], cfg.RedirectURL)
|
||||||
|
cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGoogleOAuthFrontend)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
// filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON
|
// filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON
|
||||||
// array string, returning only items with visibility != "admin".
|
// array string, returning only items with visibility != "admin".
|
||||||
func filterUserVisibleMenuItems(raw string) json.RawMessage {
|
func filterUserVisibleMenuItems(raw string) json.RawMessage {
|
||||||
@ -1052,6 +1185,16 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
|
|||||||
if settings.WeChatConnectFrontendRedirectURL == "" {
|
if settings.WeChatConnectFrontendRedirectURL == "" {
|
||||||
settings.WeChatConnectFrontendRedirectURL = defaultWeChatConnectFrontend
|
settings.WeChatConnectFrontendRedirectURL = defaultWeChatConnectFrontend
|
||||||
}
|
}
|
||||||
|
settings.GitHubOAuthRedirectURL = strings.TrimSpace(settings.GitHubOAuthRedirectURL)
|
||||||
|
settings.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(settings.GitHubOAuthFrontendRedirectURL)
|
||||||
|
if settings.GitHubOAuthFrontendRedirectURL == "" {
|
||||||
|
settings.GitHubOAuthFrontendRedirectURL = defaultGitHubOAuthFrontend
|
||||||
|
}
|
||||||
|
settings.GoogleOAuthRedirectURL = strings.TrimSpace(settings.GoogleOAuthRedirectURL)
|
||||||
|
settings.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(settings.GoogleOAuthFrontendRedirectURL)
|
||||||
|
if settings.GoogleOAuthFrontendRedirectURL == "" {
|
||||||
|
settings.GoogleOAuthFrontendRedirectURL = defaultGoogleOAuthFrontend
|
||||||
|
}
|
||||||
|
|
||||||
updates := make(map[string]string)
|
updates := make(map[string]string)
|
||||||
|
|
||||||
@ -1121,6 +1264,22 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
|
|||||||
updates[SettingKeyOIDCConnectClientSecret] = settings.OIDCConnectClientSecret
|
updates[SettingKeyOIDCConnectClientSecret] = settings.OIDCConnectClientSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GitHub / Google 邮箱快捷登录
|
||||||
|
updates[SettingKeyGitHubOAuthEnabled] = strconv.FormatBool(settings.GitHubOAuthEnabled)
|
||||||
|
updates[SettingKeyGitHubOAuthClientID] = strings.TrimSpace(settings.GitHubOAuthClientID)
|
||||||
|
updates[SettingKeyGitHubOAuthRedirectURL] = settings.GitHubOAuthRedirectURL
|
||||||
|
updates[SettingKeyGitHubOAuthFrontendRedirectURL] = settings.GitHubOAuthFrontendRedirectURL
|
||||||
|
if settings.GitHubOAuthClientSecret != "" {
|
||||||
|
updates[SettingKeyGitHubOAuthClientSecret] = strings.TrimSpace(settings.GitHubOAuthClientSecret)
|
||||||
|
}
|
||||||
|
updates[SettingKeyGoogleOAuthEnabled] = strconv.FormatBool(settings.GoogleOAuthEnabled)
|
||||||
|
updates[SettingKeyGoogleOAuthClientID] = strings.TrimSpace(settings.GoogleOAuthClientID)
|
||||||
|
updates[SettingKeyGoogleOAuthRedirectURL] = settings.GoogleOAuthRedirectURL
|
||||||
|
updates[SettingKeyGoogleOAuthFrontendRedirectURL] = settings.GoogleOAuthFrontendRedirectURL
|
||||||
|
if settings.GoogleOAuthClientSecret != "" {
|
||||||
|
updates[SettingKeyGoogleOAuthClientSecret] = strings.TrimSpace(settings.GoogleOAuthClientSecret)
|
||||||
|
}
|
||||||
|
|
||||||
// WeChat Connect OAuth 登录
|
// WeChat Connect OAuth 登录
|
||||||
updates[SettingKeyWeChatConnectEnabled] = strconv.FormatBool(settings.WeChatConnectEnabled)
|
updates[SettingKeyWeChatConnectEnabled] = strconv.FormatBool(settings.WeChatConnectEnabled)
|
||||||
updates[SettingKeyWeChatConnectAppID] = settings.WeChatConnectAppID
|
updates[SettingKeyWeChatConnectAppID] = settings.WeChatConnectAppID
|
||||||
@ -1273,17 +1432,21 @@ func (s *SettingService) buildAuthSourceDefaultUpdates(ctx context.Context, sett
|
|||||||
settings.LinuxDo.Subscriptions,
|
settings.LinuxDo.Subscriptions,
|
||||||
settings.OIDC.Subscriptions,
|
settings.OIDC.Subscriptions,
|
||||||
settings.WeChat.Subscriptions,
|
settings.WeChat.Subscriptions,
|
||||||
|
settings.GitHub.Subscriptions,
|
||||||
|
settings.Google.Subscriptions,
|
||||||
} {
|
} {
|
||||||
if err := s.validateDefaultSubscriptionGroups(ctx, subscriptions); err != nil {
|
if err := s.validateDefaultSubscriptionGroups(ctx, subscriptions); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updates := make(map[string]string, 21)
|
updates := make(map[string]string, 31)
|
||||||
writeProviderDefaultGrantUpdates(updates, emailAuthSourceDefaultKeys, settings.Email)
|
writeProviderDefaultGrantUpdates(updates, emailAuthSourceDefaultKeys, settings.Email)
|
||||||
writeProviderDefaultGrantUpdates(updates, linuxDoAuthSourceDefaultKeys, settings.LinuxDo)
|
writeProviderDefaultGrantUpdates(updates, linuxDoAuthSourceDefaultKeys, settings.LinuxDo)
|
||||||
writeProviderDefaultGrantUpdates(updates, oidcAuthSourceDefaultKeys, settings.OIDC)
|
writeProviderDefaultGrantUpdates(updates, oidcAuthSourceDefaultKeys, settings.OIDC)
|
||||||
writeProviderDefaultGrantUpdates(updates, weChatAuthSourceDefaultKeys, settings.WeChat)
|
writeProviderDefaultGrantUpdates(updates, weChatAuthSourceDefaultKeys, settings.WeChat)
|
||||||
|
writeProviderDefaultGrantUpdates(updates, gitHubAuthSourceDefaultKeys, settings.GitHub)
|
||||||
|
writeProviderDefaultGrantUpdates(updates, googleAuthSourceDefaultKeys, settings.Google)
|
||||||
updates[SettingKeyForceEmailOnThirdPartySignup] = strconv.FormatBool(settings.ForceEmailOnThirdPartySignup)
|
updates[SettingKeyForceEmailOnThirdPartySignup] = strconv.FormatBool(settings.ForceEmailOnThirdPartySignup)
|
||||||
return updates, nil
|
return updates, nil
|
||||||
}
|
}
|
||||||
@ -1362,6 +1525,61 @@ func (s *SettingService) validateDefaultSubscriptionGroups(ctx context.Context,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) GetEmailOAuthProviderConfig(ctx context.Context, provider string) (config.EmailOAuthProviderConfig, error) {
|
||||||
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
if provider != "github" && provider != "google" {
|
||||||
|
return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_PROVIDER_NOT_FOUND", "oauth provider not found")
|
||||||
|
}
|
||||||
|
keys := []string{
|
||||||
|
SettingKeyGitHubOAuthEnabled,
|
||||||
|
SettingKeyGitHubOAuthClientID,
|
||||||
|
SettingKeyGitHubOAuthClientSecret,
|
||||||
|
SettingKeyGitHubOAuthRedirectURL,
|
||||||
|
SettingKeyGitHubOAuthFrontendRedirectURL,
|
||||||
|
SettingKeyGoogleOAuthEnabled,
|
||||||
|
SettingKeyGoogleOAuthClientID,
|
||||||
|
SettingKeyGoogleOAuthClientSecret,
|
||||||
|
SettingKeyGoogleOAuthRedirectURL,
|
||||||
|
SettingKeyGoogleOAuthFrontendRedirectURL,
|
||||||
|
}
|
||||||
|
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||||||
|
if err != nil {
|
||||||
|
return config.EmailOAuthProviderConfig{}, fmt.Errorf("get email oauth settings: %w", err)
|
||||||
|
}
|
||||||
|
cfg := s.effectiveEmailOAuthConfig(settings, provider)
|
||||||
|
if !cfg.Enabled {
|
||||||
|
return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.ClientID) == "" {
|
||||||
|
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.ClientSecret) == "" {
|
||||||
|
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured")
|
||||||
|
}
|
||||||
|
for label, rawURL := range map[string]string{
|
||||||
|
"authorize": cfg.AuthorizeURL,
|
||||||
|
"token": cfg.TokenURL,
|
||||||
|
"userinfo": cfg.UserInfoURL,
|
||||||
|
"redirect": cfg.RedirectURL,
|
||||||
|
} {
|
||||||
|
if strings.TrimSpace(rawURL) == "" {
|
||||||
|
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url not configured")
|
||||||
|
}
|
||||||
|
if err := config.ValidateAbsoluteHTTPURL(rawURL); err != nil {
|
||||||
|
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.EmailsURL) != "" {
|
||||||
|
if err := config.ValidateAbsoluteHTTPURL(cfg.EmailsURL); err != nil {
|
||||||
|
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth emails url invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil {
|
||||||
|
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid")
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
// IsRegistrationEnabled 检查是否开放注册
|
// IsRegistrationEnabled 检查是否开放注册
|
||||||
func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool {
|
func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool {
|
||||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled)
|
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled)
|
||||||
@ -1711,6 +1929,16 @@ func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*Aut
|
|||||||
SettingKeyAuthSourceDefaultWeChatSubscriptions,
|
SettingKeyAuthSourceDefaultWeChatSubscriptions,
|
||||||
SettingKeyAuthSourceDefaultWeChatGrantOnSignup,
|
SettingKeyAuthSourceDefaultWeChatGrantOnSignup,
|
||||||
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind,
|
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind,
|
||||||
|
SettingKeyAuthSourceDefaultGitHubBalance,
|
||||||
|
SettingKeyAuthSourceDefaultGitHubConcurrency,
|
||||||
|
SettingKeyAuthSourceDefaultGitHubSubscriptions,
|
||||||
|
SettingKeyAuthSourceDefaultGitHubGrantOnSignup,
|
||||||
|
SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind,
|
||||||
|
SettingKeyAuthSourceDefaultGoogleBalance,
|
||||||
|
SettingKeyAuthSourceDefaultGoogleConcurrency,
|
||||||
|
SettingKeyAuthSourceDefaultGoogleSubscriptions,
|
||||||
|
SettingKeyAuthSourceDefaultGoogleGrantOnSignup,
|
||||||
|
SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind,
|
||||||
SettingKeyForceEmailOnThirdPartySignup,
|
SettingKeyForceEmailOnThirdPartySignup,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1724,6 +1952,8 @@ func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*Aut
|
|||||||
LinuxDo: parseProviderDefaultGrantSettings(settings, linuxDoAuthSourceDefaultKeys),
|
LinuxDo: parseProviderDefaultGrantSettings(settings, linuxDoAuthSourceDefaultKeys),
|
||||||
OIDC: parseProviderDefaultGrantSettings(settings, oidcAuthSourceDefaultKeys),
|
OIDC: parseProviderDefaultGrantSettings(settings, oidcAuthSourceDefaultKeys),
|
||||||
WeChat: parseProviderDefaultGrantSettings(settings, weChatAuthSourceDefaultKeys),
|
WeChat: parseProviderDefaultGrantSettings(settings, weChatAuthSourceDefaultKeys),
|
||||||
|
GitHub: parseProviderDefaultGrantSettings(settings, gitHubAuthSourceDefaultKeys),
|
||||||
|
Google: parseProviderDefaultGrantSettings(settings, googleAuthSourceDefaultKeys),
|
||||||
ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
|
ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@ -1824,6 +2054,16 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
|||||||
SettingKeyWeChatConnectScopes: "snsapi_login",
|
SettingKeyWeChatConnectScopes: "snsapi_login",
|
||||||
SettingKeyWeChatConnectRedirectURL: "",
|
SettingKeyWeChatConnectRedirectURL: "",
|
||||||
SettingKeyWeChatConnectFrontendRedirectURL: defaultWeChatConnectFrontend,
|
SettingKeyWeChatConnectFrontendRedirectURL: defaultWeChatConnectFrontend,
|
||||||
|
SettingKeyGitHubOAuthEnabled: "false",
|
||||||
|
SettingKeyGitHubOAuthClientID: "",
|
||||||
|
SettingKeyGitHubOAuthClientSecret: "",
|
||||||
|
SettingKeyGitHubOAuthRedirectURL: "",
|
||||||
|
SettingKeyGitHubOAuthFrontendRedirectURL: defaultGitHubOAuthFrontend,
|
||||||
|
SettingKeyGoogleOAuthEnabled: "false",
|
||||||
|
SettingKeyGoogleOAuthClientID: "",
|
||||||
|
SettingKeyGoogleOAuthClientSecret: "",
|
||||||
|
SettingKeyGoogleOAuthRedirectURL: "",
|
||||||
|
SettingKeyGoogleOAuthFrontendRedirectURL: defaultGoogleOAuthFrontend,
|
||||||
SettingKeyOIDCConnectEnabled: "false",
|
SettingKeyOIDCConnectEnabled: "false",
|
||||||
SettingKeyOIDCConnectProviderName: "OIDC",
|
SettingKeyOIDCConnectProviderName: "OIDC",
|
||||||
SettingKeyOIDCConnectClientID: "",
|
SettingKeyOIDCConnectClientID: "",
|
||||||
@ -1874,6 +2114,16 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
|||||||
SettingKeyAuthSourceDefaultWeChatSubscriptions: "[]",
|
SettingKeyAuthSourceDefaultWeChatSubscriptions: "[]",
|
||||||
SettingKeyAuthSourceDefaultWeChatGrantOnSignup: "false",
|
SettingKeyAuthSourceDefaultWeChatGrantOnSignup: "false",
|
||||||
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind: "false",
|
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind: "false",
|
||||||
|
SettingKeyAuthSourceDefaultGitHubBalance: "0",
|
||||||
|
SettingKeyAuthSourceDefaultGitHubConcurrency: "5",
|
||||||
|
SettingKeyAuthSourceDefaultGitHubSubscriptions: "[]",
|
||||||
|
SettingKeyAuthSourceDefaultGitHubGrantOnSignup: "false",
|
||||||
|
SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind: "false",
|
||||||
|
SettingKeyAuthSourceDefaultGoogleBalance: "0",
|
||||||
|
SettingKeyAuthSourceDefaultGoogleConcurrency: "5",
|
||||||
|
SettingKeyAuthSourceDefaultGoogleSubscriptions: "[]",
|
||||||
|
SettingKeyAuthSourceDefaultGoogleGrantOnSignup: "false",
|
||||||
|
SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind: "false",
|
||||||
SettingKeyForceEmailOnThirdPartySignup: "false",
|
SettingKeyForceEmailOnThirdPartySignup: "false",
|
||||||
SettingKeySMTPPort: "587",
|
SettingKeySMTPPort: "587",
|
||||||
SettingKeySMTPUseTLS: "false",
|
SettingKeySMTPUseTLS: "false",
|
||||||
@ -2173,6 +2423,22 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
|||||||
}
|
}
|
||||||
result.OIDCConnectClientSecretConfigured = result.OIDCConnectClientSecret != ""
|
result.OIDCConnectClientSecretConfigured = result.OIDCConnectClientSecret != ""
|
||||||
|
|
||||||
|
gitHubEffective := s.effectiveEmailOAuthConfig(settings, "github")
|
||||||
|
result.GitHubOAuthEnabled = gitHubEffective.Enabled
|
||||||
|
result.GitHubOAuthClientID = strings.TrimSpace(gitHubEffective.ClientID)
|
||||||
|
result.GitHubOAuthClientSecret = strings.TrimSpace(gitHubEffective.ClientSecret)
|
||||||
|
result.GitHubOAuthClientSecretConfigured = result.GitHubOAuthClientSecret != ""
|
||||||
|
result.GitHubOAuthRedirectURL = strings.TrimSpace(gitHubEffective.RedirectURL)
|
||||||
|
result.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(gitHubEffective.FrontendRedirectURL)
|
||||||
|
|
||||||
|
googleEffective := s.effectiveEmailOAuthConfig(settings, "google")
|
||||||
|
result.GoogleOAuthEnabled = googleEffective.Enabled
|
||||||
|
result.GoogleOAuthClientID = strings.TrimSpace(googleEffective.ClientID)
|
||||||
|
result.GoogleOAuthClientSecret = strings.TrimSpace(googleEffective.ClientSecret)
|
||||||
|
result.GoogleOAuthClientSecretConfigured = result.GoogleOAuthClientSecret != ""
|
||||||
|
result.GoogleOAuthRedirectURL = strings.TrimSpace(googleEffective.RedirectURL)
|
||||||
|
result.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(googleEffective.FrontendRedirectURL)
|
||||||
|
|
||||||
// WeChat Connect 设置:
|
// WeChat Connect 设置:
|
||||||
// - 优先读取 DB 系统设置
|
// - 优先读取 DB 系统设置
|
||||||
// - 缺失时回退到 config/env,保持升级兼容
|
// - 缺失时回退到 config/env,保持升级兼容
|
||||||
|
|||||||
@ -89,6 +89,20 @@ type SystemSettings struct {
|
|||||||
OIDCConnectUserInfoIDPath string
|
OIDCConnectUserInfoIDPath string
|
||||||
OIDCConnectUserInfoUsernamePath string
|
OIDCConnectUserInfoUsernamePath string
|
||||||
|
|
||||||
|
// GitHub / Google 邮箱快捷登录
|
||||||
|
GitHubOAuthEnabled bool
|
||||||
|
GitHubOAuthClientID string
|
||||||
|
GitHubOAuthClientSecret string
|
||||||
|
GitHubOAuthClientSecretConfigured bool
|
||||||
|
GitHubOAuthRedirectURL string
|
||||||
|
GitHubOAuthFrontendRedirectURL string
|
||||||
|
GoogleOAuthEnabled bool
|
||||||
|
GoogleOAuthClientID string
|
||||||
|
GoogleOAuthClientSecret string
|
||||||
|
GoogleOAuthClientSecretConfigured bool
|
||||||
|
GoogleOAuthRedirectURL string
|
||||||
|
GoogleOAuthFrontendRedirectURL string
|
||||||
|
|
||||||
SiteName string
|
SiteName string
|
||||||
SiteLogo string
|
SiteLogo string
|
||||||
SiteSubtitle string
|
SiteSubtitle string
|
||||||
@ -217,6 +231,8 @@ type PublicSettings struct {
|
|||||||
PaymentEnabled bool
|
PaymentEnabled bool
|
||||||
OIDCOAuthEnabled bool
|
OIDCOAuthEnabled bool
|
||||||
OIDCOAuthProviderName string
|
OIDCOAuthProviderName string
|
||||||
|
GitHubOAuthEnabled bool
|
||||||
|
GoogleOAuthEnabled bool
|
||||||
Version string
|
Version string
|
||||||
|
|
||||||
BalanceLowNotifyEnabled bool
|
BalanceLowNotifyEnabled bool
|
||||||
|
|||||||
@ -11,7 +11,13 @@ export interface DefaultSubscriptionSetting {
|
|||||||
validity_days: number;
|
validity_days: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AuthSourceType = "email" | "linuxdo" | "oidc" | "wechat";
|
export type AuthSourceType =
|
||||||
|
| "email"
|
||||||
|
| "linuxdo"
|
||||||
|
| "oidc"
|
||||||
|
| "wechat"
|
||||||
|
| "github"
|
||||||
|
| "google";
|
||||||
|
|
||||||
export interface AuthSourceDefaultsValue {
|
export interface AuthSourceDefaultsValue {
|
||||||
balance: number;
|
balance: number;
|
||||||
@ -51,6 +57,8 @@ const AUTH_SOURCE_TYPES: AuthSourceType[] = [
|
|||||||
"linuxdo",
|
"linuxdo",
|
||||||
"oidc",
|
"oidc",
|
||||||
"wechat",
|
"wechat",
|
||||||
|
"github",
|
||||||
|
"google",
|
||||||
];
|
];
|
||||||
const AUTH_SOURCE_DEFAULT_BALANCE = 0;
|
const AUTH_SOURCE_DEFAULT_BALANCE = 0;
|
||||||
const AUTH_SOURCE_DEFAULT_CONCURRENCY = 5;
|
const AUTH_SOURCE_DEFAULT_CONCURRENCY = 5;
|
||||||
@ -335,6 +343,16 @@ export interface SystemSettings {
|
|||||||
auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[];
|
auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[];
|
||||||
auth_source_default_wechat_grant_on_signup?: boolean;
|
auth_source_default_wechat_grant_on_signup?: boolean;
|
||||||
auth_source_default_wechat_grant_on_first_bind?: boolean;
|
auth_source_default_wechat_grant_on_first_bind?: boolean;
|
||||||
|
auth_source_default_github_balance?: number;
|
||||||
|
auth_source_default_github_concurrency?: number;
|
||||||
|
auth_source_default_github_subscriptions?: DefaultSubscriptionSetting[];
|
||||||
|
auth_source_default_github_grant_on_signup?: boolean;
|
||||||
|
auth_source_default_github_grant_on_first_bind?: boolean;
|
||||||
|
auth_source_default_google_balance?: number;
|
||||||
|
auth_source_default_google_concurrency?: number;
|
||||||
|
auth_source_default_google_subscriptions?: DefaultSubscriptionSetting[];
|
||||||
|
auth_source_default_google_grant_on_signup?: boolean;
|
||||||
|
auth_source_default_google_grant_on_first_bind?: boolean;
|
||||||
force_email_on_third_party_signup?: boolean;
|
force_email_on_third_party_signup?: boolean;
|
||||||
// OEM settings
|
// OEM settings
|
||||||
site_name: string;
|
site_name: string;
|
||||||
@ -410,6 +428,16 @@ export interface SystemSettings {
|
|||||||
oidc_connect_userinfo_email_path: string;
|
oidc_connect_userinfo_email_path: string;
|
||||||
oidc_connect_userinfo_id_path: string;
|
oidc_connect_userinfo_id_path: string;
|
||||||
oidc_connect_userinfo_username_path: string;
|
oidc_connect_userinfo_username_path: string;
|
||||||
|
github_oauth_enabled: boolean;
|
||||||
|
github_oauth_client_id: string;
|
||||||
|
github_oauth_client_secret_configured: boolean;
|
||||||
|
github_oauth_redirect_url: string;
|
||||||
|
github_oauth_frontend_redirect_url: string;
|
||||||
|
google_oauth_enabled: boolean;
|
||||||
|
google_oauth_client_id: string;
|
||||||
|
google_oauth_client_secret_configured: boolean;
|
||||||
|
google_oauth_redirect_url: string;
|
||||||
|
google_oauth_frontend_redirect_url: string;
|
||||||
|
|
||||||
// Model fallback configuration
|
// Model fallback configuration
|
||||||
enable_model_fallback: boolean;
|
enable_model_fallback: boolean;
|
||||||
@ -527,6 +555,16 @@ export interface UpdateSettingsRequest {
|
|||||||
auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[];
|
auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[];
|
||||||
auth_source_default_wechat_grant_on_signup?: boolean;
|
auth_source_default_wechat_grant_on_signup?: boolean;
|
||||||
auth_source_default_wechat_grant_on_first_bind?: boolean;
|
auth_source_default_wechat_grant_on_first_bind?: boolean;
|
||||||
|
auth_source_default_github_balance?: number;
|
||||||
|
auth_source_default_github_concurrency?: number;
|
||||||
|
auth_source_default_github_subscriptions?: DefaultSubscriptionSetting[];
|
||||||
|
auth_source_default_github_grant_on_signup?: boolean;
|
||||||
|
auth_source_default_github_grant_on_first_bind?: boolean;
|
||||||
|
auth_source_default_google_balance?: number;
|
||||||
|
auth_source_default_google_concurrency?: number;
|
||||||
|
auth_source_default_google_subscriptions?: DefaultSubscriptionSetting[];
|
||||||
|
auth_source_default_google_grant_on_signup?: boolean;
|
||||||
|
auth_source_default_google_grant_on_first_bind?: boolean;
|
||||||
force_email_on_third_party_signup?: boolean;
|
force_email_on_third_party_signup?: boolean;
|
||||||
site_name?: string;
|
site_name?: string;
|
||||||
site_logo?: string;
|
site_logo?: string;
|
||||||
@ -593,6 +631,16 @@ export interface UpdateSettingsRequest {
|
|||||||
oidc_connect_userinfo_email_path?: string;
|
oidc_connect_userinfo_email_path?: string;
|
||||||
oidc_connect_userinfo_id_path?: string;
|
oidc_connect_userinfo_id_path?: string;
|
||||||
oidc_connect_userinfo_username_path?: string;
|
oidc_connect_userinfo_username_path?: string;
|
||||||
|
github_oauth_enabled?: boolean;
|
||||||
|
github_oauth_client_id?: string;
|
||||||
|
github_oauth_client_secret?: string;
|
||||||
|
github_oauth_redirect_url?: string;
|
||||||
|
github_oauth_frontend_redirect_url?: string;
|
||||||
|
google_oauth_enabled?: boolean;
|
||||||
|
google_oauth_client_id?: string;
|
||||||
|
google_oauth_client_secret?: string;
|
||||||
|
google_oauth_redirect_url?: string;
|
||||||
|
google_oauth_frontend_redirect_url?: string;
|
||||||
enable_model_fallback?: boolean;
|
enable_model_fallback?: boolean;
|
||||||
fallback_model_anthropic?: string;
|
fallback_model_anthropic?: string;
|
||||||
fallback_model_openai?: string;
|
fallback_model_openai?: string;
|
||||||
|
|||||||
85
frontend/src/components/auth/EmailOAuthButtons.vue
Normal file
85
frontend/src/components/auth/EmailOAuthButtons.vue
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="hasProviders" class="space-y-4">
|
||||||
|
<div v-if="showDivider" class="flex items-center gap-3">
|
||||||
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||||
|
{{ t('auth.oauthOrContinue') }}
|
||||||
|
</span>
|
||||||
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="providerGridClass">
|
||||||
|
<button
|
||||||
|
v-for="provider in visibleProviders"
|
||||||
|
:key="provider"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled"
|
||||||
|
class="btn btn-secondary h-12 w-full justify-center gap-2"
|
||||||
|
@click="startLogin(provider)"
|
||||||
|
>
|
||||||
|
<GitHubMark v-if="provider === 'github'" class="h-5 w-5 text-gray-800 dark:text-gray-100" />
|
||||||
|
<GoogleMark v-else class="h-5 w-5" />
|
||||||
|
<span class="font-medium">{{ providerLabel(provider) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import GitHubMark from './GitHubMark.vue'
|
||||||
|
import GoogleMark from './GoogleMark.vue'
|
||||||
|
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
|
||||||
|
|
||||||
|
type EmailOAuthProvider = 'github' | 'google'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
disabled?: boolean
|
||||||
|
affCode?: string
|
||||||
|
githubEnabled?: boolean
|
||||||
|
googleEnabled?: boolean
|
||||||
|
showDivider?: boolean
|
||||||
|
}>(), {
|
||||||
|
showDivider: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const visibleProviders = computed<EmailOAuthProvider[]>(() => {
|
||||||
|
const providers: EmailOAuthProvider[] = []
|
||||||
|
if (props.githubEnabled) providers.push('github')
|
||||||
|
if (props.googleEnabled) providers.push('google')
|
||||||
|
return providers
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasProviders = computed(() => visibleProviders.value.length > 0)
|
||||||
|
const hasMultipleProviders = computed(() => visibleProviders.value.length > 1)
|
||||||
|
const providerGridClass = computed(() => [
|
||||||
|
'grid',
|
||||||
|
'grid-cols-1',
|
||||||
|
'gap-3',
|
||||||
|
hasMultipleProviders.value ? 'sm:grid-cols-2' : ''
|
||||||
|
])
|
||||||
|
|
||||||
|
function providerLabel(provider: EmailOAuthProvider): string {
|
||||||
|
const name = provider === 'github' ? 'GitHub' : 'Google'
|
||||||
|
return hasMultipleProviders.value ? name : t('auth.emailOAuth.signIn', { providerName: name })
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLogin(provider: EmailOAuthProvider): void {
|
||||||
|
const redirectTo = (route.query.redirect as string) || '/dashboard'
|
||||||
|
const affiliateCode = resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code)
|
||||||
|
storeOAuthAffiliateCode(affiliateCode)
|
||||||
|
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||||
|
const normalized = apiBase.replace(/\/$/, '')
|
||||||
|
const params = new URLSearchParams({ redirect: redirectTo })
|
||||||
|
if (affiliateCode) {
|
||||||
|
params.set('aff_code', affiliateCode)
|
||||||
|
}
|
||||||
|
const startURL = `${normalized}/auth/oauth/${provider}/start?${params.toString()}`
|
||||||
|
window.location.href = startURL
|
||||||
|
}
|
||||||
|
</script>
|
||||||
7
frontend/src/components/auth/GitHubMark.vue
Normal file
7
frontend/src/components/auth/GitHubMark.vue
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82A7.61 7.61 0 0 1 8 3.86c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
8
frontend/src/components/auth/GoogleMark.vue
Normal file
8
frontend/src/components/auth/GoogleMark.vue
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
||||||
|
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
||||||
|
<path fill="#FBBC05" d="M5.84 14.1A6.61 6.61 0 0 1 5.5 12c0-.73.12-1.43.34-2.1V7.06H2.18A10.96 10.96 0 0 0 1 12c0 1.77.42 3.45 1.18 4.94l3.66-2.84z" />
|
||||||
|
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06L5.84 9.9C6.71 7.31 9.14 5.38 12 5.38z" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
102
frontend/src/components/auth/__tests__/EmailOAuthButtons.spec.ts
Normal file
102
frontend/src/components/auth/__tests__/EmailOAuthButtons.spec.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
|
||||||
|
|
||||||
|
const routeState = vi.hoisted(() => ({
|
||||||
|
query: {} as Record<string, unknown>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const locationState = vi.hoisted(() => ({
|
||||||
|
current: { href: 'http://localhost/register?aff=AFF123' } as { href: string },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRoute: () => routeState,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string, params?: Record<string, string>) => {
|
||||||
|
if (key === 'auth.emailOAuth.signIn') {
|
||||||
|
return `使用 ${params?.providerName ?? ''} 登录`
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('EmailOAuthButtons', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
routeState.query = { redirect: '/billing?plan=pro', aff: 'AFF123' }
|
||||||
|
locationState.current = { href: 'http://localhost/register?aff=AFF123' }
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
value: locationState.current,
|
||||||
|
})
|
||||||
|
window.localStorage.clear()
|
||||||
|
window.sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes the affiliate code to the email oauth start URL', async () => {
|
||||||
|
const wrapper = mount(EmailOAuthButtons, {
|
||||||
|
props: {
|
||||||
|
githubEnabled: true,
|
||||||
|
googleEnabled: false,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
GitHubMark: true,
|
||||||
|
GoogleMark: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
|
||||||
|
expect(locationState.current.href).toBe(
|
||||||
|
'/api/v1/auth/oauth/github/start?redirect=%2Fbilling%3Fplan%3Dpro&aff_code=AFF123'
|
||||||
|
)
|
||||||
|
expect(window.sessionStorage.getItem('oauth_aff_code')).toBe('AFF123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a full-width descriptive button when only GitHub is enabled', () => {
|
||||||
|
const wrapper = mount(EmailOAuthButtons, {
|
||||||
|
props: {
|
||||||
|
githubEnabled: true,
|
||||||
|
googleEnabled: false,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
GitHubMark: true,
|
||||||
|
GoogleMark: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.grid').classes()).not.toContain('sm:grid-cols-2')
|
||||||
|
expect(wrapper.get('button').text()).toContain('使用 GitHub 登录')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses compact labels and two columns when GitHub and Google are both enabled', () => {
|
||||||
|
const wrapper = mount(EmailOAuthButtons, {
|
||||||
|
props: {
|
||||||
|
githubEnabled: true,
|
||||||
|
googleEnabled: true,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
GitHubMark: true,
|
||||||
|
GoogleMark: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.grid').classes()).toContain('sm:grid-cols-2')
|
||||||
|
const buttons = wrapper.findAll('button')
|
||||||
|
expect(buttons).toHaveLength(2)
|
||||||
|
expect(buttons[0].text()).toContain('GitHub')
|
||||||
|
expect(buttons[0].text()).not.toContain('使用 GitHub 登录')
|
||||||
|
expect(buttons[1].text()).toContain('Google')
|
||||||
|
expect(buttons[1].text()).not.toContain('使用 Google 登录')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -263,7 +263,9 @@ const providerLabels = computed<Record<UserAuthProvider, string>>(() => ({
|
|||||||
email: t('profile.authBindings.providers.email'),
|
email: t('profile.authBindings.providers.email'),
|
||||||
linuxdo: t('profile.authBindings.providers.linuxdo'),
|
linuxdo: t('profile.authBindings.providers.linuxdo'),
|
||||||
oidc: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }),
|
oidc: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }),
|
||||||
wechat: t('profile.authBindings.providers.wechat')
|
wechat: t('profile.authBindings.providers.wechat'),
|
||||||
|
github: 'GitHub',
|
||||||
|
google: 'Google'
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function formatCurrency(value: number): string {
|
function formatCurrency(value: number): string {
|
||||||
@ -272,7 +274,13 @@ function formatCurrency(value: number): string {
|
|||||||
|
|
||||||
function normalizeProvider(value: string): UserAuthProvider | null {
|
function normalizeProvider(value: string): UserAuthProvider | null {
|
||||||
const normalized = value.trim().toLowerCase()
|
const normalized = value.trim().toLowerCase()
|
||||||
if (normalized === 'email' || normalized === 'linuxdo' || normalized === 'wechat') {
|
if (
|
||||||
|
normalized === 'email' ||
|
||||||
|
normalized === 'linuxdo' ||
|
||||||
|
normalized === 'wechat' ||
|
||||||
|
normalized === 'github' ||
|
||||||
|
normalized === 'google'
|
||||||
|
) {
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
if (normalized === 'oidc' || normalized.startsWith('oidc:') || normalized.startsWith('oidc/')) {
|
if (normalized === 'oidc' || normalized.startsWith('oidc:') || normalized.startsWith('oidc/')) {
|
||||||
|
|||||||
@ -472,6 +472,9 @@ export default {
|
|||||||
completing: 'Completing registration…',
|
completing: 'Completing registration…',
|
||||||
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
|
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
|
||||||
},
|
},
|
||||||
|
emailOAuth: {
|
||||||
|
signIn: 'Continue with {providerName}'
|
||||||
|
},
|
||||||
oidc: {
|
oidc: {
|
||||||
signIn: 'Continue with {providerName}',
|
signIn: 'Continue with {providerName}',
|
||||||
callbackTitle: 'Signing you in with {providerName}',
|
callbackTitle: 'Signing you in with {providerName}',
|
||||||
@ -531,6 +534,8 @@ export default {
|
|||||||
oauth: {
|
oauth: {
|
||||||
callbackTitle: 'OAuth Callback',
|
callbackTitle: 'OAuth Callback',
|
||||||
callbackHint: 'Copy the code and state back to the admin authorization flow when needed.',
|
callbackHint: 'Copy the code and state back to the admin authorization flow when needed.',
|
||||||
|
invalidCallbackTitle: 'Invalid sign-in callback',
|
||||||
|
invalidCallbackHint: 'This page does not contain a valid authorization result. Return to the login page and start quick sign-in again.',
|
||||||
code: 'Code',
|
code: 'Code',
|
||||||
state: 'State',
|
state: 'State',
|
||||||
fullUrl: 'Full URL'
|
fullUrl: 'Full URL'
|
||||||
|
|||||||
@ -471,6 +471,9 @@ export default {
|
|||||||
completing: '正在完成注册...',
|
completing: '正在完成注册...',
|
||||||
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
|
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
|
||||||
},
|
},
|
||||||
|
emailOAuth: {
|
||||||
|
signIn: '使用 {providerName} 登录'
|
||||||
|
},
|
||||||
oidc: {
|
oidc: {
|
||||||
signIn: '使用 {providerName} 登录',
|
signIn: '使用 {providerName} 登录',
|
||||||
callbackTitle: '正在完成 {providerName} 登录',
|
callbackTitle: '正在完成 {providerName} 登录',
|
||||||
@ -529,6 +532,8 @@ export default {
|
|||||||
oauth: {
|
oauth: {
|
||||||
callbackTitle: 'OAuth 回调',
|
callbackTitle: 'OAuth 回调',
|
||||||
callbackHint: '按需将授权码和状态值复制回后台授权流程。',
|
callbackHint: '按需将授权码和状态值复制回后台授权流程。',
|
||||||
|
invalidCallbackTitle: '无效的登录回调',
|
||||||
|
invalidCallbackHint: '当前页面缺少有效的授权结果,请返回登录页重新发起快捷登录。',
|
||||||
code: '授权码',
|
code: '授权码',
|
||||||
state: '状态',
|
state: '状态',
|
||||||
fullUrl: '完整URL'
|
fullUrl: '完整URL'
|
||||||
|
|||||||
@ -68,6 +68,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: '/auth/callback',
|
path: '/auth/callback',
|
||||||
name: 'OAuthCallback',
|
name: 'OAuthCallback',
|
||||||
|
alias: '/auth/oauth/callback',
|
||||||
component: () => import('@/views/auth/OAuthCallbackView.vue'),
|
component: () => import('@/views/auth/OAuthCallbackView.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: false,
|
requiresAuth: false,
|
||||||
|
|||||||
@ -347,6 +347,8 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
wechat_oauth_mobile_enabled: false,
|
wechat_oauth_mobile_enabled: false,
|
||||||
oidc_oauth_enabled: false,
|
oidc_oauth_enabled: false,
|
||||||
oidc_oauth_provider_name: 'OIDC',
|
oidc_oauth_provider_name: 'OIDC',
|
||||||
|
github_oauth_enabled: false,
|
||||||
|
google_oauth_enabled: false,
|
||||||
backend_mode_enabled: false,
|
backend_mode_enabled: false,
|
||||||
version: siteVersion.value,
|
version: siteVersion.value,
|
||||||
balance_low_notify_enabled: false,
|
balance_low_notify_enabled: false,
|
||||||
|
|||||||
@ -34,7 +34,7 @@ export interface NotifyEmailEntry {
|
|||||||
|
|
||||||
// ==================== User & Auth Types ====================
|
// ==================== User & Auth Types ====================
|
||||||
|
|
||||||
export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat'
|
export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat' | 'github' | 'google'
|
||||||
|
|
||||||
export interface UserAuthBindingStatus {
|
export interface UserAuthBindingStatus {
|
||||||
bound?: boolean
|
bound?: boolean
|
||||||
@ -208,6 +208,8 @@ export interface PublicSettings {
|
|||||||
wechat_oauth_mobile_enabled?: boolean
|
wechat_oauth_mobile_enabled?: boolean
|
||||||
oidc_oauth_enabled: boolean
|
oidc_oauth_enabled: boolean
|
||||||
oidc_oauth_provider_name: string
|
oidc_oauth_provider_name: string
|
||||||
|
github_oauth_enabled: boolean
|
||||||
|
google_oauth_enabled: boolean
|
||||||
backend_mode_enabled: boolean
|
backend_mode_enabled: boolean
|
||||||
version: string
|
version: string
|
||||||
balance_low_notify_enabled: boolean
|
balance_low_notify_enabled: boolean
|
||||||
|
|||||||
@ -1752,6 +1752,232 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- GitHub / Google 邮箱快捷登录 -->
|
||||||
|
<div class="card">
|
||||||
|
<div
|
||||||
|
class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ localText("邮箱快捷登录", "Email OAuth Sign-in") }}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{
|
||||||
|
localText(
|
||||||
|
"开启 GitHub 或 Google 邮箱授权登录后,系统会读取已验证邮箱,存在则直接登录,不存在则自动注册。",
|
||||||
|
"After GitHub or Google email OAuth is enabled, the system reads a verified email, signs in matching users, and auto-registers missing users.",
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-6 p-6">
|
||||||
|
<div class="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||||
|
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-700">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium text-gray-900 dark:text-white">
|
||||||
|
GitHub
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{
|
||||||
|
localText(
|
||||||
|
"GitHub OAuth App 需要 read:user user:email 权限,回调地址填写下方后端地址。",
|
||||||
|
"GitHub OAuth App needs read:user user:email scopes. Use the backend callback URL below.",
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle v-model="form.github_oauth_enabled" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="form.github_oauth_enabled" class="mt-4 space-y-4">
|
||||||
|
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300">
|
||||||
|
<template v-if="isZhLocale">
|
||||||
|
开通引导:GitHub Settings → Developer settings →
|
||||||
|
<a
|
||||||
|
data-testid="github-oauth-apps-guide-link"
|
||||||
|
href="https://github.com/settings/developers"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||||
|
>OAuth Apps</a>
|
||||||
|
→ New OAuth App;Homepage URL 填站点域名,Authorization callback URL 填下面的后端回调地址。
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Setup guide: GitHub Settings → Developer settings →
|
||||||
|
<a
|
||||||
|
data-testid="github-oauth-apps-guide-link"
|
||||||
|
href="https://github.com/settings/developers"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||||
|
>OAuth Apps</a>
|
||||||
|
→ New OAuth App. Use your site origin as Homepage URL and the backend callback URL below as Authorization callback URL.
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
|
||||||
|
<input
|
||||||
|
v-model="form.github_oauth_client_id"
|
||||||
|
type="text"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
placeholder="GitHub OAuth Client ID"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
|
||||||
|
<input
|
||||||
|
v-model="form.github_oauth_client_secret"
|
||||||
|
type="password"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="
|
||||||
|
form.github_oauth_client_secret_configured
|
||||||
|
? localText('密钥已配置,留空以保留当前值。', 'Secret configured. Leave empty to keep the current value.')
|
||||||
|
: 'GitHub OAuth Client Secret'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ localText("后端回调地址", "Backend Callback URL") }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.github_oauth_redirect_url"
|
||||||
|
type="url"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
placeholder="https://your-domain.com/api/v1/auth/oauth/github/callback"
|
||||||
|
/>
|
||||||
|
<div class="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-sm w-fit"
|
||||||
|
@click="setAndCopyEmailOAuthRedirectUrl('github')"
|
||||||
|
>
|
||||||
|
{{ localText("生成并复制", "Generate and copy") }}
|
||||||
|
</button>
|
||||||
|
<code
|
||||||
|
v-if="githubOAuthRedirectUrlSuggestion"
|
||||||
|
class="select-all break-all rounded bg-gray-50 px-2 py-1 font-mono text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{{ githubOAuthRedirectUrlSuggestion }}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ localText("前端回跳地址", "Frontend Callback URL") }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.github_oauth_frontend_redirect_url"
|
||||||
|
type="text"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
placeholder="/auth/oauth/callback"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-700">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium text-gray-900 dark:text-white">
|
||||||
|
Google
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{
|
||||||
|
localText(
|
||||||
|
"Google OAuth 客户端需要 openid email profile 范围,并在凭据里登记后端回调地址。",
|
||||||
|
"Google OAuth client needs openid email profile scopes and the backend callback URL registered in credentials.",
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle v-model="form.google_oauth_enabled" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="form.google_oauth_enabled" class="mt-4 space-y-4">
|
||||||
|
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300">
|
||||||
|
{{
|
||||||
|
localText(
|
||||||
|
"开通引导:Google Cloud Console → APIs & Services → OAuth consent screen 完成同意屏幕;Credentials → Create Credentials → OAuth client ID,类型选择 Web application,并把下面地址加入 Authorized redirect URIs。",
|
||||||
|
"Setup guide: Google Cloud Console → APIs & Services → OAuth consent screen, then Credentials → Create Credentials → OAuth client ID, choose Web application, and add the URL below to Authorized redirect URIs.",
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
|
||||||
|
<input
|
||||||
|
v-model="form.google_oauth_client_id"
|
||||||
|
type="text"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
placeholder="Google OAuth Client ID"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
|
||||||
|
<input
|
||||||
|
v-model="form.google_oauth_client_secret"
|
||||||
|
type="password"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="
|
||||||
|
form.google_oauth_client_secret_configured
|
||||||
|
? localText('密钥已配置,留空以保留当前值。', 'Secret configured. Leave empty to keep the current value.')
|
||||||
|
: 'Google OAuth Client Secret'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ localText("后端回调地址", "Backend Callback URL") }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.google_oauth_redirect_url"
|
||||||
|
type="url"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
placeholder="https://your-domain.com/api/v1/auth/oauth/google/callback"
|
||||||
|
/>
|
||||||
|
<div class="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-sm w-fit"
|
||||||
|
@click="setAndCopyEmailOAuthRedirectUrl('google')"
|
||||||
|
>
|
||||||
|
{{ localText("生成并复制", "Generate and copy") }}
|
||||||
|
</button>
|
||||||
|
<code
|
||||||
|
v-if="googleOAuthRedirectUrlSuggestion"
|
||||||
|
class="select-all break-all rounded bg-gray-50 px-2 py-1 font-mono text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{{ googleOAuthRedirectUrlSuggestion }}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ localText("前端回跳地址", "Frontend Callback URL") }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.google_oauth_frontend_redirect_url"
|
||||||
|
type="text"
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
placeholder="/auth/oauth/callback"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- WeChat Connect OAuth 登录 -->
|
<!-- WeChat Connect OAuth 登录 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div
|
<div
|
||||||
@ -5646,9 +5872,10 @@ import {
|
|||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const adminSettingsStore = useAdminSettingsStore();
|
const adminSettingsStore = useAdminSettingsStore();
|
||||||
|
const isZhLocale = computed(() => locale.value.startsWith("zh"));
|
||||||
|
|
||||||
function localText(zh: string, en: string): string {
|
function localText(zh: string, en: string): string {
|
||||||
return locale.value.startsWith("zh") ? zh : en;
|
return isZhLocale.value ? zh : en;
|
||||||
}
|
}
|
||||||
|
|
||||||
const paymentGuideHref = computed(() =>
|
const paymentGuideHref = computed(() =>
|
||||||
@ -5796,6 +6023,8 @@ type SettingsForm = Omit<
|
|||||||
wechat_connect_mp_enabled: boolean;
|
wechat_connect_mp_enabled: boolean;
|
||||||
wechat_connect_mobile_enabled: boolean;
|
wechat_connect_mobile_enabled: boolean;
|
||||||
oidc_connect_client_secret: string;
|
oidc_connect_client_secret: string;
|
||||||
|
github_oauth_client_secret: string;
|
||||||
|
google_oauth_client_secret: string;
|
||||||
force_email_on_third_party_signup: boolean;
|
force_email_on_third_party_signup: boolean;
|
||||||
openai_advanced_scheduler_enabled: boolean;
|
openai_advanced_scheduler_enabled: boolean;
|
||||||
};
|
};
|
||||||
@ -5926,6 +6155,19 @@ const form = reactive<SettingsForm>({
|
|||||||
oidc_connect_userinfo_email_path: "",
|
oidc_connect_userinfo_email_path: "",
|
||||||
oidc_connect_userinfo_id_path: "",
|
oidc_connect_userinfo_id_path: "",
|
||||||
oidc_connect_userinfo_username_path: "",
|
oidc_connect_userinfo_username_path: "",
|
||||||
|
// GitHub / Google 邮箱快捷登录
|
||||||
|
github_oauth_enabled: false,
|
||||||
|
github_oauth_client_id: "",
|
||||||
|
github_oauth_client_secret: "",
|
||||||
|
github_oauth_client_secret_configured: false,
|
||||||
|
github_oauth_redirect_url: "",
|
||||||
|
github_oauth_frontend_redirect_url: "/auth/oauth/callback",
|
||||||
|
google_oauth_enabled: false,
|
||||||
|
google_oauth_client_id: "",
|
||||||
|
google_oauth_client_secret: "",
|
||||||
|
google_oauth_client_secret_configured: false,
|
||||||
|
google_oauth_redirect_url: "",
|
||||||
|
google_oauth_frontend_redirect_url: "/auth/oauth/callback",
|
||||||
// Model fallback
|
// Model fallback
|
||||||
enable_model_fallback: false,
|
enable_model_fallback: false,
|
||||||
fallback_model_anthropic: "claude-3-5-sonnet-20241022",
|
fallback_model_anthropic: "claude-3-5-sonnet-20241022",
|
||||||
@ -5991,6 +6233,22 @@ const authSourceDefaultsMeta = computed(() => [
|
|||||||
title: t("admin.settings.authSourceDefaults.sources.wechat.title"),
|
title: t("admin.settings.authSourceDefaults.sources.wechat.title"),
|
||||||
description: t("admin.settings.authSourceDefaults.sources.wechat.description"),
|
description: t("admin.settings.authSourceDefaults.sources.wechat.description"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: "github" as AuthSourceType,
|
||||||
|
title: "GitHub",
|
||||||
|
description: localText(
|
||||||
|
"通过 GitHub 已验证邮箱首次注册或首次绑定时应用。",
|
||||||
|
"Applied on first signup or first bind through a verified GitHub email.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "google" as AuthSourceType,
|
||||||
|
title: "Google",
|
||||||
|
description: localText(
|
||||||
|
"通过 Google 已验证邮箱首次注册或首次绑定时应用。",
|
||||||
|
"Applied on first signup or first bind through a verified Google email.",
|
||||||
|
),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Proxies for web search emulation ProxySelector
|
// Proxies for web search emulation ProxySelector
|
||||||
@ -6298,6 +6556,42 @@ async function setAndCopyLinuxdoRedirectUrl() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EmailOAuthProvider = "github" | "google";
|
||||||
|
|
||||||
|
const githubOAuthRedirectUrlSuggestion = computed(() => {
|
||||||
|
if (typeof window === "undefined") return "";
|
||||||
|
const origin =
|
||||||
|
window.location.origin ||
|
||||||
|
`${window.location.protocol}//${window.location.host}`;
|
||||||
|
return `${origin}/api/v1/auth/oauth/github/callback`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const googleOAuthRedirectUrlSuggestion = computed(() => {
|
||||||
|
if (typeof window === "undefined") return "";
|
||||||
|
const origin =
|
||||||
|
window.location.origin ||
|
||||||
|
`${window.location.protocol}//${window.location.host}`;
|
||||||
|
return `${origin}/api/v1/auth/oauth/google/callback`;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setAndCopyEmailOAuthRedirectUrl(provider: EmailOAuthProvider) {
|
||||||
|
const url =
|
||||||
|
provider === "github"
|
||||||
|
? githubOAuthRedirectUrlSuggestion.value
|
||||||
|
: googleOAuthRedirectUrlSuggestion.value;
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
if (provider === "github") {
|
||||||
|
form.github_oauth_redirect_url = url;
|
||||||
|
} else {
|
||||||
|
form.google_oauth_redirect_url = url;
|
||||||
|
}
|
||||||
|
await copyToClipboard(
|
||||||
|
url,
|
||||||
|
localText("回调地址已写入并复制。", "Callback URL set and copied."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const wechatRedirectUrlSuggestion = computed(() => {
|
const wechatRedirectUrlSuggestion = computed(() => {
|
||||||
if (typeof window === "undefined") return "";
|
if (typeof window === "undefined") return "";
|
||||||
const origin =
|
const origin =
|
||||||
@ -6488,6 +6782,8 @@ async function loadSettings() {
|
|||||||
smtpPasswordManuallyEdited.value = false;
|
smtpPasswordManuallyEdited.value = false;
|
||||||
form.turnstile_secret_key = "";
|
form.turnstile_secret_key = "";
|
||||||
form.linuxdo_connect_client_secret = "";
|
form.linuxdo_connect_client_secret = "";
|
||||||
|
form.github_oauth_client_secret = "";
|
||||||
|
form.google_oauth_client_secret = "";
|
||||||
form.wechat_connect_app_secret = "";
|
form.wechat_connect_app_secret = "";
|
||||||
form.wechat_connect_open_app_secret = "";
|
form.wechat_connect_open_app_secret = "";
|
||||||
form.wechat_connect_mp_app_secret = "";
|
form.wechat_connect_mp_app_secret = "";
|
||||||
@ -6846,6 +7142,20 @@ async function saveSettings() {
|
|||||||
oidc_connect_userinfo_id_path: form.oidc_connect_userinfo_id_path,
|
oidc_connect_userinfo_id_path: form.oidc_connect_userinfo_id_path,
|
||||||
oidc_connect_userinfo_username_path:
|
oidc_connect_userinfo_username_path:
|
||||||
form.oidc_connect_userinfo_username_path,
|
form.oidc_connect_userinfo_username_path,
|
||||||
|
github_oauth_enabled: form.github_oauth_enabled,
|
||||||
|
github_oauth_client_id: form.github_oauth_client_id,
|
||||||
|
github_oauth_client_secret:
|
||||||
|
form.github_oauth_client_secret || undefined,
|
||||||
|
github_oauth_redirect_url: form.github_oauth_redirect_url,
|
||||||
|
github_oauth_frontend_redirect_url:
|
||||||
|
form.github_oauth_frontend_redirect_url,
|
||||||
|
google_oauth_enabled: form.google_oauth_enabled,
|
||||||
|
google_oauth_client_id: form.google_oauth_client_id,
|
||||||
|
google_oauth_client_secret:
|
||||||
|
form.google_oauth_client_secret || undefined,
|
||||||
|
google_oauth_redirect_url: form.google_oauth_redirect_url,
|
||||||
|
google_oauth_frontend_redirect_url:
|
||||||
|
form.google_oauth_frontend_redirect_url,
|
||||||
enable_model_fallback: form.enable_model_fallback,
|
enable_model_fallback: form.enable_model_fallback,
|
||||||
fallback_model_anthropic: form.fallback_model_anthropic,
|
fallback_model_anthropic: form.fallback_model_anthropic,
|
||||||
fallback_model_openai: form.fallback_model_openai,
|
fallback_model_openai: form.fallback_model_openai,
|
||||||
@ -6960,6 +7270,8 @@ async function saveSettings() {
|
|||||||
smtpPasswordManuallyEdited.value = false;
|
smtpPasswordManuallyEdited.value = false;
|
||||||
form.turnstile_secret_key = "";
|
form.turnstile_secret_key = "";
|
||||||
form.linuxdo_connect_client_secret = "";
|
form.linuxdo_connect_client_secret = "";
|
||||||
|
form.github_oauth_client_secret = "";
|
||||||
|
form.google_oauth_client_secret = "";
|
||||||
form.wechat_connect_app_secret = "";
|
form.wechat_connect_app_secret = "";
|
||||||
form.wechat_connect_open_app_secret = "";
|
form.wechat_connect_open_app_secret = "";
|
||||||
form.wechat_connect_mp_app_secret = "";
|
form.wechat_connect_mp_app_secret = "";
|
||||||
|
|||||||
@ -817,6 +817,24 @@ describe("admin SettingsView wechat connect controls", () => {
|
|||||||
).toBe("/auth/wechat/callback");
|
).toBe("/auth/wechat/callback");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("links GitHub OAuth Apps guide to GitHub developer settings", async () => {
|
||||||
|
getSettings.mockResolvedValueOnce({
|
||||||
|
...baseSettingsResponse,
|
||||||
|
github_oauth_enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mountView();
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
await openSecurityTab(wrapper);
|
||||||
|
|
||||||
|
const link = wrapper.get('[data-testid="github-oauth-apps-guide-link"]');
|
||||||
|
expect(link.text()).toContain("OAuth Apps");
|
||||||
|
expect(link.attributes("href")).toBe("https://github.com/settings/developers");
|
||||||
|
expect(link.attributes("target")).toBe("_blank");
|
||||||
|
expect(link.attributes("rel")).toContain("noopener");
|
||||||
|
});
|
||||||
|
|
||||||
it("saves WeChat Connect fields using the backend contract and clears the secret after save", async () => {
|
it("saves WeChat Connect fields using the backend contract and clears the secret after save", async () => {
|
||||||
const wrapper = mountView();
|
const wrapper = mountView();
|
||||||
|
|
||||||
|
|||||||
@ -10,33 +10,6 @@
|
|||||||
{{ t('auth.signInToAccount') }}
|
{{ t('auth.signInToAccount') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!backendModeEnabled && (linuxdoOAuthEnabled || wechatOAuthEnabled || oidcOAuthEnabled)" class="space-y-4">
|
|
||||||
<LinuxDoOAuthSection
|
|
||||||
v-if="linuxdoOAuthEnabled"
|
|
||||||
:disabled="isLoading"
|
|
||||||
:show-divider="false"
|
|
||||||
/>
|
|
||||||
<WechatOAuthSection
|
|
||||||
v-if="wechatOAuthEnabled"
|
|
||||||
:disabled="isLoading"
|
|
||||||
:show-divider="false"
|
|
||||||
/>
|
|
||||||
<OidcOAuthSection
|
|
||||||
v-if="oidcOAuthEnabled"
|
|
||||||
:disabled="isLoading"
|
|
||||||
:provider-name="oidcOAuthProviderName"
|
|
||||||
:show-divider="false"
|
|
||||||
/>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
|
||||||
<span class="text-xs text-gray-500 dark:text-dark-400">
|
|
||||||
{{ t('auth.oauthOrContinue') }}
|
|
||||||
</span>
|
|
||||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
<form @submit.prevent="handleLogin" class="space-y-5">
|
<form @submit.prevent="handleLogin" class="space-y-5">
|
||||||
<!-- Email Input -->
|
<!-- Email Input -->
|
||||||
@ -144,6 +117,40 @@
|
|||||||
<Icon v-else name="login" size="md" class="mr-2" />
|
<Icon v-else name="login" size="md" class="mr-2" />
|
||||||
{{ isLoading ? t('auth.signingIn') : t('auth.signIn') }}
|
{{ isLoading ? t('auth.signingIn') : t('auth.signIn') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div v-if="showOAuthLogin" class="space-y-3 pt-1">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||||
|
{{ t('auth.oauthOrContinue') }}
|
||||||
|
</span>
|
||||||
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EmailOAuthButtons
|
||||||
|
:disabled="isLoading"
|
||||||
|
:github-enabled="githubOAuthEnabled"
|
||||||
|
:google-enabled="googleOAuthEnabled"
|
||||||
|
:show-divider="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LinuxDoOAuthSection
|
||||||
|
v-if="linuxdoOAuthEnabled"
|
||||||
|
:disabled="isLoading"
|
||||||
|
:show-divider="false"
|
||||||
|
/>
|
||||||
|
<WechatOAuthSection
|
||||||
|
v-if="wechatOAuthEnabled"
|
||||||
|
:disabled="isLoading"
|
||||||
|
:show-divider="false"
|
||||||
|
/>
|
||||||
|
<OidcOAuthSection
|
||||||
|
v-if="oidcOAuthEnabled"
|
||||||
|
:disabled="isLoading"
|
||||||
|
:provider-name="oidcOAuthProviderName"
|
||||||
|
:show-divider="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -180,6 +187,7 @@ import { AuthLayout } from '@/components/layout'
|
|||||||
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||||
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
||||||
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
|
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
|
||||||
|
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
|
||||||
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
|
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||||
@ -210,6 +218,8 @@ const wechatOAuthEnabled = ref<boolean>(false)
|
|||||||
const backendModeEnabled = ref<boolean>(false)
|
const backendModeEnabled = ref<boolean>(false)
|
||||||
const oidcOAuthEnabled = ref<boolean>(false)
|
const oidcOAuthEnabled = ref<boolean>(false)
|
||||||
const oidcOAuthProviderName = ref<string>('OIDC')
|
const oidcOAuthProviderName = ref<string>('OIDC')
|
||||||
|
const githubOAuthEnabled = ref<boolean>(false)
|
||||||
|
const googleOAuthEnabled = ref<boolean>(false)
|
||||||
const passwordResetEnabled = ref<boolean>(false)
|
const passwordResetEnabled = ref<boolean>(false)
|
||||||
|
|
||||||
// Turnstile
|
// Turnstile
|
||||||
@ -237,6 +247,16 @@ const validationToastMessage = computed(
|
|||||||
() => errors.email || errors.password || errors.turnstile || ''
|
() => errors.email || errors.password || errors.turnstile || ''
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const showOAuthLogin = computed(
|
||||||
|
() =>
|
||||||
|
!backendModeEnabled.value &&
|
||||||
|
(linuxdoOAuthEnabled.value ||
|
||||||
|
wechatOAuthEnabled.value ||
|
||||||
|
oidcOAuthEnabled.value ||
|
||||||
|
githubOAuthEnabled.value ||
|
||||||
|
googleOAuthEnabled.value)
|
||||||
|
)
|
||||||
|
|
||||||
watch(validationToastMessage, (value, previousValue) => {
|
watch(validationToastMessage, (value, previousValue) => {
|
||||||
if (value && value !== previousValue) {
|
if (value && value !== previousValue) {
|
||||||
appStore.showError(value)
|
appStore.showError(value)
|
||||||
@ -263,6 +283,8 @@ onMounted(async () => {
|
|||||||
backendModeEnabled.value = settings.backend_mode_enabled
|
backendModeEnabled.value = settings.backend_mode_enabled
|
||||||
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
|
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
|
||||||
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
||||||
|
githubOAuthEnabled.value = settings.github_oauth_enabled
|
||||||
|
googleOAuthEnabled.value = settings.google_oauth_enabled
|
||||||
backendModeEnabled.value = settings.backend_mode_enabled
|
backendModeEnabled.value = settings.backend_mode_enabled
|
||||||
passwordResetEnabled.value = settings.password_reset_enabled
|
passwordResetEnabled.value = settings.password_reset_enabled
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -1,7 +1,60 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-gray-50 px-4 py-10 dark:bg-dark-900">
|
<div class="min-h-screen bg-gray-50 px-4 py-10 dark:bg-dark-900">
|
||||||
<div class="mx-auto max-w-2xl">
|
<div class="mx-auto max-w-2xl">
|
||||||
<div class="card p-6">
|
<div v-if="isProcessing" class="card p-6 text-center">
|
||||||
|
<div class="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"></div>
|
||||||
|
<h1 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ t('auth.oauth.callbackTitle') }}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ t('auth.oauth.callbackHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="needsInvitation" class="card p-6">
|
||||||
|
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ t('auth.oidc.callbackTitle', { providerName }) }}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ t('auth.oidc.invitationRequired', { providerName }) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<input
|
||||||
|
v-model="invitationCode"
|
||||||
|
type="text"
|
||||||
|
class="input w-full"
|
||||||
|
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@keyup.enter="handleSubmitInvitation"
|
||||||
|
/>
|
||||||
|
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{{ invitationError }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
type="button"
|
||||||
|
:disabled="isSubmitting || !invitationCode.trim()"
|
||||||
|
@click="handleSubmitInvitation"
|
||||||
|
>
|
||||||
|
{{ isSubmitting ? t('common.processing') : t('auth.oidc.completeRegistration') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="invalidCallback" class="card p-6 text-center">
|
||||||
|
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ t('auth.oauth.invalidCallbackTitle') }}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ t('auth.oauth.invalidCallbackHint') }}
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-primary mt-6" type="button" @click="router.replace('/login')">
|
||||||
|
{{ t('auth.backToLogin') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="card p-6">
|
||||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
|
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{{ t('auth.oauth.callbackTitle') }}
|
{{ t('auth.oauth.callbackTitle') }}
|
||||||
</h1>
|
</h1>
|
||||||
@ -56,16 +109,43 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { useAppStore } from '@/stores'
|
import { useAppStore, useAuthStore } from '@/stores'
|
||||||
|
import { apiClient } from '@/api/client'
|
||||||
|
import {
|
||||||
|
exchangePendingOAuthCompletion,
|
||||||
|
persistOAuthTokenContext,
|
||||||
|
type OAuthTokenResponse
|
||||||
|
} from '@/api/auth'
|
||||||
|
import {
|
||||||
|
clearAllAffiliateReferralCodes,
|
||||||
|
loadOAuthAffiliateCode,
|
||||||
|
oauthAffiliatePayload
|
||||||
|
} from '@/utils/oauthAffiliate'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { copyToClipboard } = useClipboard()
|
const { copyToClipboard } = useClipboard()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const isProcessing = ref(false)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const needsInvitation = ref(false)
|
||||||
|
const invitationCode = ref('')
|
||||||
|
const invitationError = ref('')
|
||||||
|
const pendingProvider = ref<'github' | 'google'>('github')
|
||||||
|
const redirectTo = ref('/dashboard')
|
||||||
|
const invalidCallback = ref(false)
|
||||||
|
|
||||||
|
type EmailOAuthPendingCompletion = Partial<OAuthTokenResponse> & {
|
||||||
|
error?: string
|
||||||
|
provider?: string
|
||||||
|
redirect?: string
|
||||||
|
}
|
||||||
|
|
||||||
const code = computed(() => (route.query.code as string) || '')
|
const code = computed(() => (route.query.code as string) || '')
|
||||||
const state = computed(() => (route.query.state as string) || '')
|
const state = computed(() => (route.query.state as string) || '')
|
||||||
@ -77,6 +157,137 @@ const fullUrl = computed(() => {
|
|||||||
if (typeof window === 'undefined') return ''
|
if (typeof window === 'undefined') return ''
|
||||||
return window.location.href
|
return window.location.href
|
||||||
})
|
})
|
||||||
|
const providerName = computed(() =>
|
||||||
|
pendingProvider.value === 'google' ? 'Google' : 'GitHub'
|
||||||
|
)
|
||||||
|
|
||||||
|
function parseFragmentParams(): URLSearchParams {
|
||||||
|
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||||
|
const hash = raw.startsWith('#') ? raw.slice(1) : raw
|
||||||
|
return new URLSearchParams(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTokenResponse(params: URLSearchParams): OAuthTokenResponse | null {
|
||||||
|
const accessToken = params.get('access_token')?.trim() || ''
|
||||||
|
if (!accessToken) return null
|
||||||
|
|
||||||
|
const response: OAuthTokenResponse = { access_token: accessToken }
|
||||||
|
const refreshToken = params.get('refresh_token')?.trim() || ''
|
||||||
|
if (refreshToken) response.refresh_token = refreshToken
|
||||||
|
const expiresIn = Number.parseInt(params.get('expires_in')?.trim() || '', 10)
|
||||||
|
if (Number.isFinite(expiresIn) && expiresIn > 0) response.expires_in = expiresIn
|
||||||
|
const tokenType = params.get('token_type')?.trim() || ''
|
||||||
|
if (tokenType) response.token_type = tokenType
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeRedirectPath(path: string | null | undefined): string {
|
||||||
|
if (!path) return '/dashboard'
|
||||||
|
if (!path.startsWith('/')) return '/dashboard'
|
||||||
|
if (path.startsWith('//')) return '/dashboard'
|
||||||
|
if (path.includes('://')) return '/dashboard'
|
||||||
|
if (path.includes('\n') || path.includes('\r')) return '/dashboard'
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalizeTokenResponse(tokenResponse: OAuthTokenResponse, redirect: string) {
|
||||||
|
persistOAuthTokenContext(tokenResponse)
|
||||||
|
await authStore.setToken(tokenResponse.access_token)
|
||||||
|
clearAllAffiliateReferralCodes()
|
||||||
|
appStore.showSuccess(t('auth.loginSuccess'))
|
||||||
|
await router.replace(sanitizeRedirectPath(redirect))
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOAuthTokenResponse(value: Partial<OAuthTokenResponse>): value is OAuthTokenResponse {
|
||||||
|
return typeof value.access_token === 'string' && value.access_token.trim() !== ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumePendingEmailOAuth() {
|
||||||
|
isProcessing.value = true
|
||||||
|
try {
|
||||||
|
const completion = await exchangePendingOAuthCompletion() as EmailOAuthPendingCompletion
|
||||||
|
const completionRedirect = completion.redirect || '/dashboard'
|
||||||
|
if (hasOAuthTokenResponse(completion)) {
|
||||||
|
await finalizeTokenResponse(completion, completionRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = String(completion.provider || '').toLowerCase()
|
||||||
|
if (provider === 'github' || provider === 'google') {
|
||||||
|
pendingProvider.value = provider
|
||||||
|
}
|
||||||
|
redirectTo.value = sanitizeRedirectPath(completionRedirect)
|
||||||
|
|
||||||
|
if (completion.error === 'invitation_required') {
|
||||||
|
needsInvitation.value = true
|
||||||
|
isProcessing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appStore.showError(completion.error || t('auth.loginFailed'))
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { message?: string; response?: { data?: { message?: string } } }
|
||||||
|
const message = err.response?.data?.message || err.message || t('auth.loginFailed')
|
||||||
|
appStore.showError(message)
|
||||||
|
invalidCallback.value = true
|
||||||
|
} finally {
|
||||||
|
if (!needsInvitation.value) {
|
||||||
|
isProcessing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmitInvitation() {
|
||||||
|
invitationError.value = ''
|
||||||
|
const code = invitationCode.value.trim()
|
||||||
|
if (!code) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.post<OAuthTokenResponse>(
|
||||||
|
`/auth/oauth/${pendingProvider.value}/complete-registration`,
|
||||||
|
{
|
||||||
|
invitation_code: code,
|
||||||
|
...oauthAffiliatePayload(loadOAuthAffiliateCode())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await finalizeTokenResponse(data, redirectTo.value)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { message?: string; response?: { data?: { message?: string } } }
|
||||||
|
invitationError.value =
|
||||||
|
err.response?.data?.message || err.message || t('auth.oidc.completeRegistrationFailed')
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const params = parseFragmentParams()
|
||||||
|
const tokenResponse = readTokenResponse(params)
|
||||||
|
const fragmentError = params.get('error') || ''
|
||||||
|
const fragmentErrorDescription =
|
||||||
|
params.get('error_description') || params.get('error_message') || ''
|
||||||
|
|
||||||
|
if (fragmentError) {
|
||||||
|
appStore.showError(fragmentErrorDescription || fragmentError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!tokenResponse) {
|
||||||
|
if (route.path === '/auth/oauth/callback') {
|
||||||
|
await resumePendingEmailOAuth()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessing.value = true
|
||||||
|
try {
|
||||||
|
await finalizeTokenResponse(tokenResponse, params.get('redirect') || '/dashboard')
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = (error as { message?: string })?.message || t('auth.loginFailed')
|
||||||
|
appStore.showError(message)
|
||||||
|
isProcessing.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
error,
|
error,
|
||||||
|
|||||||
@ -11,35 +11,6 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="linuxdoOAuthEnabled || wechatOAuthEnabled || oidcOAuthEnabled" class="space-y-4">
|
|
||||||
<LinuxDoOAuthSection
|
|
||||||
v-if="linuxdoOAuthEnabled"
|
|
||||||
:disabled="isLoading"
|
|
||||||
:aff-code="formData.aff_code"
|
|
||||||
:show-divider="false"
|
|
||||||
/>
|
|
||||||
<WechatOAuthSection
|
|
||||||
v-if="wechatOAuthEnabled"
|
|
||||||
:disabled="isLoading"
|
|
||||||
:aff-code="formData.aff_code"
|
|
||||||
:show-divider="false"
|
|
||||||
/>
|
|
||||||
<OidcOAuthSection
|
|
||||||
v-if="oidcOAuthEnabled"
|
|
||||||
:disabled="isLoading"
|
|
||||||
:provider-name="oidcOAuthProviderName"
|
|
||||||
:aff-code="formData.aff_code"
|
|
||||||
:show-divider="false"
|
|
||||||
/>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
|
||||||
<span class="text-xs text-gray-500 dark:text-dark-400">
|
|
||||||
{{ t('auth.oauthOrContinue') }}
|
|
||||||
</span>
|
|
||||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Registration Disabled Message -->
|
<!-- Registration Disabled Message -->
|
||||||
<div
|
<div
|
||||||
v-if="!registrationEnabled && settingsLoaded"
|
v-if="!registrationEnabled && settingsLoaded"
|
||||||
@ -256,7 +227,46 @@
|
|||||||
: t('auth.createAccount')
|
: t('auth.createAccount')
|
||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div v-if="showOAuthLogin" class="space-y-3 pt-1">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||||
|
{{ t('auth.oauthOrContinue') }}
|
||||||
|
</span>
|
||||||
|
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EmailOAuthButtons
|
||||||
|
:disabled="isLoading"
|
||||||
|
:aff-code="formData.aff_code"
|
||||||
|
:github-enabled="githubOAuthEnabled"
|
||||||
|
:google-enabled="googleOAuthEnabled"
|
||||||
|
:show-divider="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LinuxDoOAuthSection
|
||||||
|
v-if="linuxdoOAuthEnabled"
|
||||||
|
:disabled="isLoading"
|
||||||
|
:aff-code="formData.aff_code"
|
||||||
|
:show-divider="false"
|
||||||
|
/>
|
||||||
|
<WechatOAuthSection
|
||||||
|
v-if="wechatOAuthEnabled"
|
||||||
|
:disabled="isLoading"
|
||||||
|
:aff-code="formData.aff_code"
|
||||||
|
:show-divider="false"
|
||||||
|
/>
|
||||||
|
<OidcOAuthSection
|
||||||
|
v-if="oidcOAuthEnabled"
|
||||||
|
:disabled="isLoading"
|
||||||
|
:provider-name="oidcOAuthProviderName"
|
||||||
|
:aff-code="formData.aff_code"
|
||||||
|
:show-divider="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
@ -282,6 +292,7 @@ import { AuthLayout } from '@/components/layout'
|
|||||||
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||||
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
||||||
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
|
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
|
||||||
|
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||||
import { useAuthStore, useAppStore } from '@/stores'
|
import { useAuthStore, useAppStore } from '@/stores'
|
||||||
@ -330,6 +341,8 @@ const linuxdoOAuthEnabled = ref<boolean>(false)
|
|||||||
const wechatOAuthEnabled = ref<boolean>(false)
|
const wechatOAuthEnabled = ref<boolean>(false)
|
||||||
const oidcOAuthEnabled = ref<boolean>(false)
|
const oidcOAuthEnabled = ref<boolean>(false)
|
||||||
const oidcOAuthProviderName = ref<string>('OIDC')
|
const oidcOAuthProviderName = ref<string>('OIDC')
|
||||||
|
const githubOAuthEnabled = ref<boolean>(false)
|
||||||
|
const googleOAuthEnabled = ref<boolean>(false)
|
||||||
const registrationEmailSuffixWhitelist = ref<string[]>([])
|
const registrationEmailSuffixWhitelist = ref<string[]>([])
|
||||||
|
|
||||||
// Turnstile
|
// Turnstile
|
||||||
@ -380,6 +393,15 @@ const validationToastMessage = computed(() =>
|
|||||||
''
|
''
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const showOAuthLogin = computed(
|
||||||
|
() =>
|
||||||
|
linuxdoOAuthEnabled.value ||
|
||||||
|
wechatOAuthEnabled.value ||
|
||||||
|
oidcOAuthEnabled.value ||
|
||||||
|
githubOAuthEnabled.value ||
|
||||||
|
googleOAuthEnabled.value
|
||||||
|
)
|
||||||
|
|
||||||
watch(validationToastMessage, (value, previousValue) => {
|
watch(validationToastMessage, (value, previousValue) => {
|
||||||
if (value && value !== previousValue) {
|
if (value && value !== previousValue) {
|
||||||
appStore.showError(value)
|
appStore.showError(value)
|
||||||
@ -412,6 +434,8 @@ onMounted(async () => {
|
|||||||
wechatOAuthEnabled.value = isWeChatWebOAuthEnabled(settings)
|
wechatOAuthEnabled.value = isWeChatWebOAuthEnabled(settings)
|
||||||
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
|
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
|
||||||
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
||||||
|
githubOAuthEnabled.value = settings.github_oauth_enabled
|
||||||
|
googleOAuthEnabled.value = settings.google_oauth_enabled
|
||||||
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
|
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
|
||||||
settings.registration_email_suffix_whitelist || []
|
settings.registration_email_suffix_whitelist || []
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,16 +2,34 @@ import { mount } from '@vue/test-utils'
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import OAuthCallbackView from '@/views/auth/OAuthCallbackView.vue'
|
import OAuthCallbackView from '@/views/auth/OAuthCallbackView.vue'
|
||||||
|
|
||||||
const { routeState, showErrorMock, copyToClipboardMock } = vi.hoisted(() => ({
|
const {
|
||||||
|
routeState,
|
||||||
|
routerReplaceMock,
|
||||||
|
showErrorMock,
|
||||||
|
showSuccessMock,
|
||||||
|
setTokenMock,
|
||||||
|
copyToClipboardMock,
|
||||||
|
exchangePendingOAuthCompletionMock,
|
||||||
|
apiPostMock,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
routeState: {
|
routeState: {
|
||||||
|
path: '/auth/callback',
|
||||||
query: {} as Record<string, unknown>,
|
query: {} as Record<string, unknown>,
|
||||||
},
|
},
|
||||||
|
routerReplaceMock: vi.fn(),
|
||||||
showErrorMock: vi.fn(),
|
showErrorMock: vi.fn(),
|
||||||
|
showSuccessMock: vi.fn(),
|
||||||
|
setTokenMock: vi.fn(),
|
||||||
copyToClipboardMock: vi.fn(),
|
copyToClipboardMock: vi.fn(),
|
||||||
|
exchangePendingOAuthCompletionMock: vi.fn(),
|
||||||
|
apiPostMock: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
useRoute: () => routeState,
|
useRoute: () => routeState,
|
||||||
|
useRouter: () => ({
|
||||||
|
replace: (...args: any[]) => routerReplaceMock(...args),
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('vue-i18n', () => ({
|
vi.mock('vue-i18n', () => ({
|
||||||
@ -21,11 +39,30 @@ vi.mock('vue-i18n', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/stores', () => ({
|
vi.mock('@/stores', () => ({
|
||||||
|
useAuthStore: () => ({
|
||||||
|
setToken: (...args: any[]) => setTokenMock(...args),
|
||||||
|
}),
|
||||||
useAppStore: () => ({
|
useAppStore: () => ({
|
||||||
showError: (...args: any[]) => showErrorMock(...args),
|
showError: (...args: any[]) => showErrorMock(...args),
|
||||||
|
showSuccess: (...args: any[]) => showSuccessMock(...args),
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
apiClient: {
|
||||||
|
post: (...args: any[]) => apiPostMock(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/auth', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/api/auth')>('@/api/auth')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletionMock(...args),
|
||||||
|
persistOAuthTokenContext: vi.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('@/composables/useClipboard', () => ({
|
vi.mock('@/composables/useClipboard', () => ({
|
||||||
useClipboard: () => ({
|
useClipboard: () => ({
|
||||||
copyToClipboard: (...args: any[]) => copyToClipboardMock(...args),
|
copyToClipboard: (...args: any[]) => copyToClipboardMock(...args),
|
||||||
@ -34,9 +71,17 @@ vi.mock('@/composables/useClipboard', () => ({
|
|||||||
|
|
||||||
describe('OAuthCallbackView', () => {
|
describe('OAuthCallbackView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
routeState.path = '/auth/callback'
|
||||||
routeState.query = {}
|
routeState.query = {}
|
||||||
|
window.location.hash = ''
|
||||||
|
routerReplaceMock.mockReset()
|
||||||
showErrorMock.mockReset()
|
showErrorMock.mockReset()
|
||||||
|
showSuccessMock.mockReset()
|
||||||
|
setTokenMock.mockReset()
|
||||||
copyToClipboardMock.mockReset()
|
copyToClipboardMock.mockReset()
|
||||||
|
exchangePendingOAuthCompletionMock.mockReset()
|
||||||
|
apiPostMock.mockReset()
|
||||||
|
window.sessionStorage.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders localized callback copy actions', () => {
|
it('renders localized callback copy actions', () => {
|
||||||
@ -65,4 +110,44 @@ describe('OAuthCallbackView', () => {
|
|||||||
expect(wrapper.text()).not.toContain('oauth failed')
|
expect(wrapper.text()).not.toContain('oauth failed')
|
||||||
expect(wrapper.find('.bg-red-50').exists()).toBe(false)
|
expect(wrapper.find('.bg-red-50').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not render manual copy fields for direct email oauth callback visits', async () => {
|
||||||
|
routeState.path = '/auth/oauth/callback'
|
||||||
|
exchangePendingOAuthCompletionMock.mockRejectedValue(new Error('pending session not found'))
|
||||||
|
|
||||||
|
const wrapper = mount(OAuthCallbackView)
|
||||||
|
await vi.dynamicImportSettled()
|
||||||
|
|
||||||
|
expect(exchangePendingOAuthCompletionMock).toHaveBeenCalledTimes(1)
|
||||||
|
expect(wrapper.text()).toContain('auth.oauth.invalidCallbackTitle')
|
||||||
|
expect(wrapper.text()).toContain('auth.oauth.invalidCallbackHint')
|
||||||
|
expect(wrapper.find('input[readonly]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits stored affiliate code when completing invited email oauth registration', async () => {
|
||||||
|
routeState.path = '/auth/oauth/callback'
|
||||||
|
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||||
|
error: 'invitation_required',
|
||||||
|
provider: 'google',
|
||||||
|
redirect: '/dashboard',
|
||||||
|
})
|
||||||
|
apiPostMock.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
access_token: 'token-1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
window.sessionStorage.setItem('oauth_aff_code', 'AFF456')
|
||||||
|
|
||||||
|
const wrapper = mount(OAuthCallbackView)
|
||||||
|
await vi.dynamicImportSettled()
|
||||||
|
const input = wrapper.find('input[type="text"]')
|
||||||
|
await input.setValue('INVITE456')
|
||||||
|
await wrapper.findAll('button').at(0)?.trigger('click')
|
||||||
|
|
||||||
|
expect(apiPostMock).toHaveBeenCalledWith('/auth/oauth/google/complete-registration', {
|
||||||
|
invitation_code: 'INVITE456',
|
||||||
|
aff_code: 'AFF456',
|
||||||
|
})
|
||||||
|
expect(setTokenMock).toHaveBeenCalledWith('token-1')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user