Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
b017f461
Commit
b017f461
authored
Apr 24, 2026
by
陈曦
Browse files
confict fixed by v117
parents
11b97147
d162604f
Changes
131
Hide whitespace changes
Inline
Side-by-side
Too many changes to show.
To preserve performance only
131 of 131+
files are displayed.
Plain diff
Email patch
backend/internal/handler/admin/payment_handler.go
View file @
b017f461
...
@@ -3,6 +3,7 @@ package admin
...
@@ -3,6 +3,7 @@ package admin
import
(
import
(
"strconv"
"strconv"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/service"
...
@@ -66,7 +67,7 @@ func (h *PaymentHandler) ListOrders(c *gin.Context) {
...
@@ -66,7 +67,7 @@ func (h *PaymentHandler) ListOrders(c *gin.Context) {
response
.
ErrorFrom
(
c
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
return
}
}
response
.
Paginated
(
c
,
orders
,
int64
(
total
),
page
,
pageSize
)
response
.
Paginated
(
c
,
sanitizeAdminPaymentOrdersForResponse
(
orders
)
,
int64
(
total
),
page
,
pageSize
)
}
}
// GetOrderDetail returns detailed information about a single order.
// GetOrderDetail returns detailed information about a single order.
...
@@ -82,7 +83,7 @@ func (h *PaymentHandler) GetOrderDetail(c *gin.Context) {
...
@@ -82,7 +83,7 @@ func (h *PaymentHandler) GetOrderDetail(c *gin.Context) {
return
return
}
}
auditLogs
,
_
:=
h
.
paymentService
.
GetOrderAuditLogs
(
c
.
Request
.
Context
(),
orderID
)
auditLogs
,
_
:=
h
.
paymentService
.
GetOrderAuditLogs
(
c
.
Request
.
Context
(),
orderID
)
response
.
Success
(
c
,
gin
.
H
{
"order"
:
order
,
"auditLogs"
:
auditLogs
})
response
.
Success
(
c
,
gin
.
H
{
"order"
:
sanitizeAdminPaymentOrderForResponse
(
order
)
,
"auditLogs"
:
auditLogs
})
}
}
// CancelOrder cancels a pending order (admin).
// CancelOrder cancels a pending order (admin).
...
@@ -114,6 +115,26 @@ func (h *PaymentHandler) RetryFulfillment(c *gin.Context) {
...
@@ -114,6 +115,26 @@ func (h *PaymentHandler) RetryFulfillment(c *gin.Context) {
response
.
Success
(
c
,
gin
.
H
{
"message"
:
"fulfillment retried"
})
response
.
Success
(
c
,
gin
.
H
{
"message"
:
"fulfillment retried"
})
}
}
func
sanitizeAdminPaymentOrdersForResponse
(
orders
[]
*
dbent
.
PaymentOrder
)
[]
*
dbent
.
PaymentOrder
{
if
len
(
orders
)
==
0
{
return
orders
}
out
:=
make
([]
*
dbent
.
PaymentOrder
,
0
,
len
(
orders
))
for
_
,
order
:=
range
orders
{
out
=
append
(
out
,
sanitizeAdminPaymentOrderForResponse
(
order
))
}
return
out
}
func
sanitizeAdminPaymentOrderForResponse
(
order
*
dbent
.
PaymentOrder
)
*
dbent
.
PaymentOrder
{
if
order
==
nil
{
return
nil
}
cloned
:=
*
order
cloned
.
ProviderSnapshot
=
nil
return
&
cloned
}
// AdminProcessRefundRequest is the request body for admin refund processing.
// AdminProcessRefundRequest is the request body for admin refund processing.
type
AdminProcessRefundRequest
struct
{
type
AdminProcessRefundRequest
struct
{
Amount
float64
`json:"amount"`
Amount
float64
`json:"amount"`
...
...
backend/internal/handler/admin/setting_handler.go
View file @
b017f461
...
@@ -43,6 +43,15 @@ func scopesContainOpenID(scopes string) bool {
...
@@ -43,6 +43,15 @@ func scopesContainOpenID(scopes string) bool {
return
false
return
false
}
}
func
firstNonEmpty
(
values
...
string
)
string
{
for
_
,
value
:=
range
values
{
if
trimmed
:=
strings
.
TrimSpace
(
value
);
trimmed
!=
""
{
return
trimmed
}
}
return
""
}
// SettingHandler 系统设置处理器
// SettingHandler 系统设置处理器
type
SettingHandler
struct
{
type
SettingHandler
struct
{
settingService
*
service
.
SettingService
settingService
*
service
.
SettingService
...
@@ -73,6 +82,11 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
...
@@ -73,6 +82,11 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
response
.
ErrorFrom
(
c
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
return
}
}
authSourceDefaults
,
err
:=
h
.
settingService
.
GetAuthSourceDefaultSettings
(
c
.
Request
.
Context
())
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
// Check if ops monitoring is enabled (respects config.ops.enabled)
// Check if ops monitoring is enabled (respects config.ops.enabled)
opsEnabled
:=
h
.
opsService
!=
nil
&&
h
.
opsService
.
IsMonitoringEnabled
(
c
.
Request
.
Context
())
opsEnabled
:=
h
.
opsService
!=
nil
&&
h
.
opsService
.
IsMonitoringEnabled
(
c
.
Request
.
Context
())
...
@@ -93,114 +107,142 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
...
@@ -93,114 +107,142 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
paymentCfg
=
&
service
.
PaymentConfig
{}
paymentCfg
=
&
service
.
PaymentConfig
{}
}
}
response
.
Success
(
c
,
dto
.
SystemSettings
{
payload
:=
dto
.
SystemSettings
{
RegistrationEnabled
:
settings
.
RegistrationEnabled
,
RegistrationEnabled
:
settings
.
RegistrationEnabled
,
EmailVerifyEnabled
:
settings
.
EmailVerifyEnabled
,
EmailVerifyEnabled
:
settings
.
EmailVerifyEnabled
,
RegistrationEmailSuffixWhitelist
:
settings
.
RegistrationEmailSuffixWhitelist
,
RegistrationEmailSuffixWhitelist
:
settings
.
RegistrationEmailSuffixWhitelist
,
PromoCodeEnabled
:
settings
.
PromoCodeEnabled
,
PromoCodeEnabled
:
settings
.
PromoCodeEnabled
,
PasswordResetEnabled
:
settings
.
PasswordResetEnabled
,
PasswordResetEnabled
:
settings
.
PasswordResetEnabled
,
FrontendURL
:
settings
.
FrontendURL
,
FrontendURL
:
settings
.
FrontendURL
,
InvitationCodeEnabled
:
settings
.
InvitationCodeEnabled
,
InvitationCodeEnabled
:
settings
.
InvitationCodeEnabled
,
TotpEnabled
:
settings
.
TotpEnabled
,
TotpEnabled
:
settings
.
TotpEnabled
,
TotpEncryptionKeyConfigured
:
h
.
settingService
.
IsTotpEncryptionKeyConfigured
(),
TotpEncryptionKeyConfigured
:
h
.
settingService
.
IsTotpEncryptionKeyConfigured
(),
SMTPHost
:
settings
.
SMTPHost
,
SMTPHost
:
settings
.
SMTPHost
,
SMTPPort
:
settings
.
SMTPPort
,
SMTPPort
:
settings
.
SMTPPort
,
SMTPUsername
:
settings
.
SMTPUsername
,
SMTPUsername
:
settings
.
SMTPUsername
,
SMTPPasswordConfigured
:
settings
.
SMTPPasswordConfigured
,
SMTPPasswordConfigured
:
settings
.
SMTPPasswordConfigured
,
SMTPFrom
:
settings
.
SMTPFrom
,
SMTPFrom
:
settings
.
SMTPFrom
,
SMTPFromName
:
settings
.
SMTPFromName
,
SMTPFromName
:
settings
.
SMTPFromName
,
SMTPUseTLS
:
settings
.
SMTPUseTLS
,
SMTPUseTLS
:
settings
.
SMTPUseTLS
,
TurnstileEnabled
:
settings
.
TurnstileEnabled
,
TurnstileEnabled
:
settings
.
TurnstileEnabled
,
TurnstileSiteKey
:
settings
.
TurnstileSiteKey
,
TurnstileSiteKey
:
settings
.
TurnstileSiteKey
,
TurnstileSecretKeyConfigured
:
settings
.
TurnstileSecretKeyConfigured
,
TurnstileSecretKeyConfigured
:
settings
.
TurnstileSecretKeyConfigured
,
LinuxDoConnectEnabled
:
settings
.
LinuxDoConnectEnabled
,
LinuxDoConnectEnabled
:
settings
.
LinuxDoConnectEnabled
,
LinuxDoConnectClientID
:
settings
.
LinuxDoConnectClientID
,
LinuxDoConnectClientID
:
settings
.
LinuxDoConnectClientID
,
LinuxDoConnectClientSecretConfigured
:
settings
.
LinuxDoConnectClientSecretConfigured
,
LinuxDoConnectClientSecretConfigured
:
settings
.
LinuxDoConnectClientSecretConfigured
,
LinuxDoConnectRedirectURL
:
settings
.
LinuxDoConnectRedirectURL
,
LinuxDoConnectRedirectURL
:
settings
.
LinuxDoConnectRedirectURL
,
OIDCConnectEnabled
:
settings
.
OIDCConnectEnabled
,
WeChatConnectEnabled
:
settings
.
WeChatConnectEnabled
,
OIDCConnectProviderName
:
settings
.
OIDCConnectProviderName
,
WeChatConnectAppID
:
settings
.
WeChatConnectAppID
,
OIDCConnectClientID
:
settings
.
OIDCConnectClientID
,
WeChatConnectAppSecretConfigured
:
settings
.
WeChatConnectAppSecretConfigured
,
OIDCConnectClientSecretConfigured
:
settings
.
OIDCConnectClientSecretConfigured
,
WeChatConnectOpenAppID
:
settings
.
WeChatConnectOpenAppID
,
OIDCConnectIssuerURL
:
settings
.
OIDCConnectIssuerURL
,
WeChatConnectOpenAppSecretConfigured
:
settings
.
WeChatConnectOpenAppSecretConfigured
,
OIDCConnectDiscoveryURL
:
settings
.
OIDCConnectDiscoveryURL
,
WeChatConnectMPAppID
:
settings
.
WeChatConnectMPAppID
,
OIDCConnectAuthorizeURL
:
settings
.
OIDCConnectAuthorizeURL
,
WeChatConnectMPAppSecretConfigured
:
settings
.
WeChatConnectMPAppSecretConfigured
,
OIDCConnectTokenURL
:
settings
.
OIDCConnectTokenURL
,
WeChatConnectMobileAppID
:
settings
.
WeChatConnectMobileAppID
,
OIDCConnectUserInfoURL
:
settings
.
OIDCConnectUserInfoURL
,
WeChatConnectMobileAppSecretConfigured
:
settings
.
WeChatConnectMobileAppSecretConfigured
,
OIDCConnectJWKSURL
:
settings
.
OIDCConnectJWKSURL
,
WeChatConnectOpenEnabled
:
settings
.
WeChatConnectOpenEnabled
,
OIDCConnectScopes
:
settings
.
OIDCConnectScopes
,
WeChatConnectMPEnabled
:
settings
.
WeChatConnectMPEnabled
,
OIDCConnectRedirectURL
:
settings
.
OIDCConnectRedirectURL
,
WeChatConnectMobileEnabled
:
settings
.
WeChatConnectMobileEnabled
,
OIDCConnectFrontendRedirectURL
:
settings
.
OIDCConnectFrontendRedirectURL
,
WeChatConnectMode
:
settings
.
WeChatConnectMode
,
OIDCConnectTokenAuthMethod
:
settings
.
OIDCConnectTokenAuthMethod
,
WeChatConnectScopes
:
settings
.
WeChatConnectScopes
,
OIDCConnectUsePKCE
:
settings
.
OIDCConnectUsePKCE
,
WeChatConnectRedirectURL
:
settings
.
WeChatConnectRedirectURL
,
OIDCConnectValidateIDToken
:
settings
.
OIDCConnectValidateIDToken
,
WeChatConnectFrontendRedirectURL
:
settings
.
WeChatConnectFrontendRedirectURL
,
OIDCConnectAllowedSigningAlgs
:
settings
.
OIDCConnectAllowedSigningAlgs
,
OIDCConnectEnabled
:
settings
.
OIDCConnectEnabled
,
OIDCConnectClockSkewSeconds
:
settings
.
OIDCConnectClockSkewSeconds
,
OIDCConnectProviderName
:
settings
.
OIDCConnectProviderName
,
OIDCConnectRequireEmailVerified
:
settings
.
OIDCConnectRequireEmailVerified
,
OIDCConnectClientID
:
settings
.
OIDCConnectClientID
,
OIDCConnectUserInfoEmailPath
:
settings
.
OIDCConnectUserInfoEmailPath
,
OIDCConnectClientSecretConfigured
:
settings
.
OIDCConnectClientSecretConfigured
,
OIDCConnectUserInfoIDPath
:
settings
.
OIDCConnectUserInfoIDPath
,
OIDCConnectIssuerURL
:
settings
.
OIDCConnectIssuerURL
,
OIDCConnectUserInfoUsernamePath
:
settings
.
OIDCConnectUserInfoUsernamePath
,
OIDCConnectDiscoveryURL
:
settings
.
OIDCConnectDiscoveryURL
,
SiteName
:
settings
.
SiteName
,
OIDCConnectAuthorizeURL
:
settings
.
OIDCConnectAuthorizeURL
,
SiteLogo
:
settings
.
SiteLogo
,
OIDCConnectTokenURL
:
settings
.
OIDCConnectTokenURL
,
SiteSubtitle
:
settings
.
SiteSubtitle
,
OIDCConnectUserInfoURL
:
settings
.
OIDCConnectUserInfoURL
,
APIBaseURL
:
settings
.
APIBaseURL
,
OIDCConnectJWKSURL
:
settings
.
OIDCConnectJWKSURL
,
ContactInfo
:
settings
.
ContactInfo
,
OIDCConnectScopes
:
settings
.
OIDCConnectScopes
,
DocURL
:
settings
.
DocURL
,
OIDCConnectRedirectURL
:
settings
.
OIDCConnectRedirectURL
,
HomeContent
:
settings
.
HomeContent
,
OIDCConnectFrontendRedirectURL
:
settings
.
OIDCConnectFrontendRedirectURL
,
HideCcsImportButton
:
settings
.
HideCcsImportButton
,
OIDCConnectTokenAuthMethod
:
settings
.
OIDCConnectTokenAuthMethod
,
PurchaseSubscriptionEnabled
:
settings
.
PurchaseSubscriptionEnabled
,
OIDCConnectUsePKCE
:
settings
.
OIDCConnectUsePKCE
,
PurchaseSubscriptionURL
:
settings
.
PurchaseSubscriptionURL
,
OIDCConnectValidateIDToken
:
settings
.
OIDCConnectValidateIDToken
,
TableDefaultPageSize
:
settings
.
TableDefaultPageSize
,
OIDCConnectAllowedSigningAlgs
:
settings
.
OIDCConnectAllowedSigningAlgs
,
TablePageSizeOptions
:
settings
.
TablePageSizeOptions
,
OIDCConnectClockSkewSeconds
:
settings
.
OIDCConnectClockSkewSeconds
,
CustomMenuItems
:
dto
.
ParseCustomMenuItems
(
settings
.
CustomMenuItems
),
OIDCConnectRequireEmailVerified
:
settings
.
OIDCConnectRequireEmailVerified
,
CustomEndpoints
:
dto
.
ParseCustomEndpoints
(
settings
.
CustomEndpoints
),
OIDCConnectUserInfoEmailPath
:
settings
.
OIDCConnectUserInfoEmailPath
,
DefaultConcurrency
:
settings
.
DefaultConcurrency
,
OIDCConnectUserInfoIDPath
:
settings
.
OIDCConnectUserInfoIDPath
,
DefaultBalance
:
settings
.
DefaultBalance
,
OIDCConnectUserInfoUsernamePath
:
settings
.
OIDCConnectUserInfoUsernamePath
,
DefaultSubscriptions
:
defaultSubscriptions
,
SiteName
:
settings
.
SiteName
,
EnableModelFallback
:
settings
.
EnableModelFallback
,
SiteLogo
:
settings
.
SiteLogo
,
FallbackModelAnthropic
:
settings
.
FallbackModelAnthropic
,
SiteSubtitle
:
settings
.
SiteSubtitle
,
FallbackModelOpenAI
:
settings
.
FallbackModelOpenAI
,
APIBaseURL
:
settings
.
APIBaseURL
,
FallbackModelGemini
:
settings
.
FallbackModelGemini
,
ContactInfo
:
settings
.
ContactInfo
,
FallbackModelAntigravity
:
settings
.
FallbackModelAntigravity
,
DocURL
:
settings
.
DocURL
,
EnableIdentityPatch
:
settings
.
EnableIdentityPatch
,
HomeContent
:
settings
.
HomeContent
,
IdentityPatchPrompt
:
settings
.
IdentityPatchPrompt
,
HideCcsImportButton
:
settings
.
HideCcsImportButton
,
OpsMonitoringEnabled
:
opsEnabled
&&
settings
.
OpsMonitoringEnabled
,
PurchaseSubscriptionEnabled
:
settings
.
PurchaseSubscriptionEnabled
,
OpsRealtimeMonitoringEnabled
:
settings
.
OpsRealtimeMonitoringEnabled
,
PurchaseSubscriptionURL
:
settings
.
PurchaseSubscriptionURL
,
OpsQueryModeDefault
:
settings
.
OpsQueryModeDefault
,
TableDefaultPageSize
:
settings
.
TableDefaultPageSize
,
OpsMetricsIntervalSeconds
:
settings
.
OpsMetricsIntervalSeconds
,
TablePageSizeOptions
:
settings
.
TablePageSizeOptions
,
MinClaudeCodeVersion
:
settings
.
MinClaudeCodeVersion
,
CustomMenuItems
:
dto
.
ParseCustomMenuItems
(
settings
.
CustomMenuItems
),
MaxClaudeCodeVersion
:
settings
.
MaxClaudeCodeVersion
,
CustomEndpoints
:
dto
.
ParseCustomEndpoints
(
settings
.
CustomEndpoints
),
AllowUngroupedKeyScheduling
:
settings
.
AllowUngroupedKeyScheduling
,
DefaultConcurrency
:
settings
.
DefaultConcurrency
,
BackendModeEnabled
:
settings
.
BackendModeEnabled
,
DefaultBalance
:
settings
.
DefaultBalance
,
EnableFingerprintUnification
:
settings
.
EnableFingerprintUnification
,
DefaultUserRPMLimit
:
settings
.
DefaultUserRPMLimit
,
EnableMetadataPassthrough
:
settings
.
EnableMetadataPassthrough
,
DefaultSubscriptions
:
defaultSubscriptions
,
EnableCCHSigning
:
settings
.
EnableCCHSigning
,
EnableModelFallback
:
settings
.
EnableModelFallback
,
WebSearchEmulationEnabled
:
settings
.
WebSearchEmulationEnabled
,
FallbackModelAnthropic
:
settings
.
FallbackModelAnthropic
,
BalanceLowNotifyEnabled
:
settings
.
BalanceLowNotifyEnabled
,
FallbackModelOpenAI
:
settings
.
FallbackModelOpenAI
,
BalanceLowNotifyThreshold
:
settings
.
BalanceLowNotifyThreshold
,
FallbackModelGemini
:
settings
.
FallbackModelGemini
,
BalanceLowNotifyRechargeURL
:
settings
.
BalanceLowNotifyRechargeURL
,
FallbackModelAntigravity
:
settings
.
FallbackModelAntigravity
,
AccountQuotaNotifyEnabled
:
settings
.
AccountQuotaNotifyEnabled
,
EnableIdentityPatch
:
settings
.
EnableIdentityPatch
,
AccountQuotaNotifyEmails
:
dto
.
NotifyEmailEntriesFromService
(
settings
.
AccountQuotaNotifyEmails
),
IdentityPatchPrompt
:
settings
.
IdentityPatchPrompt
,
PaymentEnabled
:
paymentCfg
.
Enabled
,
OpsMonitoringEnabled
:
opsEnabled
&&
settings
.
OpsMonitoringEnabled
,
PaymentMinAmount
:
paymentCfg
.
MinAmount
,
OpsRealtimeMonitoringEnabled
:
settings
.
OpsRealtimeMonitoringEnabled
,
PaymentMaxAmount
:
paymentCfg
.
MaxAmount
,
OpsQueryModeDefault
:
settings
.
OpsQueryModeDefault
,
PaymentDailyLimit
:
paymentCfg
.
DailyLimit
,
OpsMetricsIntervalSeconds
:
settings
.
OpsMetricsIntervalSeconds
,
PaymentOrderTimeoutMin
:
paymentCfg
.
OrderTimeoutMin
,
MinClaudeCodeVersion
:
settings
.
MinClaudeCodeVersion
,
PaymentMaxPendingOrders
:
paymentCfg
.
MaxPendingOrders
,
MaxClaudeCodeVersion
:
settings
.
MaxClaudeCodeVersion
,
PaymentEnabledTypes
:
paymentCfg
.
EnabledTypes
,
AllowUngroupedKeyScheduling
:
settings
.
AllowUngroupedKeyScheduling
,
PaymentBalanceDisabled
:
paymentCfg
.
BalanceDisabled
,
BackendModeEnabled
:
settings
.
BackendModeEnabled
,
PaymentBalanceRechargeMultiplier
:
paymentCfg
.
BalanceRechargeMultiplier
,
EnableFingerprintUnification
:
settings
.
EnableFingerprintUnification
,
PaymentRechargeFeeRate
:
paymentCfg
.
RechargeFeeRate
,
EnableMetadataPassthrough
:
settings
.
EnableMetadataPassthrough
,
PaymentLoadBalanceStrat
:
paymentCfg
.
LoadBalanceStrategy
,
EnableCCHSigning
:
settings
.
EnableCCHSigning
,
PaymentProductNamePrefix
:
paymentCfg
.
ProductNamePrefix
,
WebSearchEmulationEnabled
:
settings
.
WebSearchEmulationEnabled
,
PaymentProductNameSuffix
:
paymentCfg
.
ProductNameSuffix
,
PaymentVisibleMethodAlipaySource
:
settings
.
PaymentVisibleMethodAlipaySource
,
PaymentHelpImageURL
:
paymentCfg
.
HelpImageURL
,
PaymentVisibleMethodWxpaySource
:
settings
.
PaymentVisibleMethodWxpaySource
,
PaymentHelpText
:
paymentCfg
.
HelpText
,
PaymentVisibleMethodAlipayEnabled
:
settings
.
PaymentVisibleMethodAlipayEnabled
,
PaymentCancelRateLimitEnabled
:
paymentCfg
.
CancelRateLimitEnabled
,
PaymentVisibleMethodWxpayEnabled
:
settings
.
PaymentVisibleMethodWxpayEnabled
,
PaymentCancelRateLimitMax
:
paymentCfg
.
CancelRateLimitMax
,
OpenAIAdvancedSchedulerEnabled
:
settings
.
OpenAIAdvancedSchedulerEnabled
,
PaymentCancelRateLimitWindow
:
paymentCfg
.
CancelRateLimitWindow
,
BalanceLowNotifyEnabled
:
settings
.
BalanceLowNotifyEnabled
,
PaymentCancelRateLimitUnit
:
paymentCfg
.
CancelRateLimitUnit
,
BalanceLowNotifyThreshold
:
settings
.
BalanceLowNotifyThreshold
,
PaymentCancelRateLimitMode
:
paymentCfg
.
CancelRateLimitMode
,
BalanceLowNotifyRechargeURL
:
settings
.
BalanceLowNotifyRechargeURL
,
})
AccountQuotaNotifyEnabled
:
settings
.
AccountQuotaNotifyEnabled
,
AccountQuotaNotifyEmails
:
dto
.
NotifyEmailEntriesFromService
(
settings
.
AccountQuotaNotifyEmails
),
PaymentEnabled
:
paymentCfg
.
Enabled
,
PaymentMinAmount
:
paymentCfg
.
MinAmount
,
PaymentMaxAmount
:
paymentCfg
.
MaxAmount
,
PaymentDailyLimit
:
paymentCfg
.
DailyLimit
,
PaymentOrderTimeoutMin
:
paymentCfg
.
OrderTimeoutMin
,
PaymentMaxPendingOrders
:
paymentCfg
.
MaxPendingOrders
,
PaymentEnabledTypes
:
paymentCfg
.
EnabledTypes
,
PaymentBalanceDisabled
:
paymentCfg
.
BalanceDisabled
,
PaymentBalanceRechargeMultiplier
:
paymentCfg
.
BalanceRechargeMultiplier
,
PaymentRechargeFeeRate
:
paymentCfg
.
RechargeFeeRate
,
PaymentLoadBalanceStrat
:
paymentCfg
.
LoadBalanceStrategy
,
PaymentProductNamePrefix
:
paymentCfg
.
ProductNamePrefix
,
PaymentProductNameSuffix
:
paymentCfg
.
ProductNameSuffix
,
PaymentHelpImageURL
:
paymentCfg
.
HelpImageURL
,
PaymentHelpText
:
paymentCfg
.
HelpText
,
PaymentCancelRateLimitEnabled
:
paymentCfg
.
CancelRateLimitEnabled
,
PaymentCancelRateLimitMax
:
paymentCfg
.
CancelRateLimitMax
,
PaymentCancelRateLimitWindow
:
paymentCfg
.
CancelRateLimitWindow
,
PaymentCancelRateLimitUnit
:
paymentCfg
.
CancelRateLimitUnit
,
PaymentCancelRateLimitMode
:
paymentCfg
.
CancelRateLimitMode
,
ChannelMonitorEnabled
:
settings
.
ChannelMonitorEnabled
,
ChannelMonitorDefaultIntervalSeconds
:
settings
.
ChannelMonitorDefaultIntervalSeconds
,
AvailableChannelsEnabled
:
settings
.
AvailableChannelsEnabled
,
}
response
.
Success
(
c
,
systemSettingsResponseData
(
payload
,
authSourceDefaults
))
}
}
// UpdateSettingsRequest 更新设置请求
// UpdateSettingsRequest 更新设置请求
...
@@ -235,6 +277,24 @@ type UpdateSettingsRequest struct {
...
@@ -235,6 +277,24 @@ type UpdateSettingsRequest struct {
LinuxDoConnectClientSecret
string
`json:"linuxdo_connect_client_secret"`
LinuxDoConnectClientSecret
string
`json:"linuxdo_connect_client_secret"`
LinuxDoConnectRedirectURL
string
`json:"linuxdo_connect_redirect_url"`
LinuxDoConnectRedirectURL
string
`json:"linuxdo_connect_redirect_url"`
// WeChat Connect OAuth 登录
WeChatConnectEnabled
bool
`json:"wechat_connect_enabled"`
WeChatConnectAppID
string
`json:"wechat_connect_app_id"`
WeChatConnectAppSecret
string
`json:"wechat_connect_app_secret"`
WeChatConnectOpenAppID
string
`json:"wechat_connect_open_app_id"`
WeChatConnectOpenAppSecret
string
`json:"wechat_connect_open_app_secret"`
WeChatConnectMPAppID
string
`json:"wechat_connect_mp_app_id"`
WeChatConnectMPAppSecret
string
`json:"wechat_connect_mp_app_secret"`
WeChatConnectMobileAppID
string
`json:"wechat_connect_mobile_app_id"`
WeChatConnectMobileAppSecret
string
`json:"wechat_connect_mobile_app_secret"`
WeChatConnectOpenEnabled
bool
`json:"wechat_connect_open_enabled"`
WeChatConnectMPEnabled
bool
`json:"wechat_connect_mp_enabled"`
WeChatConnectMobileEnabled
bool
`json:"wechat_connect_mobile_enabled"`
WeChatConnectMode
string
`json:"wechat_connect_mode"`
WeChatConnectScopes
string
`json:"wechat_connect_scopes"`
WeChatConnectRedirectURL
string
`json:"wechat_connect_redirect_url"`
WeChatConnectFrontendRedirectURL
string
`json:"wechat_connect_frontend_redirect_url"`
// Generic OIDC OAuth 登录
// Generic OIDC OAuth 登录
OIDCConnectEnabled
bool
`json:"oidc_connect_enabled"`
OIDCConnectEnabled
bool
`json:"oidc_connect_enabled"`
OIDCConnectProviderName
string
`json:"oidc_connect_provider_name"`
OIDCConnectProviderName
string
`json:"oidc_connect_provider_name"`
...
@@ -250,8 +310,8 @@ type UpdateSettingsRequest struct {
...
@@ -250,8 +310,8 @@ type UpdateSettingsRequest struct {
OIDCConnectRedirectURL
string
`json:"oidc_connect_redirect_url"`
OIDCConnectRedirectURL
string
`json:"oidc_connect_redirect_url"`
OIDCConnectFrontendRedirectURL
string
`json:"oidc_connect_frontend_redirect_url"`
OIDCConnectFrontendRedirectURL
string
`json:"oidc_connect_frontend_redirect_url"`
OIDCConnectTokenAuthMethod
string
`json:"oidc_connect_token_auth_method"`
OIDCConnectTokenAuthMethod
string
`json:"oidc_connect_token_auth_method"`
OIDCConnectUsePKCE
bool
`json:"oidc_connect_use_pkce"`
OIDCConnectUsePKCE
*
bool
`json:"oidc_connect_use_pkce"`
OIDCConnectValidateIDToken
bool
`json:"oidc_connect_validate_id_token"`
OIDCConnectValidateIDToken
*
bool
`json:"oidc_connect_validate_id_token"`
OIDCConnectAllowedSigningAlgs
string
`json:"oidc_connect_allowed_signing_algs"`
OIDCConnectAllowedSigningAlgs
string
`json:"oidc_connect_allowed_signing_algs"`
OIDCConnectClockSkewSeconds
int
`json:"oidc_connect_clock_skew_seconds"`
OIDCConnectClockSkewSeconds
int
`json:"oidc_connect_clock_skew_seconds"`
OIDCConnectRequireEmailVerified
bool
`json:"oidc_connect_require_email_verified"`
OIDCConnectRequireEmailVerified
bool
`json:"oidc_connect_require_email_verified"`
...
@@ -276,9 +336,31 @@ type UpdateSettingsRequest struct {
...
@@ -276,9 +336,31 @@ type UpdateSettingsRequest struct {
CustomEndpoints
*
[]
dto
.
CustomEndpoint
`json:"custom_endpoints"`
CustomEndpoints
*
[]
dto
.
CustomEndpoint
`json:"custom_endpoints"`
// 默认配置
// 默认配置
DefaultConcurrency
int
`json:"default_concurrency"`
DefaultConcurrency
int
`json:"default_concurrency"`
DefaultBalance
float64
`json:"default_balance"`
DefaultBalance
float64
`json:"default_balance"`
DefaultSubscriptions
[]
dto
.
DefaultSubscriptionSetting
`json:"default_subscriptions"`
DefaultUserRPMLimit
int
`json:"default_user_rpm_limit"`
DefaultSubscriptions
[]
dto
.
DefaultSubscriptionSetting
`json:"default_subscriptions"`
AuthSourceDefaultEmailBalance
*
float64
`json:"auth_source_default_email_balance"`
AuthSourceDefaultEmailConcurrency
*
int
`json:"auth_source_default_email_concurrency"`
AuthSourceDefaultEmailSubscriptions
*
[]
dto
.
DefaultSubscriptionSetting
`json:"auth_source_default_email_subscriptions"`
AuthSourceDefaultEmailGrantOnSignup
*
bool
`json:"auth_source_default_email_grant_on_signup"`
AuthSourceDefaultEmailGrantOnFirstBind
*
bool
`json:"auth_source_default_email_grant_on_first_bind"`
AuthSourceDefaultLinuxDoBalance
*
float64
`json:"auth_source_default_linuxdo_balance"`
AuthSourceDefaultLinuxDoConcurrency
*
int
`json:"auth_source_default_linuxdo_concurrency"`
AuthSourceDefaultLinuxDoSubscriptions
*
[]
dto
.
DefaultSubscriptionSetting
`json:"auth_source_default_linuxdo_subscriptions"`
AuthSourceDefaultLinuxDoGrantOnSignup
*
bool
`json:"auth_source_default_linuxdo_grant_on_signup"`
AuthSourceDefaultLinuxDoGrantOnFirstBind
*
bool
`json:"auth_source_default_linuxdo_grant_on_first_bind"`
AuthSourceDefaultOIDCBalance
*
float64
`json:"auth_source_default_oidc_balance"`
AuthSourceDefaultOIDCConcurrency
*
int
`json:"auth_source_default_oidc_concurrency"`
AuthSourceDefaultOIDCSubscriptions
*
[]
dto
.
DefaultSubscriptionSetting
`json:"auth_source_default_oidc_subscriptions"`
AuthSourceDefaultOIDCGrantOnSignup
*
bool
`json:"auth_source_default_oidc_grant_on_signup"`
AuthSourceDefaultOIDCGrantOnFirstBind
*
bool
`json:"auth_source_default_oidc_grant_on_first_bind"`
AuthSourceDefaultWeChatBalance
*
float64
`json:"auth_source_default_wechat_balance"`
AuthSourceDefaultWeChatConcurrency
*
int
`json:"auth_source_default_wechat_concurrency"`
AuthSourceDefaultWeChatSubscriptions
*
[]
dto
.
DefaultSubscriptionSetting
`json:"auth_source_default_wechat_subscriptions"`
AuthSourceDefaultWeChatGrantOnSignup
*
bool
`json:"auth_source_default_wechat_grant_on_signup"`
AuthSourceDefaultWeChatGrantOnFirstBind
*
bool
`json:"auth_source_default_wechat_grant_on_first_bind"`
ForceEmailOnThirdPartySignup
*
bool
`json:"force_email_on_third_party_signup"`
// Model fallback configuration
// Model fallback configuration
EnableModelFallback
bool
`json:"enable_model_fallback"`
EnableModelFallback
bool
`json:"enable_model_fallback"`
...
@@ -311,6 +393,15 @@ type UpdateSettingsRequest struct {
...
@@ -311,6 +393,15 @@ type UpdateSettingsRequest struct {
EnableMetadataPassthrough
*
bool
`json:"enable_metadata_passthrough"`
EnableMetadataPassthrough
*
bool
`json:"enable_metadata_passthrough"`
EnableCCHSigning
*
bool
`json:"enable_cch_signing"`
EnableCCHSigning
*
bool
`json:"enable_cch_signing"`
// Payment visible method routing
PaymentVisibleMethodAlipaySource
*
string
`json:"payment_visible_method_alipay_source"`
PaymentVisibleMethodWxpaySource
*
string
`json:"payment_visible_method_wxpay_source"`
PaymentVisibleMethodAlipayEnabled
*
bool
`json:"payment_visible_method_alipay_enabled"`
PaymentVisibleMethodWxpayEnabled
*
bool
`json:"payment_visible_method_wxpay_enabled"`
// OpenAI account scheduling
OpenAIAdvancedSchedulerEnabled
*
bool
`json:"openai_advanced_scheduler_enabled"`
// Balance low notification
// Balance low notification
BalanceLowNotifyEnabled
*
bool
`json:"balance_low_notify_enabled"`
BalanceLowNotifyEnabled
*
bool
`json:"balance_low_notify_enabled"`
BalanceLowNotifyThreshold
*
float64
`json:"balance_low_notify_threshold"`
BalanceLowNotifyThreshold
*
float64
`json:"balance_low_notify_threshold"`
...
@@ -341,6 +432,13 @@ type UpdateSettingsRequest struct {
...
@@ -341,6 +432,13 @@ type UpdateSettingsRequest struct {
PaymentCancelRateLimitWindow
*
int
`json:"payment_cancel_rate_limit_window"`
PaymentCancelRateLimitWindow
*
int
`json:"payment_cancel_rate_limit_window"`
PaymentCancelRateLimitUnit
*
string
`json:"payment_cancel_rate_limit_unit"`
PaymentCancelRateLimitUnit
*
string
`json:"payment_cancel_rate_limit_unit"`
PaymentCancelRateLimitMode
*
string
`json:"payment_cancel_rate_limit_window_mode"`
PaymentCancelRateLimitMode
*
string
`json:"payment_cancel_rate_limit_window_mode"`
// Channel Monitor feature switch
ChannelMonitorEnabled
*
bool
`json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds
*
int
`json:"channel_monitor_default_interval_seconds"`
// Available Channels feature switch (user-facing)
AvailableChannelsEnabled
*
bool
`json:"available_channels_enabled"`
}
}
// UpdateSettings 更新系统设置
// UpdateSettings 更新系统设置
...
@@ -357,6 +455,11 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
...
@@ -357,6 +455,11 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response
.
ErrorFrom
(
c
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
return
}
}
previousAuthSourceDefaults
,
err
:=
h
.
settingService
.
GetAuthSourceDefaultSettings
(
c
.
Request
.
Context
())
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
// 验证参数
// 验证参数
if
req
.
DefaultConcurrency
<
1
{
if
req
.
DefaultConcurrency
<
1
{
...
@@ -381,6 +484,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
...
@@ -381,6 +484,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
req
.
SMTPPort
=
587
req
.
SMTPPort
=
587
}
}
req
.
DefaultSubscriptions
=
normalizeDefaultSubscriptions
(
req
.
DefaultSubscriptions
)
req
.
DefaultSubscriptions
=
normalizeDefaultSubscriptions
(
req
.
DefaultSubscriptions
)
req
.
AuthSourceDefaultEmailSubscriptions
=
normalizeOptionalDefaultSubscriptions
(
req
.
AuthSourceDefaultEmailSubscriptions
)
req
.
AuthSourceDefaultLinuxDoSubscriptions
=
normalizeOptionalDefaultSubscriptions
(
req
.
AuthSourceDefaultLinuxDoSubscriptions
)
req
.
AuthSourceDefaultOIDCSubscriptions
=
normalizeOptionalDefaultSubscriptions
(
req
.
AuthSourceDefaultOIDCSubscriptions
)
req
.
AuthSourceDefaultWeChatSubscriptions
=
normalizeOptionalDefaultSubscriptions
(
req
.
AuthSourceDefaultWeChatSubscriptions
)
// SMTP 配置保护:如果请求中 smtp_host 为空但数据库中已有配置,则保留已有 SMTP 配置
// SMTP 配置保护:如果请求中 smtp_host 为空但数据库中已有配置,则保留已有 SMTP 配置
// 防止前端加载设置失败时空表单覆盖已保存的 SMTP 配置
// 防止前端加载设置失败时空表单覆盖已保存的 SMTP 配置
...
@@ -459,7 +566,141 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
...
@@ -459,7 +566,141 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
}
}
}
if
req
.
WeChatConnectEnabled
{
req
.
WeChatConnectAppID
=
strings
.
TrimSpace
(
req
.
WeChatConnectAppID
)
req
.
WeChatConnectAppSecret
=
strings
.
TrimSpace
(
req
.
WeChatConnectAppSecret
)
req
.
WeChatConnectOpenAppID
=
strings
.
TrimSpace
(
req
.
WeChatConnectOpenAppID
)
req
.
WeChatConnectOpenAppSecret
=
strings
.
TrimSpace
(
req
.
WeChatConnectOpenAppSecret
)
req
.
WeChatConnectMPAppID
=
strings
.
TrimSpace
(
req
.
WeChatConnectMPAppID
)
req
.
WeChatConnectMPAppSecret
=
strings
.
TrimSpace
(
req
.
WeChatConnectMPAppSecret
)
req
.
WeChatConnectMobileAppID
=
strings
.
TrimSpace
(
req
.
WeChatConnectMobileAppID
)
req
.
WeChatConnectMobileAppSecret
=
strings
.
TrimSpace
(
req
.
WeChatConnectMobileAppSecret
)
req
.
WeChatConnectMode
=
strings
.
ToLower
(
strings
.
TrimSpace
(
req
.
WeChatConnectMode
))
req
.
WeChatConnectScopes
=
strings
.
TrimSpace
(
req
.
WeChatConnectScopes
)
req
.
WeChatConnectRedirectURL
=
strings
.
TrimSpace
(
req
.
WeChatConnectRedirectURL
)
req
.
WeChatConnectFrontendRedirectURL
=
strings
.
TrimSpace
(
req
.
WeChatConnectFrontendRedirectURL
)
req
.
WeChatConnectAppID
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
WeChatConnectAppID
,
previousSettings
.
WeChatConnectAppID
))
req
.
WeChatConnectRedirectURL
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
WeChatConnectRedirectURL
,
previousSettings
.
WeChatConnectRedirectURL
))
req
.
WeChatConnectFrontendRedirectURL
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
WeChatConnectFrontendRedirectURL
,
previousSettings
.
WeChatConnectFrontendRedirectURL
))
if
req
.
WeChatConnectMode
==
""
{
req
.
WeChatConnectMode
=
strings
.
ToLower
(
strings
.
TrimSpace
(
previousSettings
.
WeChatConnectMode
))
}
if
req
.
WeChatConnectScopes
==
""
{
req
.
WeChatConnectScopes
=
strings
.
TrimSpace
(
previousSettings
.
WeChatConnectScopes
)
}
if
req
.
WeChatConnectMPEnabled
&&
req
.
WeChatConnectMobileEnabled
{
response
.
BadRequest
(
c
,
"WeChat Official Account and Mobile App cannot be enabled at the same time"
)
return
}
if
req
.
WeChatConnectMode
!=
""
{
switch
req
.
WeChatConnectMode
{
case
"open"
,
"mp"
,
"mobile"
:
default
:
response
.
BadRequest
(
c
,
"WeChat mode must be open, mp, or mobile"
)
return
}
}
if
!
req
.
WeChatConnectOpenEnabled
&&
!
req
.
WeChatConnectMPEnabled
&&
!
req
.
WeChatConnectMobileEnabled
{
switch
req
.
WeChatConnectMode
{
case
"mp"
:
req
.
WeChatConnectMPEnabled
=
true
case
"mobile"
:
req
.
WeChatConnectMobileEnabled
=
true
default
:
req
.
WeChatConnectOpenEnabled
=
true
}
}
if
req
.
WeChatConnectMode
==
""
{
if
req
.
WeChatConnectMPEnabled
{
req
.
WeChatConnectMode
=
"mp"
}
else
if
req
.
WeChatConnectMobileEnabled
{
req
.
WeChatConnectMode
=
"mobile"
}
else
{
req
.
WeChatConnectMode
=
"open"
}
}
req
.
WeChatConnectOpenAppID
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
WeChatConnectOpenAppID
,
req
.
WeChatConnectAppID
,
previousSettings
.
WeChatConnectOpenAppID
,
previousSettings
.
WeChatConnectAppID
))
req
.
WeChatConnectMPAppID
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
WeChatConnectMPAppID
,
req
.
WeChatConnectAppID
,
previousSettings
.
WeChatConnectMPAppID
,
previousSettings
.
WeChatConnectAppID
))
req
.
WeChatConnectMobileAppID
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
WeChatConnectMobileAppID
,
req
.
WeChatConnectAppID
,
previousSettings
.
WeChatConnectMobileAppID
,
previousSettings
.
WeChatConnectAppID
))
if
req
.
WeChatConnectOpenAppSecret
==
""
{
req
.
WeChatConnectOpenAppSecret
=
strings
.
TrimSpace
(
firstNonEmpty
(
previousSettings
.
WeChatConnectOpenAppSecret
,
previousSettings
.
WeChatConnectAppSecret
,
req
.
WeChatConnectAppSecret
))
}
if
req
.
WeChatConnectMPAppSecret
==
""
{
req
.
WeChatConnectMPAppSecret
=
strings
.
TrimSpace
(
firstNonEmpty
(
previousSettings
.
WeChatConnectMPAppSecret
,
previousSettings
.
WeChatConnectAppSecret
,
req
.
WeChatConnectAppSecret
))
}
if
req
.
WeChatConnectMobileAppSecret
==
""
{
req
.
WeChatConnectMobileAppSecret
=
strings
.
TrimSpace
(
firstNonEmpty
(
previousSettings
.
WeChatConnectMobileAppSecret
,
previousSettings
.
WeChatConnectAppSecret
,
req
.
WeChatConnectAppSecret
))
}
if
req
.
WeChatConnectAppSecret
==
""
{
req
.
WeChatConnectAppSecret
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
WeChatConnectOpenAppSecret
,
req
.
WeChatConnectMPAppSecret
,
req
.
WeChatConnectMobileAppSecret
,
previousSettings
.
WeChatConnectAppSecret
))
}
if
req
.
WeChatConnectOpenEnabled
{
if
req
.
WeChatConnectOpenAppID
==
""
{
response
.
BadRequest
(
c
,
"WeChat PC App ID is required when enabled"
)
return
}
if
req
.
WeChatConnectOpenAppSecret
==
""
{
response
.
BadRequest
(
c
,
"WeChat PC App Secret is required when enabled"
)
return
}
}
if
req
.
WeChatConnectMPEnabled
{
if
req
.
WeChatConnectMPAppID
==
""
{
response
.
BadRequest
(
c
,
"WeChat Official Account App ID is required when enabled"
)
return
}
if
req
.
WeChatConnectMPAppSecret
==
""
{
response
.
BadRequest
(
c
,
"WeChat Official Account App Secret is required when enabled"
)
return
}
}
if
req
.
WeChatConnectMobileEnabled
{
if
req
.
WeChatConnectMobileAppID
==
""
{
response
.
BadRequest
(
c
,
"WeChat Mobile App ID is required when enabled"
)
return
}
if
req
.
WeChatConnectMobileAppSecret
==
""
{
response
.
BadRequest
(
c
,
"WeChat Mobile App Secret is required when enabled"
)
return
}
}
if
req
.
WeChatConnectScopes
==
""
{
if
req
.
WeChatConnectMPEnabled
{
req
.
WeChatConnectScopes
=
service
.
DefaultWeChatConnectScopesForMode
(
"mp"
)
}
else
{
req
.
WeChatConnectScopes
=
service
.
DefaultWeChatConnectScopesForMode
(
req
.
WeChatConnectMode
)
}
}
if
req
.
WeChatConnectOpenEnabled
||
req
.
WeChatConnectMPEnabled
{
if
req
.
WeChatConnectRedirectURL
==
""
{
response
.
BadRequest
(
c
,
"WeChat Redirect URL is required when web oauth is enabled"
)
return
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
req
.
WeChatConnectRedirectURL
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"WeChat Redirect URL must be an absolute http(s) URL"
)
return
}
if
req
.
WeChatConnectFrontendRedirectURL
==
""
{
req
.
WeChatConnectFrontendRedirectURL
=
"/auth/wechat/callback"
}
if
err
:=
config
.
ValidateFrontendRedirectURL
(
req
.
WeChatConnectFrontendRedirectURL
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"WeChat Frontend Redirect URL is invalid"
)
return
}
}
}
// Generic OIDC 参数验证
// Generic OIDC 参数验证
oidcUsePKCE
,
oidcValidateIDToken
,
err
:=
h
.
settingService
.
OIDCSecurityWriteDefaults
(
c
.
Request
.
Context
())
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
if
req
.
OIDCConnectEnabled
{
if
req
.
OIDCConnectEnabled
{
req
.
OIDCConnectProviderName
=
strings
.
TrimSpace
(
req
.
OIDCConnectProviderName
)
req
.
OIDCConnectProviderName
=
strings
.
TrimSpace
(
req
.
OIDCConnectProviderName
)
req
.
OIDCConnectClientID
=
strings
.
TrimSpace
(
req
.
OIDCConnectClientID
)
req
.
OIDCConnectClientID
=
strings
.
TrimSpace
(
req
.
OIDCConnectClientID
)
...
@@ -478,10 +719,35 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
...
@@ -478,10 +719,35 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
req
.
OIDCConnectUserInfoEmailPath
=
strings
.
TrimSpace
(
req
.
OIDCConnectUserInfoEmailPath
)
req
.
OIDCConnectUserInfoEmailPath
=
strings
.
TrimSpace
(
req
.
OIDCConnectUserInfoEmailPath
)
req
.
OIDCConnectUserInfoIDPath
=
strings
.
TrimSpace
(
req
.
OIDCConnectUserInfoIDPath
)
req
.
OIDCConnectUserInfoIDPath
=
strings
.
TrimSpace
(
req
.
OIDCConnectUserInfoIDPath
)
req
.
OIDCConnectUserInfoUsernamePath
=
strings
.
TrimSpace
(
req
.
OIDCConnectUserInfoUsernamePath
)
req
.
OIDCConnectUserInfoUsernamePath
=
strings
.
TrimSpace
(
req
.
OIDCConnectUserInfoUsernamePath
)
req
.
OIDCConnectProviderName
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
OIDCConnectProviderName
,
previousSettings
.
OIDCConnectProviderName
,
"OIDC"
))
if
req
.
OIDCConnectProviderName
==
""
{
req
.
OIDCConnectClientID
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
OIDCConnectClientID
,
previousSettings
.
OIDCConnectClientID
))
req
.
OIDCConnectProviderName
=
"OIDC"
req
.
OIDCConnectIssuerURL
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
OIDCConnectIssuerURL
,
previousSettings
.
OIDCConnectIssuerURL
))
req
.
OIDCConnectDiscoveryURL
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
OIDCConnectDiscoveryURL
,
previousSettings
.
OIDCConnectDiscoveryURL
))
req
.
OIDCConnectAuthorizeURL
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
OIDCConnectAuthorizeURL
,
previousSettings
.
OIDCConnectAuthorizeURL
))
req
.
OIDCConnectTokenURL
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
OIDCConnectTokenURL
,
previousSettings
.
OIDCConnectTokenURL
))
req
.
OIDCConnectUserInfoURL
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
OIDCConnectUserInfoURL
,
previousSettings
.
OIDCConnectUserInfoURL
))
req
.
OIDCConnectJWKSURL
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
OIDCConnectJWKSURL
,
previousSettings
.
OIDCConnectJWKSURL
))
req
.
OIDCConnectScopes
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
OIDCConnectScopes
,
previousSettings
.
OIDCConnectScopes
,
"openid email profile"
))
req
.
OIDCConnectRedirectURL
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
OIDCConnectRedirectURL
,
previousSettings
.
OIDCConnectRedirectURL
))
req
.
OIDCConnectFrontendRedirectURL
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
OIDCConnectFrontendRedirectURL
,
previousSettings
.
OIDCConnectFrontendRedirectURL
,
"/auth/oidc/callback"
))
req
.
OIDCConnectTokenAuthMethod
=
strings
.
ToLower
(
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
OIDCConnectTokenAuthMethod
,
previousSettings
.
OIDCConnectTokenAuthMethod
,
"client_secret_post"
)))
req
.
OIDCConnectAllowedSigningAlgs
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
OIDCConnectAllowedSigningAlgs
,
previousSettings
.
OIDCConnectAllowedSigningAlgs
,
"RS256,ES256,PS256"
))
req
.
OIDCConnectUserInfoEmailPath
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
OIDCConnectUserInfoEmailPath
,
previousSettings
.
OIDCConnectUserInfoEmailPath
))
req
.
OIDCConnectUserInfoIDPath
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
OIDCConnectUserInfoIDPath
,
previousSettings
.
OIDCConnectUserInfoIDPath
))
req
.
OIDCConnectUserInfoUsernamePath
=
strings
.
TrimSpace
(
firstNonEmpty
(
req
.
OIDCConnectUserInfoUsernamePath
,
previousSettings
.
OIDCConnectUserInfoUsernamePath
))
if
req
.
OIDCConnectUsePKCE
!=
nil
{
oidcUsePKCE
=
*
req
.
OIDCConnectUsePKCE
}
}
if
req
.
OIDCConnectValidateIDToken
!=
nil
{
oidcValidateIDToken
=
*
req
.
OIDCConnectValidateIDToken
}
if
req
.
OIDCConnectClockSkewSeconds
==
0
{
req
.
OIDCConnectClockSkewSeconds
=
previousSettings
.
OIDCConnectClockSkewSeconds
if
req
.
OIDCConnectClockSkewSeconds
==
0
{
req
.
OIDCConnectClockSkewSeconds
=
120
}
}
if
req
.
OIDCConnectClientID
==
""
{
if
req
.
OIDCConnectClientID
==
""
{
response
.
BadRequest
(
c
,
"OIDC Client ID is required when enabled"
)
response
.
BadRequest
(
c
,
"OIDC Client ID is required when enabled"
)
return
return
...
@@ -544,19 +810,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
...
@@ -544,19 +810,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response
.
BadRequest
(
c
,
"OIDC Token Auth Method must be one of client_secret_post/client_secret_basic/none"
)
response
.
BadRequest
(
c
,
"OIDC Token Auth Method must be one of client_secret_post/client_secret_basic/none"
)
return
return
}
}
if
req
.
OIDCConnectTokenAuthMethod
==
"none"
&&
!
req
.
OIDCConnectUsePKCE
{
response
.
BadRequest
(
c
,
"OIDC PKCE must be enabled when token_auth_method=none"
)
return
}
if
req
.
OIDCConnectClockSkewSeconds
<
0
||
req
.
OIDCConnectClockSkewSeconds
>
600
{
if
req
.
OIDCConnectClockSkewSeconds
<
0
||
req
.
OIDCConnectClockSkewSeconds
>
600
{
response
.
BadRequest
(
c
,
"OIDC clock skew seconds must be between 0 and 600"
)
response
.
BadRequest
(
c
,
"OIDC clock skew seconds must be between 0 and 600"
)
return
return
}
}
if
req
.
OIDCConnectValidateIDToken
{
if
oidcValidateIDToken
&&
req
.
OIDCConnectAllowedSigningAlgs
==
""
{
if
req
.
OIDCConnectAllowedSigningAlgs
==
""
{
response
.
BadRequest
(
c
,
"OIDC Allowed Signing Algs is required when validate_id_token=true"
)
response
.
BadRequest
(
c
,
"OIDC Allowed Signing Algs is required when validate_id_token=true"
)
return
return
}
}
}
if
req
.
OIDCConnectJWKSURL
!=
""
{
if
req
.
OIDCConnectJWKSURL
!=
""
{
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
req
.
OIDCConnectJWKSURL
);
err
!=
nil
{
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
req
.
OIDCConnectJWKSURL
);
err
!=
nil
{
...
@@ -805,6 +1065,22 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
...
@@ -805,6 +1065,22 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
LinuxDoConnectClientID
:
req
.
LinuxDoConnectClientID
,
LinuxDoConnectClientID
:
req
.
LinuxDoConnectClientID
,
LinuxDoConnectClientSecret
:
req
.
LinuxDoConnectClientSecret
,
LinuxDoConnectClientSecret
:
req
.
LinuxDoConnectClientSecret
,
LinuxDoConnectRedirectURL
:
req
.
LinuxDoConnectRedirectURL
,
LinuxDoConnectRedirectURL
:
req
.
LinuxDoConnectRedirectURL
,
WeChatConnectEnabled
:
req
.
WeChatConnectEnabled
,
WeChatConnectAppID
:
req
.
WeChatConnectAppID
,
WeChatConnectAppSecret
:
req
.
WeChatConnectAppSecret
,
WeChatConnectOpenAppID
:
req
.
WeChatConnectOpenAppID
,
WeChatConnectOpenAppSecret
:
req
.
WeChatConnectOpenAppSecret
,
WeChatConnectMPAppID
:
req
.
WeChatConnectMPAppID
,
WeChatConnectMPAppSecret
:
req
.
WeChatConnectMPAppSecret
,
WeChatConnectMobileAppID
:
req
.
WeChatConnectMobileAppID
,
WeChatConnectMobileAppSecret
:
req
.
WeChatConnectMobileAppSecret
,
WeChatConnectOpenEnabled
:
req
.
WeChatConnectOpenEnabled
,
WeChatConnectMPEnabled
:
req
.
WeChatConnectMPEnabled
,
WeChatConnectMobileEnabled
:
req
.
WeChatConnectMobileEnabled
,
WeChatConnectMode
:
req
.
WeChatConnectMode
,
WeChatConnectScopes
:
req
.
WeChatConnectScopes
,
WeChatConnectRedirectURL
:
req
.
WeChatConnectRedirectURL
,
WeChatConnectFrontendRedirectURL
:
req
.
WeChatConnectFrontendRedirectURL
,
OIDCConnectEnabled
:
req
.
OIDCConnectEnabled
,
OIDCConnectEnabled
:
req
.
OIDCConnectEnabled
,
OIDCConnectProviderName
:
req
.
OIDCConnectProviderName
,
OIDCConnectProviderName
:
req
.
OIDCConnectProviderName
,
OIDCConnectClientID
:
req
.
OIDCConnectClientID
,
OIDCConnectClientID
:
req
.
OIDCConnectClientID
,
...
@@ -819,8 +1095,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
...
@@ -819,8 +1095,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
OIDCConnectRedirectURL
:
req
.
OIDCConnectRedirectURL
,
OIDCConnectRedirectURL
:
req
.
OIDCConnectRedirectURL
,
OIDCConnectFrontendRedirectURL
:
req
.
OIDCConnectFrontendRedirectURL
,
OIDCConnectFrontendRedirectURL
:
req
.
OIDCConnectFrontendRedirectURL
,
OIDCConnectTokenAuthMethod
:
req
.
OIDCConnectTokenAuthMethod
,
OIDCConnectTokenAuthMethod
:
req
.
OIDCConnectTokenAuthMethod
,
OIDCConnectUsePKCE
:
req
.
OIDCConnect
UsePKCE
,
OIDCConnectUsePKCE
:
oidc
UsePKCE
,
OIDCConnectValidateIDToken
:
req
.
OIDCConnect
ValidateIDToken
,
OIDCConnectValidateIDToken
:
oidc
ValidateIDToken
,
OIDCConnectAllowedSigningAlgs
:
req
.
OIDCConnectAllowedSigningAlgs
,
OIDCConnectAllowedSigningAlgs
:
req
.
OIDCConnectAllowedSigningAlgs
,
OIDCConnectClockSkewSeconds
:
req
.
OIDCConnectClockSkewSeconds
,
OIDCConnectClockSkewSeconds
:
req
.
OIDCConnectClockSkewSeconds
,
OIDCConnectRequireEmailVerified
:
req
.
OIDCConnectRequireEmailVerified
,
OIDCConnectRequireEmailVerified
:
req
.
OIDCConnectRequireEmailVerified
,
...
@@ -843,6 +1119,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
...
@@ -843,6 +1119,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
CustomEndpoints
:
customEndpointsJSON
,
CustomEndpoints
:
customEndpointsJSON
,
DefaultConcurrency
:
req
.
DefaultConcurrency
,
DefaultConcurrency
:
req
.
DefaultConcurrency
,
DefaultBalance
:
req
.
DefaultBalance
,
DefaultBalance
:
req
.
DefaultBalance
,
DefaultUserRPMLimit
:
req
.
DefaultUserRPMLimit
,
DefaultSubscriptions
:
defaultSubscriptions
,
DefaultSubscriptions
:
defaultSubscriptions
,
EnableModelFallback
:
req
.
EnableModelFallback
,
EnableModelFallback
:
req
.
EnableModelFallback
,
FallbackModelAnthropic
:
req
.
FallbackModelAnthropic
,
FallbackModelAnthropic
:
req
.
FallbackModelAnthropic
,
...
@@ -897,6 +1174,36 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
...
@@ -897,6 +1174,36 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
}
return
previousSettings
.
EnableCCHSigning
return
previousSettings
.
EnableCCHSigning
}(),
}(),
PaymentVisibleMethodAlipaySource
:
func
()
string
{
if
req
.
PaymentVisibleMethodAlipaySource
!=
nil
{
return
strings
.
TrimSpace
(
*
req
.
PaymentVisibleMethodAlipaySource
)
}
return
previousSettings
.
PaymentVisibleMethodAlipaySource
}(),
PaymentVisibleMethodWxpaySource
:
func
()
string
{
if
req
.
PaymentVisibleMethodWxpaySource
!=
nil
{
return
strings
.
TrimSpace
(
*
req
.
PaymentVisibleMethodWxpaySource
)
}
return
previousSettings
.
PaymentVisibleMethodWxpaySource
}(),
PaymentVisibleMethodAlipayEnabled
:
func
()
bool
{
if
req
.
PaymentVisibleMethodAlipayEnabled
!=
nil
{
return
*
req
.
PaymentVisibleMethodAlipayEnabled
}
return
previousSettings
.
PaymentVisibleMethodAlipayEnabled
}(),
PaymentVisibleMethodWxpayEnabled
:
func
()
bool
{
if
req
.
PaymentVisibleMethodWxpayEnabled
!=
nil
{
return
*
req
.
PaymentVisibleMethodWxpayEnabled
}
return
previousSettings
.
PaymentVisibleMethodWxpayEnabled
}(),
OpenAIAdvancedSchedulerEnabled
:
func
()
bool
{
if
req
.
OpenAIAdvancedSchedulerEnabled
!=
nil
{
return
*
req
.
OpenAIAdvancedSchedulerEnabled
}
return
previousSettings
.
OpenAIAdvancedSchedulerEnabled
}(),
BalanceLowNotifyEnabled
:
func
()
bool
{
BalanceLowNotifyEnabled
:
func
()
bool
{
if
req
.
BalanceLowNotifyEnabled
!=
nil
{
if
req
.
BalanceLowNotifyEnabled
!=
nil
{
return
*
req
.
BalanceLowNotifyEnabled
return
*
req
.
BalanceLowNotifyEnabled
...
@@ -927,9 +1234,58 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
...
@@ -927,9 +1234,58 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
}
return
previousSettings
.
AccountQuotaNotifyEmails
return
previousSettings
.
AccountQuotaNotifyEmails
}(),
}(),
ChannelMonitorEnabled
:
func
()
bool
{
if
req
.
ChannelMonitorEnabled
!=
nil
{
return
*
req
.
ChannelMonitorEnabled
}
return
previousSettings
.
ChannelMonitorEnabled
}(),
ChannelMonitorDefaultIntervalSeconds
:
func
()
int
{
if
req
.
ChannelMonitorDefaultIntervalSeconds
!=
nil
{
return
*
req
.
ChannelMonitorDefaultIntervalSeconds
}
return
previousSettings
.
ChannelMonitorDefaultIntervalSeconds
}(),
AvailableChannelsEnabled
:
func
()
bool
{
if
req
.
AvailableChannelsEnabled
!=
nil
{
return
*
req
.
AvailableChannelsEnabled
}
return
previousSettings
.
AvailableChannelsEnabled
}(),
}
}
if
err
:=
h
.
settingService
.
UpdateSettings
(
c
.
Request
.
Context
(),
settings
);
err
!=
nil
{
authSourceDefaults
:=
&
service
.
AuthSourceDefaultSettings
{
Email
:
service
.
ProviderDefaultGrantSettings
{
Balance
:
float64ValueOrDefault
(
req
.
AuthSourceDefaultEmailBalance
,
previousAuthSourceDefaults
.
Email
.
Balance
),
Concurrency
:
intValueOrDefault
(
req
.
AuthSourceDefaultEmailConcurrency
,
previousAuthSourceDefaults
.
Email
.
Concurrency
),
Subscriptions
:
defaultSubscriptionsValueOrDefault
(
req
.
AuthSourceDefaultEmailSubscriptions
,
previousAuthSourceDefaults
.
Email
.
Subscriptions
),
GrantOnSignup
:
boolValueOrDefault
(
req
.
AuthSourceDefaultEmailGrantOnSignup
,
previousAuthSourceDefaults
.
Email
.
GrantOnSignup
),
GrantOnFirstBind
:
boolValueOrDefault
(
req
.
AuthSourceDefaultEmailGrantOnFirstBind
,
previousAuthSourceDefaults
.
Email
.
GrantOnFirstBind
),
},
LinuxDo
:
service
.
ProviderDefaultGrantSettings
{
Balance
:
float64ValueOrDefault
(
req
.
AuthSourceDefaultLinuxDoBalance
,
previousAuthSourceDefaults
.
LinuxDo
.
Balance
),
Concurrency
:
intValueOrDefault
(
req
.
AuthSourceDefaultLinuxDoConcurrency
,
previousAuthSourceDefaults
.
LinuxDo
.
Concurrency
),
Subscriptions
:
defaultSubscriptionsValueOrDefault
(
req
.
AuthSourceDefaultLinuxDoSubscriptions
,
previousAuthSourceDefaults
.
LinuxDo
.
Subscriptions
),
GrantOnSignup
:
boolValueOrDefault
(
req
.
AuthSourceDefaultLinuxDoGrantOnSignup
,
previousAuthSourceDefaults
.
LinuxDo
.
GrantOnSignup
),
GrantOnFirstBind
:
boolValueOrDefault
(
req
.
AuthSourceDefaultLinuxDoGrantOnFirstBind
,
previousAuthSourceDefaults
.
LinuxDo
.
GrantOnFirstBind
),
},
OIDC
:
service
.
ProviderDefaultGrantSettings
{
Balance
:
float64ValueOrDefault
(
req
.
AuthSourceDefaultOIDCBalance
,
previousAuthSourceDefaults
.
OIDC
.
Balance
),
Concurrency
:
intValueOrDefault
(
req
.
AuthSourceDefaultOIDCConcurrency
,
previousAuthSourceDefaults
.
OIDC
.
Concurrency
),
Subscriptions
:
defaultSubscriptionsValueOrDefault
(
req
.
AuthSourceDefaultOIDCSubscriptions
,
previousAuthSourceDefaults
.
OIDC
.
Subscriptions
),
GrantOnSignup
:
boolValueOrDefault
(
req
.
AuthSourceDefaultOIDCGrantOnSignup
,
previousAuthSourceDefaults
.
OIDC
.
GrantOnSignup
),
GrantOnFirstBind
:
boolValueOrDefault
(
req
.
AuthSourceDefaultOIDCGrantOnFirstBind
,
previousAuthSourceDefaults
.
OIDC
.
GrantOnFirstBind
),
},
WeChat
:
service
.
ProviderDefaultGrantSettings
{
Balance
:
float64ValueOrDefault
(
req
.
AuthSourceDefaultWeChatBalance
,
previousAuthSourceDefaults
.
WeChat
.
Balance
),
Concurrency
:
intValueOrDefault
(
req
.
AuthSourceDefaultWeChatConcurrency
,
previousAuthSourceDefaults
.
WeChat
.
Concurrency
),
Subscriptions
:
defaultSubscriptionsValueOrDefault
(
req
.
AuthSourceDefaultWeChatSubscriptions
,
previousAuthSourceDefaults
.
WeChat
.
Subscriptions
),
GrantOnSignup
:
boolValueOrDefault
(
req
.
AuthSourceDefaultWeChatGrantOnSignup
,
previousAuthSourceDefaults
.
WeChat
.
GrantOnSignup
),
GrantOnFirstBind
:
boolValueOrDefault
(
req
.
AuthSourceDefaultWeChatGrantOnFirstBind
,
previousAuthSourceDefaults
.
WeChat
.
GrantOnFirstBind
),
},
ForceEmailOnThirdPartySignup
:
boolValueOrDefault
(
req
.
ForceEmailOnThirdPartySignup
,
previousAuthSourceDefaults
.
ForceEmailOnThirdPartySignup
),
}
if
err
:=
h
.
settingService
.
UpdateSettingsWithAuthSourceDefaults
(
c
.
Request
.
Context
(),
settings
,
authSourceDefaults
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
return
}
}
...
@@ -969,7 +1325,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
...
@@ -969,7 +1325,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
}
}
}
h
.
auditSettingsUpdate
(
c
,
previousSettings
,
settings
,
req
)
h
.
auditSettingsUpdate
(
c
,
previousSettings
,
settings
,
previousAuthSourceDefaults
,
authSourceDefaults
,
req
)
// 重新获取设置返回
// 重新获取设置返回
updatedSettings
,
err
:=
h
.
settingService
.
GetAllSettings
(
c
.
Request
.
Context
())
updatedSettings
,
err
:=
h
.
settingService
.
GetAllSettings
(
c
.
Request
.
Context
())
...
@@ -977,6 +1333,11 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
...
@@ -977,6 +1333,11 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response
.
ErrorFrom
(
c
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
return
}
}
updatedAuthSourceDefaults
,
err
:=
h
.
settingService
.
GetAuthSourceDefaultSettings
(
c
.
Request
.
Context
())
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
updatedDefaultSubscriptions
:=
make
([]
dto
.
DefaultSubscriptionSetting
,
0
,
len
(
updatedSettings
.
DefaultSubscriptions
))
updatedDefaultSubscriptions
:=
make
([]
dto
.
DefaultSubscriptionSetting
,
0
,
len
(
updatedSettings
.
DefaultSubscriptions
))
for
_
,
sub
:=
range
updatedSettings
.
DefaultSubscriptions
{
for
_
,
sub
:=
range
updatedSettings
.
DefaultSubscriptions
{
updatedDefaultSubscriptions
=
append
(
updatedDefaultSubscriptions
,
dto
.
DefaultSubscriptionSetting
{
updatedDefaultSubscriptions
=
append
(
updatedDefaultSubscriptions
,
dto
.
DefaultSubscriptionSetting
{
...
@@ -994,113 +1355,141 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
...
@@ -994,113 +1355,141 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
updatedPaymentCfg
=
&
service
.
PaymentConfig
{}
updatedPaymentCfg
=
&
service
.
PaymentConfig
{}
}
}
response
.
Success
(
c
,
dto
.
SystemSettings
{
payload
:=
dto
.
SystemSettings
{
RegistrationEnabled
:
updatedSettings
.
RegistrationEnabled
,
RegistrationEnabled
:
updatedSettings
.
RegistrationEnabled
,
EmailVerifyEnabled
:
updatedSettings
.
EmailVerifyEnabled
,
EmailVerifyEnabled
:
updatedSettings
.
EmailVerifyEnabled
,
RegistrationEmailSuffixWhitelist
:
updatedSettings
.
RegistrationEmailSuffixWhitelist
,
RegistrationEmailSuffixWhitelist
:
updatedSettings
.
RegistrationEmailSuffixWhitelist
,
PromoCodeEnabled
:
updatedSettings
.
PromoCodeEnabled
,
PromoCodeEnabled
:
updatedSettings
.
PromoCodeEnabled
,
PasswordResetEnabled
:
updatedSettings
.
PasswordResetEnabled
,
PasswordResetEnabled
:
updatedSettings
.
PasswordResetEnabled
,
FrontendURL
:
updatedSettings
.
FrontendURL
,
FrontendURL
:
updatedSettings
.
FrontendURL
,
InvitationCodeEnabled
:
updatedSettings
.
InvitationCodeEnabled
,
InvitationCodeEnabled
:
updatedSettings
.
InvitationCodeEnabled
,
TotpEnabled
:
updatedSettings
.
TotpEnabled
,
TotpEnabled
:
updatedSettings
.
TotpEnabled
,
TotpEncryptionKeyConfigured
:
h
.
settingService
.
IsTotpEncryptionKeyConfigured
(),
TotpEncryptionKeyConfigured
:
h
.
settingService
.
IsTotpEncryptionKeyConfigured
(),
SMTPHost
:
updatedSettings
.
SMTPHost
,
SMTPHost
:
updatedSettings
.
SMTPHost
,
SMTPPort
:
updatedSettings
.
SMTPPort
,
SMTPPort
:
updatedSettings
.
SMTPPort
,
SMTPUsername
:
updatedSettings
.
SMTPUsername
,
SMTPUsername
:
updatedSettings
.
SMTPUsername
,
SMTPPasswordConfigured
:
updatedSettings
.
SMTPPasswordConfigured
,
SMTPPasswordConfigured
:
updatedSettings
.
SMTPPasswordConfigured
,
SMTPFrom
:
updatedSettings
.
SMTPFrom
,
SMTPFrom
:
updatedSettings
.
SMTPFrom
,
SMTPFromName
:
updatedSettings
.
SMTPFromName
,
SMTPFromName
:
updatedSettings
.
SMTPFromName
,
SMTPUseTLS
:
updatedSettings
.
SMTPUseTLS
,
SMTPUseTLS
:
updatedSettings
.
SMTPUseTLS
,
TurnstileEnabled
:
updatedSettings
.
TurnstileEnabled
,
TurnstileEnabled
:
updatedSettings
.
TurnstileEnabled
,
TurnstileSiteKey
:
updatedSettings
.
TurnstileSiteKey
,
TurnstileSiteKey
:
updatedSettings
.
TurnstileSiteKey
,
TurnstileSecretKeyConfigured
:
updatedSettings
.
TurnstileSecretKeyConfigured
,
TurnstileSecretKeyConfigured
:
updatedSettings
.
TurnstileSecretKeyConfigured
,
LinuxDoConnectEnabled
:
updatedSettings
.
LinuxDoConnectEnabled
,
LinuxDoConnectEnabled
:
updatedSettings
.
LinuxDoConnectEnabled
,
LinuxDoConnectClientID
:
updatedSettings
.
LinuxDoConnectClientID
,
LinuxDoConnectClientID
:
updatedSettings
.
LinuxDoConnectClientID
,
LinuxDoConnectClientSecretConfigured
:
updatedSettings
.
LinuxDoConnectClientSecretConfigured
,
LinuxDoConnectClientSecretConfigured
:
updatedSettings
.
LinuxDoConnectClientSecretConfigured
,
LinuxDoConnectRedirectURL
:
updatedSettings
.
LinuxDoConnectRedirectURL
,
LinuxDoConnectRedirectURL
:
updatedSettings
.
LinuxDoConnectRedirectURL
,
OIDCConnectEnabled
:
updatedSettings
.
OIDCConnectEnabled
,
WeChatConnectEnabled
:
updatedSettings
.
WeChatConnectEnabled
,
OIDCConnectProviderName
:
updatedSettings
.
OIDCConnectProviderName
,
WeChatConnectAppID
:
updatedSettings
.
WeChatConnectAppID
,
OIDCConnectClientID
:
updatedSettings
.
OIDCConnectClientID
,
WeChatConnectAppSecretConfigured
:
updatedSettings
.
WeChatConnectAppSecretConfigured
,
OIDCConnectClientSecretConfigured
:
updatedSettings
.
OIDCConnectClientSecretConfigured
,
WeChatConnectOpenAppID
:
updatedSettings
.
WeChatConnectOpenAppID
,
OIDCConnectIssuerURL
:
updatedSettings
.
OIDCConnectIssuerURL
,
WeChatConnectOpenAppSecretConfigured
:
updatedSettings
.
WeChatConnectOpenAppSecretConfigured
,
OIDCConnectDiscoveryURL
:
updatedSettings
.
OIDCConnectDiscoveryURL
,
WeChatConnectMPAppID
:
updatedSettings
.
WeChatConnectMPAppID
,
OIDCConnectAuthorizeURL
:
updatedSettings
.
OIDCConnectAuthorizeURL
,
WeChatConnectMPAppSecretConfigured
:
updatedSettings
.
WeChatConnectMPAppSecretConfigured
,
OIDCConnectTokenURL
:
updatedSettings
.
OIDCConnectTokenURL
,
WeChatConnectMobileAppID
:
updatedSettings
.
WeChatConnectMobileAppID
,
OIDCConnectUserInfoURL
:
updatedSettings
.
OIDCConnectUserInfoURL
,
WeChatConnectMobileAppSecretConfigured
:
updatedSettings
.
WeChatConnectMobileAppSecretConfigured
,
OIDCConnectJWKSURL
:
updatedSettings
.
OIDCConnectJWKSURL
,
WeChatConnectOpenEnabled
:
updatedSettings
.
WeChatConnectOpenEnabled
,
OIDCConnectScopes
:
updatedSettings
.
OIDCConnectScopes
,
WeChatConnectMPEnabled
:
updatedSettings
.
WeChatConnectMPEnabled
,
OIDCConnectRedirectURL
:
updatedSettings
.
OIDCConnectRedirectURL
,
WeChatConnectMobileEnabled
:
updatedSettings
.
WeChatConnectMobileEnabled
,
OIDCConnectFrontendRedirectURL
:
updatedSettings
.
OIDCConnectFrontendRedirectURL
,
WeChatConnectMode
:
updatedSettings
.
WeChatConnectMode
,
OIDCConnectTokenAuthMethod
:
updatedSettings
.
OIDCConnectTokenAuthMethod
,
WeChatConnectScopes
:
updatedSettings
.
WeChatConnectScopes
,
OIDCConnectUsePKCE
:
updatedSettings
.
OIDCConnectUsePKCE
,
WeChatConnectRedirectURL
:
updatedSettings
.
WeChatConnectRedirectURL
,
OIDCConnectValidateIDToken
:
updatedSettings
.
OIDCConnectValidateIDToken
,
WeChatConnectFrontendRedirectURL
:
updatedSettings
.
WeChatConnectFrontendRedirectURL
,
OIDCConnectAllowedSigningAlgs
:
updatedSettings
.
OIDCConnectAllowedSigningAlgs
,
OIDCConnectEnabled
:
updatedSettings
.
OIDCConnectEnabled
,
OIDCConnectClockSkewSeconds
:
updatedSettings
.
OIDCConnectClockSkewSeconds
,
OIDCConnectProviderName
:
updatedSettings
.
OIDCConnectProviderName
,
OIDCConnectRequireEmailVerified
:
updatedSettings
.
OIDCConnectRequireEmailVerified
,
OIDCConnectClientID
:
updatedSettings
.
OIDCConnectClientID
,
OIDCConnectUserInfoEmailPath
:
updatedSettings
.
OIDCConnectUserInfoEmailPath
,
OIDCConnectClientSecretConfigured
:
updatedSettings
.
OIDCConnectClientSecretConfigured
,
OIDCConnectUserInfoIDPath
:
updatedSettings
.
OIDCConnectUserInfoIDPath
,
OIDCConnectIssuerURL
:
updatedSettings
.
OIDCConnectIssuerURL
,
OIDCConnectUserInfoUsernamePath
:
updatedSettings
.
OIDCConnectUserInfoUsernamePath
,
OIDCConnectDiscoveryURL
:
updatedSettings
.
OIDCConnectDiscoveryURL
,
SiteName
:
updatedSettings
.
SiteName
,
OIDCConnectAuthorizeURL
:
updatedSettings
.
OIDCConnectAuthorizeURL
,
SiteLogo
:
updatedSettings
.
SiteLogo
,
OIDCConnectTokenURL
:
updatedSettings
.
OIDCConnectTokenURL
,
SiteSubtitle
:
updatedSettings
.
SiteSubtitle
,
OIDCConnectUserInfoURL
:
updatedSettings
.
OIDCConnectUserInfoURL
,
APIBaseURL
:
updatedSettings
.
APIBaseURL
,
OIDCConnectJWKSURL
:
updatedSettings
.
OIDCConnectJWKSURL
,
ContactInfo
:
updatedSettings
.
ContactInfo
,
OIDCConnectScopes
:
updatedSettings
.
OIDCConnectScopes
,
DocURL
:
updatedSettings
.
DocURL
,
OIDCConnectRedirectURL
:
updatedSettings
.
OIDCConnectRedirectURL
,
HomeContent
:
updatedSettings
.
HomeContent
,
OIDCConnectFrontendRedirectURL
:
updatedSettings
.
OIDCConnectFrontendRedirectURL
,
HideCcsImportButton
:
updatedSettings
.
HideCcsImportButton
,
OIDCConnectTokenAuthMethod
:
updatedSettings
.
OIDCConnectTokenAuthMethod
,
PurchaseSubscriptionEnabled
:
updatedSettings
.
PurchaseSubscriptionEnabled
,
OIDCConnectUsePKCE
:
updatedSettings
.
OIDCConnectUsePKCE
,
PurchaseSubscriptionURL
:
updatedSettings
.
PurchaseSubscriptionURL
,
OIDCConnectValidateIDToken
:
updatedSettings
.
OIDCConnectValidateIDToken
,
TableDefaultPageSize
:
updatedSettings
.
TableDefaultPageSize
,
OIDCConnectAllowedSigningAlgs
:
updatedSettings
.
OIDCConnectAllowedSigningAlgs
,
TablePageSizeOptions
:
updatedSettings
.
TablePageSizeOptions
,
OIDCConnectClockSkewSeconds
:
updatedSettings
.
OIDCConnectClockSkewSeconds
,
CustomMenuItems
:
dto
.
ParseCustomMenuItems
(
updatedSettings
.
CustomMenuItems
),
OIDCConnectRequireEmailVerified
:
updatedSettings
.
OIDCConnectRequireEmailVerified
,
CustomEndpoints
:
dto
.
ParseCustomEndpoints
(
updatedSettings
.
CustomEndpoints
),
OIDCConnectUserInfoEmailPath
:
updatedSettings
.
OIDCConnectUserInfoEmailPath
,
DefaultConcurrency
:
updatedSettings
.
DefaultConcurrency
,
OIDCConnectUserInfoIDPath
:
updatedSettings
.
OIDCConnectUserInfoIDPath
,
DefaultBalance
:
updatedSettings
.
DefaultBalance
,
OIDCConnectUserInfoUsernamePath
:
updatedSettings
.
OIDCConnectUserInfoUsernamePath
,
DefaultSubscriptions
:
updatedDefaultSubscriptions
,
SiteName
:
updatedSettings
.
SiteName
,
EnableModelFallback
:
updatedSettings
.
EnableModelFallback
,
SiteLogo
:
updatedSettings
.
SiteLogo
,
FallbackModelAnthropic
:
updatedSettings
.
FallbackModelAnthropic
,
SiteSubtitle
:
updatedSettings
.
SiteSubtitle
,
FallbackModelOpenAI
:
updatedSettings
.
FallbackModelOpenAI
,
APIBaseURL
:
updatedSettings
.
APIBaseURL
,
FallbackModelGemini
:
updatedSettings
.
FallbackModelGemini
,
ContactInfo
:
updatedSettings
.
ContactInfo
,
FallbackModelAntigravity
:
updatedSettings
.
FallbackModelAntigravity
,
DocURL
:
updatedSettings
.
DocURL
,
EnableIdentityPatch
:
updatedSettings
.
EnableIdentityPatch
,
HomeContent
:
updatedSettings
.
HomeContent
,
IdentityPatchPrompt
:
updatedSettings
.
IdentityPatchPrompt
,
HideCcsImportButton
:
updatedSettings
.
HideCcsImportButton
,
OpsMonitoringEnabled
:
updatedSettings
.
OpsMonitoringEnabled
,
PurchaseSubscriptionEnabled
:
updatedSettings
.
PurchaseSubscriptionEnabled
,
OpsRealtimeMonitoringEnabled
:
updatedSettings
.
OpsRealtimeMonitoringEnabled
,
PurchaseSubscriptionURL
:
updatedSettings
.
PurchaseSubscriptionURL
,
OpsQueryModeDefault
:
updatedSettings
.
OpsQueryModeDefault
,
TableDefaultPageSize
:
updatedSettings
.
TableDefaultPageSize
,
OpsMetricsIntervalSeconds
:
updatedSettings
.
OpsMetricsIntervalSeconds
,
TablePageSizeOptions
:
updatedSettings
.
TablePageSizeOptions
,
MinClaudeCodeVersion
:
updatedSettings
.
MinClaudeCodeVersion
,
CustomMenuItems
:
dto
.
ParseCustomMenuItems
(
updatedSettings
.
CustomMenuItems
),
MaxClaudeCodeVersion
:
updatedSettings
.
MaxClaudeCodeVersion
,
CustomEndpoints
:
dto
.
ParseCustomEndpoints
(
updatedSettings
.
CustomEndpoints
),
AllowUngroupedKeyScheduling
:
updatedSettings
.
AllowUngroupedKeyScheduling
,
DefaultConcurrency
:
updatedSettings
.
DefaultConcurrency
,
BackendModeEnabled
:
updatedSettings
.
BackendModeEnabled
,
DefaultBalance
:
updatedSettings
.
DefaultBalance
,
EnableFingerprintUnification
:
updatedSettings
.
EnableFingerprintUnification
,
DefaultUserRPMLimit
:
updatedSettings
.
DefaultUserRPMLimit
,
EnableMetadataPassthrough
:
updatedSettings
.
EnableMetadataPassthrough
,
DefaultSubscriptions
:
updatedDefaultSubscriptions
,
EnableCCHSigning
:
updatedSettings
.
EnableCCHSigning
,
EnableModelFallback
:
updatedSettings
.
EnableModelFallback
,
BalanceLowNotifyEnabled
:
updatedSettings
.
BalanceLowNotifyEnabled
,
FallbackModelAnthropic
:
updatedSettings
.
FallbackModelAnthropic
,
BalanceLowNotifyThreshold
:
updatedSettings
.
BalanceLowNotifyThreshold
,
FallbackModelOpenAI
:
updatedSettings
.
FallbackModelOpenAI
,
BalanceLowNotifyRechargeURL
:
updatedSettings
.
BalanceLowNotifyRechargeURL
,
FallbackModelGemini
:
updatedSettings
.
FallbackModelGemini
,
AccountQuotaNotifyEnabled
:
updatedSettings
.
AccountQuotaNotifyEnabled
,
FallbackModelAntigravity
:
updatedSettings
.
FallbackModelAntigravity
,
AccountQuotaNotifyEmails
:
dto
.
NotifyEmailEntriesFromService
(
updatedSettings
.
AccountQuotaNotifyEmails
),
EnableIdentityPatch
:
updatedSettings
.
EnableIdentityPatch
,
PaymentEnabled
:
updatedPaymentCfg
.
Enabled
,
IdentityPatchPrompt
:
updatedSettings
.
IdentityPatchPrompt
,
PaymentMinAmount
:
updatedPaymentCfg
.
MinAmount
,
OpsMonitoringEnabled
:
updatedSettings
.
OpsMonitoringEnabled
,
PaymentMaxAmount
:
updatedPaymentCfg
.
MaxAmount
,
OpsRealtimeMonitoringEnabled
:
updatedSettings
.
OpsRealtimeMonitoringEnabled
,
PaymentDailyLimit
:
updatedPaymentCfg
.
DailyLimit
,
OpsQueryModeDefault
:
updatedSettings
.
OpsQueryModeDefault
,
PaymentOrderTimeoutMin
:
updatedPaymentCfg
.
OrderTimeoutMin
,
OpsMetricsIntervalSeconds
:
updatedSettings
.
OpsMetricsIntervalSeconds
,
PaymentMaxPendingOrders
:
updatedPaymentCfg
.
MaxPendingOrders
,
MinClaudeCodeVersion
:
updatedSettings
.
MinClaudeCodeVersion
,
PaymentEnabledTypes
:
updatedPaymentCfg
.
EnabledTypes
,
MaxClaudeCodeVersion
:
updatedSettings
.
MaxClaudeCodeVersion
,
PaymentBalanceDisabled
:
updatedPaymentCfg
.
BalanceDisabled
,
AllowUngroupedKeyScheduling
:
updatedSettings
.
AllowUngroupedKeyScheduling
,
PaymentBalanceRechargeMultiplier
:
updatedPaymentCfg
.
BalanceRechargeMultiplier
,
BackendModeEnabled
:
updatedSettings
.
BackendModeEnabled
,
PaymentRechargeFeeRate
:
updatedPaymentCfg
.
RechargeFeeRate
,
EnableFingerprintUnification
:
updatedSettings
.
EnableFingerprintUnification
,
PaymentLoadBalanceStrat
:
updatedPaymentCfg
.
LoadBalanceStrategy
,
EnableMetadataPassthrough
:
updatedSettings
.
EnableMetadataPassthrough
,
PaymentProductNamePrefix
:
updatedPaymentCfg
.
ProductNamePrefix
,
EnableCCHSigning
:
updatedSettings
.
EnableCCHSigning
,
PaymentProductNameSuffix
:
updatedPaymentCfg
.
ProductNameSuffix
,
PaymentVisibleMethodAlipaySource
:
updatedSettings
.
PaymentVisibleMethodAlipaySource
,
PaymentHelpImageURL
:
updatedPaymentCfg
.
HelpImageURL
,
PaymentVisibleMethodWxpaySource
:
updatedSettings
.
PaymentVisibleMethodWxpaySource
,
PaymentHelpText
:
updatedPaymentCfg
.
HelpText
,
PaymentVisibleMethodAlipayEnabled
:
updatedSettings
.
PaymentVisibleMethodAlipayEnabled
,
PaymentCancelRateLimitEnabled
:
updatedPaymentCfg
.
CancelRateLimitEnabled
,
PaymentVisibleMethodWxpayEnabled
:
updatedSettings
.
PaymentVisibleMethodWxpayEnabled
,
PaymentCancelRateLimitMax
:
updatedPaymentCfg
.
CancelRateLimitMax
,
OpenAIAdvancedSchedulerEnabled
:
updatedSettings
.
OpenAIAdvancedSchedulerEnabled
,
PaymentCancelRateLimitWindow
:
updatedPaymentCfg
.
CancelRateLimitWindow
,
BalanceLowNotifyEnabled
:
updatedSettings
.
BalanceLowNotifyEnabled
,
PaymentCancelRateLimitUnit
:
updatedPaymentCfg
.
CancelRateLimitUnit
,
BalanceLowNotifyThreshold
:
updatedSettings
.
BalanceLowNotifyThreshold
,
PaymentCancelRateLimitMode
:
updatedPaymentCfg
.
CancelRateLimitMode
,
BalanceLowNotifyRechargeURL
:
updatedSettings
.
BalanceLowNotifyRechargeURL
,
})
AccountQuotaNotifyEnabled
:
updatedSettings
.
AccountQuotaNotifyEnabled
,
AccountQuotaNotifyEmails
:
dto
.
NotifyEmailEntriesFromService
(
updatedSettings
.
AccountQuotaNotifyEmails
),
PaymentEnabled
:
updatedPaymentCfg
.
Enabled
,
PaymentMinAmount
:
updatedPaymentCfg
.
MinAmount
,
PaymentMaxAmount
:
updatedPaymentCfg
.
MaxAmount
,
PaymentDailyLimit
:
updatedPaymentCfg
.
DailyLimit
,
PaymentOrderTimeoutMin
:
updatedPaymentCfg
.
OrderTimeoutMin
,
PaymentMaxPendingOrders
:
updatedPaymentCfg
.
MaxPendingOrders
,
PaymentEnabledTypes
:
updatedPaymentCfg
.
EnabledTypes
,
PaymentBalanceDisabled
:
updatedPaymentCfg
.
BalanceDisabled
,
PaymentBalanceRechargeMultiplier
:
updatedPaymentCfg
.
BalanceRechargeMultiplier
,
PaymentRechargeFeeRate
:
updatedPaymentCfg
.
RechargeFeeRate
,
PaymentLoadBalanceStrat
:
updatedPaymentCfg
.
LoadBalanceStrategy
,
PaymentProductNamePrefix
:
updatedPaymentCfg
.
ProductNamePrefix
,
PaymentProductNameSuffix
:
updatedPaymentCfg
.
ProductNameSuffix
,
PaymentHelpImageURL
:
updatedPaymentCfg
.
HelpImageURL
,
PaymentHelpText
:
updatedPaymentCfg
.
HelpText
,
PaymentCancelRateLimitEnabled
:
updatedPaymentCfg
.
CancelRateLimitEnabled
,
PaymentCancelRateLimitMax
:
updatedPaymentCfg
.
CancelRateLimitMax
,
PaymentCancelRateLimitWindow
:
updatedPaymentCfg
.
CancelRateLimitWindow
,
PaymentCancelRateLimitUnit
:
updatedPaymentCfg
.
CancelRateLimitUnit
,
PaymentCancelRateLimitMode
:
updatedPaymentCfg
.
CancelRateLimitMode
,
ChannelMonitorEnabled
:
updatedSettings
.
ChannelMonitorEnabled
,
ChannelMonitorDefaultIntervalSeconds
:
updatedSettings
.
ChannelMonitorDefaultIntervalSeconds
,
AvailableChannelsEnabled
:
updatedSettings
.
AvailableChannelsEnabled
,
}
response
.
Success
(
c
,
systemSettingsResponseData
(
payload
,
updatedAuthSourceDefaults
))
}
}
// hasPaymentFields returns true if any payment-related field was explicitly provided.
// hasPaymentFields returns true if any payment-related field was explicitly provided.
...
@@ -1117,12 +1506,12 @@ func hasPaymentFields(req UpdateSettingsRequest) bool {
...
@@ -1117,12 +1506,12 @@ func hasPaymentFields(req UpdateSettingsRequest) bool {
req
.
PaymentCancelRateLimitUnit
!=
nil
||
req
.
PaymentCancelRateLimitMode
!=
nil
req
.
PaymentCancelRateLimitUnit
!=
nil
||
req
.
PaymentCancelRateLimitMode
!=
nil
}
}
func
(
h
*
SettingHandler
)
auditSettingsUpdate
(
c
*
gin
.
Context
,
before
*
service
.
SystemSettings
,
after
*
service
.
SystemSettings
,
req
UpdateSettingsRequest
)
{
func
(
h
*
SettingHandler
)
auditSettingsUpdate
(
c
*
gin
.
Context
,
before
*
service
.
SystemSettings
,
after
*
service
.
SystemSettings
,
beforeAuthSourceDefaults
*
service
.
AuthSourceDefaultSettings
,
afterAuthSourceDefaults
*
service
.
AuthSourceDefaultSettings
,
req
UpdateSettingsRequest
)
{
if
before
==
nil
||
after
==
nil
{
if
before
==
nil
||
after
==
nil
{
return
return
}
}
changed
:=
diffSettings
(
before
,
after
,
req
)
changed
:=
diffSettings
(
before
,
after
,
beforeAuthSourceDefaults
,
afterAuthSourceDefaults
,
req
)
if
len
(
changed
)
==
0
{
if
len
(
changed
)
==
0
{
return
return
}
}
...
@@ -1137,7 +1526,7 @@ func (h *SettingHandler) auditSettingsUpdate(c *gin.Context, before *service.Sys
...
@@ -1137,7 +1526,7 @@ func (h *SettingHandler) auditSettingsUpdate(c *gin.Context, before *service.Sys
)
)
}
}
func
diffSettings
(
before
*
service
.
SystemSettings
,
after
*
service
.
SystemSettings
,
req
UpdateSettingsRequest
)
[]
string
{
func
diffSettings
(
before
*
service
.
SystemSettings
,
after
*
service
.
SystemSettings
,
beforeAuthSourceDefaults
*
service
.
AuthSourceDefaultSettings
,
afterAuthSourceDefaults
*
service
.
AuthSourceDefaultSettings
,
req
UpdateSettingsRequest
)
[]
string
{
changed
:=
make
([]
string
,
0
,
20
)
changed
:=
make
([]
string
,
0
,
20
)
if
before
.
RegistrationEnabled
!=
after
.
RegistrationEnabled
{
if
before
.
RegistrationEnabled
!=
after
.
RegistrationEnabled
{
changed
=
append
(
changed
,
"registration_enabled"
)
changed
=
append
(
changed
,
"registration_enabled"
)
...
@@ -1205,6 +1594,54 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
...
@@ -1205,6 +1594,54 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if
before
.
LinuxDoConnectRedirectURL
!=
after
.
LinuxDoConnectRedirectURL
{
if
before
.
LinuxDoConnectRedirectURL
!=
after
.
LinuxDoConnectRedirectURL
{
changed
=
append
(
changed
,
"linuxdo_connect_redirect_url"
)
changed
=
append
(
changed
,
"linuxdo_connect_redirect_url"
)
}
}
if
before
.
WeChatConnectEnabled
!=
after
.
WeChatConnectEnabled
{
changed
=
append
(
changed
,
"wechat_connect_enabled"
)
}
if
before
.
WeChatConnectAppID
!=
after
.
WeChatConnectAppID
{
changed
=
append
(
changed
,
"wechat_connect_app_id"
)
}
if
req
.
WeChatConnectAppSecret
!=
""
{
changed
=
append
(
changed
,
"wechat_connect_app_secret"
)
}
if
before
.
WeChatConnectOpenAppID
!=
after
.
WeChatConnectOpenAppID
{
changed
=
append
(
changed
,
"wechat_connect_open_app_id"
)
}
if
req
.
WeChatConnectOpenAppSecret
!=
""
{
changed
=
append
(
changed
,
"wechat_connect_open_app_secret"
)
}
if
before
.
WeChatConnectMPAppID
!=
after
.
WeChatConnectMPAppID
{
changed
=
append
(
changed
,
"wechat_connect_mp_app_id"
)
}
if
req
.
WeChatConnectMPAppSecret
!=
""
{
changed
=
append
(
changed
,
"wechat_connect_mp_app_secret"
)
}
if
before
.
WeChatConnectMobileAppID
!=
after
.
WeChatConnectMobileAppID
{
changed
=
append
(
changed
,
"wechat_connect_mobile_app_id"
)
}
if
req
.
WeChatConnectMobileAppSecret
!=
""
{
changed
=
append
(
changed
,
"wechat_connect_mobile_app_secret"
)
}
if
before
.
WeChatConnectOpenEnabled
!=
after
.
WeChatConnectOpenEnabled
{
changed
=
append
(
changed
,
"wechat_connect_open_enabled"
)
}
if
before
.
WeChatConnectMPEnabled
!=
after
.
WeChatConnectMPEnabled
{
changed
=
append
(
changed
,
"wechat_connect_mp_enabled"
)
}
if
before
.
WeChatConnectMobileEnabled
!=
after
.
WeChatConnectMobileEnabled
{
changed
=
append
(
changed
,
"wechat_connect_mobile_enabled"
)
}
if
before
.
WeChatConnectMode
!=
after
.
WeChatConnectMode
{
changed
=
append
(
changed
,
"wechat_connect_mode"
)
}
if
before
.
WeChatConnectScopes
!=
after
.
WeChatConnectScopes
{
changed
=
append
(
changed
,
"wechat_connect_scopes"
)
}
if
before
.
WeChatConnectRedirectURL
!=
after
.
WeChatConnectRedirectURL
{
changed
=
append
(
changed
,
"wechat_connect_redirect_url"
)
}
if
before
.
WeChatConnectFrontendRedirectURL
!=
after
.
WeChatConnectFrontendRedirectURL
{
changed
=
append
(
changed
,
"wechat_connect_frontend_redirect_url"
)
}
if
before
.
OIDCConnectEnabled
!=
after
.
OIDCConnectEnabled
{
if
before
.
OIDCConnectEnabled
!=
after
.
OIDCConnectEnabled
{
changed
=
append
(
changed
,
"oidc_connect_enabled"
)
changed
=
append
(
changed
,
"oidc_connect_enabled"
)
}
}
...
@@ -1376,6 +1813,21 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
...
@@ -1376,6 +1813,21 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if
before
.
EnableCCHSigning
!=
after
.
EnableCCHSigning
{
if
before
.
EnableCCHSigning
!=
after
.
EnableCCHSigning
{
changed
=
append
(
changed
,
"enable_cch_signing"
)
changed
=
append
(
changed
,
"enable_cch_signing"
)
}
}
if
before
.
PaymentVisibleMethodAlipaySource
!=
after
.
PaymentVisibleMethodAlipaySource
{
changed
=
append
(
changed
,
"payment_visible_method_alipay_source"
)
}
if
before
.
PaymentVisibleMethodWxpaySource
!=
after
.
PaymentVisibleMethodWxpaySource
{
changed
=
append
(
changed
,
"payment_visible_method_wxpay_source"
)
}
if
before
.
PaymentVisibleMethodAlipayEnabled
!=
after
.
PaymentVisibleMethodAlipayEnabled
{
changed
=
append
(
changed
,
"payment_visible_method_alipay_enabled"
)
}
if
before
.
PaymentVisibleMethodWxpayEnabled
!=
after
.
PaymentVisibleMethodWxpayEnabled
{
changed
=
append
(
changed
,
"payment_visible_method_wxpay_enabled"
)
}
if
before
.
OpenAIAdvancedSchedulerEnabled
!=
after
.
OpenAIAdvancedSchedulerEnabled
{
changed
=
append
(
changed
,
"openai_advanced_scheduler_enabled"
)
}
// Balance & quota notification
// Balance & quota notification
if
before
.
BalanceLowNotifyEnabled
!=
after
.
BalanceLowNotifyEnabled
{
if
before
.
BalanceLowNotifyEnabled
!=
after
.
BalanceLowNotifyEnabled
{
changed
=
append
(
changed
,
"balance_low_notify_enabled"
)
changed
=
append
(
changed
,
"balance_low_notify_enabled"
)
...
@@ -1392,6 +1844,59 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
...
@@ -1392,6 +1844,59 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if
!
equalNotifyEmailEntries
(
before
.
AccountQuotaNotifyEmails
,
after
.
AccountQuotaNotifyEmails
)
{
if
!
equalNotifyEmailEntries
(
before
.
AccountQuotaNotifyEmails
,
after
.
AccountQuotaNotifyEmails
)
{
changed
=
append
(
changed
,
"account_quota_notify_emails"
)
changed
=
append
(
changed
,
"account_quota_notify_emails"
)
}
}
if
before
.
ChannelMonitorEnabled
!=
after
.
ChannelMonitorEnabled
{
changed
=
append
(
changed
,
"channel_monitor_enabled"
)
}
if
before
.
ChannelMonitorDefaultIntervalSeconds
!=
after
.
ChannelMonitorDefaultIntervalSeconds
{
changed
=
append
(
changed
,
"channel_monitor_default_interval_seconds"
)
}
if
before
.
AvailableChannelsEnabled
!=
after
.
AvailableChannelsEnabled
{
changed
=
append
(
changed
,
"available_channels_enabled"
)
}
changed
=
appendAuthSourceDefaultChanges
(
changed
,
beforeAuthSourceDefaults
,
afterAuthSourceDefaults
)
return
changed
}
func
appendAuthSourceDefaultChanges
(
changed
[]
string
,
before
*
service
.
AuthSourceDefaultSettings
,
after
*
service
.
AuthSourceDefaultSettings
)
[]
string
{
if
before
==
nil
{
before
=
&
service
.
AuthSourceDefaultSettings
{}
}
if
after
==
nil
{
after
=
&
service
.
AuthSourceDefaultSettings
{}
}
type
providerDefaultGrantField
struct
{
name
string
before
service
.
ProviderDefaultGrantSettings
after
service
.
ProviderDefaultGrantSettings
}
fields
:=
[]
providerDefaultGrantField
{
{
name
:
"email"
,
before
:
before
.
Email
,
after
:
after
.
Email
},
{
name
:
"linuxdo"
,
before
:
before
.
LinuxDo
,
after
:
after
.
LinuxDo
},
{
name
:
"oidc"
,
before
:
before
.
OIDC
,
after
:
after
.
OIDC
},
{
name
:
"wechat"
,
before
:
before
.
WeChat
,
after
:
after
.
WeChat
},
}
for
_
,
field
:=
range
fields
{
if
field
.
before
.
Balance
!=
field
.
after
.
Balance
{
changed
=
append
(
changed
,
"auth_source_default_"
+
field
.
name
+
"_balance"
)
}
if
field
.
before
.
Concurrency
!=
field
.
after
.
Concurrency
{
changed
=
append
(
changed
,
"auth_source_default_"
+
field
.
name
+
"_concurrency"
)
}
if
!
equalDefaultSubscriptions
(
field
.
before
.
Subscriptions
,
field
.
after
.
Subscriptions
)
{
changed
=
append
(
changed
,
"auth_source_default_"
+
field
.
name
+
"_subscriptions"
)
}
if
field
.
before
.
GrantOnSignup
!=
field
.
after
.
GrantOnSignup
{
changed
=
append
(
changed
,
"auth_source_default_"
+
field
.
name
+
"_grant_on_signup"
)
}
if
field
.
before
.
GrantOnFirstBind
!=
field
.
after
.
GrantOnFirstBind
{
changed
=
append
(
changed
,
"auth_source_default_"
+
field
.
name
+
"_grant_on_first_bind"
)
}
}
if
before
.
ForceEmailOnThirdPartySignup
!=
after
.
ForceEmailOnThirdPartySignup
{
changed
=
append
(
changed
,
"force_email_on_third_party_signup"
)
}
return
changed
return
changed
}
}
...
@@ -1412,6 +1917,84 @@ func normalizeDefaultSubscriptions(input []dto.DefaultSubscriptionSetting) []dto
...
@@ -1412,6 +1917,84 @@ func normalizeDefaultSubscriptions(input []dto.DefaultSubscriptionSetting) []dto
return
normalized
return
normalized
}
}
func
normalizeOptionalDefaultSubscriptions
(
input
*
[]
dto
.
DefaultSubscriptionSetting
)
*
[]
dto
.
DefaultSubscriptionSetting
{
if
input
==
nil
{
return
nil
}
normalized
:=
normalizeDefaultSubscriptions
(
*
input
)
return
&
normalized
}
func
float64ValueOrDefault
(
value
*
float64
,
fallback
float64
)
float64
{
if
value
==
nil
{
return
fallback
}
return
*
value
}
func
intValueOrDefault
(
value
*
int
,
fallback
int
)
int
{
if
value
==
nil
{
return
fallback
}
return
*
value
}
func
boolValueOrDefault
(
value
*
bool
,
fallback
bool
)
bool
{
if
value
==
nil
{
return
fallback
}
return
*
value
}
func
defaultSubscriptionsValueOrDefault
(
input
*
[]
dto
.
DefaultSubscriptionSetting
,
fallback
[]
service
.
DefaultSubscriptionSetting
)
[]
service
.
DefaultSubscriptionSetting
{
if
input
==
nil
{
return
fallback
}
result
:=
make
([]
service
.
DefaultSubscriptionSetting
,
0
,
len
(
*
input
))
for
_
,
item
:=
range
*
input
{
result
=
append
(
result
,
service
.
DefaultSubscriptionSetting
{
GroupID
:
item
.
GroupID
,
ValidityDays
:
item
.
ValidityDays
,
})
}
return
result
}
func
systemSettingsResponseData
(
settings
dto
.
SystemSettings
,
authSourceDefaults
*
service
.
AuthSourceDefaultSettings
)
map
[
string
]
any
{
data
:=
make
(
map
[
string
]
any
)
raw
,
err
:=
json
.
Marshal
(
settings
)
if
err
==
nil
{
_
=
json
.
Unmarshal
(
raw
,
&
data
)
}
if
authSourceDefaults
==
nil
{
authSourceDefaults
=
&
service
.
AuthSourceDefaultSettings
{}
}
data
[
"auth_source_default_email_balance"
]
=
authSourceDefaults
.
Email
.
Balance
data
[
"auth_source_default_email_concurrency"
]
=
authSourceDefaults
.
Email
.
Concurrency
data
[
"auth_source_default_email_subscriptions"
]
=
authSourceDefaults
.
Email
.
Subscriptions
data
[
"auth_source_default_email_grant_on_signup"
]
=
authSourceDefaults
.
Email
.
GrantOnSignup
data
[
"auth_source_default_email_grant_on_first_bind"
]
=
authSourceDefaults
.
Email
.
GrantOnFirstBind
data
[
"auth_source_default_linuxdo_balance"
]
=
authSourceDefaults
.
LinuxDo
.
Balance
data
[
"auth_source_default_linuxdo_concurrency"
]
=
authSourceDefaults
.
LinuxDo
.
Concurrency
data
[
"auth_source_default_linuxdo_subscriptions"
]
=
authSourceDefaults
.
LinuxDo
.
Subscriptions
data
[
"auth_source_default_linuxdo_grant_on_signup"
]
=
authSourceDefaults
.
LinuxDo
.
GrantOnSignup
data
[
"auth_source_default_linuxdo_grant_on_first_bind"
]
=
authSourceDefaults
.
LinuxDo
.
GrantOnFirstBind
data
[
"auth_source_default_oidc_balance"
]
=
authSourceDefaults
.
OIDC
.
Balance
data
[
"auth_source_default_oidc_concurrency"
]
=
authSourceDefaults
.
OIDC
.
Concurrency
data
[
"auth_source_default_oidc_subscriptions"
]
=
authSourceDefaults
.
OIDC
.
Subscriptions
data
[
"auth_source_default_oidc_grant_on_signup"
]
=
authSourceDefaults
.
OIDC
.
GrantOnSignup
data
[
"auth_source_default_oidc_grant_on_first_bind"
]
=
authSourceDefaults
.
OIDC
.
GrantOnFirstBind
data
[
"auth_source_default_wechat_balance"
]
=
authSourceDefaults
.
WeChat
.
Balance
data
[
"auth_source_default_wechat_concurrency"
]
=
authSourceDefaults
.
WeChat
.
Concurrency
data
[
"auth_source_default_wechat_subscriptions"
]
=
authSourceDefaults
.
WeChat
.
Subscriptions
data
[
"auth_source_default_wechat_grant_on_signup"
]
=
authSourceDefaults
.
WeChat
.
GrantOnSignup
data
[
"auth_source_default_wechat_grant_on_first_bind"
]
=
authSourceDefaults
.
WeChat
.
GrantOnFirstBind
data
[
"force_email_on_third_party_signup"
]
=
authSourceDefaults
.
ForceEmailOnThirdPartySignup
return
data
}
func
equalStringSlice
(
a
,
b
[]
string
)
bool
{
func
equalStringSlice
(
a
,
b
[]
string
)
bool
{
if
len
(
a
)
!=
len
(
b
)
{
if
len
(
a
)
!=
len
(
b
)
{
return
false
return
false
...
...
backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go
0 → 100644
View file @
b017f461
package
admin
import
(
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type
settingHandlerRepoStub
struct
{
values
map
[
string
]
string
lastUpdates
map
[
string
]
string
}
func
(
s
*
settingHandlerRepoStub
)
Get
(
ctx
context
.
Context
,
key
string
)
(
*
service
.
Setting
,
error
)
{
panic
(
"unexpected Get call"
)
}
func
(
s
*
settingHandlerRepoStub
)
GetValue
(
ctx
context
.
Context
,
key
string
)
(
string
,
error
)
{
panic
(
"unexpected GetValue call"
)
}
func
(
s
*
settingHandlerRepoStub
)
Set
(
ctx
context
.
Context
,
key
,
value
string
)
error
{
panic
(
"unexpected Set call"
)
}
func
(
s
*
settingHandlerRepoStub
)
GetMultiple
(
ctx
context
.
Context
,
keys
[]
string
)
(
map
[
string
]
string
,
error
)
{
out
:=
make
(
map
[
string
]
string
,
len
(
keys
))
for
_
,
key
:=
range
keys
{
if
value
,
ok
:=
s
.
values
[
key
];
ok
{
out
[
key
]
=
value
}
}
return
out
,
nil
}
func
(
s
*
settingHandlerRepoStub
)
SetMultiple
(
ctx
context
.
Context
,
settings
map
[
string
]
string
)
error
{
s
.
lastUpdates
=
make
(
map
[
string
]
string
,
len
(
settings
))
for
key
,
value
:=
range
settings
{
s
.
lastUpdates
[
key
]
=
value
if
s
.
values
==
nil
{
s
.
values
=
map
[
string
]
string
{}
}
s
.
values
[
key
]
=
value
}
return
nil
}
func
(
s
*
settingHandlerRepoStub
)
GetAll
(
ctx
context
.
Context
)
(
map
[
string
]
string
,
error
)
{
out
:=
make
(
map
[
string
]
string
,
len
(
s
.
values
))
for
key
,
value
:=
range
s
.
values
{
out
[
key
]
=
value
}
return
out
,
nil
}
func
(
s
*
settingHandlerRepoStub
)
Delete
(
ctx
context
.
Context
,
key
string
)
error
{
panic
(
"unexpected Delete call"
)
}
type
failingAuthSourceSettingsRepoStub
struct
{
values
map
[
string
]
string
err
error
}
func
(
s
*
failingAuthSourceSettingsRepoStub
)
Get
(
ctx
context
.
Context
,
key
string
)
(
*
service
.
Setting
,
error
)
{
panic
(
"unexpected Get call"
)
}
func
(
s
*
failingAuthSourceSettingsRepoStub
)
GetValue
(
ctx
context
.
Context
,
key
string
)
(
string
,
error
)
{
panic
(
"unexpected GetValue call"
)
}
func
(
s
*
failingAuthSourceSettingsRepoStub
)
Set
(
ctx
context
.
Context
,
key
,
value
string
)
error
{
panic
(
"unexpected Set call"
)
}
func
(
s
*
failingAuthSourceSettingsRepoStub
)
GetMultiple
(
ctx
context
.
Context
,
keys
[]
string
)
(
map
[
string
]
string
,
error
)
{
out
:=
make
(
map
[
string
]
string
,
len
(
keys
))
for
_
,
key
:=
range
keys
{
if
value
,
ok
:=
s
.
values
[
key
];
ok
{
out
[
key
]
=
value
}
}
return
out
,
nil
}
func
(
s
*
failingAuthSourceSettingsRepoStub
)
SetMultiple
(
ctx
context
.
Context
,
settings
map
[
string
]
string
)
error
{
if
_
,
ok
:=
settings
[
service
.
SettingKeyAuthSourceDefaultEmailBalance
];
ok
{
return
s
.
err
}
for
key
,
value
:=
range
settings
{
if
s
.
values
==
nil
{
s
.
values
=
map
[
string
]
string
{}
}
s
.
values
[
key
]
=
value
}
return
nil
}
func
(
s
*
failingAuthSourceSettingsRepoStub
)
GetAll
(
ctx
context
.
Context
)
(
map
[
string
]
string
,
error
)
{
out
:=
make
(
map
[
string
]
string
,
len
(
s
.
values
))
for
key
,
value
:=
range
s
.
values
{
out
[
key
]
=
value
}
return
out
,
nil
}
func
(
s
*
failingAuthSourceSettingsRepoStub
)
Delete
(
ctx
context
.
Context
,
key
string
)
error
{
panic
(
"unexpected Delete call"
)
}
func
TestSettingHandler_GetSettings_InjectsAuthSourceDefaults
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
repo
:=
&
settingHandlerRepoStub
{
values
:
map
[
string
]
string
{
service
.
SettingKeyRegistrationEnabled
:
"true"
,
service
.
SettingKeyPromoCodeEnabled
:
"true"
,
service
.
SettingKeyAuthSourceDefaultEmailBalance
:
"9.5"
,
service
.
SettingKeyAuthSourceDefaultEmailConcurrency
:
"8"
,
service
.
SettingKeyAuthSourceDefaultEmailSubscriptions
:
`[{"group_id":31,"validity_days":15}]`
,
service
.
SettingKeyForceEmailOnThirdPartySignup
:
"true"
,
},
}
svc
:=
service
.
NewSettingService
(
repo
,
&
config
.
Config
{
Default
:
config
.
DefaultConfig
{
UserConcurrency
:
5
}})
handler
:=
NewSettingHandler
(
svc
,
nil
,
nil
,
nil
,
nil
,
nil
)
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/settings"
,
nil
)
handler
.
GetSettings
(
c
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
var
resp
response
.
Response
require
.
NoError
(
t
,
json
.
Unmarshal
(
rec
.
Body
.
Bytes
(),
&
resp
))
data
,
ok
:=
resp
.
Data
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
9.5
,
data
[
"auth_source_default_email_balance"
])
require
.
Equal
(
t
,
float64
(
8
),
data
[
"auth_source_default_email_concurrency"
])
require
.
Equal
(
t
,
true
,
data
[
"force_email_on_third_party_signup"
])
subscriptions
,
ok
:=
data
[
"auth_source_default_email_subscriptions"
]
.
([]
any
)
require
.
True
(
t
,
ok
)
require
.
Len
(
t
,
subscriptions
,
1
)
}
func
TestSettingHandler_UpdateSettings_PreservesOmittedAuthSourceDefaults
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
repo
:=
&
settingHandlerRepoStub
{
values
:
map
[
string
]
string
{
service
.
SettingKeyRegistrationEnabled
:
"false"
,
service
.
SettingKeyPromoCodeEnabled
:
"true"
,
service
.
SettingKeyAuthSourceDefaultEmailBalance
:
"9.5"
,
service
.
SettingKeyAuthSourceDefaultEmailConcurrency
:
"8"
,
service
.
SettingKeyAuthSourceDefaultEmailSubscriptions
:
`[{"group_id":31,"validity_days":15}]`
,
service
.
SettingKeyAuthSourceDefaultEmailGrantOnSignup
:
"true"
,
service
.
SettingKeyAuthSourceDefaultEmailGrantOnFirstBind
:
"false"
,
service
.
SettingKeyForceEmailOnThirdPartySignup
:
"true"
,
},
}
svc
:=
service
.
NewSettingService
(
repo
,
&
config
.
Config
{
Default
:
config
.
DefaultConfig
{
UserConcurrency
:
5
}})
handler
:=
NewSettingHandler
(
svc
,
nil
,
nil
,
nil
,
nil
,
nil
)
body
:=
map
[
string
]
any
{
"registration_enabled"
:
true
,
"promo_code_enabled"
:
true
,
"auth_source_default_email_balance"
:
12.75
,
}
rawBody
,
err
:=
json
.
Marshal
(
body
)
require
.
NoError
(
t
,
err
)
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPut
,
"/api/v1/admin/settings"
,
bytes
.
NewReader
(
rawBody
))
c
.
Request
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
handler
.
UpdateSettings
(
c
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
require
.
Equal
(
t
,
"12.75000000"
,
repo
.
values
[
service
.
SettingKeyAuthSourceDefaultEmailBalance
])
require
.
Equal
(
t
,
"8"
,
repo
.
values
[
service
.
SettingKeyAuthSourceDefaultEmailConcurrency
])
require
.
Equal
(
t
,
`[{"group_id":31,"validity_days":15}]`
,
repo
.
values
[
service
.
SettingKeyAuthSourceDefaultEmailSubscriptions
])
require
.
Equal
(
t
,
"true"
,
repo
.
values
[
service
.
SettingKeyForceEmailOnThirdPartySignup
])
var
resp
response
.
Response
require
.
NoError
(
t
,
json
.
Unmarshal
(
rec
.
Body
.
Bytes
(),
&
resp
))
data
,
ok
:=
resp
.
Data
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
12.75
,
data
[
"auth_source_default_email_balance"
])
require
.
Equal
(
t
,
float64
(
8
),
data
[
"auth_source_default_email_concurrency"
])
require
.
Equal
(
t
,
true
,
data
[
"force_email_on_third_party_signup"
])
}
func
TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedScheduler
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
repo
:=
&
settingHandlerRepoStub
{
values
:
map
[
string
]
string
{
service
.
SettingKeyPromoCodeEnabled
:
"true"
,
},
}
svc
:=
service
.
NewSettingService
(
repo
,
&
config
.
Config
{
Default
:
config
.
DefaultConfig
{
UserConcurrency
:
5
}})
handler
:=
NewSettingHandler
(
svc
,
nil
,
nil
,
nil
,
nil
,
nil
)
body
:=
map
[
string
]
any
{
"promo_code_enabled"
:
true
,
"payment_visible_method_alipay_source"
:
"easypay"
,
"payment_visible_method_wxpay_source"
:
"wxpay"
,
"payment_visible_method_alipay_enabled"
:
true
,
"payment_visible_method_wxpay_enabled"
:
false
,
"openai_advanced_scheduler_enabled"
:
true
,
}
rawBody
,
err
:=
json
.
Marshal
(
body
)
require
.
NoError
(
t
,
err
)
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPut
,
"/api/v1/admin/settings"
,
bytes
.
NewReader
(
rawBody
))
c
.
Request
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
handler
.
UpdateSettings
(
c
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
require
.
Equal
(
t
,
service
.
VisibleMethodSourceEasyPayAlipay
,
repo
.
values
[
service
.
SettingPaymentVisibleMethodAlipaySource
])
require
.
Equal
(
t
,
service
.
VisibleMethodSourceOfficialWechat
,
repo
.
values
[
service
.
SettingPaymentVisibleMethodWxpaySource
])
require
.
Equal
(
t
,
"true"
,
repo
.
values
[
service
.
SettingPaymentVisibleMethodAlipayEnabled
])
require
.
Equal
(
t
,
"false"
,
repo
.
values
[
service
.
SettingPaymentVisibleMethodWxpayEnabled
])
require
.
Equal
(
t
,
"true"
,
repo
.
values
[
"openai_advanced_scheduler_enabled"
])
var
resp
response
.
Response
require
.
NoError
(
t
,
json
.
Unmarshal
(
rec
.
Body
.
Bytes
(),
&
resp
))
data
,
ok
:=
resp
.
Data
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
service
.
VisibleMethodSourceEasyPayAlipay
,
data
[
"payment_visible_method_alipay_source"
])
require
.
Equal
(
t
,
service
.
VisibleMethodSourceOfficialWechat
,
data
[
"payment_visible_method_wxpay_source"
])
require
.
Equal
(
t
,
true
,
data
[
"payment_visible_method_alipay_enabled"
])
require
.
Equal
(
t
,
false
,
data
[
"payment_visible_method_wxpay_enabled"
])
require
.
Equal
(
t
,
true
,
data
[
"openai_advanced_scheduler_enabled"
])
}
func
TestSettingHandler_UpdateSettings_PreservesLegacyBlankPaymentVisibleMethodSource
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
repo
:=
&
settingHandlerRepoStub
{
values
:
map
[
string
]
string
{
service
.
SettingKeyPromoCodeEnabled
:
"true"
,
service
.
SettingPaymentVisibleMethodAlipayEnabled
:
"true"
,
service
.
SettingPaymentVisibleMethodAlipaySource
:
""
,
service
.
SettingPaymentVisibleMethodWxpayEnabled
:
"false"
,
service
.
SettingPaymentVisibleMethodWxpaySource
:
""
,
},
}
svc
:=
service
.
NewSettingService
(
repo
,
&
config
.
Config
{
Default
:
config
.
DefaultConfig
{
UserConcurrency
:
5
}})
handler
:=
NewSettingHandler
(
svc
,
nil
,
nil
,
nil
,
nil
,
nil
)
body
:=
map
[
string
]
any
{
"promo_code_enabled"
:
false
,
}
rawBody
,
err
:=
json
.
Marshal
(
body
)
require
.
NoError
(
t
,
err
)
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPut
,
"/api/v1/admin/settings"
,
bytes
.
NewReader
(
rawBody
))
c
.
Request
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
handler
.
UpdateSettings
(
c
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
require
.
Equal
(
t
,
""
,
repo
.
values
[
service
.
SettingPaymentVisibleMethodAlipaySource
])
require
.
Equal
(
t
,
"true"
,
repo
.
values
[
service
.
SettingPaymentVisibleMethodAlipayEnabled
])
}
func
TestSettingHandler_UpdateSettings_PersistsExplicitFalseOIDCCompatibilityFlags
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
repo
:=
&
settingHandlerRepoStub
{
values
:
map
[
string
]
string
{
service
.
SettingKeyPromoCodeEnabled
:
"true"
,
service
.
SettingKeyOIDCConnectEnabled
:
"true"
,
service
.
SettingKeyOIDCConnectProviderName
:
"OIDC"
,
service
.
SettingKeyOIDCConnectClientID
:
"oidc-client"
,
service
.
SettingKeyOIDCConnectClientSecret
:
"oidc-secret"
,
service
.
SettingKeyOIDCConnectIssuerURL
:
"https://issuer.example.com"
,
service
.
SettingKeyOIDCConnectAuthorizeURL
:
"https://issuer.example.com/auth"
,
service
.
SettingKeyOIDCConnectTokenURL
:
"https://issuer.example.com/token"
,
service
.
SettingKeyOIDCConnectUserInfoURL
:
"https://issuer.example.com/userinfo"
,
service
.
SettingKeyOIDCConnectJWKSURL
:
"https://issuer.example.com/jwks"
,
service
.
SettingKeyOIDCConnectScopes
:
"openid email profile"
,
service
.
SettingKeyOIDCConnectRedirectURL
:
"https://example.com/api/v1/auth/oauth/oidc/callback"
,
service
.
SettingKeyOIDCConnectFrontendRedirectURL
:
"/auth/oidc/callback"
,
service
.
SettingKeyOIDCConnectTokenAuthMethod
:
"client_secret_post"
,
service
.
SettingKeyOIDCConnectUsePKCE
:
"true"
,
service
.
SettingKeyOIDCConnectValidateIDToken
:
"true"
,
service
.
SettingKeyOIDCConnectAllowedSigningAlgs
:
"RS256"
,
service
.
SettingKeyOIDCConnectClockSkewSeconds
:
"120"
,
},
}
svc
:=
service
.
NewSettingService
(
repo
,
&
config
.
Config
{
Default
:
config
.
DefaultConfig
{
UserConcurrency
:
5
}})
handler
:=
NewSettingHandler
(
svc
,
nil
,
nil
,
nil
,
nil
,
nil
)
body
:=
map
[
string
]
any
{
"promo_code_enabled"
:
true
,
"oidc_connect_enabled"
:
true
,
"oidc_connect_use_pkce"
:
false
,
"oidc_connect_validate_id_token"
:
false
,
"oidc_connect_allowed_signing_algs"
:
""
,
}
rawBody
,
err
:=
json
.
Marshal
(
body
)
require
.
NoError
(
t
,
err
)
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPut
,
"/api/v1/admin/settings"
,
bytes
.
NewReader
(
rawBody
))
c
.
Request
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
handler
.
UpdateSettings
(
c
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
require
.
Equal
(
t
,
"false"
,
repo
.
values
[
service
.
SettingKeyOIDCConnectUsePKCE
])
require
.
Equal
(
t
,
"false"
,
repo
.
values
[
service
.
SettingKeyOIDCConnectValidateIDToken
])
var
resp
response
.
Response
require
.
NoError
(
t
,
json
.
Unmarshal
(
rec
.
Body
.
Bytes
(),
&
resp
))
data
,
ok
:=
resp
.
Data
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
false
,
data
[
"oidc_connect_use_pkce"
])
require
.
Equal
(
t
,
false
,
data
[
"oidc_connect_validate_id_token"
])
}
func
TestSettingHandler_UpdateSettings_DoesNotSolidifyImplicitOIDCSecurityDefaultsOnLegacyUpgrade
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
repo
:=
&
settingHandlerRepoStub
{
values
:
map
[
string
]
string
{
service
.
SettingKeyPromoCodeEnabled
:
"true"
,
service
.
SettingKeyOIDCConnectEnabled
:
"true"
,
service
.
SettingKeyOIDCConnectProviderName
:
"OIDC"
,
service
.
SettingKeyOIDCConnectClientID
:
"oidc-client"
,
service
.
SettingKeyOIDCConnectClientSecret
:
"oidc-secret"
,
service
.
SettingKeyOIDCConnectIssuerURL
:
"https://issuer.example.com"
,
service
.
SettingKeyOIDCConnectAuthorizeURL
:
"https://issuer.example.com/auth"
,
service
.
SettingKeyOIDCConnectTokenURL
:
"https://issuer.example.com/token"
,
service
.
SettingKeyOIDCConnectUserInfoURL
:
"https://issuer.example.com/userinfo"
,
service
.
SettingKeyOIDCConnectJWKSURL
:
"https://issuer.example.com/jwks"
,
service
.
SettingKeyOIDCConnectScopes
:
"openid email profile"
,
service
.
SettingKeyOIDCConnectRedirectURL
:
"https://example.com/api/v1/auth/oauth/oidc/callback"
,
service
.
SettingKeyOIDCConnectFrontendRedirectURL
:
"/auth/oidc/callback"
,
service
.
SettingKeyOIDCConnectTokenAuthMethod
:
"client_secret_post"
,
service
.
SettingKeyOIDCConnectAllowedSigningAlgs
:
"RS256"
,
service
.
SettingKeyOIDCConnectClockSkewSeconds
:
"120"
,
service
.
SettingKeyOIDCConnectRequireEmailVerified
:
"false"
,
service
.
SettingKeyOIDCConnectUserInfoEmailPath
:
""
,
service
.
SettingKeyOIDCConnectUserInfoIDPath
:
""
,
service
.
SettingKeyOIDCConnectUserInfoUsernamePath
:
""
,
},
}
svc
:=
service
.
NewSettingService
(
repo
,
&
config
.
Config
{
Default
:
config
.
DefaultConfig
{
UserConcurrency
:
5
},
OIDC
:
config
.
OIDCConnectConfig
{
Enabled
:
true
,
ProviderName
:
"OIDC"
,
ClientID
:
"oidc-client"
,
ClientSecret
:
"oidc-secret"
,
IssuerURL
:
"https://issuer.example.com"
,
AuthorizeURL
:
"https://issuer.example.com/auth"
,
TokenURL
:
"https://issuer.example.com/token"
,
UserInfoURL
:
"https://issuer.example.com/userinfo"
,
JWKSURL
:
"https://issuer.example.com/jwks"
,
Scopes
:
"openid email profile"
,
RedirectURL
:
"https://example.com/api/v1/auth/oauth/oidc/callback"
,
FrontendRedirectURL
:
"/auth/oidc/callback"
,
TokenAuthMethod
:
"client_secret_post"
,
UsePKCE
:
true
,
ValidateIDToken
:
true
,
AllowedSigningAlgs
:
"RS256"
,
ClockSkewSeconds
:
120
,
},
})
handler
:=
NewSettingHandler
(
svc
,
nil
,
nil
,
nil
,
nil
,
nil
)
body
:=
map
[
string
]
any
{
"promo_code_enabled"
:
true
,
"oidc_connect_enabled"
:
true
,
}
rawBody
,
err
:=
json
.
Marshal
(
body
)
require
.
NoError
(
t
,
err
)
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPut
,
"/api/v1/admin/settings"
,
bytes
.
NewReader
(
rawBody
))
c
.
Request
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
handler
.
UpdateSettings
(
c
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
require
.
Equal
(
t
,
"false"
,
repo
.
values
[
service
.
SettingKeyOIDCConnectUsePKCE
])
require
.
Equal
(
t
,
"false"
,
repo
.
values
[
service
.
SettingKeyOIDCConnectValidateIDToken
])
}
func
TestSettingHandler_UpdateSettings_RejectsInvalidPaymentVisibleMethodSource
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
repo
:=
&
settingHandlerRepoStub
{
values
:
map
[
string
]
string
{
service
.
SettingKeyPromoCodeEnabled
:
"true"
,
},
}
svc
:=
service
.
NewSettingService
(
repo
,
&
config
.
Config
{
Default
:
config
.
DefaultConfig
{
UserConcurrency
:
5
}})
handler
:=
NewSettingHandler
(
svc
,
nil
,
nil
,
nil
,
nil
,
nil
)
body
:=
map
[
string
]
any
{
"promo_code_enabled"
:
true
,
"payment_visible_method_alipay_source"
:
"bogus"
,
}
rawBody
,
err
:=
json
.
Marshal
(
body
)
require
.
NoError
(
t
,
err
)
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPut
,
"/api/v1/admin/settings"
,
bytes
.
NewReader
(
rawBody
))
c
.
Request
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
handler
.
UpdateSettings
(
c
)
require
.
Equal
(
t
,
http
.
StatusBadRequest
,
rec
.
Code
)
require
.
NotContains
(
t
,
repo
.
values
,
service
.
SettingPaymentVisibleMethodAlipaySource
)
}
func
TestSettingHandler_UpdateSettings_DoesNotPersistPartialSystemSettingsWhenAuthSourceDefaultsFail
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
repo
:=
&
failingAuthSourceSettingsRepoStub
{
values
:
map
[
string
]
string
{
service
.
SettingKeyRegistrationEnabled
:
"false"
,
service
.
SettingKeyPromoCodeEnabled
:
"true"
,
service
.
SettingKeyAuthSourceDefaultEmailBalance
:
"9.5"
,
service
.
SettingKeyAuthSourceDefaultEmailConcurrency
:
"8"
,
service
.
SettingKeyAuthSourceDefaultEmailSubscriptions
:
`[{"group_id":31,"validity_days":15}]`
,
},
err
:
errors
.
New
(
"write auth source defaults failed"
),
}
svc
:=
service
.
NewSettingService
(
repo
,
&
config
.
Config
{
Default
:
config
.
DefaultConfig
{
UserConcurrency
:
5
}})
handler
:=
NewSettingHandler
(
svc
,
nil
,
nil
,
nil
,
nil
,
nil
)
body
:=
map
[
string
]
any
{
"registration_enabled"
:
true
,
"promo_code_enabled"
:
true
,
"auth_source_default_email_balance"
:
12.75
,
}
rawBody
,
err
:=
json
.
Marshal
(
body
)
require
.
NoError
(
t
,
err
)
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPut
,
"/api/v1/admin/settings"
,
bytes
.
NewReader
(
rawBody
))
c
.
Request
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
handler
.
UpdateSettings
(
c
)
require
.
Equal
(
t
,
http
.
StatusInternalServerError
,
rec
.
Code
)
require
.
Equal
(
t
,
"false"
,
repo
.
values
[
service
.
SettingKeyRegistrationEnabled
])
require
.
Equal
(
t
,
"9.5"
,
repo
.
values
[
service
.
SettingKeyAuthSourceDefaultEmailBalance
])
}
func
TestDiffSettings_IncludesAuthSourceDefaultsAndForceEmail
(
t
*
testing
.
T
)
{
changed
:=
diffSettings
(
&
service
.
SystemSettings
{},
&
service
.
SystemSettings
{},
&
service
.
AuthSourceDefaultSettings
{
Email
:
service
.
ProviderDefaultGrantSettings
{
Balance
:
0
,
Concurrency
:
5
,
Subscriptions
:
nil
,
GrantOnSignup
:
true
,
GrantOnFirstBind
:
false
,
},
ForceEmailOnThirdPartySignup
:
false
,
},
&
service
.
AuthSourceDefaultSettings
{
Email
:
service
.
ProviderDefaultGrantSettings
{
Balance
:
12.5
,
Concurrency
:
7
,
Subscriptions
:
[]
service
.
DefaultSubscriptionSetting
{{
GroupID
:
21
,
ValidityDays
:
30
}},
GrantOnSignup
:
false
,
GrantOnFirstBind
:
true
,
},
ForceEmailOnThirdPartySignup
:
true
,
},
UpdateSettingsRequest
{},
)
require
.
Contains
(
t
,
changed
,
"auth_source_default_email_balance"
)
require
.
Contains
(
t
,
changed
,
"auth_source_default_email_concurrency"
)
require
.
Contains
(
t
,
changed
,
"auth_source_default_email_subscriptions"
)
require
.
Contains
(
t
,
changed
,
"auth_source_default_email_grant_on_signup"
)
require
.
Contains
(
t
,
changed
,
"auth_source_default_email_grant_on_first_bind"
)
require
.
Contains
(
t
,
changed
,
"force_email_on_third_party_signup"
)
}
backend/internal/handler/admin/user_handler.go
View file @
b017f461
...
@@ -40,6 +40,7 @@ type CreateUserRequest struct {
...
@@ -40,6 +40,7 @@ type CreateUserRequest struct {
Notes
string
`json:"notes"`
Notes
string
`json:"notes"`
Balance
float64
`json:"balance"`
Balance
float64
`json:"balance"`
Concurrency
int
`json:"concurrency"`
Concurrency
int
`json:"concurrency"`
RPMLimit
int
`json:"rpm_limit"`
AllowedGroups
[]
int64
`json:"allowed_groups"`
AllowedGroups
[]
int64
`json:"allowed_groups"`
}
}
...
@@ -52,6 +53,7 @@ type UpdateUserRequest struct {
...
@@ -52,6 +53,7 @@ type UpdateUserRequest struct {
Notes
*
string
`json:"notes"`
Notes
*
string
`json:"notes"`
Balance
*
float64
`json:"balance"`
Balance
*
float64
`json:"balance"`
Concurrency
*
int
`json:"concurrency"`
Concurrency
*
int
`json:"concurrency"`
RPMLimit
*
int
`json:"rpm_limit"`
Status
string
`json:"status" binding:"omitempty,oneof=active disabled"`
Status
string
`json:"status" binding:"omitempty,oneof=active disabled"`
AllowedGroups
*
[]
int64
`json:"allowed_groups"`
AllowedGroups
*
[]
int64
`json:"allowed_groups"`
// GroupRates 用户专属分组倍率配置
// GroupRates 用户专属分组倍率配置
...
@@ -66,6 +68,22 @@ type UpdateBalanceRequest struct {
...
@@ -66,6 +68,22 @@ type UpdateBalanceRequest struct {
Notes
string
`json:"notes"`
Notes
string
`json:"notes"`
}
}
type
BindUserAuthIdentityRequest
struct
{
ProviderType
string
`json:"provider_type"`
ProviderKey
string
`json:"provider_key"`
ProviderSubject
string
`json:"provider_subject"`
Issuer
*
string
`json:"issuer"`
Metadata
map
[
string
]
any
`json:"metadata"`
Channel
*
BindUserAuthIdentityChannelRequest
`json:"channel"`
}
type
BindUserAuthIdentityChannelRequest
struct
{
Channel
string
`json:"channel"`
ChannelAppID
string
`json:"channel_app_id"`
ChannelSubject
string
`json:"channel_subject"`
Metadata
map
[
string
]
any
`json:"metadata"`
}
// List handles listing all users with pagination
// List handles listing all users with pagination
// GET /api/v1/admin/users
// GET /api/v1/admin/users
// Query params:
// Query params:
...
@@ -172,6 +190,45 @@ func (h *UserHandler) GetByID(c *gin.Context) {
...
@@ -172,6 +190,45 @@ func (h *UserHandler) GetByID(c *gin.Context) {
response
.
Success
(
c
,
dto
.
UserFromServiceAdmin
(
user
))
response
.
Success
(
c
,
dto
.
UserFromServiceAdmin
(
user
))
}
}
// BindAuthIdentity manually binds a canonical auth identity to a user.
// POST /api/v1/admin/users/:id/auth-identities
func
(
h
*
UserHandler
)
BindAuthIdentity
(
c
*
gin
.
Context
)
{
userID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid user ID"
)
return
}
var
req
BindUserAuthIdentityRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
input
:=
service
.
AdminBindAuthIdentityInput
{
ProviderType
:
req
.
ProviderType
,
ProviderKey
:
req
.
ProviderKey
,
ProviderSubject
:
req
.
ProviderSubject
,
Issuer
:
req
.
Issuer
,
Metadata
:
req
.
Metadata
,
}
if
req
.
Channel
!=
nil
{
input
.
Channel
=
&
service
.
AdminBindAuthIdentityChannelInput
{
Channel
:
req
.
Channel
.
Channel
,
ChannelAppID
:
req
.
Channel
.
ChannelAppID
,
ChannelSubject
:
req
.
Channel
.
ChannelSubject
,
Metadata
:
req
.
Channel
.
Metadata
,
}
}
result
,
err
:=
h
.
adminService
.
BindUserAuthIdentity
(
c
.
Request
.
Context
(),
userID
,
input
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
result
)
}
// Create handles creating a new user
// Create handles creating a new user
// POST /api/v1/admin/users
// POST /api/v1/admin/users
func
(
h
*
UserHandler
)
Create
(
c
*
gin
.
Context
)
{
func
(
h
*
UserHandler
)
Create
(
c
*
gin
.
Context
)
{
...
@@ -188,6 +245,7 @@ func (h *UserHandler) Create(c *gin.Context) {
...
@@ -188,6 +245,7 @@ func (h *UserHandler) Create(c *gin.Context) {
Notes
:
req
.
Notes
,
Notes
:
req
.
Notes
,
Balance
:
req
.
Balance
,
Balance
:
req
.
Balance
,
Concurrency
:
req
.
Concurrency
,
Concurrency
:
req
.
Concurrency
,
RPMLimit
:
req
.
RPMLimit
,
AllowedGroups
:
req
.
AllowedGroups
,
AllowedGroups
:
req
.
AllowedGroups
,
})
})
if
err
!=
nil
{
if
err
!=
nil
{
...
@@ -221,6 +279,7 @@ func (h *UserHandler) Update(c *gin.Context) {
...
@@ -221,6 +279,7 @@ func (h *UserHandler) Update(c *gin.Context) {
Notes
:
req
.
Notes
,
Notes
:
req
.
Notes
,
Balance
:
req
.
Balance
,
Balance
:
req
.
Balance
,
Concurrency
:
req
.
Concurrency
,
Concurrency
:
req
.
Concurrency
,
RPMLimit
:
req
.
RPMLimit
,
Status
:
req
.
Status
,
Status
:
req
.
Status
,
AllowedGroups
:
req
.
AllowedGroups
,
AllowedGroups
:
req
.
AllowedGroups
,
GroupRates
:
req
.
GroupRates
,
GroupRates
:
req
.
GroupRates
,
...
@@ -400,3 +459,21 @@ func (h *UserHandler) ReplaceGroup(c *gin.Context) {
...
@@ -400,3 +459,21 @@ func (h *UserHandler) ReplaceGroup(c *gin.Context) {
"migrated_keys"
:
result
.
MigratedKeys
,
"migrated_keys"
:
result
.
MigratedKeys
,
})
})
}
}
// GetUserRPMStatus 返回指定用户当前分钟的 RPM 用量
// GET /api/v1/admin/users/:id/rpm-status
func
(
h
*
UserHandler
)
GetUserRPMStatus
(
c
*
gin
.
Context
)
{
userID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid user ID"
)
return
}
status
,
err
:=
h
.
adminService
.
GetUserRPMStatus
(
c
.
Request
.
Context
(),
userID
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
status
)
}
backend/internal/handler/admin/user_handler_activity_test.go
0 → 100644
View file @
b017f461
//go:build unit
package
admin
import
(
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func
TestUserHandlerListIncludesActivityFieldsAndSortParams
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
lastLoginAt
:=
time
.
Date
(
2026
,
4
,
20
,
8
,
0
,
0
,
0
,
time
.
UTC
)
lastActiveAt
:=
lastLoginAt
.
Add
(
30
*
time
.
Minute
)
lastUsedAt
:=
lastLoginAt
.
Add
(
90
*
time
.
Minute
)
adminSvc
:=
newStubAdminService
()
adminSvc
.
users
=
[]
service
.
User
{
{
ID
:
7
,
Email
:
"activity@example.com"
,
Username
:
"activity-user"
,
Role
:
service
.
RoleUser
,
Status
:
service
.
StatusActive
,
LastActiveAt
:
&
lastActiveAt
,
LastUsedAt
:
&
lastUsedAt
,
CreatedAt
:
lastLoginAt
.
Add
(
-
24
*
time
.
Hour
),
UpdatedAt
:
lastLoginAt
,
},
}
handler
:=
NewUserHandler
(
adminSvc
,
nil
)
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/users?sort_by=last_used_at&sort_order=asc&search=activity"
,
nil
,
)
handler
.
List
(
c
)
require
.
Equal
(
t
,
http
.
StatusOK
,
recorder
.
Code
)
require
.
Equal
(
t
,
"last_used_at"
,
adminSvc
.
lastListUsers
.
sortBy
)
require
.
Equal
(
t
,
"asc"
,
adminSvc
.
lastListUsers
.
sortOrder
)
require
.
Equal
(
t
,
"activity"
,
adminSvc
.
lastListUsers
.
filters
.
Search
)
var
resp
struct
{
Code
int
`json:"code"`
Data
struct
{
Items
[]
struct
{
LastActiveAt
*
time
.
Time
`json:"last_active_at"`
LastUsedAt
*
time
.
Time
`json:"last_used_at"`
}
`json:"items"`
}
`json:"data"`
}
require
.
NoError
(
t
,
json
.
Unmarshal
(
recorder
.
Body
.
Bytes
(),
&
resp
))
require
.
Equal
(
t
,
0
,
resp
.
Code
)
require
.
Len
(
t
,
resp
.
Data
.
Items
,
1
)
require
.
WithinDuration
(
t
,
lastActiveAt
,
*
resp
.
Data
.
Items
[
0
]
.
LastActiveAt
,
time
.
Second
)
require
.
WithinDuration
(
t
,
lastUsedAt
,
*
resp
.
Data
.
Items
[
0
]
.
LastUsedAt
,
time
.
Second
)
}
func
TestUserHandlerGetByIDIncludesActivityFields
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
lastLoginAt
:=
time
.
Date
(
2026
,
4
,
20
,
8
,
0
,
0
,
0
,
time
.
UTC
)
lastActiveAt
:=
lastLoginAt
.
Add
(
30
*
time
.
Minute
)
lastUsedAt
:=
lastLoginAt
.
Add
(
90
*
time
.
Minute
)
adminSvc
:=
newStubAdminService
()
adminSvc
.
users
=
[]
service
.
User
{
{
ID
:
8
,
Email
:
"detail@example.com"
,
Username
:
"detail-user"
,
Role
:
service
.
RoleUser
,
Status
:
service
.
StatusActive
,
LastActiveAt
:
&
lastActiveAt
,
LastUsedAt
:
&
lastUsedAt
,
CreatedAt
:
lastLoginAt
.
Add
(
-
24
*
time
.
Hour
),
UpdatedAt
:
lastLoginAt
,
},
}
handler
:=
NewUserHandler
(
adminSvc
,
nil
)
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
c
.
Params
=
gin
.
Params
{{
Key
:
"id"
,
Value
:
"8"
}}
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/users/8"
,
nil
)
handler
.
GetByID
(
c
)
require
.
Equal
(
t
,
http
.
StatusOK
,
recorder
.
Code
)
var
resp
struct
{
Code
int
`json:"code"`
Data
struct
{
LastActiveAt
*
time
.
Time
`json:"last_active_at"`
LastUsedAt
*
time
.
Time
`json:"last_used_at"`
}
`json:"data"`
}
require
.
NoError
(
t
,
json
.
Unmarshal
(
recorder
.
Body
.
Bytes
(),
&
resp
))
require
.
Equal
(
t
,
0
,
resp
.
Code
)
require
.
WithinDuration
(
t
,
lastActiveAt
,
*
resp
.
Data
.
LastActiveAt
,
time
.
Second
)
require
.
WithinDuration
(
t
,
lastUsedAt
,
*
resp
.
Data
.
LastUsedAt
,
time
.
Second
)
}
backend/internal/handler/auth_current_user_test.go
0 → 100644
View file @
b017f461
//go:build unit
package
handler
import
(
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
middleware2
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func
TestAuthHandlerGetCurrentUserReturnsProfileCompatibilityFields
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
verifiedAt
:=
time
.
Date
(
2026
,
4
,
20
,
8
,
30
,
0
,
0
,
time
.
UTC
)
repo
:=
&
userHandlerRepoStub
{
user
:
&
service
.
User
{
ID
:
31
,
Email
:
"me@example.com"
,
Username
:
"linuxdo-handle"
,
Role
:
service
.
RoleUser
,
Status
:
service
.
StatusActive
,
AvatarURL
:
"https://cdn.example.com/linuxdo.png"
,
AvatarSource
:
"remote_url"
,
},
identities
:
[]
service
.
UserAuthIdentityRecord
{
{
ProviderType
:
"linuxdo"
,
ProviderKey
:
"linuxdo"
,
ProviderSubject
:
"linuxdo-subject-31"
,
VerifiedAt
:
&
verifiedAt
,
Metadata
:
map
[
string
]
any
{
"username"
:
"linuxdo-handle"
,
"avatar_url"
:
"https://cdn.example.com/linuxdo.png"
,
},
},
},
}
handler
:=
&
AuthHandler
{
userService
:
service
.
NewUserService
(
repo
,
nil
,
nil
,
nil
),
}
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/auth/me"
,
nil
)
c
.
Set
(
string
(
middleware2
.
ContextKeyUser
),
middleware2
.
AuthSubject
{
UserID
:
31
})
handler
.
GetCurrentUser
(
c
)
require
.
Equal
(
t
,
http
.
StatusOK
,
recorder
.
Code
)
var
resp
struct
{
Code
int
`json:"code"`
Data
map
[
string
]
any
`json:"data"`
}
require
.
NoError
(
t
,
json
.
Unmarshal
(
recorder
.
Body
.
Bytes
(),
&
resp
))
require
.
Equal
(
t
,
0
,
resp
.
Code
)
require
.
Equal
(
t
,
true
,
resp
.
Data
[
"email_bound"
])
require
.
Equal
(
t
,
true
,
resp
.
Data
[
"linuxdo_bound"
])
require
.
Equal
(
t
,
"https://cdn.example.com/linuxdo.png"
,
resp
.
Data
[
"avatar_url"
])
authBindings
,
ok
:=
resp
.
Data
[
"auth_bindings"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
linuxdoBinding
,
ok
:=
authBindings
[
"linuxdo"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
true
,
linuxdoBinding
[
"bound"
])
avatarSource
,
ok
:=
resp
.
Data
[
"avatar_source"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
"linuxdo"
,
avatarSource
[
"provider"
])
require
.
Equal
(
t
,
"linuxdo"
,
avatarSource
[
"source"
])
profileSources
,
ok
:=
resp
.
Data
[
"profile_sources"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
usernameSource
,
ok
:=
profileSources
[
"username"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
"linuxdo"
,
usernameSource
[
"provider"
])
require
.
Equal
(
t
,
"linuxdo"
,
usernameSource
[
"source"
])
}
backend/internal/handler/auth_handler.go
View file @
b017f461
package
handler
package
handler
import
(
import
(
"context"
"log/slog"
"log/slog"
"strings"
"strings"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
middleware2
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
...
@@ -76,9 +78,24 @@ type AuthResponse struct {
...
@@ -76,9 +78,24 @@ type AuthResponse struct {
User
*
dto
.
User
`json:"user"`
User
*
dto
.
User
`json:"user"`
}
}
func
ensureLoginUserActive
(
user
*
service
.
User
)
error
{
if
user
==
nil
{
return
infraerrors
.
Unauthorized
(
"INVALID_USER"
,
"user not found"
)
}
if
!
user
.
IsActive
()
{
return
service
.
ErrUserNotActive
}
return
nil
}
// respondWithTokenPair 生成 Token 对并返回认证响应
// respondWithTokenPair 生成 Token 对并返回认证响应
// 如果 Token 对生成失败,回退到只返回 Access Token(向后兼容)
// 如果 Token 对生成失败,回退到只返回 Access Token(向后兼容)
func
(
h
*
AuthHandler
)
respondWithTokenPair
(
c
*
gin
.
Context
,
user
*
service
.
User
)
{
func
(
h
*
AuthHandler
)
respondWithTokenPair
(
c
*
gin
.
Context
,
user
*
service
.
User
)
{
if
err
:=
ensureLoginUserActive
(
user
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
tokenPair
,
err
:=
h
.
authService
.
GenerateTokenPair
(
c
.
Request
.
Context
(),
user
,
""
)
tokenPair
,
err
:=
h
.
authService
.
GenerateTokenPair
(
c
.
Request
.
Context
(),
user
,
""
)
if
err
!=
nil
{
if
err
!=
nil
{
slog
.
Error
(
"failed to generate token pair"
,
"error"
,
err
,
"user_id"
,
user
.
ID
)
slog
.
Error
(
"failed to generate token pair"
,
"error"
,
err
,
"user_id"
,
user
.
ID
)
...
@@ -104,6 +121,34 @@ func (h *AuthHandler) respondWithTokenPair(c *gin.Context, user *service.User) {
...
@@ -104,6 +121,34 @@ func (h *AuthHandler) respondWithTokenPair(c *gin.Context, user *service.User) {
})
})
}
}
func
(
h
*
AuthHandler
)
ensureBackendModeAllowsUser
(
ctx
context
.
Context
,
user
*
service
.
User
)
error
{
if
user
==
nil
{
return
infraerrors
.
Unauthorized
(
"INVALID_USER"
,
"user not found"
)
}
if
h
==
nil
||
!
h
.
isBackendModeEnabled
(
ctx
)
||
user
.
IsAdmin
()
{
return
nil
}
return
infraerrors
.
Forbidden
(
"BACKEND_MODE_ADMIN_ONLY"
,
"Backend mode is active. Only admin login is allowed."
)
}
func
(
h
*
AuthHandler
)
ensureBackendModeAllowsNewUserLogin
(
ctx
context
.
Context
)
error
{
if
h
==
nil
||
!
h
.
isBackendModeEnabled
(
ctx
)
{
return
nil
}
return
infraerrors
.
Forbidden
(
"BACKEND_MODE_ADMIN_ONLY"
,
"Backend mode is active. Only admin login is allowed."
)
}
func
(
h
*
AuthHandler
)
isBackendModeEnabled
(
ctx
context
.
Context
)
bool
{
if
h
==
nil
||
h
.
settingSvc
==
nil
{
return
false
}
settings
,
err
:=
h
.
settingSvc
.
GetPublicSettings
(
ctx
)
if
err
==
nil
&&
settings
!=
nil
{
return
settings
.
BackendModeEnabled
}
return
h
.
settingSvc
.
IsBackendModeEnabled
(
ctx
)
}
// Register handles user registration
// Register handles user registration
// POST /api/v1/auth/register
// POST /api/v1/auth/register
func
(
h
*
AuthHandler
)
Register
(
c
*
gin
.
Context
)
{
func
(
h
*
AuthHandler
)
Register
(
c
*
gin
.
Context
)
{
...
@@ -177,6 +222,11 @@ func (h *AuthHandler) Login(c *gin.Context) {
...
@@ -177,6 +222,11 @@ func (h *AuthHandler) Login(c *gin.Context) {
}
}
_
=
token
// token 由 authService.Login 返回但此处由 respondWithTokenPair 重新生成
_
=
token
// token 由 authService.Login 返回但此处由 respondWithTokenPair 重新生成
if
err
:=
h
.
ensureBackendModeAllowsUser
(
c
.
Request
.
Context
(),
user
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
// Check if TOTP 2FA is enabled for this user
// Check if TOTP 2FA is enabled for this user
if
h
.
totpService
!=
nil
&&
h
.
settingSvc
.
IsTotpEnabled
(
c
.
Request
.
Context
())
&&
user
.
TotpEnabled
{
if
h
.
totpService
!=
nil
&&
h
.
settingSvc
.
IsTotpEnabled
(
c
.
Request
.
Context
())
&&
user
.
TotpEnabled
{
// Create a temporary login session for 2FA
// Create a temporary login session for 2FA
...
@@ -194,11 +244,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
...
@@ -194,11 +244,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
return
return
}
}
// Backend mode: only admin can login
h
.
authService
.
RecordSuccessfulLogin
(
c
.
Request
.
Context
(),
user
.
ID
)
if
h
.
settingSvc
.
IsBackendModeEnabled
(
c
.
Request
.
Context
())
&&
!
user
.
IsAdmin
()
{
response
.
Forbidden
(
c
,
"Backend mode is active. Only admin login is allowed."
)
return
}
h
.
respondWithTokenPair
(
c
,
user
)
h
.
respondWithTokenPair
(
c
,
user
)
}
}
...
@@ -262,16 +308,80 @@ func (h *AuthHandler) Login2FA(c *gin.Context) {
...
@@ -262,16 +308,80 @@ func (h *AuthHandler) Login2FA(c *gin.Context) {
response
.
ErrorFrom
(
c
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
return
}
}
if
err
:=
ensureLoginUserActive
(
user
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
// Backend mode: only admin can login (check BEFORE deleting session)
if
err
:=
h
.
ensureBackendModeAllowsUser
(
c
.
Request
.
Context
(),
user
);
err
!=
nil
{
if
h
.
settingSvc
.
IsBackendModeEnabled
(
c
.
Request
.
Context
())
&&
!
user
.
IsAdmin
()
{
response
.
ErrorFrom
(
c
,
err
)
response
.
Forbidden
(
c
,
"Backend mode is active. Only admin login is allowed."
)
return
return
}
}
if
session
.
PendingOAuthBind
!=
nil
{
pendingSvc
,
err
:=
h
.
pendingIdentityService
()
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
pendingSession
,
err
:=
pendingSvc
.
GetBrowserSession
(
c
.
Request
.
Context
(),
session
.
PendingOAuthBind
.
PendingSessionToken
,
session
.
PendingOAuthBind
.
BrowserSessionKey
,
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
decision
,
err
:=
h
.
ensurePendingOAuthAdoptionDecision
(
c
,
pendingSession
.
ID
,
oauthAdoptionDecisionRequest
{})
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
if
err
:=
applyPendingOAuthBinding
(
c
.
Request
.
Context
(),
h
.
entClient
(),
h
.
authService
,
h
.
userService
,
pendingSession
,
decision
,
&
user
.
ID
,
true
,
true
,
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_BIND_APPLY_FAILED"
,
"failed to bind pending oauth identity"
)
.
WithCause
(
err
))
return
}
if
_
,
err
:=
pendingSvc
.
ConsumeBrowserSession
(
c
.
Request
.
Context
(),
pendingSession
.
SessionToken
,
pendingSession
.
BrowserSessionKey
,
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
secureCookie
:=
isRequestHTTPS
(
c
)
clearOAuthPendingSessionCookie
(
c
,
secureCookie
)
clearOAuthPendingBrowserCookie
(
c
,
secureCookie
)
h
.
authService
.
RecordSuccessfulLogin
(
c
.
Request
.
Context
(),
user
.
ID
)
user
,
err
=
h
.
userService
.
GetByID
(
c
.
Request
.
Context
(),
session
.
UserID
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
}
// Delete the login session (only after all checks pass)
// Delete the login session (only after all checks pass)
_
=
h
.
totpService
.
DeleteLoginSession
(
c
.
Request
.
Context
(),
req
.
TempToken
)
_
=
h
.
totpService
.
DeleteLoginSession
(
c
.
Request
.
Context
(),
req
.
TempToken
)
if
session
.
PendingOAuthBind
==
nil
{
h
.
authService
.
RecordSuccessfulLogin
(
c
.
Request
.
Context
(),
user
.
ID
)
}
h
.
respondWithTokenPair
(
c
,
user
)
h
.
respondWithTokenPair
(
c
,
user
)
}
}
...
@@ -290,8 +400,14 @@ func (h *AuthHandler) GetCurrentUser(c *gin.Context) {
...
@@ -290,8 +400,14 @@ func (h *AuthHandler) GetCurrentUser(c *gin.Context) {
return
return
}
}
identities
,
err
:=
h
.
userService
.
GetProfileIdentitySummaries
(
c
.
Request
.
Context
(),
subject
.
UserID
,
user
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
type
UserResponse
struct
{
type
UserResponse
struct
{
*
dto
.
User
userProfileResponse
RunMode
string
`json:"run_mode"`
RunMode
string
`json:"run_mode"`
}
}
...
@@ -300,7 +416,10 @@ func (h *AuthHandler) GetCurrentUser(c *gin.Context) {
...
@@ -300,7 +416,10 @@ func (h *AuthHandler) GetCurrentUser(c *gin.Context) {
runMode
=
h
.
cfg
.
RunMode
runMode
=
h
.
cfg
.
RunMode
}
}
response
.
Success
(
c
,
UserResponse
{
User
:
dto
.
UserFromService
(
user
),
RunMode
:
runMode
})
response
.
Success
(
c
,
UserResponse
{
userProfileResponse
:
userProfileResponseFromService
(
user
,
identities
),
RunMode
:
runMode
,
})
}
}
// ValidatePromoCodeRequest 验证优惠码请求
// ValidatePromoCodeRequest 验证优惠码请求
...
@@ -578,6 +697,8 @@ func (h *AuthHandler) Logout(c *gin.Context) {
...
@@ -578,6 +697,8 @@ func (h *AuthHandler) Logout(c *gin.Context) {
// 不影响登出流程
// 不影响登出流程
}
}
}
}
h
.
consumePendingOAuthSessionOnLogout
(
c
)
clearOAuthLogoutCookies
(
c
)
response
.
Success
(
c
,
LogoutResponse
{
response
.
Success
(
c
,
LogoutResponse
{
Message
:
"Logged out successfully"
,
Message
:
"Logged out successfully"
,
...
@@ -598,7 +719,7 @@ func (h *AuthHandler) RevokeAllSessions(c *gin.Context) {
...
@@ -598,7 +719,7 @@ func (h *AuthHandler) RevokeAllSessions(c *gin.Context) {
return
return
}
}
if
err
:=
h
.
authService
.
RevokeAllUser
Sessio
ns
(
c
.
Request
.
Context
(),
subject
.
UserID
);
err
!=
nil
{
if
err
:=
h
.
authService
.
RevokeAllUser
Toke
ns
(
c
.
Request
.
Context
(),
subject
.
UserID
);
err
!=
nil
{
slog
.
Error
(
"failed to revoke all sessions"
,
"user_id"
,
subject
.
UserID
,
"error"
,
err
)
slog
.
Error
(
"failed to revoke all sessions"
,
"user_id"
,
subject
.
UserID
,
"error"
,
err
)
response
.
InternalError
(
c
,
"Failed to revoke sessions"
)
response
.
InternalError
(
c
,
"Failed to revoke sessions"
)
return
return
...
...
backend/internal/handler/auth_linuxdo_oauth.go
View file @
b017f461
...
@@ -2,6 +2,8 @@ package handler
...
@@ -2,6 +2,8 @@ package handler
import
(
import
(
"context"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/base64"
"errors"
"errors"
"fmt"
"fmt"
...
@@ -13,10 +15,13 @@ import (
...
@@ -13,10 +15,13 @@ import (
"time"
"time"
"unicode/utf8"
"unicode/utf8"
dbent
"github.com/Wei-Shaw/sub2api/ent"
dbuser
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
servermiddleware
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin"
...
@@ -25,17 +30,24 @@ import (
...
@@ -25,17 +30,24 @@ import (
)
)
const
(
const
(
linuxDoOAuthCookiePath
=
"/api/v1/auth/oauth/linuxdo"
linuxDoOAuthCookiePath
=
"/api/v1/auth/oauth/linuxdo"
linuxDoOAuthStateCookieName
=
"linuxdo_oauth_state"
oauthBindAccessTokenCookiePath
=
"/api/v1/auth/oauth"
linuxDoOAuthVerifierCookie
=
"linuxdo_oauth_verifier"
linuxDoOAuthStateCookieName
=
"linuxdo_oauth_state"
linuxDoOAuthRedirectCookie
=
"linuxdo_oauth_redirect"
linuxDoOAuthVerifierCookie
=
"linuxdo_oauth_verifier"
linuxDoOAuthCookieMaxAgeSec
=
10
*
60
// 10 minutes
linuxDoOAuthRedirectCookie
=
"linuxdo_oauth_redirect"
linuxDoOAuthDefaultRedirectTo
=
"/dashboard"
linuxDoOAuthIntentCookieName
=
"linuxdo_oauth_intent"
linuxDoOAuthDefaultFrontendCB
=
"/auth/linuxdo/callback"
linuxDoOAuthBindUserCookieName
=
"linuxdo_oauth_bind_user"
oauthBindAccessTokenCookieName
=
"oauth_bind_access_token"
linuxDoOAuthCookieMaxAgeSec
=
10
*
60
// 10 minutes
linuxDoOAuthDefaultRedirectTo
=
"/dashboard"
linuxDoOAuthDefaultFrontendCB
=
"/auth/linuxdo/callback"
linuxDoOAuthMaxRedirectLen
=
2048
linuxDoOAuthMaxRedirectLen
=
2048
linuxDoOAuthMaxFragmentValueLen
=
512
linuxDoOAuthMaxFragmentValueLen
=
512
linuxDoOAuthMaxSubjectLen
=
64
-
len
(
"linuxdo-"
)
linuxDoOAuthMaxSubjectLen
=
64
-
len
(
"linuxdo-"
)
oauthIntentLogin
=
"login"
oauthIntentBindCurrentUser
=
"bind_current_user"
)
)
type
linuxDoTokenResponse
struct
{
type
linuxDoTokenResponse
struct
{
...
@@ -87,9 +99,29 @@ func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
...
@@ -87,9 +99,29 @@ func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
redirectTo
=
linuxDoOAuthDefaultRedirectTo
redirectTo
=
linuxDoOAuthDefaultRedirectTo
}
}
browserSessionKey
,
err
:=
generateOAuthPendingBrowserSession
()
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"OAUTH_BROWSER_SESSION_GEN_FAILED"
,
"failed to generate oauth browser session"
)
.
WithCause
(
err
))
return
}
secureCookie
:=
isRequestHTTPS
(
c
)
secureCookie
:=
isRequestHTTPS
(
c
)
setCookie
(
c
,
linuxDoOAuthStateCookieName
,
encodeCookieValue
(
state
),
linuxDoOAuthCookieMaxAgeSec
,
secureCookie
)
setCookie
(
c
,
linuxDoOAuthStateCookieName
,
encodeCookieValue
(
state
),
linuxDoOAuthCookieMaxAgeSec
,
secureCookie
)
setCookie
(
c
,
linuxDoOAuthRedirectCookie
,
encodeCookieValue
(
redirectTo
),
linuxDoOAuthCookieMaxAgeSec
,
secureCookie
)
setCookie
(
c
,
linuxDoOAuthRedirectCookie
,
encodeCookieValue
(
redirectTo
),
linuxDoOAuthCookieMaxAgeSec
,
secureCookie
)
intent
:=
normalizeOAuthIntent
(
c
.
Query
(
"intent"
))
setCookie
(
c
,
linuxDoOAuthIntentCookieName
,
encodeCookieValue
(
intent
),
linuxDoOAuthCookieMaxAgeSec
,
secureCookie
)
setOAuthPendingBrowserCookie
(
c
,
browserSessionKey
,
secureCookie
)
clearOAuthPendingSessionCookie
(
c
,
secureCookie
)
if
intent
==
oauthIntentBindCurrentUser
{
bindCookieValue
,
err
:=
h
.
buildOAuthBindUserCookieFromContext
(
c
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
setCookie
(
c
,
linuxDoOAuthBindUserCookieName
,
encodeCookieValue
(
bindCookieValue
),
linuxDoOAuthCookieMaxAgeSec
,
secureCookie
)
}
else
{
clearCookie
(
c
,
linuxDoOAuthBindUserCookieName
,
secureCookie
)
}
codeChallenge
:=
""
codeChallenge
:=
""
if
cfg
.
UsePKCE
{
if
cfg
.
UsePKCE
{
...
@@ -148,6 +180,8 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
...
@@ -148,6 +180,8 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
clearCookie
(
c
,
linuxDoOAuthStateCookieName
,
secureCookie
)
clearCookie
(
c
,
linuxDoOAuthStateCookieName
,
secureCookie
)
clearCookie
(
c
,
linuxDoOAuthVerifierCookie
,
secureCookie
)
clearCookie
(
c
,
linuxDoOAuthVerifierCookie
,
secureCookie
)
clearCookie
(
c
,
linuxDoOAuthRedirectCookie
,
secureCookie
)
clearCookie
(
c
,
linuxDoOAuthRedirectCookie
,
secureCookie
)
clearCookie
(
c
,
linuxDoOAuthIntentCookieName
,
secureCookie
)
clearCookie
(
c
,
linuxDoOAuthBindUserCookieName
,
secureCookie
)
}()
}()
expectedState
,
err
:=
readCookieDecoded
(
c
,
linuxDoOAuthStateCookieName
)
expectedState
,
err
:=
readCookieDecoded
(
c
,
linuxDoOAuthStateCookieName
)
...
@@ -161,6 +195,13 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
...
@@ -161,6 +195,13 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
if
redirectTo
==
""
{
if
redirectTo
==
""
{
redirectTo
=
linuxDoOAuthDefaultRedirectTo
redirectTo
=
linuxDoOAuthDefaultRedirectTo
}
}
browserSessionKey
,
_
:=
readOAuthPendingBrowserCookie
(
c
)
if
strings
.
TrimSpace
(
browserSessionKey
)
==
""
{
redirectOAuthError
(
c
,
frontendCallback
,
"missing_browser_session"
,
"missing oauth browser session"
,
""
)
return
}
intent
,
_
:=
readCookieDecoded
(
c
,
linuxDoOAuthIntentCookieName
)
intent
=
normalizeOAuthIntent
(
intent
)
codeVerifier
:=
""
codeVerifier
:=
""
if
cfg
.
UsePKCE
{
if
cfg
.
UsePKCE
{
...
@@ -198,52 +239,204 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
...
@@ -198,52 +239,204 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
return
return
}
}
email
,
username
,
subject
,
err
:=
linuxDoFetchUserInfo
(
c
.
Request
.
Context
(),
cfg
,
tokenResp
)
email
,
username
,
subject
,
displayName
,
avatarURL
,
err
:=
linuxDoFetchUserInfo
(
c
.
Request
.
Context
(),
cfg
,
tokenResp
)
if
err
!=
nil
{
if
err
!=
nil
{
log
.
Printf
(
"[LinuxDo OAuth] userinfo fetch failed: %v"
,
err
)
log
.
Printf
(
"[LinuxDo OAuth] userinfo fetch failed: %v"
,
err
)
redirectOAuthError
(
c
,
frontendCallback
,
"userinfo_failed"
,
"failed to fetch user info"
,
""
)
redirectOAuthError
(
c
,
frontendCallback
,
"userinfo_failed"
,
"failed to fetch user info"
,
""
)
return
return
}
}
compatEmail
:=
strings
.
TrimSpace
(
email
)
// 安全考虑:不要把第三方返回的 email 直接映射到本地账号(可能与本地邮箱用户冲突导致账号被接管)。
// 安全考虑:不要把第三方返回的 email 直接映射到本地账号(可能与本地邮箱用户冲突导致账号被接管)。
// 统一使用基于 subject 的稳定合成邮箱来做账号绑定。
// 统一使用基于 subject 的稳定合成邮箱来做账号绑定。
if
subject
!=
""
{
if
subject
!=
""
{
email
=
linuxDoSyntheticEmail
(
subject
)
email
=
linuxDoSyntheticEmail
(
subject
)
}
}
identityKey
:=
service
.
PendingAuthIdentityKey
{
ProviderType
:
"linuxdo"
,
ProviderKey
:
"linuxdo"
,
ProviderSubject
:
subject
,
}
upstreamClaims
:=
map
[
string
]
any
{
"email"
:
email
,
"username"
:
username
,
"subject"
:
subject
,
"suggested_display_name"
:
displayName
,
"suggested_avatar_url"
:
avatarURL
,
}
if
compatEmail
!=
""
&&
!
strings
.
EqualFold
(
strings
.
TrimSpace
(
compatEmail
),
strings
.
TrimSpace
(
email
))
{
upstreamClaims
[
"compat_email"
]
=
compatEmail
}
if
intent
==
oauthIntentBindCurrentUser
{
targetUserID
,
err
:=
h
.
readOAuthBindUserIDFromCookie
(
c
,
linuxDoOAuthBindUserCookieName
)
if
err
!=
nil
{
redirectOAuthError
(
c
,
frontendCallback
,
"invalid_state"
,
"invalid oauth bind target"
,
""
)
return
}
if
err
:=
h
.
createOAuthPendingSession
(
c
,
oauthPendingSessionPayload
{
Intent
:
oauthIntentBindCurrentUser
,
Identity
:
identityKey
,
TargetUserID
:
&
targetUserID
,
ResolvedEmail
:
email
,
RedirectTo
:
redirectTo
,
BrowserSessionKey
:
browserSessionKey
,
UpstreamIdentityClaims
:
upstreamClaims
,
CompletionResponse
:
map
[
string
]
any
{
"redirect"
:
redirectTo
,
},
});
err
!=
nil
{
redirectOAuthError
(
c
,
frontendCallback
,
"session_error"
,
"failed to continue oauth bind"
,
""
)
return
}
redirectToFrontendCallback
(
c
,
frontendCallback
)
return
}
// 传入空邀请码;如果需要邀请码,服务层返回 ErrOAuthInvitationRequired
existingIdentityUser
,
err
:=
h
.
findOAuthIdentityUser
(
c
.
Request
.
Context
(),
identityKey
)
tokenPair
,
_
,
err
:=
h
.
authService
.
LoginOrRegisterOAuthWithTokenPair
(
c
.
Request
.
Context
(),
email
,
username
,
""
)
if
err
!=
nil
{
if
err
!=
nil
{
if
errors
.
Is
(
err
,
service
.
ErrOAuthInvitationRequired
)
{
redirectOAuthError
(
c
,
frontendCallback
,
"session_error"
,
infraerrors
.
Reason
(
err
),
infraerrors
.
Message
(
err
))
pendingToken
,
tokenErr
:=
h
.
authService
.
CreatePendingOAuthToken
(
email
,
username
)
return
if
tokenErr
!=
nil
{
}
redirectOAuthError
(
c
,
frontendCallback
,
"login_failed"
,
"service_error"
,
""
)
if
existingIdentityUser
!=
nil
{
return
if
err
:=
h
.
createOAuthPendingSession
(
c
,
oauthPendingSessionPayload
{
}
Intent
:
oauthIntentLogin
,
fragment
:=
url
.
Values
{}
Identity
:
identityKey
,
fragment
.
Set
(
"error"
,
"invitation_required"
)
TargetUserID
:
&
existingIdentityUser
.
ID
,
fragment
.
Set
(
"pending_oauth_token"
,
pendingToken
)
ResolvedEmail
:
existingIdentityUser
.
Email
,
fragment
.
Set
(
"redirect"
,
redirectTo
)
RedirectTo
:
redirectTo
,
redirectWithFragment
(
c
,
frontendCallback
,
fragment
)
BrowserSessionKey
:
browserSessionKey
,
UpstreamIdentityClaims
:
upstreamClaims
,
CompletionResponse
:
map
[
string
]
any
{
"redirect"
:
redirectTo
,
},
});
err
!=
nil
{
redirectOAuthError
(
c
,
frontendCallback
,
"session_error"
,
"failed to continue oauth login"
,
""
)
return
return
}
}
// 避免把内部细节泄露给客户端;给前端保留结构化原因与提示信息即可。
redirectToFrontendCallback
(
c
,
frontendCallback
)
redirectOAuthError
(
c
,
frontendCallback
,
"login_failed"
,
infraerrors
.
Reason
(
err
),
infraerrors
.
Message
(
err
))
return
return
}
}
fragment
:=
url
.
Values
{}
compatEmailUser
,
err
:=
h
.
findLinuxDoCompatEmailUser
(
c
.
Request
.
Context
(),
compatEmail
)
fragment
.
Set
(
"access_token"
,
tokenPair
.
AccessToken
)
if
err
!=
nil
{
fragment
.
Set
(
"refresh_token"
,
tokenPair
.
RefreshToken
)
redirectOAuthError
(
c
,
frontendCallback
,
"session_error"
,
infraerrors
.
Reason
(
err
),
infraerrors
.
Message
(
err
))
fragment
.
Set
(
"expires_in"
,
fmt
.
Sprintf
(
"%d"
,
tokenPair
.
ExpiresIn
))
return
fragment
.
Set
(
"token_type"
,
"Bearer"
)
}
fragment
.
Set
(
"redirect"
,
redirectTo
)
if
err
:=
h
.
createLinuxDoOAuthChoicePendingSession
(
redirectWithFragment
(
c
,
frontendCallback
,
fragment
)
c
,
identityKey
,
email
,
email
,
redirectTo
,
browserSessionKey
,
upstreamClaims
,
compatEmail
,
compatEmailUser
,
h
.
isForceEmailOnThirdPartySignup
(
c
.
Request
.
Context
()),
);
err
!=
nil
{
redirectOAuthError
(
c
,
frontendCallback
,
"session_error"
,
"failed to continue oauth login"
,
""
)
return
}
redirectToFrontendCallback
(
c
,
frontendCallback
)
}
func
(
h
*
AuthHandler
)
findLinuxDoCompatEmailUser
(
ctx
context
.
Context
,
email
string
)
(
*
dbent
.
User
,
error
)
{
client
:=
h
.
entClient
()
if
client
==
nil
{
return
nil
,
infraerrors
.
ServiceUnavailable
(
"PENDING_AUTH_NOT_READY"
,
"pending auth service is not ready"
)
}
email
=
strings
.
TrimSpace
(
strings
.
ToLower
(
email
))
if
email
==
""
||
strings
.
HasSuffix
(
email
,
service
.
LinuxDoConnectSyntheticEmailDomain
)
||
strings
.
HasSuffix
(
email
,
service
.
OIDCConnectSyntheticEmailDomain
)
||
strings
.
HasSuffix
(
email
,
service
.
WeChatConnectSyntheticEmailDomain
)
{
return
nil
,
nil
}
userEntity
,
err
:=
client
.
User
.
Query
()
.
Where
(
userNormalizedEmailPredicate
(
email
))
.
Order
(
dbent
.
Asc
(
dbuser
.
FieldID
))
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
infraerrors
.
InternalServer
(
"COMPAT_EMAIL_LOOKUP_FAILED"
,
"failed to look up compat email user"
)
.
WithCause
(
err
)
}
switch
len
(
userEntity
)
{
case
0
:
return
nil
,
nil
case
1
:
return
userEntity
[
0
],
nil
default
:
return
nil
,
infraerrors
.
Conflict
(
"USER_EMAIL_CONFLICT"
,
"normalized email matched multiple users"
)
}
}
func
(
h
*
AuthHandler
)
createLinuxDoOAuthChoicePendingSession
(
c
*
gin
.
Context
,
identity
service
.
PendingAuthIdentityKey
,
suggestedEmail
string
,
resolvedEmail
string
,
redirectTo
string
,
browserSessionKey
string
,
upstreamClaims
map
[
string
]
any
,
compatEmail
string
,
compatEmailUser
*
dbent
.
User
,
forceEmailOnSignup
bool
,
)
error
{
suggestionEmail
:=
strings
.
TrimSpace
(
suggestedEmail
)
canonicalEmail
:=
strings
.
TrimSpace
(
resolvedEmail
)
if
suggestionEmail
==
""
{
suggestionEmail
=
canonicalEmail
}
completionResponse
:=
map
[
string
]
any
{
"step"
:
oauthPendingChoiceStep
,
"adoption_required"
:
true
,
"redirect"
:
strings
.
TrimSpace
(
redirectTo
),
"email"
:
suggestionEmail
,
"resolved_email"
:
canonicalEmail
,
"existing_account_email"
:
""
,
"existing_account_bindable"
:
false
,
"create_account_allowed"
:
true
,
"force_email_on_signup"
:
forceEmailOnSignup
,
"choice_reason"
:
"third_party_signup"
,
}
if
strings
.
TrimSpace
(
compatEmail
)
!=
""
{
completionResponse
[
"compat_email"
]
=
strings
.
TrimSpace
(
compatEmail
)
}
resolvedChoiceEmail
:=
suggestionEmail
if
compatEmailUser
!=
nil
{
completionResponse
[
"email"
]
=
strings
.
TrimSpace
(
compatEmailUser
.
Email
)
completionResponse
[
"existing_account_email"
]
=
strings
.
TrimSpace
(
compatEmailUser
.
Email
)
completionResponse
[
"existing_account_bindable"
]
=
true
completionResponse
[
"choice_reason"
]
=
"compat_email_match"
resolvedChoiceEmail
=
strings
.
TrimSpace
(
compatEmailUser
.
Email
)
}
if
forceEmailOnSignup
&&
compatEmailUser
==
nil
{
completionResponse
[
"choice_reason"
]
=
"force_email_on_signup"
}
var
targetUserID
*
int64
if
compatEmailUser
!=
nil
&&
compatEmailUser
.
ID
>
0
{
targetUserID
=
&
compatEmailUser
.
ID
}
return
h
.
createOAuthPendingSession
(
c
,
oauthPendingSessionPayload
{
Intent
:
oauthIntentLogin
,
Identity
:
identity
,
TargetUserID
:
targetUserID
,
ResolvedEmail
:
resolvedChoiceEmail
,
RedirectTo
:
redirectTo
,
BrowserSessionKey
:
browserSessionKey
,
UpstreamIdentityClaims
:
upstreamClaims
,
CompletionResponse
:
completionResponse
,
})
}
}
type
completeLinuxDoOAuthRequest
struct
{
type
completeLinuxDoOAuthRequest
struct
{
PendingOAuthToken
string
`json:"pending_oauth_token" binding:"required"`
InvitationCode
string
`json:"invitation_code" binding:"required"`
InvitationCode
string
`json:"invitation_code" binding:"required"`
AdoptDisplayName
*
bool
`json:"adopt_display_name,omitempty"`
AdoptAvatar
*
bool
`json:"adopt_avatar,omitempty"`
}
}
// CompleteLinuxDoOAuthRegistration completes a pending OAuth registration by validating
// CompleteLinuxDoOAuthRegistration completes a pending OAuth registration by validating
...
@@ -256,17 +449,87 @@ func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) {
...
@@ -256,17 +449,87 @@ func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) {
return
return
}
}
email
,
username
,
err
:=
h
.
authService
.
VerifyPendingOAuthToken
(
req
.
PendingOAuthToken
)
secureCookie
:=
isRequestHTTPS
(
c
)
sessionToken
,
err
:=
readOAuthPendingSessionCookie
(
c
)
if
err
!=
nil
{
clearOAuthPendingSessionCookie
(
c
,
secureCookie
)
clearOAuthPendingBrowserCookie
(
c
,
secureCookie
)
response
.
ErrorFrom
(
c
,
service
.
ErrPendingAuthSessionNotFound
)
return
}
browserSessionKey
,
err
:=
readOAuthPendingBrowserCookie
(
c
)
if
err
!=
nil
{
clearOAuthPendingSessionCookie
(
c
,
secureCookie
)
clearOAuthPendingBrowserCookie
(
c
,
secureCookie
)
response
.
ErrorFrom
(
c
,
service
.
ErrPendingAuthBrowserMismatch
)
return
}
pendingSvc
,
err
:=
h
.
pendingIdentityService
()
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
session
,
err
:=
pendingSvc
.
GetBrowserSession
(
c
.
Request
.
Context
(),
sessionToken
,
browserSessionKey
)
if
err
!=
nil
{
if
err
!=
nil
{
c
.
JSON
(
http
.
StatusUnauthorized
,
gin
.
H
{
"error"
:
"INVALID_TOKEN"
,
"message"
:
"invalid or expired registration token"
})
clearOAuthPendingSessionCookie
(
c
,
secureCookie
)
clearOAuthPendingBrowserCookie
(
c
,
secureCookie
)
response
.
ErrorFrom
(
c
,
err
)
return
}
if
err
:=
ensurePendingOAuthCompleteRegistrationSession
(
session
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
if
updatedSession
,
handled
,
err
:=
h
.
legacyCompleteRegistrationSessionStatus
(
c
,
session
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
else
if
handled
{
c
.
JSON
(
http
.
StatusOK
,
buildPendingOAuthSessionStatusPayload
(
updatedSession
))
return
}
else
{
session
=
updatedSession
}
if
err
:=
h
.
ensureBackendModeAllowsNewUserLogin
(
c
.
Request
.
Context
());
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
return
}
}
tokenPair
,
_
,
err
:=
h
.
authService
.
LoginOrRegisterOAuthWithTokenPair
(
c
.
Request
.
Context
(),
email
,
username
,
req
.
InvitationCode
)
email
:=
strings
.
TrimSpace
(
session
.
ResolvedEmail
)
username
:=
pendingSessionStringValue
(
session
.
UpstreamIdentityClaims
,
"username"
)
if
email
==
""
||
username
==
""
{
response
.
ErrorFrom
(
c
,
infraerrors
.
BadRequest
(
"PENDING_AUTH_SESSION_INVALID"
,
"pending auth registration context is invalid"
))
return
}
client
:=
h
.
entClient
()
if
client
==
nil
{
response
.
ErrorFrom
(
c
,
infraerrors
.
ServiceUnavailable
(
"PENDING_AUTH_NOT_READY"
,
"pending auth service is not ready"
))
return
}
if
err
:=
ensurePendingOAuthRegistrationIdentityAvailable
(
c
.
Request
.
Context
(),
client
,
session
);
err
!=
nil
{
respondPendingOAuthBindingApplyError
(
c
,
err
)
return
}
decision
,
err
:=
h
.
ensurePendingOAuthAdoptionDecision
(
c
,
session
.
ID
,
oauthAdoptionDecisionRequest
{
AdoptDisplayName
:
req
.
AdoptDisplayName
,
AdoptAvatar
:
req
.
AdoptAvatar
,
})
if
err
!=
nil
{
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
return
}
}
tokenPair
,
user
,
err
:=
h
.
authService
.
LoginOrRegisterOAuthWithTokenPair
(
c
.
Request
.
Context
(),
email
,
username
,
req
.
InvitationCode
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
if
err
:=
applyPendingOAuthAdoptionAndConsumeSession
(
c
.
Request
.
Context
(),
client
,
h
.
authService
,
h
.
userService
,
session
,
decision
,
user
.
ID
);
err
!=
nil
{
respondPendingOAuthBindingApplyError
(
c
,
err
)
return
}
h
.
authService
.
RecordSuccessfulLogin
(
c
.
Request
.
Context
(),
user
.
ID
)
clearOAuthPendingSessionCookie
(
c
,
secureCookie
)
clearOAuthPendingBrowserCookie
(
c
,
secureCookie
)
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"access_token"
:
tokenPair
.
AccessToken
,
"access_token"
:
tokenPair
.
AccessToken
,
...
@@ -303,7 +566,7 @@ func linuxDoExchangeCode(
...
@@ -303,7 +566,7 @@ func linuxDoExchangeCode(
form
.
Set
(
"client_id"
,
cfg
.
ClientID
)
form
.
Set
(
"client_id"
,
cfg
.
ClientID
)
form
.
Set
(
"code"
,
code
)
form
.
Set
(
"code"
,
code
)
form
.
Set
(
"redirect_uri"
,
redirectURI
)
form
.
Set
(
"redirect_uri"
,
redirectURI
)
if
cfg
.
UsePKCE
{
if
strings
.
TrimSpace
(
codeVerifier
)
!=
""
{
form
.
Set
(
"code_verifier"
,
codeVerifier
)
form
.
Set
(
"code_verifier"
,
codeVerifier
)
}
}
...
@@ -353,11 +616,11 @@ func linuxDoFetchUserInfo(
...
@@ -353,11 +616,11 @@ func linuxDoFetchUserInfo(
ctx
context
.
Context
,
ctx
context
.
Context
,
cfg
config
.
LinuxDoConnectConfig
,
cfg
config
.
LinuxDoConnectConfig
,
token
*
linuxDoTokenResponse
,
token
*
linuxDoTokenResponse
,
)
(
email
string
,
username
string
,
subject
string
,
err
error
)
{
)
(
email
string
,
username
string
,
subject
string
,
displayName
string
,
avatarURL
string
,
err
error
)
{
client
:=
req
.
C
()
.
SetTimeout
(
30
*
time
.
Second
)
client
:=
req
.
C
()
.
SetTimeout
(
30
*
time
.
Second
)
authorization
,
err
:=
buildBearerAuthorization
(
token
.
TokenType
,
token
.
AccessToken
)
authorization
,
err
:=
buildBearerAuthorization
(
token
.
TokenType
,
token
.
AccessToken
)
if
err
!=
nil
{
if
err
!=
nil
{
return
""
,
""
,
""
,
fmt
.
Errorf
(
"invalid token for userinfo request: %w"
,
err
)
return
""
,
""
,
""
,
""
,
""
,
fmt
.
Errorf
(
"invalid token for userinfo request: %w"
,
err
)
}
}
resp
,
err
:=
client
.
R
()
.
resp
,
err
:=
client
.
R
()
.
...
@@ -366,16 +629,16 @@ func linuxDoFetchUserInfo(
...
@@ -366,16 +629,16 @@ func linuxDoFetchUserInfo(
SetHeader
(
"Authorization"
,
authorization
)
.
SetHeader
(
"Authorization"
,
authorization
)
.
Get
(
cfg
.
UserInfoURL
)
Get
(
cfg
.
UserInfoURL
)
if
err
!=
nil
{
if
err
!=
nil
{
return
""
,
""
,
""
,
fmt
.
Errorf
(
"request userinfo: %w"
,
err
)
return
""
,
""
,
""
,
""
,
""
,
fmt
.
Errorf
(
"request userinfo: %w"
,
err
)
}
}
if
!
resp
.
IsSuccessState
()
{
if
!
resp
.
IsSuccessState
()
{
return
""
,
""
,
""
,
fmt
.
Errorf
(
"userinfo status=%d"
,
resp
.
StatusCode
)
return
""
,
""
,
""
,
""
,
""
,
fmt
.
Errorf
(
"userinfo status=%d"
,
resp
.
StatusCode
)
}
}
return
linuxDoParseUserInfo
(
resp
.
String
(),
cfg
)
return
linuxDoParseUserInfo
(
resp
.
String
(),
cfg
)
}
}
func
linuxDoParseUserInfo
(
body
string
,
cfg
config
.
LinuxDoConnectConfig
)
(
email
string
,
username
string
,
subject
string
,
err
error
)
{
func
linuxDoParseUserInfo
(
body
string
,
cfg
config
.
LinuxDoConnectConfig
)
(
email
string
,
username
string
,
subject
string
,
displayName
string
,
avatarURL
string
,
err
error
)
{
email
=
firstNonEmpty
(
email
=
firstNonEmpty
(
getGJSON
(
body
,
cfg
.
UserInfoEmailPath
),
getGJSON
(
body
,
cfg
.
UserInfoEmailPath
),
getGJSON
(
body
,
"email"
),
getGJSON
(
body
,
"email"
),
...
@@ -400,12 +663,29 @@ func linuxDoParseUserInfo(body string, cfg config.LinuxDoConnectConfig) (email s
...
@@ -400,12 +663,29 @@ func linuxDoParseUserInfo(body string, cfg config.LinuxDoConnectConfig) (email s
getGJSON
(
body
,
"user.id"
),
getGJSON
(
body
,
"user.id"
),
)
)
displayName
=
firstNonEmpty
(
getGJSON
(
body
,
"name"
),
getGJSON
(
body
,
"nickname"
),
getGJSON
(
body
,
"display_name"
),
getGJSON
(
body
,
"user.name"
),
getGJSON
(
body
,
"user.username"
),
username
,
)
avatarURL
=
firstNonEmpty
(
getGJSON
(
body
,
"avatar_url"
),
getGJSON
(
body
,
"avatar"
),
getGJSON
(
body
,
"picture"
),
getGJSON
(
body
,
"profile_image_url"
),
getGJSON
(
body
,
"user.avatar"
),
getGJSON
(
body
,
"user.avatar_url"
),
)
subject
=
strings
.
TrimSpace
(
subject
)
subject
=
strings
.
TrimSpace
(
subject
)
if
subject
==
""
{
if
subject
==
""
{
return
""
,
""
,
""
,
errors
.
New
(
"userinfo missing id field"
)
return
""
,
""
,
""
,
""
,
""
,
errors
.
New
(
"userinfo missing id field"
)
}
}
if
!
isSafeLinuxDoSubject
(
subject
)
{
if
!
isSafeLinuxDoSubject
(
subject
)
{
return
""
,
""
,
""
,
errors
.
New
(
"userinfo returned invalid id field"
)
return
""
,
""
,
""
,
""
,
""
,
errors
.
New
(
"userinfo returned invalid id field"
)
}
}
email
=
strings
.
TrimSpace
(
email
)
email
=
strings
.
TrimSpace
(
email
)
...
@@ -418,8 +698,13 @@ func linuxDoParseUserInfo(body string, cfg config.LinuxDoConnectConfig) (email s
...
@@ -418,8 +698,13 @@ func linuxDoParseUserInfo(body string, cfg config.LinuxDoConnectConfig) (email s
if
username
==
""
{
if
username
==
""
{
username
=
"linuxdo_"
+
subject
username
=
"linuxdo_"
+
subject
}
}
displayName
=
strings
.
TrimSpace
(
displayName
)
if
displayName
==
""
{
displayName
=
username
}
avatarURL
=
strings
.
TrimSpace
(
avatarURL
)
return
email
,
username
,
subject
,
nil
return
email
,
username
,
subject
,
displayName
,
avatarURL
,
nil
}
}
func
buildLinuxDoAuthorizeURL
(
cfg
config
.
LinuxDoConnectConfig
,
state
string
,
codeChallenge
string
,
redirectURI
string
)
(
string
,
error
)
{
func
buildLinuxDoAuthorizeURL
(
cfg
config
.
LinuxDoConnectConfig
,
state
string
,
codeChallenge
string
,
redirectURI
string
)
(
string
,
error
)
{
...
@@ -436,7 +721,7 @@ func buildLinuxDoAuthorizeURL(cfg config.LinuxDoConnectConfig, state string, cod
...
@@ -436,7 +721,7 @@ func buildLinuxDoAuthorizeURL(cfg config.LinuxDoConnectConfig, state string, cod
q
.
Set
(
"scope"
,
cfg
.
Scopes
)
q
.
Set
(
"scope"
,
cfg
.
Scopes
)
}
}
q
.
Set
(
"state"
,
state
)
q
.
Set
(
"state"
,
state
)
if
cfg
.
UsePKCE
{
if
strings
.
TrimSpace
(
codeChallenge
)
!=
""
{
q
.
Set
(
"code_challenge"
,
codeChallenge
)
q
.
Set
(
"code_challenge"
,
codeChallenge
)
q
.
Set
(
"code_challenge_method"
,
"S256"
)
q
.
Set
(
"code_challenge_method"
,
"S256"
)
}
}
...
@@ -670,6 +955,30 @@ func clearCookie(c *gin.Context, name string, secure bool) {
...
@@ -670,6 +955,30 @@ func clearCookie(c *gin.Context, name string, secure bool) {
})
})
}
}
func
clearOAuthBindAccessTokenCookie
(
c
*
gin
.
Context
,
secure
bool
)
{
http
.
SetCookie
(
c
.
Writer
,
&
http
.
Cookie
{
Name
:
oauthBindAccessTokenCookieName
,
Value
:
""
,
Path
:
oauthBindAccessTokenCookiePath
,
MaxAge
:
-
1
,
HttpOnly
:
true
,
Secure
:
secure
,
SameSite
:
http
.
SameSiteLaxMode
,
})
}
func
setOAuthBindAccessTokenCookie
(
c
*
gin
.
Context
,
token
string
,
secure
bool
)
{
http
.
SetCookie
(
c
.
Writer
,
&
http
.
Cookie
{
Name
:
oauthBindAccessTokenCookieName
,
Value
:
url
.
QueryEscape
(
strings
.
TrimSpace
(
token
)),
Path
:
oauthBindAccessTokenCookiePath
,
MaxAge
:
linuxDoOAuthCookieMaxAgeSec
,
HttpOnly
:
true
,
Secure
:
secure
,
SameSite
:
http
.
SameSiteLaxMode
,
})
}
func
truncateFragmentValue
(
value
string
)
string
{
func
truncateFragmentValue
(
value
string
)
string
{
value
=
strings
.
TrimSpace
(
value
)
value
=
strings
.
TrimSpace
(
value
)
if
value
==
""
{
if
value
==
""
{
...
@@ -728,3 +1037,127 @@ func linuxDoSyntheticEmail(subject string) string {
...
@@ -728,3 +1037,127 @@ func linuxDoSyntheticEmail(subject string) string {
}
}
return
"linuxdo-"
+
subject
+
service
.
LinuxDoConnectSyntheticEmailDomain
return
"linuxdo-"
+
subject
+
service
.
LinuxDoConnectSyntheticEmailDomain
}
}
func
normalizeOAuthIntent
(
raw
string
)
string
{
switch
strings
.
ToLower
(
strings
.
TrimSpace
(
raw
))
{
case
""
,
oauthIntentLogin
:
return
oauthIntentLogin
case
"bind"
,
oauthIntentBindCurrentUser
:
return
oauthIntentBindCurrentUser
default
:
return
oauthIntentLogin
}
}
func
(
h
*
AuthHandler
)
buildOAuthBindUserCookieFromContext
(
c
*
gin
.
Context
)
(
string
,
error
)
{
userID
,
err
:=
h
.
resolveOAuthBindTargetUserID
(
c
)
if
err
!=
nil
||
userID
==
nil
||
*
userID
<=
0
{
return
""
,
infraerrors
.
Unauthorized
(
"UNAUTHORIZED"
,
"authentication required"
)
}
return
buildOAuthBindUserCookieValue
(
*
userID
,
h
.
oauthBindCookieSecret
())
}
func
(
h
*
AuthHandler
)
PrepareOAuthBindAccessTokenCookie
(
c
*
gin
.
Context
)
{
const
bearerPrefix
=
"Bearer "
authHeader
:=
strings
.
TrimSpace
(
c
.
GetHeader
(
"Authorization"
))
if
!
strings
.
HasPrefix
(
strings
.
ToLower
(
authHeader
),
strings
.
ToLower
(
bearerPrefix
))
{
response
.
ErrorFrom
(
c
,
infraerrors
.
Unauthorized
(
"UNAUTHORIZED"
,
"authentication required"
))
return
}
token
:=
strings
.
TrimSpace
(
authHeader
[
len
(
bearerPrefix
)
:
])
if
token
==
""
{
response
.
ErrorFrom
(
c
,
infraerrors
.
Unauthorized
(
"UNAUTHORIZED"
,
"authentication required"
))
return
}
setOAuthBindAccessTokenCookie
(
c
,
token
,
isRequestHTTPS
(
c
))
c
.
Status
(
http
.
StatusNoContent
)
c
.
Writer
.
WriteHeaderNow
()
}
func
(
h
*
AuthHandler
)
resolveOAuthBindTargetUserID
(
c
*
gin
.
Context
)
(
*
int64
,
error
)
{
if
subject
,
ok
:=
servermiddleware
.
GetAuthSubjectFromContext
(
c
);
ok
&&
subject
.
UserID
>
0
{
return
&
subject
.
UserID
,
nil
}
if
h
==
nil
||
h
.
authService
==
nil
||
h
.
userService
==
nil
{
return
nil
,
service
.
ErrInvalidToken
}
ck
,
err
:=
c
.
Request
.
Cookie
(
oauthBindAccessTokenCookieName
)
clearOAuthBindAccessTokenCookie
(
c
,
isRequestHTTPS
(
c
))
if
err
!=
nil
{
return
nil
,
err
}
tokenString
,
err
:=
url
.
QueryUnescape
(
strings
.
TrimSpace
(
ck
.
Value
))
if
err
!=
nil
{
return
nil
,
err
}
if
tokenString
==
""
{
return
nil
,
service
.
ErrInvalidToken
}
claims
,
err
:=
h
.
authService
.
ValidateToken
(
tokenString
)
if
err
!=
nil
{
return
nil
,
err
}
user
,
err
:=
h
.
userService
.
GetByID
(
c
.
Request
.
Context
(),
claims
.
UserID
)
if
err
!=
nil
{
return
nil
,
err
}
if
user
==
nil
||
!
user
.
IsActive
()
||
claims
.
TokenVersion
!=
user
.
TokenVersion
{
return
nil
,
service
.
ErrInvalidToken
}
return
&
user
.
ID
,
nil
}
func
(
h
*
AuthHandler
)
readOAuthBindUserIDFromCookie
(
c
*
gin
.
Context
,
cookieName
string
)
(
int64
,
error
)
{
value
,
err
:=
readCookieDecoded
(
c
,
cookieName
)
if
err
!=
nil
{
return
0
,
err
}
return
parseOAuthBindUserCookieValue
(
value
,
h
.
oauthBindCookieSecret
())
}
func
(
h
*
AuthHandler
)
oauthBindCookieSecret
()
string
{
if
h
==
nil
||
h
.
cfg
==
nil
{
return
""
}
return
strings
.
TrimSpace
(
h
.
cfg
.
JWT
.
Secret
)
}
func
buildOAuthBindUserCookieValue
(
userID
int64
,
secret
string
)
(
string
,
error
)
{
secret
=
strings
.
TrimSpace
(
secret
)
if
userID
<=
0
||
secret
==
""
{
return
""
,
errors
.
New
(
"invalid oauth bind cookie input"
)
}
payload
:=
strconv
.
FormatInt
(
userID
,
10
)
mac
:=
hmac
.
New
(
sha256
.
New
,
[]
byte
(
secret
))
_
,
_
=
mac
.
Write
([]
byte
(
payload
))
signature
:=
base64
.
RawURLEncoding
.
EncodeToString
(
mac
.
Sum
(
nil
))
return
payload
+
"."
+
signature
,
nil
}
func
parseOAuthBindUserCookieValue
(
value
string
,
secret
string
)
(
int64
,
error
)
{
secret
=
strings
.
TrimSpace
(
secret
)
if
secret
==
""
{
return
0
,
errors
.
New
(
"missing oauth bind cookie secret"
)
}
payload
,
signature
,
ok
:=
strings
.
Cut
(
strings
.
TrimSpace
(
value
),
"."
)
if
!
ok
||
payload
==
""
||
signature
==
""
{
return
0
,
errors
.
New
(
"invalid oauth bind cookie"
)
}
mac
:=
hmac
.
New
(
sha256
.
New
,
[]
byte
(
secret
))
_
,
_
=
mac
.
Write
([]
byte
(
payload
))
expectedSignature
:=
base64
.
RawURLEncoding
.
EncodeToString
(
mac
.
Sum
(
nil
))
if
!
hmac
.
Equal
([]
byte
(
signature
),
[]
byte
(
expectedSignature
))
{
return
0
,
errors
.
New
(
"invalid oauth bind cookie signature"
)
}
userID
,
err
:=
strconv
.
ParseInt
(
payload
,
10
,
64
)
if
err
!=
nil
||
userID
<=
0
{
return
0
,
errors
.
New
(
"invalid oauth bind cookie user"
)
}
return
userID
,
nil
}
backend/internal/handler/auth_linuxdo_oauth_test.go
View file @
b017f461
package
handler
package
handler
import
(
import
(
"bytes"
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"strings"
"testing"
"testing"
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/authidentity"
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
"github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
dbuser
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/config"
servermiddleware
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/require"
)
)
...
@@ -41,11 +55,13 @@ func TestLinuxDoParseUserInfoParsesIDAndUsername(t *testing.T) {
...
@@ -41,11 +55,13 @@ func TestLinuxDoParseUserInfoParsesIDAndUsername(t *testing.T) {
UserInfoURL
:
"https://connect.linux.do/api/user"
,
UserInfoURL
:
"https://connect.linux.do/api/user"
,
}
}
email
,
username
,
subject
,
err
:=
linuxDoParseUserInfo
(
`{"id":123,"username":"alice"}`
,
cfg
)
email
,
username
,
subject
,
displayName
,
avatarURL
,
err
:=
linuxDoParseUserInfo
(
`{"id":123,"username":"alice"
,"name":"Alice","avatar_url":"https://cdn.example/avatar.png"
}`
,
cfg
)
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
"123"
,
subject
)
require
.
Equal
(
t
,
"123"
,
subject
)
require
.
Equal
(
t
,
"alice"
,
username
)
require
.
Equal
(
t
,
"alice"
,
username
)
require
.
Equal
(
t
,
"linuxdo-123@linuxdo-connect.invalid"
,
email
)
require
.
Equal
(
t
,
"linuxdo-123@linuxdo-connect.invalid"
,
email
)
require
.
Equal
(
t
,
"Alice"
,
displayName
)
require
.
Equal
(
t
,
"https://cdn.example/avatar.png"
,
avatarURL
)
}
}
func
TestLinuxDoParseUserInfoDefaultsUsername
(
t
*
testing
.
T
)
{
func
TestLinuxDoParseUserInfoDefaultsUsername
(
t
*
testing
.
T
)
{
...
@@ -53,11 +69,13 @@ func TestLinuxDoParseUserInfoDefaultsUsername(t *testing.T) {
...
@@ -53,11 +69,13 @@ func TestLinuxDoParseUserInfoDefaultsUsername(t *testing.T) {
UserInfoURL
:
"https://connect.linux.do/api/user"
,
UserInfoURL
:
"https://connect.linux.do/api/user"
,
}
}
email
,
username
,
subject
,
err
:=
linuxDoParseUserInfo
(
`{"id":"123"}`
,
cfg
)
email
,
username
,
subject
,
displayName
,
avatarURL
,
err
:=
linuxDoParseUserInfo
(
`{"id":"123"}`
,
cfg
)
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
"123"
,
subject
)
require
.
Equal
(
t
,
"123"
,
subject
)
require
.
Equal
(
t
,
"linuxdo_123"
,
username
)
require
.
Equal
(
t
,
"linuxdo_123"
,
username
)
require
.
Equal
(
t
,
"linuxdo-123@linuxdo-connect.invalid"
,
email
)
require
.
Equal
(
t
,
"linuxdo-123@linuxdo-connect.invalid"
,
email
)
require
.
Equal
(
t
,
"linuxdo_123"
,
displayName
)
require
.
Equal
(
t
,
""
,
avatarURL
)
}
}
func
TestLinuxDoParseUserInfoRejectsUnsafeSubject
(
t
*
testing
.
T
)
{
func
TestLinuxDoParseUserInfoRejectsUnsafeSubject
(
t
*
testing
.
T
)
{
...
@@ -65,11 +83,11 @@ func TestLinuxDoParseUserInfoRejectsUnsafeSubject(t *testing.T) {
...
@@ -65,11 +83,11 @@ func TestLinuxDoParseUserInfoRejectsUnsafeSubject(t *testing.T) {
UserInfoURL
:
"https://connect.linux.do/api/user"
,
UserInfoURL
:
"https://connect.linux.do/api/user"
,
}
}
_
,
_
,
_
,
err
:=
linuxDoParseUserInfo
(
`{"id":"123@456"}`
,
cfg
)
_
,
_
,
_
,
_
,
_
,
err
:=
linuxDoParseUserInfo
(
`{"id":"123@456"}`
,
cfg
)
require
.
Error
(
t
,
err
)
require
.
Error
(
t
,
err
)
tooLong
:=
strings
.
Repeat
(
"a"
,
linuxDoOAuthMaxSubjectLen
+
1
)
tooLong
:=
strings
.
Repeat
(
"a"
,
linuxDoOAuthMaxSubjectLen
+
1
)
_
,
_
,
_
,
err
=
linuxDoParseUserInfo
(
`{"id":"`
+
tooLong
+
`"}`
,
cfg
)
_
,
_
,
_
,
_
,
_
,
err
=
linuxDoParseUserInfo
(
`{"id":"`
+
tooLong
+
`"}`
,
cfg
)
require
.
Error
(
t
,
err
)
require
.
Error
(
t
,
err
)
}
}
...
@@ -106,3 +124,906 @@ func TestSingleLineStripsWhitespace(t *testing.T) {
...
@@ -106,3 +124,906 @@ func TestSingleLineStripsWhitespace(t *testing.T) {
require
.
Equal
(
t
,
"hello world"
,
singleLine
(
"hello
\r\n
world"
))
require
.
Equal
(
t
,
"hello world"
,
singleLine
(
"hello
\r\n
world"
))
require
.
Equal
(
t
,
""
,
singleLine
(
"
\n\t\r
"
))
require
.
Equal
(
t
,
""
,
singleLine
(
"
\n\t\r
"
))
}
}
func
TestLinuxDoOAuthBindStartRedirectsAndSetsBindCookies
(
t
*
testing
.
T
)
{
handler
:=
newLinuxDoOAuthTestHandler
(
t
,
false
,
config
.
LinuxDoConnectConfig
{
Enabled
:
true
,
ClientID
:
"linuxdo-client"
,
ClientSecret
:
"linuxdo-secret"
,
AuthorizeURL
:
"https://connect.linux.do/oauth/authorize"
,
TokenURL
:
"https://connect.linux.do/oauth/token"
,
UserInfoURL
:
"https://connect.linux.do/api/user"
,
Scopes
:
"read"
,
RedirectURL
:
"https://api.example.com/api/v1/auth/oauth/linuxdo/callback"
,
FrontendRedirectURL
:
"/auth/linuxdo/callback"
,
TokenAuthMethod
:
"client_secret_post"
,
UsePKCE
:
true
,
})
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/auth/oauth/linuxdo/bind/start?intent=bind_current_user&redirect=/settings/connections"
,
nil
)
c
.
Request
=
req
c
.
Set
(
string
(
servermiddleware
.
ContextKeyUser
),
servermiddleware
.
AuthSubject
{
UserID
:
42
})
handler
.
LinuxDoOAuthStart
(
c
)
require
.
Equal
(
t
,
http
.
StatusFound
,
recorder
.
Code
)
location
:=
recorder
.
Header
()
.
Get
(
"Location"
)
require
.
Contains
(
t
,
location
,
"connect.linux.do/oauth/authorize"
)
require
.
Contains
(
t
,
location
,
"client_id=linuxdo-client"
)
require
.
Contains
(
t
,
location
,
"code_challenge="
)
cookies
:=
recorder
.
Result
()
.
Cookies
()
require
.
NotNil
(
t
,
findCookie
(
cookies
,
linuxDoOAuthStateCookieName
))
require
.
NotNil
(
t
,
findCookie
(
cookies
,
linuxDoOAuthRedirectCookie
))
require
.
NotNil
(
t
,
findCookie
(
cookies
,
linuxDoOAuthVerifierCookie
))
require
.
NotNil
(
t
,
findCookie
(
cookies
,
oauthPendingBrowserCookieName
))
intentCookie
:=
findCookie
(
cookies
,
linuxDoOAuthIntentCookieName
)
require
.
NotNil
(
t
,
intentCookie
)
require
.
Equal
(
t
,
oauthIntentBindCurrentUser
,
decodeCookieValueForTest
(
t
,
intentCookie
.
Value
))
bindCookie
:=
findCookie
(
cookies
,
linuxDoOAuthBindUserCookieName
)
require
.
NotNil
(
t
,
bindCookie
)
userID
,
err
:=
parseOAuthBindUserCookieValue
(
decodeCookieValueForTest
(
t
,
bindCookie
.
Value
),
"test-secret"
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
int64
(
42
),
userID
)
}
func
TestLinuxDoOAuthStartOmitsPKCEWhenDisabled
(
t
*
testing
.
T
)
{
handler
:=
newLinuxDoOAuthTestHandler
(
t
,
false
,
config
.
LinuxDoConnectConfig
{
Enabled
:
true
,
ClientID
:
"linuxdo-client"
,
ClientSecret
:
"linuxdo-secret"
,
AuthorizeURL
:
"https://connect.linux.do/oauth/authorize"
,
TokenURL
:
"https://connect.linux.do/oauth/token"
,
UserInfoURL
:
"https://connect.linux.do/api/user"
,
Scopes
:
"read"
,
RedirectURL
:
"https://api.example.com/api/v1/auth/oauth/linuxdo/callback"
,
FrontendRedirectURL
:
"/auth/linuxdo/callback"
,
TokenAuthMethod
:
"client_secret_post"
,
UsePKCE
:
false
,
})
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/auth/oauth/linuxdo/start?redirect=/dashboard"
,
nil
)
handler
.
LinuxDoOAuthStart
(
c
)
require
.
Equal
(
t
,
http
.
StatusFound
,
recorder
.
Code
)
require
.
NotContains
(
t
,
recorder
.
Header
()
.
Get
(
"Location"
),
"code_challenge="
)
require
.
Nil
(
t
,
findCookie
(
recorder
.
Result
()
.
Cookies
(),
linuxDoOAuthVerifierCookie
))
}
func
TestLinuxDoOAuthCallbackAllowsMissingVerifierWhenPKCEDisabled
(
t
*
testing
.
T
)
{
upstream
:=
httptest
.
NewServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
switch
r
.
URL
.
Path
{
case
"/token"
:
require
.
NoError
(
t
,
r
.
ParseForm
())
require
.
Empty
(
t
,
r
.
PostForm
.
Get
(
"code_verifier"
))
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
w
.
Write
([]
byte
(
`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`
))
case
"/userinfo"
:
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
w
.
Write
([]
byte
(
`{"id":"compat-subject","username":"linuxdo_user","name":"LinuxDo Display"}`
))
default
:
http
.
NotFound
(
w
,
r
)
}
}))
defer
upstream
.
Close
()
handler
,
client
:=
newLinuxDoOAuthHandlerAndClient
(
t
,
false
,
config
.
LinuxDoConnectConfig
{
Enabled
:
true
,
ClientID
:
"linuxdo-client"
,
ClientSecret
:
"linuxdo-secret"
,
AuthorizeURL
:
upstream
.
URL
+
"/authorize"
,
TokenURL
:
upstream
.
URL
+
"/token"
,
UserInfoURL
:
upstream
.
URL
+
"/userinfo"
,
Scopes
:
"read"
,
RedirectURL
:
"https://api.example.com/api/v1/auth/oauth/linuxdo/callback"
,
FrontendRedirectURL
:
"/auth/linuxdo/callback"
,
TokenAuthMethod
:
"client_secret_post"
,
UsePKCE
:
false
,
})
t
.
Cleanup
(
func
()
{
_
=
client
.
Close
()
})
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/auth/oauth/linuxdo/callback?code=linuxdo-code&state=state-123"
,
nil
)
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthStateCookieName
,
"state-123"
))
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthRedirectCookie
,
"/dashboard"
))
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthIntentCookieName
,
oauthIntentLogin
))
req
.
AddCookie
(
encodedCookie
(
oauthPendingBrowserCookieName
,
"browser-123"
))
c
.
Request
=
req
handler
.
LinuxDoOAuthCallback
(
c
)
require
.
Equal
(
t
,
http
.
StatusFound
,
recorder
.
Code
)
require
.
Equal
(
t
,
"/auth/linuxdo/callback"
,
recorder
.
Header
()
.
Get
(
"Location"
))
require
.
NotNil
(
t
,
findCookie
(
recorder
.
Result
()
.
Cookies
(),
oauthPendingSessionCookieName
))
}
func
TestLinuxDoOAuthBindStartAcceptsAccessTokenCookie
(
t
*
testing
.
T
)
{
handler
,
client
:=
newLinuxDoOAuthHandlerAndClient
(
t
,
false
,
config
.
LinuxDoConnectConfig
{
Enabled
:
true
,
ClientID
:
"linuxdo-client"
,
ClientSecret
:
"linuxdo-secret"
,
AuthorizeURL
:
"https://connect.linux.do/oauth/authorize"
,
TokenURL
:
"https://connect.linux.do/oauth/token"
,
UserInfoURL
:
"https://connect.linux.do/api/user"
,
Scopes
:
"read"
,
RedirectURL
:
"https://api.example.com/api/v1/auth/oauth/linuxdo/callback"
,
FrontendRedirectURL
:
"/auth/linuxdo/callback"
,
TokenAuthMethod
:
"client_secret_post"
,
UsePKCE
:
true
,
})
t
.
Cleanup
(
func
()
{
_
=
client
.
Close
()
})
user
,
err
:=
client
.
User
.
Create
()
.
SetEmail
(
"bind-cookie@example.com"
)
.
SetUsername
(
"bind-cookie-user"
)
.
SetPasswordHash
(
"hash"
)
.
SetRole
(
service
.
RoleUser
)
.
SetStatus
(
service
.
StatusActive
)
.
Save
(
context
.
Background
())
require
.
NoError
(
t
,
err
)
token
,
err
:=
handler
.
authService
.
GenerateToken
(
&
service
.
User
{
ID
:
user
.
ID
,
Email
:
user
.
Email
,
Username
:
user
.
Username
,
PasswordHash
:
user
.
PasswordHash
,
Role
:
user
.
Role
,
Status
:
user
.
Status
,
})
require
.
NoError
(
t
,
err
)
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/auth/oauth/linuxdo/start?intent=bind_current_user&redirect=/settings/connections"
,
nil
)
req
.
AddCookie
(
&
http
.
Cookie
{
Name
:
oauthBindAccessTokenCookieName
,
Value
:
token
,
Path
:
oauthBindAccessTokenCookiePath
})
c
.
Request
=
req
handler
.
LinuxDoOAuthStart
(
c
)
require
.
Equal
(
t
,
http
.
StatusFound
,
recorder
.
Code
)
bindCookie
:=
findCookie
(
recorder
.
Result
()
.
Cookies
(),
linuxDoOAuthBindUserCookieName
)
require
.
NotNil
(
t
,
bindCookie
)
userID
,
err
:=
parseOAuthBindUserCookieValue
(
decodeCookieValueForTest
(
t
,
bindCookie
.
Value
),
"test-secret"
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
user
.
ID
,
userID
)
accessTokenCookie
:=
findCookie
(
recorder
.
Result
()
.
Cookies
(),
oauthBindAccessTokenCookieName
)
require
.
NotNil
(
t
,
accessTokenCookie
)
require
.
Equal
(
t
,
-
1
,
accessTokenCookie
.
MaxAge
)
}
func
TestPrepareOAuthBindAccessTokenCookieSetsHttpOnlyCookie
(
t
*
testing
.
T
)
{
handler
,
client
:=
newLinuxDoOAuthHandlerAndClient
(
t
,
false
,
config
.
LinuxDoConnectConfig
{})
t
.
Cleanup
(
func
()
{
_
=
client
.
Close
()
})
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/auth/oauth/bind-token"
,
nil
)
req
.
Header
.
Set
(
"Authorization"
,
"Bearer access-token-value"
)
c
.
Request
=
req
handler
.
PrepareOAuthBindAccessTokenCookie
(
c
)
require
.
Equal
(
t
,
http
.
StatusNoContent
,
recorder
.
Code
)
accessTokenCookie
:=
findCookie
(
recorder
.
Result
()
.
Cookies
(),
oauthBindAccessTokenCookieName
)
require
.
NotNil
(
t
,
accessTokenCookie
)
require
.
Equal
(
t
,
oauthBindAccessTokenCookiePath
,
accessTokenCookie
.
Path
)
require
.
Equal
(
t
,
linuxDoOAuthCookieMaxAgeSec
,
accessTokenCookie
.
MaxAge
)
require
.
True
(
t
,
accessTokenCookie
.
HttpOnly
)
require
.
Equal
(
t
,
url
.
QueryEscape
(
"access-token-value"
),
accessTokenCookie
.
Value
)
}
func
TestLinuxDoOAuthCallbackCreatesLoginPendingSessionForExistingIdentityUser
(
t
*
testing
.
T
)
{
upstream
:=
httptest
.
NewServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
switch
r
.
URL
.
Path
{
case
"/token"
:
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
w
.
Write
([]
byte
(
`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`
))
case
"/userinfo"
:
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
w
.
Write
([]
byte
(
`{"id":"321","username":"linuxdo_user","name":"LinuxDo Display","avatar_url":"https://cdn.example/linuxdo.png"}`
))
default
:
http
.
NotFound
(
w
,
r
)
}
}))
defer
upstream
.
Close
()
handler
,
client
:=
newLinuxDoOAuthHandlerAndClient
(
t
,
false
,
config
.
LinuxDoConnectConfig
{
Enabled
:
true
,
ClientID
:
"linuxdo-client"
,
ClientSecret
:
"linuxdo-secret"
,
AuthorizeURL
:
upstream
.
URL
+
"/authorize"
,
TokenURL
:
upstream
.
URL
+
"/token"
,
UserInfoURL
:
upstream
.
URL
+
"/userinfo"
,
Scopes
:
"read"
,
RedirectURL
:
"https://api.example.com/api/v1/auth/oauth/linuxdo/callback"
,
FrontendRedirectURL
:
"/auth/linuxdo/callback"
,
TokenAuthMethod
:
"client_secret_post"
,
UsePKCE
:
true
,
})
t
.
Cleanup
(
func
()
{
_
=
client
.
Close
()
})
ctx
:=
context
.
Background
()
existingUser
,
err
:=
client
.
User
.
Create
()
.
SetEmail
(
linuxDoSyntheticEmail
(
"321"
))
.
SetUsername
(
"legacy-user"
)
.
SetPasswordHash
(
"hash"
)
.
SetRole
(
service
.
RoleUser
)
.
SetStatus
(
service
.
StatusActive
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
_
,
err
=
client
.
AuthIdentity
.
Create
()
.
SetUserID
(
existingUser
.
ID
)
.
SetProviderType
(
"linuxdo"
)
.
SetProviderKey
(
"linuxdo"
)
.
SetProviderSubject
(
"321"
)
.
SetMetadata
(
map
[
string
]
any
{
"username"
:
"legacy-user"
})
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/auth/oauth/linuxdo/callback?code=code-123&state=state-123"
,
nil
)
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthStateCookieName
,
"state-123"
))
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthRedirectCookie
,
"/dashboard"
))
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthVerifierCookie
,
"verifier-123"
))
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthIntentCookieName
,
oauthIntentLogin
))
req
.
AddCookie
(
encodedCookie
(
oauthPendingBrowserCookieName
,
"browser-123"
))
c
.
Request
=
req
handler
.
LinuxDoOAuthCallback
(
c
)
require
.
Equal
(
t
,
http
.
StatusFound
,
recorder
.
Code
)
require
.
Equal
(
t
,
"/auth/linuxdo/callback"
,
recorder
.
Header
()
.
Get
(
"Location"
))
sessionCookie
:=
findCookie
(
recorder
.
Result
()
.
Cookies
(),
oauthPendingSessionCookieName
)
require
.
NotNil
(
t
,
sessionCookie
)
session
,
err
:=
client
.
PendingAuthSession
.
Query
()
.
Where
(
pendingauthsession
.
SessionTokenEQ
(
decodeCookieValueForTest
(
t
,
sessionCookie
.
Value
)))
.
Only
(
ctx
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
oauthIntentLogin
,
session
.
Intent
)
require
.
NotNil
(
t
,
session
.
TargetUserID
)
require
.
Equal
(
t
,
existingUser
.
ID
,
*
session
.
TargetUserID
)
require
.
Equal
(
t
,
linuxDoSyntheticEmail
(
"321"
),
session
.
ResolvedEmail
)
require
.
Equal
(
t
,
"LinuxDo Display"
,
session
.
UpstreamIdentityClaims
[
"suggested_display_name"
])
completion
,
ok
:=
session
.
LocalFlowState
[
oauthCompletionResponseKey
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
"/dashboard"
,
completion
[
"redirect"
])
_
,
hasAccessToken
:=
completion
[
"access_token"
]
require
.
False
(
t
,
hasAccessToken
)
_
,
hasRefreshToken
:=
completion
[
"refresh_token"
]
require
.
False
(
t
,
hasRefreshToken
)
require
.
Nil
(
t
,
completion
[
"error"
])
}
func
TestLinuxDoOAuthCallbackRejectsDisabledExistingIdentityUser
(
t
*
testing
.
T
)
{
upstream
:=
httptest
.
NewServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
switch
r
.
URL
.
Path
{
case
"/token"
:
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
w
.
Write
([]
byte
(
`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`
))
case
"/userinfo"
:
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
w
.
Write
([]
byte
(
`{"id":"654","username":"linuxdo_disabled","name":"LinuxDo Disabled"}`
))
default
:
http
.
NotFound
(
w
,
r
)
}
}))
defer
upstream
.
Close
()
handler
,
client
:=
newLinuxDoOAuthHandlerAndClient
(
t
,
false
,
config
.
LinuxDoConnectConfig
{
Enabled
:
true
,
ClientID
:
"linuxdo-client"
,
ClientSecret
:
"linuxdo-secret"
,
AuthorizeURL
:
upstream
.
URL
+
"/authorize"
,
TokenURL
:
upstream
.
URL
+
"/token"
,
UserInfoURL
:
upstream
.
URL
+
"/userinfo"
,
Scopes
:
"read"
,
RedirectURL
:
"https://api.example.com/api/v1/auth/oauth/linuxdo/callback"
,
FrontendRedirectURL
:
"/auth/linuxdo/callback"
,
TokenAuthMethod
:
"client_secret_post"
,
UsePKCE
:
true
,
})
t
.
Cleanup
(
func
()
{
_
=
client
.
Close
()
})
ctx
:=
context
.
Background
()
existingUser
,
err
:=
client
.
User
.
Create
()
.
SetEmail
(
linuxDoSyntheticEmail
(
"654"
))
.
SetUsername
(
"disabled-user"
)
.
SetPasswordHash
(
"hash"
)
.
SetRole
(
service
.
RoleUser
)
.
SetStatus
(
service
.
StatusDisabled
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
_
,
err
=
client
.
AuthIdentity
.
Create
()
.
SetUserID
(
existingUser
.
ID
)
.
SetProviderType
(
"linuxdo"
)
.
SetProviderKey
(
"linuxdo"
)
.
SetProviderSubject
(
"654"
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/auth/oauth/linuxdo/callback?code=code-disabled&state=state-disabled"
,
nil
)
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthStateCookieName
,
"state-disabled"
))
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthRedirectCookie
,
"/dashboard"
))
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthVerifierCookie
,
"verifier-disabled"
))
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthIntentCookieName
,
oauthIntentLogin
))
req
.
AddCookie
(
encodedCookie
(
oauthPendingBrowserCookieName
,
"browser-disabled"
))
c
.
Request
=
req
handler
.
LinuxDoOAuthCallback
(
c
)
require
.
Equal
(
t
,
http
.
StatusFound
,
recorder
.
Code
)
require
.
Nil
(
t
,
findCookie
(
recorder
.
Result
()
.
Cookies
(),
oauthPendingSessionCookieName
))
assertOAuthRedirectError
(
t
,
recorder
.
Header
()
.
Get
(
"Location"
),
"session_error"
,
"USER_NOT_ACTIVE"
)
count
,
err
:=
client
.
PendingAuthSession
.
Query
()
.
Count
(
ctx
)
require
.
NoError
(
t
,
err
)
require
.
Zero
(
t
,
count
)
}
func
TestLinuxDoOAuthCallbackCreatesBindPendingSessionForCompatEmailUser
(
t
*
testing
.
T
)
{
upstream
:=
httptest
.
NewServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
switch
r
.
URL
.
Path
{
case
"/token"
:
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
w
.
Write
([]
byte
(
`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`
))
case
"/userinfo"
:
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
w
.
Write
([]
byte
(
`{"id":"321","email":"legacy@example.com","username":"linuxdo_user","name":"LinuxDo Display","avatar_url":"https://cdn.example/linuxdo.png"}`
))
default
:
http
.
NotFound
(
w
,
r
)
}
}))
defer
upstream
.
Close
()
handler
,
client
:=
newLinuxDoOAuthHandlerAndClient
(
t
,
false
,
config
.
LinuxDoConnectConfig
{
Enabled
:
true
,
ClientID
:
"linuxdo-client"
,
ClientSecret
:
"linuxdo-secret"
,
AuthorizeURL
:
upstream
.
URL
+
"/authorize"
,
TokenURL
:
upstream
.
URL
+
"/token"
,
UserInfoURL
:
upstream
.
URL
+
"/userinfo"
,
Scopes
:
"read"
,
RedirectURL
:
"https://api.example.com/api/v1/auth/oauth/linuxdo/callback"
,
FrontendRedirectURL
:
"/auth/linuxdo/callback"
,
TokenAuthMethod
:
"client_secret_post"
,
UsePKCE
:
true
,
})
t
.
Cleanup
(
func
()
{
_
=
client
.
Close
()
})
ctx
:=
context
.
Background
()
existingUser
,
err
:=
client
.
User
.
Create
()
.
SetEmail
(
" Legacy@Example.com "
)
.
SetUsername
(
"legacy-user"
)
.
SetPasswordHash
(
"hash"
)
.
SetRole
(
service
.
RoleUser
)
.
SetStatus
(
service
.
StatusActive
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/auth/oauth/linuxdo/callback?code=code-compat&state=state-compat"
,
nil
)
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthStateCookieName
,
"state-compat"
))
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthRedirectCookie
,
"/dashboard"
))
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthVerifierCookie
,
"verifier-compat"
))
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthIntentCookieName
,
oauthIntentLogin
))
req
.
AddCookie
(
encodedCookie
(
oauthPendingBrowserCookieName
,
"browser-compat"
))
c
.
Request
=
req
handler
.
LinuxDoOAuthCallback
(
c
)
require
.
Equal
(
t
,
http
.
StatusFound
,
recorder
.
Code
)
require
.
Equal
(
t
,
"/auth/linuxdo/callback"
,
recorder
.
Header
()
.
Get
(
"Location"
))
sessionCookie
:=
findCookie
(
recorder
.
Result
()
.
Cookies
(),
oauthPendingSessionCookieName
)
require
.
NotNil
(
t
,
sessionCookie
)
session
,
err
:=
client
.
PendingAuthSession
.
Query
()
.
Where
(
pendingauthsession
.
SessionTokenEQ
(
decodeCookieValueForTest
(
t
,
sessionCookie
.
Value
)))
.
Only
(
ctx
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
oauthIntentLogin
,
session
.
Intent
)
require
.
NotNil
(
t
,
session
.
TargetUserID
)
require
.
Equal
(
t
,
existingUser
.
ID
,
*
session
.
TargetUserID
)
require
.
Equal
(
t
,
strings
.
TrimSpace
(
existingUser
.
Email
),
session
.
ResolvedEmail
)
require
.
Equal
(
t
,
"legacy@example.com"
,
session
.
UpstreamIdentityClaims
[
"compat_email"
])
completion
,
ok
:=
session
.
LocalFlowState
[
oauthCompletionResponseKey
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
"/dashboard"
,
completion
[
"redirect"
])
require
.
Equal
(
t
,
oauthPendingChoiceStep
,
completion
[
"step"
])
require
.
Equal
(
t
,
strings
.
TrimSpace
(
existingUser
.
Email
),
completion
[
"email"
])
require
.
Equal
(
t
,
strings
.
TrimSpace
(
existingUser
.
Email
),
completion
[
"existing_account_email"
])
require
.
Equal
(
t
,
true
,
completion
[
"existing_account_bindable"
])
require
.
Equal
(
t
,
"compat_email_match"
,
completion
[
"choice_reason"
])
_
,
hasAccessToken
:=
completion
[
"access_token"
]
require
.
False
(
t
,
hasAccessToken
)
}
func
TestLinuxDoOAuthCallbackCreatesChoicePendingSessionWhenSignupRequiresInvite
(
t
*
testing
.
T
)
{
upstream
:=
httptest
.
NewServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
switch
r
.
URL
.
Path
{
case
"/token"
:
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
w
.
Write
([]
byte
(
`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`
))
case
"/userinfo"
:
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
w
.
Write
([]
byte
(
`{"id":"654","username":"linuxdo_invite","name":"Need Invite","avatar_url":"https://cdn.example/invite.png"}`
))
default
:
http
.
NotFound
(
w
,
r
)
}
}))
defer
upstream
.
Close
()
handler
,
client
:=
newLinuxDoOAuthHandlerAndClient
(
t
,
true
,
config
.
LinuxDoConnectConfig
{
Enabled
:
true
,
ClientID
:
"linuxdo-client"
,
ClientSecret
:
"linuxdo-secret"
,
AuthorizeURL
:
upstream
.
URL
+
"/authorize"
,
TokenURL
:
upstream
.
URL
+
"/token"
,
UserInfoURL
:
upstream
.
URL
+
"/userinfo"
,
Scopes
:
"read"
,
RedirectURL
:
"https://api.example.com/api/v1/auth/oauth/linuxdo/callback"
,
FrontendRedirectURL
:
"/auth/linuxdo/callback"
,
TokenAuthMethod
:
"client_secret_post"
,
UsePKCE
:
true
,
})
t
.
Cleanup
(
func
()
{
_
=
client
.
Close
()
})
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/auth/oauth/linuxdo/callback?code=code-456&state=state-456"
,
nil
)
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthStateCookieName
,
"state-456"
))
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthRedirectCookie
,
"/dashboard"
))
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthVerifierCookie
,
"verifier-456"
))
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthIntentCookieName
,
oauthIntentLogin
))
req
.
AddCookie
(
encodedCookie
(
oauthPendingBrowserCookieName
,
"browser-456"
))
c
.
Request
=
req
handler
.
LinuxDoOAuthCallback
(
c
)
require
.
Equal
(
t
,
http
.
StatusFound
,
recorder
.
Code
)
require
.
Equal
(
t
,
"/auth/linuxdo/callback"
,
recorder
.
Header
()
.
Get
(
"Location"
))
sessionCookie
:=
findCookie
(
recorder
.
Result
()
.
Cookies
(),
oauthPendingSessionCookieName
)
require
.
NotNil
(
t
,
sessionCookie
)
ctx
:=
context
.
Background
()
session
,
err
:=
client
.
PendingAuthSession
.
Query
()
.
Where
(
pendingauthsession
.
SessionTokenEQ
(
decodeCookieValueForTest
(
t
,
sessionCookie
.
Value
)))
.
Only
(
ctx
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
oauthIntentLogin
,
session
.
Intent
)
require
.
Nil
(
t
,
session
.
TargetUserID
)
completion
,
ok
:=
session
.
LocalFlowState
[
oauthCompletionResponseKey
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
oauthPendingChoiceStep
,
completion
[
"step"
])
require
.
Equal
(
t
,
"/dashboard"
,
completion
[
"redirect"
])
require
.
Equal
(
t
,
"third_party_signup"
,
completion
[
"choice_reason"
])
}
func
TestLinuxDoOAuthCallbackCreatesBindPendingSessionForCurrentUser
(
t
*
testing
.
T
)
{
upstream
:=
httptest
.
NewServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
switch
r
.
URL
.
Path
{
case
"/token"
:
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
w
.
Write
([]
byte
(
`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`
))
case
"/userinfo"
:
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
w
.
Write
([]
byte
(
`{"id":"999","username":"bind_user","name":"Bind Display","avatar_url":"https://cdn.example/bind.png"}`
))
default
:
http
.
NotFound
(
w
,
r
)
}
}))
defer
upstream
.
Close
()
handler
,
client
:=
newLinuxDoOAuthHandlerAndClient
(
t
,
false
,
config
.
LinuxDoConnectConfig
{
Enabled
:
true
,
ClientID
:
"linuxdo-client"
,
ClientSecret
:
"linuxdo-secret"
,
AuthorizeURL
:
upstream
.
URL
+
"/authorize"
,
TokenURL
:
upstream
.
URL
+
"/token"
,
UserInfoURL
:
upstream
.
URL
+
"/userinfo"
,
Scopes
:
"read"
,
RedirectURL
:
"https://api.example.com/api/v1/auth/oauth/linuxdo/callback"
,
FrontendRedirectURL
:
"/auth/linuxdo/callback"
,
TokenAuthMethod
:
"client_secret_post"
,
UsePKCE
:
true
,
})
t
.
Cleanup
(
func
()
{
_
=
client
.
Close
()
})
ctx
:=
context
.
Background
()
currentUser
,
err
:=
client
.
User
.
Create
()
.
SetEmail
(
"current@example.com"
)
.
SetUsername
(
"current-user"
)
.
SetPasswordHash
(
"hash"
)
.
SetRole
(
service
.
RoleUser
)
.
SetStatus
(
service
.
StatusActive
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/auth/oauth/linuxdo/callback?code=code-bind&state=state-bind"
,
nil
)
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthStateCookieName
,
"state-bind"
))
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthRedirectCookie
,
"/settings/connections"
))
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthVerifierCookie
,
"verifier-bind"
))
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthIntentCookieName
,
oauthIntentBindCurrentUser
))
req
.
AddCookie
(
encodedCookie
(
linuxDoOAuthBindUserCookieName
,
buildEncodedOAuthBindUserCookie
(
t
,
currentUser
.
ID
,
"test-secret"
)))
req
.
AddCookie
(
encodedCookie
(
oauthPendingBrowserCookieName
,
"browser-bind"
))
c
.
Request
=
req
handler
.
LinuxDoOAuthCallback
(
c
)
require
.
Equal
(
t
,
http
.
StatusFound
,
recorder
.
Code
)
require
.
Equal
(
t
,
"/auth/linuxdo/callback"
,
recorder
.
Header
()
.
Get
(
"Location"
))
sessionCookie
:=
findCookie
(
recorder
.
Result
()
.
Cookies
(),
oauthPendingSessionCookieName
)
require
.
NotNil
(
t
,
sessionCookie
)
session
,
err
:=
client
.
PendingAuthSession
.
Query
()
.
Where
(
pendingauthsession
.
SessionTokenEQ
(
decodeCookieValueForTest
(
t
,
sessionCookie
.
Value
)))
.
Only
(
ctx
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
oauthIntentBindCurrentUser
,
session
.
Intent
)
require
.
NotNil
(
t
,
session
.
TargetUserID
)
require
.
Equal
(
t
,
currentUser
.
ID
,
*
session
.
TargetUserID
)
require
.
Equal
(
t
,
linuxDoSyntheticEmail
(
"999"
),
session
.
ResolvedEmail
)
completion
,
ok
:=
session
.
LocalFlowState
[
oauthCompletionResponseKey
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
"/settings/connections"
,
completion
[
"redirect"
])
require
.
Empty
(
t
,
completion
[
"access_token"
])
require
.
Equal
(
t
,
"Bind Display"
,
session
.
UpstreamIdentityClaims
[
"suggested_display_name"
])
userCount
,
err
:=
client
.
User
.
Query
()
.
Count
(
ctx
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
1
,
userCount
)
}
func
TestCompleteLinuxDoOAuthRegistrationAppliesPendingAdoptionDecision
(
t
*
testing
.
T
)
{
handler
,
client
:=
newOAuthPendingFlowTestHandler
(
t
,
false
)
ctx
:=
context
.
Background
()
session
,
err
:=
client
.
PendingAuthSession
.
Create
()
.
SetSessionToken
(
"linuxdo-complete-session"
)
.
SetIntent
(
"login"
)
.
SetProviderType
(
"linuxdo"
)
.
SetProviderKey
(
"linuxdo"
)
.
SetProviderSubject
(
"linuxdo-subject-1"
)
.
SetResolvedEmail
(
"linuxdo-subject-1@linuxdo-connect.invalid"
)
.
SetBrowserSessionKey
(
"linuxdo-browser"
)
.
SetUpstreamIdentityClaims
(
map
[
string
]
any
{
"username"
:
"linuxdo_user"
,
"suggested_display_name"
:
"LinuxDo Display"
,
"suggested_avatar_url"
:
"https://cdn.example/linuxdo.png"
,
})
.
SetExpiresAt
(
time
.
Now
()
.
UTC
()
.
Add
(
10
*
time
.
Minute
))
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
_
,
err
=
service
.
NewAuthPendingIdentityService
(
client
)
.
UpsertAdoptionDecision
(
ctx
,
service
.
PendingIdentityAdoptionDecisionInput
{
PendingAuthSessionID
:
session
.
ID
,
AdoptAvatar
:
true
,
})
require
.
NoError
(
t
,
err
)
body
:=
bytes
.
NewBufferString
(
`{"invitation_code":"invite-1","adopt_display_name":true}`
)
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/auth/oauth/linuxdo/complete-registration"
,
body
)
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
req
.
AddCookie
(
&
http
.
Cookie
{
Name
:
oauthPendingSessionCookieName
,
Value
:
encodeCookieValue
(
session
.
SessionToken
)})
req
.
AddCookie
(
&
http
.
Cookie
{
Name
:
oauthPendingBrowserCookieName
,
Value
:
encodeCookieValue
(
"linuxdo-browser"
)})
c
.
Request
=
req
handler
.
CompleteLinuxDoOAuthRegistration
(
c
)
require
.
Equal
(
t
,
http
.
StatusOK
,
recorder
.
Code
)
responseData
:=
decodeJSONBody
(
t
,
recorder
)
require
.
NotEmpty
(
t
,
responseData
[
"access_token"
])
userEntity
,
err
:=
client
.
User
.
Query
()
.
Where
(
dbuser
.
EmailEQ
(
session
.
ResolvedEmail
))
.
Only
(
ctx
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
"LinuxDo Display"
,
userEntity
.
Username
)
identity
,
err
:=
client
.
AuthIdentity
.
Query
()
.
Where
(
authidentity
.
ProviderTypeEQ
(
"linuxdo"
),
authidentity
.
ProviderKeyEQ
(
"linuxdo"
),
authidentity
.
ProviderSubjectEQ
(
"linuxdo-subject-1"
),
)
.
Only
(
ctx
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
userEntity
.
ID
,
identity
.
UserID
)
require
.
Equal
(
t
,
"LinuxDo Display"
,
identity
.
Metadata
[
"display_name"
])
require
.
Equal
(
t
,
"https://cdn.example/linuxdo.png"
,
identity
.
Metadata
[
"avatar_url"
])
decision
,
err
:=
client
.
IdentityAdoptionDecision
.
Query
()
.
Where
(
identityadoptiondecision
.
PendingAuthSessionIDEQ
(
session
.
ID
))
.
Only
(
ctx
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
decision
.
IdentityID
)
require
.
Equal
(
t
,
identity
.
ID
,
*
decision
.
IdentityID
)
require
.
True
(
t
,
decision
.
AdoptDisplayName
)
require
.
True
(
t
,
decision
.
AdoptAvatar
)
consumed
,
err
:=
client
.
PendingAuthSession
.
Query
()
.
Where
(
pendingauthsession
.
IDEQ
(
session
.
ID
))
.
Only
(
ctx
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
consumed
.
ConsumedAt
)
}
func
TestCompleteLinuxDoOAuthRegistrationRejectsAdoptExistingUserSession
(
t
*
testing
.
T
)
{
handler
,
client
:=
newOAuthPendingFlowTestHandler
(
t
,
false
)
ctx
:=
context
.
Background
()
existingUser
,
err
:=
client
.
User
.
Create
()
.
SetEmail
(
"owner@example.com"
)
.
SetUsername
(
"owner-user"
)
.
SetPasswordHash
(
"hash"
)
.
SetRole
(
service
.
RoleUser
)
.
SetStatus
(
service
.
StatusActive
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
session
,
err
:=
client
.
PendingAuthSession
.
Create
()
.
SetSessionToken
(
"linuxdo-complete-invalid-session"
)
.
SetIntent
(
"adopt_existing_user_by_email"
)
.
SetProviderType
(
"linuxdo"
)
.
SetProviderKey
(
"linuxdo"
)
.
SetProviderSubject
(
"linuxdo-invalid-subject-1"
)
.
SetTargetUserID
(
existingUser
.
ID
)
.
SetResolvedEmail
(
existingUser
.
Email
)
.
SetBrowserSessionKey
(
"linuxdo-invalid-browser"
)
.
SetUpstreamIdentityClaims
(
map
[
string
]
any
{
"username"
:
"linuxdo_user"
,
})
.
SetLocalFlowState
(
map
[
string
]
any
{
oauthCompletionResponseKey
:
map
[
string
]
any
{
"step"
:
"bind_login_required"
,
},
})
.
SetExpiresAt
(
time
.
Now
()
.
UTC
()
.
Add
(
10
*
time
.
Minute
))
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
body
:=
bytes
.
NewBufferString
(
`{"invitation_code":"invite-1"}`
)
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/auth/oauth/linuxdo/complete-registration"
,
body
)
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
req
.
AddCookie
(
&
http
.
Cookie
{
Name
:
oauthPendingSessionCookieName
,
Value
:
encodeCookieValue
(
session
.
SessionToken
)})
req
.
AddCookie
(
&
http
.
Cookie
{
Name
:
oauthPendingBrowserCookieName
,
Value
:
encodeCookieValue
(
"linuxdo-invalid-browser"
)})
c
.
Request
=
req
handler
.
CompleteLinuxDoOAuthRegistration
(
c
)
require
.
Equal
(
t
,
http
.
StatusBadRequest
,
recorder
.
Code
)
storedSession
,
err
:=
client
.
PendingAuthSession
.
Get
(
ctx
,
session
.
ID
)
require
.
NoError
(
t
,
err
)
require
.
Nil
(
t
,
storedSession
.
ConsumedAt
)
}
func
TestCompleteLinuxDoOAuthRegistrationReturnsPendingSessionWhenChoiceStillRequired
(
t
*
testing
.
T
)
{
handler
,
client
:=
newOAuthPendingFlowTestHandler
(
t
,
false
)
ctx
:=
context
.
Background
()
session
,
err
:=
client
.
PendingAuthSession
.
Create
()
.
SetSessionToken
(
"linuxdo-complete-choice-session"
)
.
SetIntent
(
"login"
)
.
SetProviderType
(
"linuxdo"
)
.
SetProviderKey
(
"linuxdo"
)
.
SetProviderSubject
(
"linuxdo-choice-subject-1"
)
.
SetResolvedEmail
(
"linuxdo-choice-subject-1@linuxdo-connect.invalid"
)
.
SetBrowserSessionKey
(
"linuxdo-choice-browser"
)
.
SetUpstreamIdentityClaims
(
map
[
string
]
any
{
"username"
:
"linuxdo_user"
,
})
.
SetLocalFlowState
(
map
[
string
]
any
{
oauthCompletionResponseKey
:
map
[
string
]
any
{
"step"
:
oauthPendingChoiceStep
,
"redirect"
:
"/dashboard"
,
"email"
:
"fresh@example.com"
,
"resolved_email"
:
"fresh@example.com"
,
"force_email_on_signup"
:
true
,
},
})
.
SetExpiresAt
(
time
.
Now
()
.
UTC
()
.
Add
(
10
*
time
.
Minute
))
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
body
:=
bytes
.
NewBufferString
(
`{"invitation_code":"invite-1"}`
)
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/auth/oauth/linuxdo/complete-registration"
,
body
)
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
req
.
AddCookie
(
&
http
.
Cookie
{
Name
:
oauthPendingSessionCookieName
,
Value
:
encodeCookieValue
(
session
.
SessionToken
)})
req
.
AddCookie
(
&
http
.
Cookie
{
Name
:
oauthPendingBrowserCookieName
,
Value
:
encodeCookieValue
(
"linuxdo-choice-browser"
)})
c
.
Request
=
req
handler
.
CompleteLinuxDoOAuthRegistration
(
c
)
require
.
Equal
(
t
,
http
.
StatusOK
,
recorder
.
Code
)
responseData
:=
decodeJSONBody
(
t
,
recorder
)
require
.
Equal
(
t
,
"pending_session"
,
responseData
[
"auth_result"
])
require
.
Equal
(
t
,
oauthPendingChoiceStep
,
responseData
[
"step"
])
require
.
Equal
(
t
,
true
,
responseData
[
"force_email_on_signup"
])
require
.
Empty
(
t
,
responseData
[
"access_token"
])
userCount
,
err
:=
client
.
User
.
Query
()
.
Count
(
ctx
)
require
.
NoError
(
t
,
err
)
require
.
Zero
(
t
,
userCount
)
storedSession
,
err
:=
client
.
PendingAuthSession
.
Get
(
ctx
,
session
.
ID
)
require
.
NoError
(
t
,
err
)
require
.
Nil
(
t
,
storedSession
.
ConsumedAt
)
}
func
TestCompleteLinuxDoOAuthRegistrationBindsIdentityWithoutAdoptionFlags
(
t
*
testing
.
T
)
{
handler
,
client
:=
newOAuthPendingFlowTestHandler
(
t
,
false
)
ctx
:=
context
.
Background
()
session
,
err
:=
client
.
PendingAuthSession
.
Create
()
.
SetSessionToken
(
"linuxdo-complete-no-adoption-session"
)
.
SetIntent
(
"login"
)
.
SetProviderType
(
"linuxdo"
)
.
SetProviderKey
(
"linuxdo"
)
.
SetProviderSubject
(
"linuxdo-subject-no-adoption"
)
.
SetResolvedEmail
(
"linuxdo-subject-no-adoption@linuxdo-connect.invalid"
)
.
SetBrowserSessionKey
(
"linuxdo-browser-no-adoption"
)
.
SetUpstreamIdentityClaims
(
map
[
string
]
any
{
"username"
:
"linuxdo_user"
,
"suggested_display_name"
:
"LinuxDo Legacy"
,
"suggested_avatar_url"
:
"https://cdn.example/linuxdo-legacy.png"
,
})
.
SetExpiresAt
(
time
.
Now
()
.
UTC
()
.
Add
(
10
*
time
.
Minute
))
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
body
:=
bytes
.
NewBufferString
(
`{"invitation_code":"invite-1"}`
)
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/auth/oauth/linuxdo/complete-registration"
,
body
)
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
req
.
AddCookie
(
&
http
.
Cookie
{
Name
:
oauthPendingSessionCookieName
,
Value
:
encodeCookieValue
(
session
.
SessionToken
)})
req
.
AddCookie
(
&
http
.
Cookie
{
Name
:
oauthPendingBrowserCookieName
,
Value
:
encodeCookieValue
(
"linuxdo-browser-no-adoption"
)})
c
.
Request
=
req
handler
.
CompleteLinuxDoOAuthRegistration
(
c
)
require
.
Equal
(
t
,
http
.
StatusOK
,
recorder
.
Code
)
responseData
:=
decodeJSONBody
(
t
,
recorder
)
require
.
NotEmpty
(
t
,
responseData
[
"access_token"
])
require
.
NotEmpty
(
t
,
responseData
[
"refresh_token"
])
userEntity
,
err
:=
client
.
User
.
Query
()
.
Where
(
dbuser
.
EmailEQ
(
session
.
ResolvedEmail
))
.
Only
(
ctx
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
"linuxdo_user"
,
userEntity
.
Username
)
identity
,
err
:=
client
.
AuthIdentity
.
Query
()
.
Where
(
authidentity
.
ProviderTypeEQ
(
"linuxdo"
),
authidentity
.
ProviderKeyEQ
(
"linuxdo"
),
authidentity
.
ProviderSubjectEQ
(
"linuxdo-subject-no-adoption"
),
)
.
Only
(
ctx
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
userEntity
.
ID
,
identity
.
UserID
)
decision
,
err
:=
client
.
IdentityAdoptionDecision
.
Query
()
.
Where
(
identityadoptiondecision
.
PendingAuthSessionIDEQ
(
session
.
ID
))
.
Only
(
ctx
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
decision
.
IdentityID
)
require
.
Equal
(
t
,
identity
.
ID
,
*
decision
.
IdentityID
)
require
.
False
(
t
,
decision
.
AdoptDisplayName
)
require
.
False
(
t
,
decision
.
AdoptAvatar
)
}
func
TestCompleteLinuxDoOAuthRegistrationRejectsIdentityOwnershipConflictBeforeUserCreation
(
t
*
testing
.
T
)
{
handler
,
client
:=
newOAuthPendingFlowTestHandler
(
t
,
false
)
ctx
:=
context
.
Background
()
existingOwner
,
err
:=
client
.
User
.
Create
()
.
SetEmail
(
"owner@example.com"
)
.
SetUsername
(
"owner-user"
)
.
SetPasswordHash
(
"hash"
)
.
SetRole
(
service
.
RoleUser
)
.
SetStatus
(
service
.
StatusActive
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
_
,
err
=
client
.
AuthIdentity
.
Create
()
.
SetUserID
(
existingOwner
.
ID
)
.
SetProviderType
(
"linuxdo"
)
.
SetProviderKey
(
"linuxdo"
)
.
SetProviderSubject
(
"linuxdo-conflict-subject"
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
session
,
err
:=
client
.
PendingAuthSession
.
Create
()
.
SetSessionToken
(
"linuxdo-complete-conflict-session"
)
.
SetIntent
(
"login"
)
.
SetProviderType
(
"linuxdo"
)
.
SetProviderKey
(
"linuxdo"
)
.
SetProviderSubject
(
"linuxdo-conflict-subject"
)
.
SetResolvedEmail
(
"linuxdo-conflict-subject@linuxdo-connect.invalid"
)
.
SetBrowserSessionKey
(
"linuxdo-conflict-browser"
)
.
SetUpstreamIdentityClaims
(
map
[
string
]
any
{
"username"
:
"linuxdo_user"
,
})
.
SetExpiresAt
(
time
.
Now
()
.
UTC
()
.
Add
(
10
*
time
.
Minute
))
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
body
:=
bytes
.
NewBufferString
(
`{"invitation_code":"invite-1"}`
)
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/auth/oauth/linuxdo/complete-registration"
,
body
)
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
req
.
AddCookie
(
&
http
.
Cookie
{
Name
:
oauthPendingSessionCookieName
,
Value
:
encodeCookieValue
(
session
.
SessionToken
)})
req
.
AddCookie
(
&
http
.
Cookie
{
Name
:
oauthPendingBrowserCookieName
,
Value
:
encodeCookieValue
(
"linuxdo-conflict-browser"
)})
c
.
Request
=
req
handler
.
CompleteLinuxDoOAuthRegistration
(
c
)
require
.
Equal
(
t
,
http
.
StatusConflict
,
recorder
.
Code
)
payload
:=
decodeJSONBody
(
t
,
recorder
)
require
.
Equal
(
t
,
"AUTH_IDENTITY_OWNERSHIP_CONFLICT"
,
payload
[
"reason"
])
userCount
,
err
:=
client
.
User
.
Query
()
.
Where
(
dbuser
.
EmailEQ
(
"linuxdo-conflict-subject@linuxdo-connect.invalid"
))
.
Count
(
ctx
)
require
.
NoError
(
t
,
err
)
require
.
Zero
(
t
,
userCount
)
storedSession
,
err
:=
client
.
PendingAuthSession
.
Get
(
ctx
,
session
.
ID
)
require
.
NoError
(
t
,
err
)
require
.
Nil
(
t
,
storedSession
.
ConsumedAt
)
}
func
newLinuxDoOAuthTestHandler
(
t
*
testing
.
T
,
invitationEnabled
bool
,
oauthCfg
config
.
LinuxDoConnectConfig
)
*
AuthHandler
{
t
.
Helper
()
handler
,
_
:=
newLinuxDoOAuthHandlerAndClient
(
t
,
invitationEnabled
,
oauthCfg
)
return
handler
}
func
newLinuxDoOAuthHandlerAndClient
(
t
*
testing
.
T
,
invitationEnabled
bool
,
oauthCfg
config
.
LinuxDoConnectConfig
)
(
*
AuthHandler
,
*
dbent
.
Client
)
{
t
.
Helper
()
handler
,
client
:=
newOAuthPendingFlowTestHandler
(
t
,
invitationEnabled
)
handler
.
settingSvc
=
nil
handler
.
cfg
=
&
config
.
Config
{
JWT
:
config
.
JWTConfig
{
Secret
:
"test-secret"
,
ExpireHour
:
1
,
AccessTokenExpireMinutes
:
60
,
RefreshTokenExpireDays
:
7
,
},
LinuxDo
:
oauthCfg
,
}
return
handler
,
client
}
backend/internal/handler/auth_oauth_logout_test.go
0 → 100644
View file @
b017f461
package
handler
import
(
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func
TestLogoutClearsOAuthStateCookiesAndConsumesPendingSession
(
t
*
testing
.
T
)
{
handler
,
client
:=
newOAuthPendingFlowTestHandler
(
t
,
false
)
ctx
:=
context
.
Background
()
session
,
err
:=
client
.
PendingAuthSession
.
Create
()
.
SetSessionToken
(
"logout-pending-session-token"
)
.
SetIntent
(
"login"
)
.
SetProviderType
(
"oidc"
)
.
SetProviderKey
(
"https://issuer.example"
)
.
SetProviderSubject
(
"logout-subject-123"
)
.
SetBrowserSessionKey
(
"logout-browser-session-key"
)
.
SetResolvedEmail
(
"logout@example.com"
)
.
SetExpiresAt
(
time
.
Now
()
.
UTC
()
.
Add
(
10
*
time
.
Minute
))
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
recorder
:=
httptest
.
NewRecorder
()
ginCtx
,
_
:=
gin
.
CreateTestContext
(
recorder
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/auth/logout"
,
nil
)
req
.
AddCookie
(
&
http
.
Cookie
{
Name
:
oauthPendingSessionCookieName
,
Value
:
encodeCookieValue
(
session
.
SessionToken
)})
req
.
AddCookie
(
&
http
.
Cookie
{
Name
:
oauthPendingBrowserCookieName
,
Value
:
encodeCookieValue
(
"logout-browser-session-key"
)})
req
.
AddCookie
(
&
http
.
Cookie
{
Name
:
oauthBindAccessTokenCookieName
,
Value
:
"bind-access-token"
})
req
.
AddCookie
(
&
http
.
Cookie
{
Name
:
linuxDoOAuthStateCookieName
,
Value
:
encodeCookieValue
(
"linuxdo-state"
)})
req
.
AddCookie
(
&
http
.
Cookie
{
Name
:
oidcOAuthStateCookieName
,
Value
:
encodeCookieValue
(
"oidc-state"
)})
req
.
AddCookie
(
&
http
.
Cookie
{
Name
:
wechatOAuthStateCookieName
,
Value
:
encodeCookieValue
(
"wechat-state"
)})
req
.
AddCookie
(
&
http
.
Cookie
{
Name
:
wechatPaymentOAuthStateName
,
Value
:
encodeCookieValue
(
"wechat-payment-state"
)})
ginCtx
.
Request
=
req
handler
.
Logout
(
ginCtx
)
require
.
Equal
(
t
,
http
.
StatusOK
,
recorder
.
Code
)
cookies
:=
recorder
.
Result
()
.
Cookies
()
for
_
,
name
:=
range
[]
string
{
oauthPendingSessionCookieName
,
oauthPendingBrowserCookieName
,
oauthBindAccessTokenCookieName
,
linuxDoOAuthStateCookieName
,
oidcOAuthStateCookieName
,
wechatOAuthStateCookieName
,
wechatPaymentOAuthStateName
,
}
{
cookie
:=
findCookie
(
cookies
,
name
)
require
.
NotNil
(
t
,
cookie
,
name
)
require
.
Equal
(
t
,
-
1
,
cookie
.
MaxAge
,
name
)
require
.
True
(
t
,
cookie
.
HttpOnly
,
name
)
}
storedSession
,
err
:=
client
.
PendingAuthSession
.
Query
()
.
Where
(
pendingauthsession
.
IDEQ
(
session
.
ID
))
.
Only
(
ctx
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
storedSession
.
ConsumedAt
)
}
backend/internal/handler/auth_oauth_pending_flow.go
0 → 100644
View file @
b017f461
package
handler
import
(
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/authidentity"
"github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
"github.com/Wei-Shaw/sub2api/ent/predicate"
dbuser
"github.com/Wei-Shaw/sub2api/ent/user"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
entsql
"entgo.io/ent/dialect/sql"
"github.com/gin-gonic/gin"
)
const
(
oauthPendingBrowserCookiePath
=
"/api/v1/auth/oauth"
oauthPendingBrowserCookieName
=
"oauth_pending_browser_session"
oauthPendingSessionCookiePath
=
"/api/v1/auth/oauth"
oauthPendingSessionCookieName
=
"oauth_pending_session"
oauthPendingCookieMaxAgeSec
=
10
*
60
oauthPendingChoiceStep
=
"choose_account_action_required"
oauthCompletionResponseKey
=
"completion_response"
)
var
pendingOAuthCreateAccountPreCommitHook
func
(
context
.
Context
,
*
dbent
.
PendingAuthSession
)
error
type
oauthPendingSessionPayload
struct
{
Intent
string
Identity
service
.
PendingAuthIdentityKey
TargetUserID
*
int64
ResolvedEmail
string
RedirectTo
string
BrowserSessionKey
string
UpstreamIdentityClaims
map
[
string
]
any
CompletionResponse
map
[
string
]
any
}
type
oauthAdoptionDecisionRequest
struct
{
AdoptDisplayName
*
bool
`json:"adopt_display_name,omitempty"`
AdoptAvatar
*
bool
`json:"adopt_avatar,omitempty"`
}
type
bindPendingOAuthLoginRequest
struct
{
Email
string
`json:"email" binding:"required,email"`
Password
string
`json:"password" binding:"required"`
AdoptDisplayName
*
bool
`json:"adopt_display_name,omitempty"`
AdoptAvatar
*
bool
`json:"adopt_avatar,omitempty"`
}
type
createPendingOAuthAccountRequest
struct
{
Email
string
`json:"email" binding:"required,email"`
VerifyCode
string
`json:"verify_code,omitempty"`
Password
string
`json:"password" binding:"required,min=6"`
InvitationCode
string
`json:"invitation_code,omitempty"`
AdoptDisplayName
*
bool
`json:"adopt_display_name,omitempty"`
AdoptAvatar
*
bool
`json:"adopt_avatar,omitempty"`
}
type
sendPendingOAuthVerifyCodeRequest
struct
{
Email
string
`json:"email" binding:"required,email"`
TurnstileToken
string
`json:"turnstile_token,omitempty"`
PendingAuthToken
string
`json:"pending_auth_token,omitempty"`
PendingOAuthToken
string
`json:"pending_oauth_token,omitempty"`
}
func
(
r
bindPendingOAuthLoginRequest
)
adoptionDecision
()
oauthAdoptionDecisionRequest
{
return
oauthAdoptionDecisionRequest
{
AdoptDisplayName
:
r
.
AdoptDisplayName
,
AdoptAvatar
:
r
.
AdoptAvatar
,
}
}
func
(
r
createPendingOAuthAccountRequest
)
adoptionDecision
()
oauthAdoptionDecisionRequest
{
return
oauthAdoptionDecisionRequest
{
AdoptDisplayName
:
r
.
AdoptDisplayName
,
AdoptAvatar
:
r
.
AdoptAvatar
,
}
}
func
(
h
*
AuthHandler
)
pendingIdentityService
()
(
*
service
.
AuthPendingIdentityService
,
error
)
{
if
h
==
nil
||
h
.
authService
==
nil
||
h
.
authService
.
EntClient
()
==
nil
{
return
nil
,
infraerrors
.
ServiceUnavailable
(
"PENDING_AUTH_NOT_READY"
,
"pending auth service is not ready"
)
}
return
service
.
NewAuthPendingIdentityService
(
h
.
authService
.
EntClient
()),
nil
}
func
generateOAuthPendingBrowserSession
()
(
string
,
error
)
{
return
oauth
.
GenerateState
()
}
func
setOAuthPendingBrowserCookie
(
c
*
gin
.
Context
,
sessionKey
string
,
secure
bool
)
{
http
.
SetCookie
(
c
.
Writer
,
&
http
.
Cookie
{
Name
:
oauthPendingBrowserCookieName
,
Value
:
encodeCookieValue
(
sessionKey
),
Path
:
oauthPendingBrowserCookiePath
,
MaxAge
:
oauthPendingCookieMaxAgeSec
,
HttpOnly
:
true
,
Secure
:
secure
,
SameSite
:
http
.
SameSiteLaxMode
,
})
}
func
clearOAuthPendingBrowserCookie
(
c
*
gin
.
Context
,
secure
bool
)
{
http
.
SetCookie
(
c
.
Writer
,
&
http
.
Cookie
{
Name
:
oauthPendingBrowserCookieName
,
Value
:
""
,
Path
:
oauthPendingBrowserCookiePath
,
MaxAge
:
-
1
,
HttpOnly
:
true
,
Secure
:
secure
,
SameSite
:
http
.
SameSiteLaxMode
,
})
}
func
readOAuthPendingBrowserCookie
(
c
*
gin
.
Context
)
(
string
,
error
)
{
return
readCookieDecoded
(
c
,
oauthPendingBrowserCookieName
)
}
func
setOAuthPendingSessionCookie
(
c
*
gin
.
Context
,
sessionToken
string
,
secure
bool
)
{
http
.
SetCookie
(
c
.
Writer
,
&
http
.
Cookie
{
Name
:
oauthPendingSessionCookieName
,
Value
:
encodeCookieValue
(
sessionToken
),
Path
:
oauthPendingSessionCookiePath
,
MaxAge
:
oauthPendingCookieMaxAgeSec
,
HttpOnly
:
true
,
Secure
:
secure
,
SameSite
:
http
.
SameSiteLaxMode
,
})
}
func
clearOAuthPendingSessionCookie
(
c
*
gin
.
Context
,
secure
bool
)
{
http
.
SetCookie
(
c
.
Writer
,
&
http
.
Cookie
{
Name
:
oauthPendingSessionCookieName
,
Value
:
""
,
Path
:
oauthPendingSessionCookiePath
,
MaxAge
:
-
1
,
HttpOnly
:
true
,
Secure
:
secure
,
SameSite
:
http
.
SameSiteLaxMode
,
})
}
func
readOAuthPendingSessionCookie
(
c
*
gin
.
Context
)
(
string
,
error
)
{
return
readCookieDecoded
(
c
,
oauthPendingSessionCookieName
)
}
func
redirectToFrontendCallback
(
c
*
gin
.
Context
,
frontendCallback
string
)
{
u
,
err
:=
url
.
Parse
(
frontendCallback
)
if
err
!=
nil
{
c
.
Redirect
(
http
.
StatusFound
,
linuxDoOAuthDefaultRedirectTo
)
return
}
if
u
.
Scheme
!=
""
&&
!
strings
.
EqualFold
(
u
.
Scheme
,
"http"
)
&&
!
strings
.
EqualFold
(
u
.
Scheme
,
"https"
)
{
c
.
Redirect
(
http
.
StatusFound
,
linuxDoOAuthDefaultRedirectTo
)
return
}
u
.
Fragment
=
""
c
.
Header
(
"Cache-Control"
,
"no-store"
)
c
.
Header
(
"Pragma"
,
"no-cache"
)
c
.
Redirect
(
http
.
StatusFound
,
u
.
String
())
}
func
(
h
*
AuthHandler
)
createOAuthPendingSession
(
c
*
gin
.
Context
,
payload
oauthPendingSessionPayload
)
error
{
svc
,
err
:=
h
.
pendingIdentityService
()
if
err
!=
nil
{
return
err
}
session
,
err
:=
svc
.
CreatePendingSession
(
c
.
Request
.
Context
(),
service
.
CreatePendingAuthSessionInput
{
Intent
:
strings
.
TrimSpace
(
payload
.
Intent
),
Identity
:
payload
.
Identity
,
TargetUserID
:
payload
.
TargetUserID
,
ResolvedEmail
:
strings
.
TrimSpace
(
payload
.
ResolvedEmail
),
RedirectTo
:
strings
.
TrimSpace
(
payload
.
RedirectTo
),
BrowserSessionKey
:
strings
.
TrimSpace
(
payload
.
BrowserSessionKey
),
UpstreamIdentityClaims
:
payload
.
UpstreamIdentityClaims
,
LocalFlowState
:
map
[
string
]
any
{
oauthCompletionResponseKey
:
payload
.
CompletionResponse
,
},
})
if
err
!=
nil
{
return
infraerrors
.
InternalServer
(
"PENDING_AUTH_SESSION_CREATE_FAILED"
,
"failed to create pending auth session"
)
.
WithCause
(
err
)
}
setOAuthPendingSessionCookie
(
c
,
session
.
SessionToken
,
isRequestHTTPS
(
c
))
return
nil
}
func
readCompletionResponse
(
session
map
[
string
]
any
)
(
map
[
string
]
any
,
bool
)
{
if
len
(
session
)
==
0
{
return
nil
,
false
}
value
,
ok
:=
session
[
oauthCompletionResponseKey
]
if
!
ok
{
return
nil
,
false
}
result
,
ok
:=
value
.
(
map
[
string
]
any
)
if
!
ok
{
return
nil
,
false
}
return
result
,
true
}
func
clonePendingMap
(
values
map
[
string
]
any
)
map
[
string
]
any
{
if
len
(
values
)
==
0
{
return
map
[
string
]
any
{}
}
cloned
:=
make
(
map
[
string
]
any
,
len
(
values
))
for
key
,
value
:=
range
values
{
cloned
[
key
]
=
value
}
return
cloned
}
func
mergePendingCompletionResponse
(
session
*
dbent
.
PendingAuthSession
,
overrides
map
[
string
]
any
)
map
[
string
]
any
{
payload
,
_
:=
readCompletionResponse
(
session
.
LocalFlowState
)
merged
:=
clonePendingMap
(
payload
)
if
strings
.
TrimSpace
(
session
.
RedirectTo
)
!=
""
{
if
_
,
exists
:=
merged
[
"redirect"
];
!
exists
{
merged
[
"redirect"
]
=
session
.
RedirectTo
}
}
for
key
,
value
:=
range
overrides
{
if
value
==
nil
{
delete
(
merged
,
key
)
continue
}
merged
[
key
]
=
value
}
applySuggestedProfileToCompletionResponse
(
merged
,
session
.
UpstreamIdentityClaims
)
return
merged
}
func
pendingSessionStringValue
(
values
map
[
string
]
any
,
key
string
)
string
{
if
len
(
values
)
==
0
{
return
""
}
raw
,
ok
:=
values
[
key
]
if
!
ok
{
return
""
}
value
,
ok
:=
raw
.
(
string
)
if
!
ok
{
return
""
}
return
strings
.
TrimSpace
(
value
)
}
func
pendingSessionWantsInvitation
(
payload
map
[
string
]
any
)
bool
{
return
strings
.
EqualFold
(
strings
.
TrimSpace
(
pendingSessionStringValue
(
payload
,
"error"
)),
"invitation_required"
)
}
func
pendingOAuthCompletionCanIssueTokenPair
(
session
*
dbent
.
PendingAuthSession
,
payload
map
[
string
]
any
)
bool
{
if
session
==
nil
{
return
false
}
if
!
strings
.
EqualFold
(
strings
.
TrimSpace
(
session
.
Intent
),
oauthIntentLogin
)
{
return
false
}
if
session
.
TargetUserID
==
nil
||
*
session
.
TargetUserID
<=
0
{
return
false
}
if
pendingSessionWantsInvitation
(
payload
)
{
return
false
}
return
strings
.
TrimSpace
(
pendingSessionStringValue
(
payload
,
"step"
))
==
""
}
func
ensurePendingOAuthCompleteRegistrationSession
(
session
*
dbent
.
PendingAuthSession
)
error
{
if
session
==
nil
{
return
infraerrors
.
BadRequest
(
"PENDING_AUTH_SESSION_INVALID"
,
"pending auth registration context is invalid"
)
}
if
strings
.
TrimSpace
(
session
.
Intent
)
!=
oauthIntentLogin
{
return
infraerrors
.
BadRequest
(
"PENDING_AUTH_SESSION_INVALID"
,
"pending auth registration context is invalid"
)
}
if
session
.
TargetUserID
!=
nil
&&
*
session
.
TargetUserID
>
0
{
return
infraerrors
.
BadRequest
(
"PENDING_AUTH_SESSION_INVALID"
,
"pending auth registration context is invalid"
)
}
payload
,
_
:=
readCompletionResponse
(
session
.
LocalFlowState
)
if
strings
.
EqualFold
(
strings
.
TrimSpace
(
pendingSessionStringValue
(
payload
,
"step"
)),
"bind_login_required"
)
{
return
infraerrors
.
BadRequest
(
"PENDING_AUTH_SESSION_INVALID"
,
"pending auth registration context is invalid"
)
}
return
nil
}
func
buildLegacyCompleteRegistrationPendingResponse
(
session
*
dbent
.
PendingAuthSession
,
forceEmailOnSignup
bool
,
emailVerificationRequired
bool
,
)
map
[
string
]
any
{
completionResponse
:=
normalizePendingOAuthCompletionResponse
(
mergePendingCompletionResponse
(
session
,
map
[
string
]
any
{
"step"
:
oauthPendingChoiceStep
,
"adoption_required"
:
true
,
"create_account_allowed"
:
true
,
"force_email_on_signup"
:
forceEmailOnSignup
,
}))
if
email
:=
strings
.
TrimSpace
(
session
.
ResolvedEmail
);
email
!=
""
{
if
_
,
exists
:=
completionResponse
[
"email"
];
!
exists
{
completionResponse
[
"email"
]
=
email
}
if
_
,
exists
:=
completionResponse
[
"resolved_email"
];
!
exists
{
completionResponse
[
"resolved_email"
]
=
email
}
}
if
_
,
exists
:=
completionResponse
[
"choice_reason"
];
!
exists
{
switch
{
case
forceEmailOnSignup
:
completionResponse
[
"choice_reason"
]
=
"force_email_on_signup"
case
emailVerificationRequired
:
completionResponse
[
"choice_reason"
]
=
"email_verification_required"
default
:
completionResponse
[
"choice_reason"
]
=
"third_party_signup"
}
}
return
completionResponse
}
func
(
h
*
AuthHandler
)
legacyCompleteRegistrationSessionStatus
(
c
*
gin
.
Context
,
session
*
dbent
.
PendingAuthSession
,
)
(
*
dbent
.
PendingAuthSession
,
bool
,
error
)
{
if
session
==
nil
{
return
nil
,
false
,
infraerrors
.
BadRequest
(
"PENDING_AUTH_SESSION_INVALID"
,
"pending auth registration context is invalid"
)
}
payload
:=
normalizePendingOAuthCompletionResponse
(
mergePendingCompletionResponse
(
session
,
nil
))
if
step
:=
pendingSessionStringValue
(
payload
,
"step"
);
step
!=
""
{
return
session
,
true
,
nil
}
emailVerificationRequired
:=
h
!=
nil
&&
h
.
authService
!=
nil
&&
h
.
authService
.
IsEmailVerifyEnabled
(
c
.
Request
.
Context
())
forceEmailOnSignup
:=
h
.
isForceEmailOnThirdPartySignup
(
c
.
Request
.
Context
())
if
!
emailVerificationRequired
&&
!
forceEmailOnSignup
{
return
session
,
false
,
nil
}
client
:=
h
.
entClient
()
if
client
==
nil
{
return
nil
,
false
,
infraerrors
.
ServiceUnavailable
(
"PENDING_AUTH_NOT_READY"
,
"pending auth service is not ready"
)
}
updatedSession
,
err
:=
updatePendingOAuthSessionProgress
(
c
.
Request
.
Context
(),
client
,
session
,
strings
.
TrimSpace
(
session
.
Intent
),
strings
.
TrimSpace
(
session
.
ResolvedEmail
),
nil
,
buildLegacyCompleteRegistrationPendingResponse
(
session
,
forceEmailOnSignup
,
emailVerificationRequired
),
)
if
err
!=
nil
{
return
nil
,
false
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_SESSION_UPDATE_FAILED"
,
"failed to update pending oauth session"
)
.
WithCause
(
err
)
}
return
updatedSession
,
true
,
nil
}
func
(
r
oauthAdoptionDecisionRequest
)
hasDecision
()
bool
{
return
r
.
AdoptDisplayName
!=
nil
||
r
.
AdoptAvatar
!=
nil
}
func
bindOptionalOAuthAdoptionDecision
(
c
*
gin
.
Context
)
(
oauthAdoptionDecisionRequest
,
error
)
{
var
req
oauthAdoptionDecisionRequest
if
c
==
nil
||
c
.
Request
==
nil
||
c
.
Request
.
Body
==
nil
{
return
req
,
nil
}
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
if
errors
.
Is
(
err
,
io
.
EOF
)
{
return
req
,
nil
}
return
req
,
err
}
return
req
,
nil
}
func
cloneOAuthMetadata
(
values
map
[
string
]
any
)
map
[
string
]
any
{
if
len
(
values
)
==
0
{
return
map
[
string
]
any
{}
}
cloned
:=
make
(
map
[
string
]
any
,
len
(
values
))
for
key
,
value
:=
range
values
{
cloned
[
key
]
=
value
}
return
cloned
}
func
mergeOAuthMetadata
(
base
map
[
string
]
any
,
overlay
map
[
string
]
any
)
map
[
string
]
any
{
merged
:=
cloneOAuthMetadata
(
base
)
for
key
,
value
:=
range
overlay
{
merged
[
key
]
=
value
}
return
merged
}
func
normalizeAdoptedOAuthDisplayName
(
value
string
)
string
{
value
=
strings
.
TrimSpace
(
value
)
if
len
([]
rune
(
value
))
>
100
{
value
=
string
([]
rune
(
value
)[
:
100
])
}
return
value
}
func
(
h
*
AuthHandler
)
entClient
()
*
dbent
.
Client
{
if
h
==
nil
||
h
.
authService
==
nil
{
return
nil
}
return
h
.
authService
.
EntClient
()
}
func
(
h
*
AuthHandler
)
isForceEmailOnThirdPartySignup
(
ctx
context
.
Context
)
bool
{
if
h
==
nil
||
h
.
settingSvc
==
nil
{
return
false
}
defaults
,
err
:=
h
.
settingSvc
.
GetAuthSourceDefaultSettings
(
ctx
)
if
err
!=
nil
||
defaults
==
nil
{
return
false
}
return
defaults
.
ForceEmailOnThirdPartySignup
}
func
(
h
*
AuthHandler
)
findOAuthIdentityUser
(
ctx
context
.
Context
,
identity
service
.
PendingAuthIdentityKey
)
(
*
dbent
.
User
,
error
)
{
client
:=
h
.
entClient
()
if
client
==
nil
{
return
nil
,
infraerrors
.
ServiceUnavailable
(
"PENDING_AUTH_NOT_READY"
,
"pending auth service is not ready"
)
}
record
,
err
:=
client
.
AuthIdentity
.
Query
()
.
Where
(
authidentity
.
ProviderTypeEQ
(
strings
.
TrimSpace
(
identity
.
ProviderType
)),
authidentity
.
ProviderKeyEQ
(
strings
.
TrimSpace
(
identity
.
ProviderKey
)),
authidentity
.
ProviderSubjectEQ
(
strings
.
TrimSpace
(
identity
.
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
)
}
return
findActiveUserByID
(
ctx
,
client
,
record
.
UserID
)
}
func
(
h
*
AuthHandler
)
BindLinuxDoOAuthLogin
(
c
*
gin
.
Context
)
{
h
.
bindPendingOAuthLogin
(
c
,
"linuxdo"
)
}
func
(
h
*
AuthHandler
)
BindOIDCOAuthLogin
(
c
*
gin
.
Context
)
{
h
.
bindPendingOAuthLogin
(
c
,
"oidc"
)
}
func
(
h
*
AuthHandler
)
BindWeChatOAuthLogin
(
c
*
gin
.
Context
)
{
h
.
bindPendingOAuthLogin
(
c
,
"wechat"
)
}
func
(
h
*
AuthHandler
)
BindPendingOAuthLogin
(
c
*
gin
.
Context
)
{
h
.
bindPendingOAuthLogin
(
c
,
""
)
}
func
(
h
*
AuthHandler
)
CreateLinuxDoOAuthAccount
(
c
*
gin
.
Context
)
{
h
.
createPendingOAuthAccount
(
c
,
"linuxdo"
)
}
func
(
h
*
AuthHandler
)
CreateOIDCOAuthAccount
(
c
*
gin
.
Context
)
{
h
.
createPendingOAuthAccount
(
c
,
"oidc"
)
}
func
(
h
*
AuthHandler
)
CreateWeChatOAuthAccount
(
c
*
gin
.
Context
)
{
h
.
createPendingOAuthAccount
(
c
,
"wechat"
)
}
func
(
h
*
AuthHandler
)
CreatePendingOAuthAccount
(
c
*
gin
.
Context
)
{
h
.
createPendingOAuthAccount
(
c
,
""
)
}
// SendPendingOAuthVerifyCode sends a verification code for a browser-bound
// pending OAuth account-creation flow.
// POST /api/v1/auth/oauth/pending/send-verify-code
func
(
h
*
AuthHandler
)
SendPendingOAuthVerifyCode
(
c
*
gin
.
Context
)
{
var
req
sendPendingOAuthVerifyCodeRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
if
err
:=
h
.
authService
.
VerifyTurnstile
(
c
.
Request
.
Context
(),
req
.
TurnstileToken
,
ip
.
GetClientIP
(
c
));
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
_
,
session
,
_
,
err
:=
readPendingOAuthBrowserSession
(
c
,
h
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
if
err
:=
ensurePendingOAuthCompleteRegistrationSession
(
session
);
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
}
email
:=
strings
.
TrimSpace
(
strings
.
ToLower
(
req
.
Email
))
if
existingUser
,
err
:=
findUserByNormalizedEmail
(
c
.
Request
.
Context
(),
client
,
email
);
err
==
nil
&&
existingUser
!=
nil
{
session
,
err
=
h
.
transitionPendingOAuthAccountToChoiceState
(
c
,
client
,
session
,
existingUser
,
email
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
c
.
JSON
(
http
.
StatusOK
,
buildPendingOAuthSessionStatusPayload
(
session
))
return
}
else
if
err
!=
nil
&&
!
errors
.
Is
(
err
,
service
.
ErrUserNotFound
)
{
response
.
ErrorFrom
(
c
,
err
)
return
}
result
,
err
:=
h
.
authService
.
SendPendingOAuthVerifyCode
(
c
.
Request
.
Context
(),
req
.
Email
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
SendVerifyCodeResponse
{
Message
:
"Verification code sent successfully"
,
Countdown
:
result
.
Countdown
,
})
}
func
(
h
*
AuthHandler
)
upsertPendingOAuthAdoptionDecision
(
c
*
gin
.
Context
,
sessionID
int64
,
req
oauthAdoptionDecisionRequest
,
)
(
*
dbent
.
IdentityAdoptionDecision
,
error
)
{
client
:=
h
.
entClient
()
if
client
==
nil
{
return
nil
,
infraerrors
.
ServiceUnavailable
(
"PENDING_AUTH_NOT_READY"
,
"pending auth service is not ready"
)
}
existing
,
err
:=
client
.
IdentityAdoptionDecision
.
Query
()
.
Where
(
identityadoptiondecision
.
PendingAuthSessionIDEQ
(
sessionID
))
.
Only
(
c
.
Request
.
Context
())
if
err
!=
nil
&&
!
dbent
.
IsNotFound
(
err
)
{
return
nil
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_ADOPTION_LOAD_FAILED"
,
"failed to load oauth profile adoption decision"
)
.
WithCause
(
err
)
}
if
existing
!=
nil
&&
!
req
.
hasDecision
()
{
return
existing
,
nil
}
if
existing
==
nil
&&
!
req
.
hasDecision
()
{
return
nil
,
nil
}
input
:=
service
.
PendingIdentityAdoptionDecisionInput
{
PendingAuthSessionID
:
sessionID
,
}
if
existing
!=
nil
{
input
.
AdoptDisplayName
=
existing
.
AdoptDisplayName
input
.
AdoptAvatar
=
existing
.
AdoptAvatar
input
.
IdentityID
=
existing
.
IdentityID
}
if
req
.
AdoptDisplayName
!=
nil
{
input
.
AdoptDisplayName
=
*
req
.
AdoptDisplayName
}
if
req
.
AdoptAvatar
!=
nil
{
input
.
AdoptAvatar
=
*
req
.
AdoptAvatar
}
svc
,
err
:=
h
.
pendingIdentityService
()
if
err
!=
nil
{
return
nil
,
err
}
decision
,
err
:=
svc
.
UpsertAdoptionDecision
(
c
.
Request
.
Context
(),
input
)
if
err
!=
nil
{
return
nil
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_ADOPTION_SAVE_FAILED"
,
"failed to save oauth profile adoption decision"
)
.
WithCause
(
err
)
}
return
decision
,
nil
}
func
(
h
*
AuthHandler
)
ensurePendingOAuthAdoptionDecision
(
c
*
gin
.
Context
,
sessionID
int64
,
req
oauthAdoptionDecisionRequest
,
)
(
*
dbent
.
IdentityAdoptionDecision
,
error
)
{
decision
,
err
:=
h
.
upsertPendingOAuthAdoptionDecision
(
c
,
sessionID
,
req
)
if
err
!=
nil
{
return
nil
,
err
}
if
decision
!=
nil
{
return
decision
,
nil
}
svc
,
err
:=
h
.
pendingIdentityService
()
if
err
!=
nil
{
return
nil
,
err
}
decision
,
err
=
svc
.
UpsertAdoptionDecision
(
c
.
Request
.
Context
(),
service
.
PendingIdentityAdoptionDecisionInput
{
PendingAuthSessionID
:
sessionID
,
})
if
err
!=
nil
{
return
nil
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_ADOPTION_SAVE_FAILED"
,
"failed to save oauth profile adoption decision"
)
.
WithCause
(
err
)
}
return
decision
,
nil
}
func
updatePendingOAuthSessionProgress
(
ctx
context
.
Context
,
client
*
dbent
.
Client
,
session
*
dbent
.
PendingAuthSession
,
intent
string
,
resolvedEmail
string
,
targetUserID
*
int64
,
completionResponse
map
[
string
]
any
,
)
(
*
dbent
.
PendingAuthSession
,
error
)
{
if
client
==
nil
||
session
==
nil
{
return
nil
,
infraerrors
.
BadRequest
(
"PENDING_AUTH_SESSION_INVALID"
,
"pending auth session is invalid"
)
}
localFlowState
:=
clonePendingMap
(
session
.
LocalFlowState
)
localFlowState
[
oauthCompletionResponseKey
]
=
clonePendingMap
(
completionResponse
)
update
:=
client
.
PendingAuthSession
.
UpdateOneID
(
session
.
ID
)
.
SetIntent
(
strings
.
TrimSpace
(
intent
))
.
SetResolvedEmail
(
strings
.
TrimSpace
(
resolvedEmail
))
.
SetLocalFlowState
(
localFlowState
)
if
targetUserID
!=
nil
&&
*
targetUserID
>
0
{
update
=
update
.
SetTargetUserID
(
*
targetUserID
)
}
else
{
update
=
update
.
ClearTargetUserID
()
}
return
update
.
Save
(
ctx
)
}
func
resolvePendingOAuthTargetUserID
(
ctx
context
.
Context
,
client
*
dbent
.
Client
,
session
*
dbent
.
PendingAuthSession
)
(
int64
,
error
)
{
if
session
==
nil
{
return
0
,
infraerrors
.
BadRequest
(
"PENDING_AUTH_SESSION_INVALID"
,
"pending auth session is invalid"
)
}
if
session
.
TargetUserID
!=
nil
&&
*
session
.
TargetUserID
>
0
{
return
*
session
.
TargetUserID
,
nil
}
email
:=
strings
.
TrimSpace
(
session
.
ResolvedEmail
)
if
email
==
""
{
return
0
,
infraerrors
.
BadRequest
(
"PENDING_AUTH_TARGET_USER_MISSING"
,
"pending auth target user is missing"
)
}
userEntity
,
err
:=
findUserByNormalizedEmail
(
ctx
,
client
,
email
)
if
err
!=
nil
{
if
errors
.
Is
(
err
,
service
.
ErrUserNotFound
)
{
return
0
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_TARGET_USER_NOT_FOUND"
,
"pending auth target user was not found"
)
}
return
0
,
err
}
return
userEntity
.
ID
,
nil
}
func
userNormalizedEmailPredicate
(
email
string
)
predicate
.
User
{
normalized
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
email
))
if
normalized
==
""
{
return
dbuser
.
EmailEQ
(
email
)
}
return
predicate
.
User
(
func
(
s
*
entsql
.
Selector
)
{
s
.
Where
(
entsql
.
P
(
func
(
b
*
entsql
.
Builder
)
{
b
.
WriteString
(
"LOWER(TRIM("
)
.
Ident
(
s
.
C
(
dbuser
.
FieldEmail
))
.
WriteString
(
")) = "
)
.
Arg
(
normalized
)
}))
})
}
func
findUserByNormalizedEmail
(
ctx
context
.
Context
,
client
*
dbent
.
Client
,
email
string
)
(
*
dbent
.
User
,
error
)
{
if
client
==
nil
{
return
nil
,
infraerrors
.
ServiceUnavailable
(
"PENDING_AUTH_NOT_READY"
,
"pending auth service is not ready"
)
}
matches
,
err
:=
client
.
User
.
Query
()
.
Where
(
userNormalizedEmailPredicate
(
email
))
.
Order
(
dbent
.
Asc
(
dbuser
.
FieldID
))
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
if
len
(
matches
)
==
0
{
return
nil
,
service
.
ErrUserNotFound
}
if
len
(
matches
)
>
1
{
return
nil
,
infraerrors
.
Conflict
(
"USER_EMAIL_CONFLICT"
,
"normalized email matched multiple users"
)
}
return
matches
[
0
],
nil
}
func
ensurePendingOAuthRegistrationIdentityAvailable
(
ctx
context
.
Context
,
client
*
dbent
.
Client
,
session
*
dbent
.
PendingAuthSession
)
error
{
if
client
==
nil
||
session
==
nil
{
return
infraerrors
.
BadRequest
(
"PENDING_AUTH_SESSION_INVALID"
,
"pending auth registration context is invalid"
)
}
identity
,
err
:=
client
.
AuthIdentity
.
Query
()
.
Where
(
authidentity
.
ProviderTypeEQ
(
strings
.
TrimSpace
(
session
.
ProviderType
)),
authidentity
.
ProviderKeyEQ
(
strings
.
TrimSpace
(
session
.
ProviderKey
)),
authidentity
.
ProviderSubjectEQ
(
strings
.
TrimSpace
(
session
.
ProviderSubject
)),
)
.
Only
(
ctx
)
if
err
!=
nil
{
if
dbent
.
IsNotFound
(
err
)
{
return
nil
}
return
err
}
if
identity
==
nil
||
identity
.
UserID
<=
0
{
return
nil
}
activeOwner
,
err
:=
findActiveUserByID
(
ctx
,
client
,
identity
.
UserID
)
if
err
!=
nil
{
return
err
}
if
activeOwner
!=
nil
{
return
infraerrors
.
Conflict
(
"AUTH_IDENTITY_OWNERSHIP_CONFLICT"
,
"auth identity already belongs to another user"
)
}
return
nil
}
func
oauthIdentityIssuer
(
session
*
dbent
.
PendingAuthSession
)
*
string
{
if
session
==
nil
{
return
nil
}
switch
strings
.
TrimSpace
(
session
.
ProviderType
)
{
case
"oidc"
:
issuer
:=
strings
.
TrimSpace
(
session
.
ProviderKey
)
if
issuer
==
""
{
issuer
=
pendingSessionStringValue
(
session
.
UpstreamIdentityClaims
,
"issuer"
)
}
if
issuer
==
""
{
return
nil
}
return
&
issuer
default
:
issuer
:=
pendingSessionStringValue
(
session
.
UpstreamIdentityClaims
,
"issuer"
)
if
issuer
==
""
{
return
nil
}
return
&
issuer
}
}
func
ensurePendingOAuthIdentityForUser
(
ctx
context
.
Context
,
tx
*
dbent
.
Tx
,
session
*
dbent
.
PendingAuthSession
,
userID
int64
)
(
*
dbent
.
AuthIdentity
,
error
)
{
if
session
!=
nil
&&
strings
.
EqualFold
(
strings
.
TrimSpace
(
session
.
ProviderType
),
"wechat"
)
{
return
ensurePendingWeChatOAuthIdentityForUser
(
ctx
,
tx
,
session
,
userID
)
}
client
:=
tx
.
Client
()
identity
,
err
:=
client
.
AuthIdentity
.
Query
()
.
Where
(
authidentity
.
ProviderTypeEQ
(
strings
.
TrimSpace
(
session
.
ProviderType
)),
authidentity
.
ProviderKeyEQ
(
strings
.
TrimSpace
(
session
.
ProviderKey
)),
authidentity
.
ProviderSubjectEQ
(
strings
.
TrimSpace
(
session
.
ProviderSubject
)),
)
.
Only
(
ctx
)
if
err
!=
nil
&&
!
dbent
.
IsNotFound
(
err
)
{
return
nil
,
err
}
if
identity
!=
nil
{
if
identity
.
UserID
!=
userID
{
activeOwner
,
err
:=
findActiveUserByID
(
ctx
,
client
,
identity
.
UserID
)
if
err
!=
nil
{
return
nil
,
err
}
if
activeOwner
!=
nil
{
return
nil
,
infraerrors
.
Conflict
(
"AUTH_IDENTITY_OWNERSHIP_CONFLICT"
,
"auth identity already belongs to another user"
)
}
return
client
.
AuthIdentity
.
UpdateOneID
(
identity
.
ID
)
.
SetUserID
(
userID
)
.
Save
(
ctx
)
}
return
identity
,
nil
}
create
:=
client
.
AuthIdentity
.
Create
()
.
SetUserID
(
userID
)
.
SetProviderType
(
strings
.
TrimSpace
(
session
.
ProviderType
))
.
SetProviderKey
(
strings
.
TrimSpace
(
session
.
ProviderKey
))
.
SetProviderSubject
(
strings
.
TrimSpace
(
session
.
ProviderSubject
))
.
SetMetadata
(
cloneOAuthMetadata
(
session
.
UpstreamIdentityClaims
))
if
issuer
:=
oauthIdentityIssuer
(
session
);
issuer
!=
nil
{
create
=
create
.
SetIssuer
(
strings
.
TrimSpace
(
*
issuer
))
}
return
create
.
Save
(
ctx
)
}
func
ensurePendingWeChatOAuthIdentityForUser
(
ctx
context
.
Context
,
tx
*
dbent
.
Tx
,
session
*
dbent
.
PendingAuthSession
,
userID
int64
)
(
*
dbent
.
AuthIdentity
,
error
)
{
client
:=
tx
.
Client
()
providerType
:=
strings
.
TrimSpace
(
session
.
ProviderType
)
providerKey
:=
strings
.
TrimSpace
(
session
.
ProviderKey
)
providerSubject
:=
strings
.
TrimSpace
(
session
.
ProviderSubject
)
providerKeys
:=
wechatCompatibleProviderKeys
(
providerKey
)
channel
:=
strings
.
TrimSpace
(
pendingSessionStringValue
(
session
.
UpstreamIdentityClaims
,
"channel"
))
channelAppID
:=
strings
.
TrimSpace
(
pendingSessionStringValue
(
session
.
UpstreamIdentityClaims
,
"channel_app_id"
))
channelSubject
:=
strings
.
TrimSpace
(
pendingSessionStringValue
(
session
.
UpstreamIdentityClaims
,
"channel_subject"
))
metadata
:=
cloneOAuthMetadata
(
session
.
UpstreamIdentityClaims
)
identityRecords
,
err
:=
client
.
AuthIdentity
.
Query
()
.
Where
(
authidentity
.
ProviderTypeEQ
(
providerType
),
authidentity
.
ProviderKeyIn
(
providerKeys
...
),
authidentity
.
ProviderSubjectEQ
(
providerSubject
),
)
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
identity
,
hasCanonicalKey
,
err
:=
chooseWeChatIdentityForUser
(
ctx
,
client
,
identityRecords
,
userID
,
providerKey
)
if
err
!=
nil
{
return
nil
,
err
}
var
legacyOpenIDIdentity
*
dbent
.
AuthIdentity
if
channelSubject
!=
""
&&
channelSubject
!=
providerSubject
{
legacyOpenIDRecords
,
err
:=
client
.
AuthIdentity
.
Query
()
.
Where
(
authidentity
.
ProviderTypeEQ
(
providerType
),
authidentity
.
ProviderKeyIn
(
providerKeys
...
),
authidentity
.
ProviderSubjectEQ
(
channelSubject
),
)
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
legacyOpenIDIdentity
,
_
,
err
=
chooseWeChatIdentityForUser
(
ctx
,
client
,
legacyOpenIDRecords
,
userID
,
providerKey
)
if
err
!=
nil
{
return
nil
,
err
}
}
switch
{
case
identity
!=
nil
:
update
:=
client
.
AuthIdentity
.
UpdateOneID
(
identity
.
ID
)
.
SetMetadata
(
mergeOAuthMetadata
(
identity
.
Metadata
,
metadata
))
if
identity
.
UserID
!=
userID
{
update
=
update
.
SetUserID
(
userID
)
}
if
!
strings
.
EqualFold
(
strings
.
TrimSpace
(
identity
.
ProviderKey
),
providerKey
)
&&
!
hasCanonicalKey
{
update
=
update
.
SetProviderKey
(
providerKey
)
}
if
issuer
:=
oauthIdentityIssuer
(
session
);
issuer
!=
nil
{
update
=
update
.
SetIssuer
(
strings
.
TrimSpace
(
*
issuer
))
}
identity
,
err
=
update
.
Save
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
case
legacyOpenIDIdentity
!=
nil
:
update
:=
client
.
AuthIdentity
.
UpdateOneID
(
legacyOpenIDIdentity
.
ID
)
.
SetProviderKey
(
providerKey
)
.
SetProviderSubject
(
providerSubject
)
.
SetMetadata
(
mergeOAuthMetadata
(
legacyOpenIDIdentity
.
Metadata
,
metadata
))
if
issuer
:=
oauthIdentityIssuer
(
session
);
issuer
!=
nil
{
update
=
update
.
SetIssuer
(
strings
.
TrimSpace
(
*
issuer
))
}
identity
,
err
=
update
.
Save
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
default
:
create
:=
client
.
AuthIdentity
.
Create
()
.
SetUserID
(
userID
)
.
SetProviderType
(
providerType
)
.
SetProviderKey
(
providerKey
)
.
SetProviderSubject
(
providerSubject
)
.
SetMetadata
(
metadata
)
if
issuer
:=
oauthIdentityIssuer
(
session
);
issuer
!=
nil
{
create
=
create
.
SetIssuer
(
strings
.
TrimSpace
(
*
issuer
))
}
identity
,
err
=
create
.
Save
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
}
if
channel
==
""
||
channelAppID
==
""
||
channelSubject
==
""
{
return
identity
,
nil
}
channelRecords
,
err
:=
client
.
AuthIdentityChannel
.
Query
()
.
Where
(
authidentitychannel
.
ProviderTypeEQ
(
providerType
),
authidentitychannel
.
ProviderKeyIn
(
providerKeys
...
),
authidentitychannel
.
ChannelEQ
(
channel
),
authidentitychannel
.
ChannelAppIDEQ
(
channelAppID
),
authidentitychannel
.
ChannelSubjectEQ
(
channelSubject
),
)
.
WithIdentity
()
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
channelRecord
,
hasCanonicalChannelKey
,
err
:=
chooseWeChatChannelForUser
(
ctx
,
client
,
channelRecords
,
userID
,
providerKey
)
if
err
!=
nil
{
return
nil
,
err
}
channelMetadata
:=
mergeOAuthMetadata
(
channelRecordMetadata
(
channelRecord
),
metadata
)
if
channelRecord
==
nil
{
if
_
,
err
:=
client
.
AuthIdentityChannel
.
Create
()
.
SetIdentityID
(
identity
.
ID
)
.
SetProviderType
(
providerType
)
.
SetProviderKey
(
providerKey
)
.
SetChannel
(
channel
)
.
SetChannelAppID
(
channelAppID
)
.
SetChannelSubject
(
channelSubject
)
.
SetMetadata
(
channelMetadata
)
.
Save
(
ctx
);
err
!=
nil
{
return
nil
,
err
}
return
identity
,
nil
}
updateChannel
:=
client
.
AuthIdentityChannel
.
UpdateOneID
(
channelRecord
.
ID
)
.
SetIdentityID
(
identity
.
ID
)
.
SetMetadata
(
channelMetadata
)
if
!
strings
.
EqualFold
(
strings
.
TrimSpace
(
channelRecord
.
ProviderKey
),
providerKey
)
&&
!
hasCanonicalChannelKey
{
updateChannel
=
updateChannel
.
SetProviderKey
(
providerKey
)
}
_
,
err
=
updateChannel
.
Save
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
return
identity
,
nil
}
func
chooseWeChatIdentityForUser
(
ctx
context
.
Context
,
client
*
dbent
.
Client
,
records
[]
*
dbent
.
AuthIdentity
,
userID
int64
,
preferredProviderKey
string
)
(
*
dbent
.
AuthIdentity
,
bool
,
error
)
{
var
preferred
*
dbent
.
AuthIdentity
var
fallback
*
dbent
.
AuthIdentity
hasCanonicalKey
:=
false
for
_
,
record
:=
range
records
{
if
record
==
nil
{
continue
}
if
record
.
UserID
!=
userID
{
activeOwner
,
err
:=
findActiveUserByID
(
ctx
,
client
,
record
.
UserID
)
if
err
!=
nil
{
return
nil
,
false
,
err
}
if
activeOwner
!=
nil
{
return
nil
,
false
,
infraerrors
.
Conflict
(
"AUTH_IDENTITY_OWNERSHIP_CONFLICT"
,
"auth identity already belongs to another user"
)
}
}
if
strings
.
EqualFold
(
strings
.
TrimSpace
(
record
.
ProviderKey
),
preferredProviderKey
)
{
hasCanonicalKey
=
true
if
preferred
==
nil
{
preferred
=
record
}
continue
}
if
fallback
==
nil
{
fallback
=
record
}
}
if
preferred
!=
nil
{
return
preferred
,
hasCanonicalKey
,
nil
}
return
fallback
,
hasCanonicalKey
,
nil
}
func
chooseWeChatChannelForUser
(
ctx
context
.
Context
,
client
*
dbent
.
Client
,
records
[]
*
dbent
.
AuthIdentityChannel
,
userID
int64
,
preferredProviderKey
string
)
(
*
dbent
.
AuthIdentityChannel
,
bool
,
error
)
{
var
preferred
*
dbent
.
AuthIdentityChannel
var
fallback
*
dbent
.
AuthIdentityChannel
hasCanonicalKey
:=
false
for
_
,
record
:=
range
records
{
if
record
==
nil
{
continue
}
if
record
.
Edges
.
Identity
!=
nil
&&
record
.
Edges
.
Identity
.
UserID
!=
userID
{
activeOwner
,
err
:=
findActiveUserByID
(
ctx
,
client
,
record
.
Edges
.
Identity
.
UserID
)
if
err
!=
nil
{
return
nil
,
false
,
err
}
if
activeOwner
!=
nil
{
return
nil
,
false
,
infraerrors
.
Conflict
(
"AUTH_IDENTITY_CHANNEL_OWNERSHIP_CONFLICT"
,
"auth identity channel already belongs to another user"
)
}
}
if
strings
.
EqualFold
(
strings
.
TrimSpace
(
record
.
ProviderKey
),
preferredProviderKey
)
{
hasCanonicalKey
=
true
if
preferred
==
nil
{
preferred
=
record
}
continue
}
if
fallback
==
nil
{
fallback
=
record
}
}
if
preferred
!=
nil
{
return
preferred
,
hasCanonicalKey
,
nil
}
return
fallback
,
hasCanonicalKey
,
nil
}
func
findActiveUserByID
(
ctx
context
.
Context
,
client
*
dbent
.
Client
,
userID
int64
)
(
*
dbent
.
User
,
error
)
{
if
client
==
nil
||
userID
<=
0
{
return
nil
,
nil
}
userEntity
,
err
:=
client
.
User
.
Get
(
ctx
,
userID
)
if
err
!=
nil
{
if
dbent
.
IsNotFound
(
err
)
{
return
nil
,
nil
}
return
nil
,
infraerrors
.
InternalServer
(
"AUTH_IDENTITY_USER_LOOKUP_FAILED"
,
"failed to load auth identity user"
)
.
WithCause
(
err
)
}
if
!
strings
.
EqualFold
(
strings
.
TrimSpace
(
userEntity
.
Status
),
service
.
StatusActive
)
{
return
nil
,
service
.
ErrUserNotActive
}
return
userEntity
,
nil
}
func
channelRecordMetadata
(
channel
*
dbent
.
AuthIdentityChannel
)
map
[
string
]
any
{
if
channel
==
nil
{
return
map
[
string
]
any
{}
}
return
cloneOAuthMetadata
(
channel
.
Metadata
)
}
func
shouldBindPendingOAuthIdentity
(
session
*
dbent
.
PendingAuthSession
,
decision
*
dbent
.
IdentityAdoptionDecision
)
bool
{
if
session
==
nil
||
decision
==
nil
{
return
false
}
switch
strings
.
ToLower
(
strings
.
TrimSpace
(
session
.
Intent
))
{
case
"bind_current_user"
,
"login"
,
"adopt_existing_user_by_email"
:
return
true
default
:
return
decision
.
AdoptDisplayName
||
decision
.
AdoptAvatar
}
}
func
shouldSkipAvatarAdoption
(
err
error
)
bool
{
return
errors
.
Is
(
err
,
service
.
ErrAvatarInvalid
)
||
errors
.
Is
(
err
,
service
.
ErrAvatarTooLarge
)
||
errors
.
Is
(
err
,
service
.
ErrAvatarNotImage
)
}
func
applyPendingOAuthBinding
(
ctx
context
.
Context
,
client
*
dbent
.
Client
,
authService
*
service
.
AuthService
,
userService
*
service
.
UserService
,
session
*
dbent
.
PendingAuthSession
,
decision
*
dbent
.
IdentityAdoptionDecision
,
overrideUserID
*
int64
,
forceBind
bool
,
applyFirstBindDefaults
bool
,
)
error
{
if
client
==
nil
||
session
==
nil
{
return
nil
}
if
!
forceBind
&&
!
shouldBindPendingOAuthIdentity
(
session
,
decision
)
{
return
nil
}
if
tx
:=
dbent
.
TxFromContext
(
ctx
);
tx
!=
nil
{
return
applyPendingOAuthBindingTx
(
ctx
,
tx
,
authService
,
userService
,
session
,
decision
,
overrideUserID
,
forceBind
,
applyFirstBindDefaults
)
}
tx
,
err
:=
client
.
Tx
(
ctx
)
if
err
!=
nil
{
return
err
}
defer
func
()
{
_
=
tx
.
Rollback
()
}()
txCtx
:=
dbent
.
NewTxContext
(
ctx
,
tx
)
if
err
:=
applyPendingOAuthBindingTx
(
txCtx
,
tx
,
authService
,
userService
,
session
,
decision
,
overrideUserID
,
forceBind
,
applyFirstBindDefaults
);
err
!=
nil
{
return
err
}
return
tx
.
Commit
()
}
func
applyPendingOAuthBindingTx
(
ctx
context
.
Context
,
tx
*
dbent
.
Tx
,
authService
*
service
.
AuthService
,
userService
*
service
.
UserService
,
session
*
dbent
.
PendingAuthSession
,
decision
*
dbent
.
IdentityAdoptionDecision
,
overrideUserID
*
int64
,
forceBind
bool
,
applyFirstBindDefaults
bool
,
)
error
{
if
tx
==
nil
||
session
==
nil
{
return
nil
}
if
!
forceBind
&&
!
shouldBindPendingOAuthIdentity
(
session
,
decision
)
{
return
nil
}
targetUserID
:=
int64
(
0
)
if
overrideUserID
!=
nil
&&
*
overrideUserID
>
0
{
targetUserID
=
*
overrideUserID
}
else
{
resolvedUserID
,
err
:=
resolvePendingOAuthTargetUserID
(
ctx
,
tx
.
Client
(),
session
)
if
err
!=
nil
{
return
err
}
targetUserID
=
resolvedUserID
}
adoptedDisplayName
:=
""
if
decision
!=
nil
&&
decision
.
AdoptDisplayName
{
adoptedDisplayName
=
normalizeAdoptedOAuthDisplayName
(
pendingSessionStringValue
(
session
.
UpstreamIdentityClaims
,
"suggested_display_name"
))
}
adoptedAvatarURL
:=
""
if
decision
!=
nil
&&
decision
.
AdoptAvatar
{
adoptedAvatarURL
=
pendingSessionStringValue
(
session
.
UpstreamIdentityClaims
,
"suggested_avatar_url"
)
}
shouldAdoptAvatar
:=
false
if
decision
!=
nil
&&
decision
.
AdoptAvatar
&&
adoptedAvatarURL
!=
""
{
if
err
:=
service
.
ValidateUserAvatar
(
adoptedAvatarURL
);
err
==
nil
{
shouldAdoptAvatar
=
true
}
else
if
!
shouldSkipAvatarAdoption
(
err
)
{
return
err
}
}
if
decision
!=
nil
&&
decision
.
AdoptDisplayName
&&
adoptedDisplayName
!=
""
{
if
err
:=
tx
.
Client
()
.
User
.
UpdateOneID
(
targetUserID
)
.
SetUsername
(
adoptedDisplayName
)
.
Exec
(
ctx
);
err
!=
nil
{
return
err
}
}
identity
,
err
:=
ensurePendingOAuthIdentityForUser
(
ctx
,
tx
,
session
,
targetUserID
)
if
err
!=
nil
{
return
err
}
metadata
:=
cloneOAuthMetadata
(
identity
.
Metadata
)
for
key
,
value
:=
range
session
.
UpstreamIdentityClaims
{
metadata
[
key
]
=
value
}
if
decision
!=
nil
&&
decision
.
AdoptDisplayName
&&
adoptedDisplayName
!=
""
{
metadata
[
"display_name"
]
=
adoptedDisplayName
}
if
shouldAdoptAvatar
{
metadata
[
"avatar_url"
]
=
adoptedAvatarURL
}
updateIdentity
:=
tx
.
Client
()
.
AuthIdentity
.
UpdateOneID
(
identity
.
ID
)
.
SetMetadata
(
metadata
)
if
issuer
:=
oauthIdentityIssuer
(
session
);
issuer
!=
nil
{
updateIdentity
=
updateIdentity
.
SetIssuer
(
strings
.
TrimSpace
(
*
issuer
))
}
if
_
,
err
:=
updateIdentity
.
Save
(
ctx
);
err
!=
nil
{
return
err
}
if
decision
!=
nil
&&
(
decision
.
IdentityID
==
nil
||
*
decision
.
IdentityID
!=
identity
.
ID
)
{
if
_
,
err
:=
tx
.
Client
()
.
IdentityAdoptionDecision
.
Update
()
.
Where
(
identityadoptiondecision
.
IdentityIDEQ
(
identity
.
ID
),
identityadoptiondecision
.
IDNEQ
(
decision
.
ID
),
)
.
ClearIdentityID
()
.
Save
(
ctx
);
err
!=
nil
{
return
err
}
if
_
,
err
:=
tx
.
Client
()
.
IdentityAdoptionDecision
.
UpdateOneID
(
decision
.
ID
)
.
SetIdentityID
(
identity
.
ID
)
.
Save
(
ctx
);
err
!=
nil
{
return
err
}
}
if
applyFirstBindDefaults
&&
authService
!=
nil
{
if
err
:=
authService
.
ApplyProviderDefaultSettingsOnFirstBind
(
ctx
,
targetUserID
,
session
.
ProviderType
);
err
!=
nil
{
return
err
}
}
if
shouldAdoptAvatar
&&
userService
!=
nil
{
if
_
,
err
:=
userService
.
SetAvatar
(
ctx
,
targetUserID
,
adoptedAvatarURL
);
err
!=
nil
{
return
err
}
}
return
nil
}
func
consumePendingOAuthBrowserSessionTx
(
ctx
context
.
Context
,
tx
*
dbent
.
Tx
,
session
*
dbent
.
PendingAuthSession
,
)
error
{
if
tx
==
nil
||
session
==
nil
{
return
service
.
ErrPendingAuthSessionNotFound
}
storedSession
,
err
:=
tx
.
Client
()
.
PendingAuthSession
.
Get
(
ctx
,
session
.
ID
)
if
err
!=
nil
{
if
dbent
.
IsNotFound
(
err
)
{
return
service
.
ErrPendingAuthSessionNotFound
}
return
err
}
now
:=
time
.
Now
()
.
UTC
()
if
storedSession
.
ConsumedAt
!=
nil
{
return
service
.
ErrPendingAuthSessionConsumed
}
if
!
storedSession
.
ExpiresAt
.
IsZero
()
&&
now
.
After
(
storedSession
.
ExpiresAt
)
{
return
service
.
ErrPendingAuthSessionExpired
}
if
strings
.
TrimSpace
(
storedSession
.
BrowserSessionKey
)
!=
""
&&
strings
.
TrimSpace
(
storedSession
.
BrowserSessionKey
)
!=
strings
.
TrimSpace
(
session
.
BrowserSessionKey
)
{
return
service
.
ErrPendingAuthBrowserMismatch
}
if
_
,
err
:=
tx
.
Client
()
.
PendingAuthSession
.
UpdateOneID
(
storedSession
.
ID
)
.
SetConsumedAt
(
now
)
.
SetCompletionCodeHash
(
""
)
.
ClearCompletionCodeExpiresAt
()
.
Save
(
ctx
);
err
!=
nil
{
return
err
}
return
nil
}
func
applyPendingOAuthAdoptionAndConsumeSession
(
ctx
context
.
Context
,
client
*
dbent
.
Client
,
authService
*
service
.
AuthService
,
userService
*
service
.
UserService
,
session
*
dbent
.
PendingAuthSession
,
decision
*
dbent
.
IdentityAdoptionDecision
,
userID
int64
,
)
error
{
if
client
==
nil
{
return
infraerrors
.
ServiceUnavailable
(
"PENDING_AUTH_NOT_READY"
,
"pending auth service is not ready"
)
}
if
session
==
nil
||
userID
<=
0
{
return
infraerrors
.
BadRequest
(
"PENDING_AUTH_SESSION_INVALID"
,
"pending auth registration context is invalid"
)
}
tx
,
err
:=
client
.
Tx
(
ctx
)
if
err
!=
nil
{
return
err
}
defer
func
()
{
_
=
tx
.
Rollback
()
}()
txCtx
:=
dbent
.
NewTxContext
(
ctx
,
tx
)
if
err
:=
applyPendingOAuthAdoption
(
txCtx
,
client
,
authService
,
userService
,
session
,
decision
,
&
userID
);
err
!=
nil
{
return
err
}
if
err
:=
consumePendingOAuthBrowserSessionTx
(
txCtx
,
tx
,
session
);
err
!=
nil
{
return
err
}
return
tx
.
Commit
()
}
func
applyPendingOAuthAdoption
(
ctx
context
.
Context
,
client
*
dbent
.
Client
,
authService
*
service
.
AuthService
,
userService
*
service
.
UserService
,
session
*
dbent
.
PendingAuthSession
,
decision
*
dbent
.
IdentityAdoptionDecision
,
overrideUserID
*
int64
,
)
error
{
return
applyPendingOAuthBinding
(
ctx
,
client
,
authService
,
userService
,
session
,
decision
,
overrideUserID
,
false
,
strings
.
EqualFold
(
strings
.
TrimSpace
(
session
.
Intent
),
"bind_current_user"
),
)
}
func
applySuggestedProfileToCompletionResponse
(
payload
map
[
string
]
any
,
upstream
map
[
string
]
any
)
{
if
len
(
payload
)
==
0
||
len
(
upstream
)
==
0
{
return
}
displayName
:=
pendingSessionStringValue
(
upstream
,
"suggested_display_name"
)
avatarURL
:=
pendingSessionStringValue
(
upstream
,
"suggested_avatar_url"
)
if
displayName
!=
""
{
if
_
,
exists
:=
payload
[
"suggested_display_name"
];
!
exists
{
payload
[
"suggested_display_name"
]
=
displayName
}
}
if
avatarURL
!=
""
{
if
_
,
exists
:=
payload
[
"suggested_avatar_url"
];
!
exists
{
payload
[
"suggested_avatar_url"
]
=
avatarURL
}
}
if
displayName
!=
""
||
avatarURL
!=
""
{
payload
[
"adoption_required"
]
=
true
}
}
func
pendingOAuthIdentityExistsForUser
(
ctx
context
.
Context
,
client
*
dbent
.
Client
,
session
*
dbent
.
PendingAuthSession
,
userID
int64
,
)
(
bool
,
error
)
{
if
client
==
nil
||
session
==
nil
||
userID
<=
0
{
return
false
,
nil
}
providerType
:=
strings
.
TrimSpace
(
session
.
ProviderType
)
providerKey
:=
strings
.
TrimSpace
(
session
.
ProviderKey
)
providerSubject
:=
strings
.
TrimSpace
(
session
.
ProviderSubject
)
if
providerType
==
""
||
providerSubject
==
""
{
return
false
,
nil
}
query
:=
client
.
AuthIdentity
.
Query
()
.
Where
(
authidentity
.
ProviderTypeEQ
(
providerType
),
authidentity
.
ProviderSubjectEQ
(
providerSubject
),
authidentity
.
UserIDEQ
(
userID
),
)
if
strings
.
EqualFold
(
providerType
,
"wechat"
)
{
query
=
query
.
Where
(
authidentity
.
ProviderKeyIn
(
wechatCompatibleProviderKeys
(
providerKey
)
...
))
}
else
if
providerKey
!=
""
{
query
=
query
.
Where
(
authidentity
.
ProviderKeyEQ
(
providerKey
))
}
count
,
err
:=
query
.
Count
(
ctx
)
if
err
!=
nil
{
return
false
,
infraerrors
.
InternalServer
(
"AUTH_IDENTITY_LOOKUP_FAILED"
,
"failed to inspect auth identity ownership"
)
.
WithCause
(
err
)
}
return
count
>
0
,
nil
}
func
(
h
*
AuthHandler
)
shouldSkipPendingOAuthAdoptionPrompt
(
ctx
context
.
Context
,
session
*
dbent
.
PendingAuthSession
,
payload
map
[
string
]
any
,
)
(
bool
,
error
)
{
if
session
==
nil
||
len
(
payload
)
==
0
{
return
false
,
nil
}
if
!
pendingOAuthCompletionCanIssueTokenPair
(
session
,
payload
)
{
return
false
,
nil
}
if
pendingSessionStringValue
(
session
.
UpstreamIdentityClaims
,
"suggested_display_name"
)
==
""
&&
pendingSessionStringValue
(
session
.
UpstreamIdentityClaims
,
"suggested_avatar_url"
)
==
""
{
return
false
,
nil
}
return
pendingOAuthIdentityExistsForUser
(
ctx
,
h
.
entClient
(),
session
,
*
session
.
TargetUserID
)
}
func
readPendingOAuthBrowserSession
(
c
*
gin
.
Context
,
h
*
AuthHandler
)
(
*
service
.
AuthPendingIdentityService
,
*
dbent
.
PendingAuthSession
,
func
(),
error
)
{
secureCookie
:=
isRequestHTTPS
(
c
)
clearCookies
:=
func
()
{
clearOAuthPendingSessionCookie
(
c
,
secureCookie
)
clearOAuthPendingBrowserCookie
(
c
,
secureCookie
)
}
sessionToken
,
err
:=
readOAuthPendingSessionCookie
(
c
)
if
err
!=
nil
||
strings
.
TrimSpace
(
sessionToken
)
==
""
{
clearCookies
()
return
nil
,
nil
,
clearCookies
,
service
.
ErrPendingAuthSessionNotFound
}
browserSessionKey
,
err
:=
readOAuthPendingBrowserCookie
(
c
)
if
err
!=
nil
||
strings
.
TrimSpace
(
browserSessionKey
)
==
""
{
clearCookies
()
return
nil
,
nil
,
clearCookies
,
service
.
ErrPendingAuthBrowserMismatch
}
svc
,
err
:=
h
.
pendingIdentityService
()
if
err
!=
nil
{
clearCookies
()
return
nil
,
nil
,
clearCookies
,
err
}
session
,
err
:=
svc
.
GetBrowserSession
(
c
.
Request
.
Context
(),
sessionToken
,
browserSessionKey
)
if
err
!=
nil
{
clearCookies
()
return
nil
,
nil
,
clearCookies
,
err
}
return
svc
,
session
,
clearCookies
,
nil
}
func
(
h
*
AuthHandler
)
consumePendingOAuthSessionOnLogout
(
c
*
gin
.
Context
)
{
if
c
==
nil
||
c
.
Request
==
nil
{
return
}
sessionToken
,
err
:=
readOAuthPendingSessionCookie
(
c
)
if
err
!=
nil
||
strings
.
TrimSpace
(
sessionToken
)
==
""
{
return
}
browserSessionKey
,
err
:=
readOAuthPendingBrowserCookie
(
c
)
if
err
!=
nil
||
strings
.
TrimSpace
(
browserSessionKey
)
==
""
{
return
}
svc
,
err
:=
h
.
pendingIdentityService
()
if
err
!=
nil
{
return
}
_
,
_
=
svc
.
ConsumeBrowserSession
(
c
.
Request
.
Context
(),
sessionToken
,
browserSessionKey
)
}
func
clearOAuthLogoutCookies
(
c
*
gin
.
Context
)
{
secureCookie
:=
isRequestHTTPS
(
c
)
clearOAuthPendingSessionCookie
(
c
,
secureCookie
)
clearOAuthPendingBrowserCookie
(
c
,
secureCookie
)
clearOAuthBindAccessTokenCookie
(
c
,
secureCookie
)
clearCookie
(
c
,
linuxDoOAuthStateCookieName
,
secureCookie
)
clearCookie
(
c
,
linuxDoOAuthVerifierCookie
,
secureCookie
)
clearCookie
(
c
,
linuxDoOAuthRedirectCookie
,
secureCookie
)
clearCookie
(
c
,
linuxDoOAuthIntentCookieName
,
secureCookie
)
clearCookie
(
c
,
linuxDoOAuthBindUserCookieName
,
secureCookie
)
oidcClearCookie
(
c
,
oidcOAuthStateCookieName
,
secureCookie
)
oidcClearCookie
(
c
,
oidcOAuthVerifierCookie
,
secureCookie
)
oidcClearCookie
(
c
,
oidcOAuthRedirectCookie
,
secureCookie
)
oidcClearCookie
(
c
,
oidcOAuthNonceCookie
,
secureCookie
)
oidcClearCookie
(
c
,
oidcOAuthIntentCookieName
,
secureCookie
)
oidcClearCookie
(
c
,
oidcOAuthBindUserCookieName
,
secureCookie
)
wechatClearCookie
(
c
,
wechatOAuthStateCookieName
,
secureCookie
)
wechatClearCookie
(
c
,
wechatOAuthRedirectCookieName
,
secureCookie
)
wechatClearCookie
(
c
,
wechatOAuthIntentCookieName
,
secureCookie
)
wechatClearCookie
(
c
,
wechatOAuthModeCookieName
,
secureCookie
)
wechatClearCookie
(
c
,
wechatOAuthBindUserCookieName
,
secureCookie
)
wechatPaymentClearCookie
(
c
,
wechatPaymentOAuthStateName
,
secureCookie
)
wechatPaymentClearCookie
(
c
,
wechatPaymentOAuthRedirect
,
secureCookie
)
wechatPaymentClearCookie
(
c
,
wechatPaymentOAuthContextName
,
secureCookie
)
wechatPaymentClearCookie
(
c
,
wechatPaymentOAuthScope
,
secureCookie
)
}
func
buildPendingOAuthSessionStatusPayload
(
session
*
dbent
.
PendingAuthSession
)
gin
.
H
{
completionResponse
:=
normalizePendingOAuthCompletionResponse
(
mergePendingCompletionResponse
(
session
,
nil
))
payload
:=
gin
.
H
{
"auth_result"
:
"pending_session"
,
"provider"
:
strings
.
TrimSpace
(
session
.
ProviderType
),
"intent"
:
strings
.
TrimSpace
(
session
.
Intent
),
}
for
key
,
value
:=
range
completionResponse
{
payload
[
key
]
=
value
}
if
email
:=
strings
.
TrimSpace
(
session
.
ResolvedEmail
);
email
!=
""
{
payload
[
"email"
]
=
email
}
return
payload
}
func
normalizePendingOAuthCompletionResponse
(
payload
map
[
string
]
any
)
map
[
string
]
any
{
normalized
:=
clonePendingMap
(
payload
)
for
_
,
key
:=
range
[]
string
{
"access_token"
,
"refresh_token"
,
"expires_in"
,
"token_type"
}
{
delete
(
normalized
,
key
)
}
step
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
pendingSessionStringValue
(
normalized
,
"step"
)))
switch
step
{
case
"choice"
,
"choose_account_action"
,
"choose_account"
,
"choose"
,
"email_required"
,
"bind_login_required"
:
normalized
[
"step"
]
=
oauthPendingChoiceStep
}
if
strings
.
EqualFold
(
strings
.
TrimSpace
(
pendingSessionStringValue
(
normalized
,
"step"
)),
oauthPendingChoiceStep
)
{
normalized
[
"adoption_required"
]
=
true
}
if
_
,
exists
:=
normalized
[
"adoption_required"
];
!
exists
{
if
_
,
hasChoiceFields
:=
normalized
[
"email_binding_required"
];
hasChoiceFields
{
normalized
[
"adoption_required"
]
=
true
}
}
return
normalized
}
func
pendingOAuthChoiceCompletionResponse
(
session
*
dbent
.
PendingAuthSession
,
email
string
)
map
[
string
]
any
{
response
:=
mergePendingCompletionResponse
(
session
,
map
[
string
]
any
{
"step"
:
oauthPendingChoiceStep
,
"adoption_required"
:
true
,
"force_email_on_signup"
:
true
,
"email_binding_required"
:
true
,
"existing_account_bindable"
:
true
,
})
if
email
=
strings
.
TrimSpace
(
email
);
email
!=
""
{
response
[
"email"
]
=
email
response
[
"resolved_email"
]
=
email
}
return
response
}
func
(
h
*
AuthHandler
)
transitionPendingOAuthAccountToChoiceState
(
c
*
gin
.
Context
,
client
*
dbent
.
Client
,
session
*
dbent
.
PendingAuthSession
,
targetUser
*
dbent
.
User
,
email
string
,
)
(
*
dbent
.
PendingAuthSession
,
error
)
{
completionResponse
:=
pendingOAuthChoiceCompletionResponse
(
session
,
email
)
var
targetUserID
*
int64
if
targetUser
!=
nil
&&
targetUser
.
ID
>
0
{
targetUserID
=
&
targetUser
.
ID
}
session
,
err
:=
updatePendingOAuthSessionProgress
(
c
.
Request
.
Context
(),
client
,
session
,
strings
.
TrimSpace
(
session
.
Intent
),
email
,
targetUserID
,
completionResponse
,
)
if
err
!=
nil
{
return
nil
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_SESSION_UPDATE_FAILED"
,
"failed to update pending oauth session"
)
.
WithCause
(
err
)
}
return
session
,
nil
}
func
writeOAuthTokenPairResponse
(
c
*
gin
.
Context
,
tokenPair
*
service
.
TokenPair
)
{
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"access_token"
:
tokenPair
.
AccessToken
,
"refresh_token"
:
tokenPair
.
RefreshToken
,
"expires_in"
:
tokenPair
.
ExpiresIn
,
"token_type"
:
"Bearer"
,
})
}
func
(
h
*
AuthHandler
)
bindPendingOAuthLogin
(
c
*
gin
.
Context
,
provider
string
)
{
var
req
bindPendingOAuthLoginRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
pendingSvc
,
session
,
clearCookies
,
err
:=
readPendingOAuthBrowserSession
(
c
,
h
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
if
strings
.
TrimSpace
(
provider
)
!=
""
&&
!
strings
.
EqualFold
(
strings
.
TrimSpace
(
session
.
ProviderType
),
provider
)
{
response
.
BadRequest
(
c
,
"Pending oauth session provider mismatch"
)
return
}
user
,
err
:=
h
.
authService
.
ValidatePasswordCredentials
(
c
.
Request
.
Context
(),
strings
.
TrimSpace
(
req
.
Email
),
req
.
Password
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
if
session
.
TargetUserID
!=
nil
&&
*
session
.
TargetUserID
>
0
&&
user
.
ID
!=
*
session
.
TargetUserID
{
response
.
ErrorFrom
(
c
,
infraerrors
.
Conflict
(
"PENDING_AUTH_TARGET_USER_MISMATCH"
,
"pending oauth session must be completed by the targeted user"
))
return
}
if
err
:=
h
.
ensureBackendModeAllowsUser
(
c
.
Request
.
Context
(),
user
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
decision
,
err
:=
h
.
ensurePendingOAuthAdoptionDecision
(
c
,
session
.
ID
,
req
.
adoptionDecision
())
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
if
h
.
totpService
!=
nil
&&
h
.
settingSvc
.
IsTotpEnabled
(
c
.
Request
.
Context
())
&&
user
.
TotpEnabled
{
tempToken
,
err
:=
h
.
totpService
.
CreatePendingOAuthBindLoginSession
(
c
.
Request
.
Context
(),
user
.
ID
,
user
.
Email
,
session
.
SessionToken
,
session
.
BrowserSessionKey
,
)
if
err
!=
nil
{
response
.
InternalError
(
c
,
"Failed to create 2FA session"
)
return
}
response
.
Success
(
c
,
TotpLoginResponse
{
Requires2FA
:
true
,
TempToken
:
tempToken
,
UserEmailMasked
:
service
.
MaskEmail
(
user
.
Email
),
})
return
}
if
err
:=
applyPendingOAuthBinding
(
c
.
Request
.
Context
(),
h
.
entClient
(),
h
.
authService
,
h
.
userService
,
session
,
decision
,
&
user
.
ID
,
true
,
true
);
err
!=
nil
{
respondPendingOAuthBindingApplyError
(
c
,
err
)
return
}
h
.
authService
.
RecordSuccessfulLogin
(
c
.
Request
.
Context
(),
user
.
ID
)
tokenPair
,
err
:=
h
.
authService
.
GenerateTokenPair
(
c
.
Request
.
Context
(),
user
,
""
)
if
err
!=
nil
{
response
.
InternalError
(
c
,
"Failed to generate token pair"
)
return
}
if
_
,
err
:=
pendingSvc
.
ConsumeBrowserSession
(
c
.
Request
.
Context
(),
session
.
SessionToken
,
session
.
BrowserSessionKey
);
err
!=
nil
{
clearCookies
()
response
.
ErrorFrom
(
c
,
err
)
return
}
clearCookies
()
writeOAuthTokenPairResponse
(
c
,
tokenPair
)
}
func
respondPendingOAuthBindingApplyError
(
c
*
gin
.
Context
,
err
error
)
{
if
code
:=
infraerrors
.
Code
(
err
);
code
>=
http
.
StatusBadRequest
&&
code
<
http
.
StatusInternalServerError
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_BIND_APPLY_FAILED"
,
"failed to bind pending oauth identity"
)
.
WithCause
(
err
))
}
func
(
h
*
AuthHandler
)
createPendingOAuthAccount
(
c
*
gin
.
Context
,
provider
string
)
{
var
req
createPendingOAuthAccountRequest
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
.
TrimSpace
(
provider
)
!=
""
&&
!
strings
.
EqualFold
(
strings
.
TrimSpace
(
session
.
ProviderType
),
provider
)
{
response
.
BadRequest
(
c
,
"Pending oauth session provider mismatch"
)
return
}
client
:=
h
.
entClient
()
if
client
==
nil
{
response
.
ErrorFrom
(
c
,
infraerrors
.
ServiceUnavailable
(
"PENDING_AUTH_NOT_READY"
,
"pending auth service is not ready"
))
return
}
email
:=
strings
.
TrimSpace
(
strings
.
ToLower
(
req
.
Email
))
existingUser
,
err
:=
findUserByNormalizedEmail
(
c
.
Request
.
Context
(),
client
,
email
)
if
err
!=
nil
{
switch
{
case
errors
.
Is
(
err
,
service
.
ErrUserNotFound
)
:
existingUser
=
nil
case
infraerrors
.
Code
(
err
)
>=
http
.
StatusBadRequest
&&
infraerrors
.
Code
(
err
)
<
http
.
StatusInternalServerError
:
response
.
ErrorFrom
(
c
,
err
)
return
default
:
response
.
ErrorFrom
(
c
,
infraerrors
.
ServiceUnavailable
(
"SERVICE_UNAVAILABLE"
,
"service temporarily unavailable"
))
return
}
}
if
existingUser
!=
nil
{
session
,
err
=
h
.
transitionPendingOAuthAccountToChoiceState
(
c
,
client
,
session
,
existingUser
,
email
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
c
.
JSON
(
http
.
StatusOK
,
buildPendingOAuthSessionStatusPayload
(
session
))
return
}
if
err
:=
h
.
ensureBackendModeAllowsNewUserLogin
(
c
.
Request
.
Context
());
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
tokenPair
,
user
,
err
:=
h
.
authService
.
RegisterOAuthEmailAccount
(
c
.
Request
.
Context
(),
email
,
req
.
Password
,
strings
.
TrimSpace
(
req
.
VerifyCode
),
strings
.
TrimSpace
(
req
.
InvitationCode
),
strings
.
TrimSpace
(
session
.
ProviderType
),
)
if
err
!=
nil
{
if
errors
.
Is
(
err
,
service
.
ErrEmailExists
)
{
existingUser
,
lookupErr
:=
findUserByNormalizedEmail
(
c
.
Request
.
Context
(),
client
,
email
)
if
lookupErr
!=
nil
{
response
.
ErrorFrom
(
c
,
lookupErr
)
return
}
session
,
err
=
h
.
transitionPendingOAuthAccountToChoiceState
(
c
,
client
,
session
,
existingUser
,
email
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
c
.
JSON
(
http
.
StatusOK
,
buildPendingOAuthSessionStatusPayload
(
session
))
return
}
response
.
ErrorFrom
(
c
,
err
)
return
}
rollbackCreatedUser
:=
func
(
originalErr
error
)
bool
{
if
user
==
nil
||
user
.
ID
<=
0
{
return
false
}
if
rollbackErr
:=
h
.
authService
.
RollbackOAuthEmailAccountCreation
(
c
.
Request
.
Context
(),
user
.
ID
,
strings
.
TrimSpace
(
req
.
InvitationCode
),
);
rollbackErr
!=
nil
{
response
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_ACCOUNT_ROLLBACK_FAILED"
,
"failed to rollback pending oauth account creation"
,
)
.
WithCause
(
fmt
.
Errorf
(
"original error: %w; rollback error: %v"
,
originalErr
,
rollbackErr
)))
return
true
}
user
=
nil
return
false
}
decision
,
err
:=
h
.
ensurePendingOAuthAdoptionDecision
(
c
,
session
.
ID
,
req
.
adoptionDecision
())
if
err
!=
nil
{
if
rollbackCreatedUser
(
err
)
{
return
}
response
.
ErrorFrom
(
c
,
err
)
return
}
tx
,
err
:=
client
.
Tx
(
c
.
Request
.
Context
())
if
err
!=
nil
{
if
rollbackCreatedUser
(
err
)
{
return
}
response
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_BIND_APPLY_FAILED"
,
"failed to bind pending oauth identity"
)
.
WithCause
(
err
))
return
}
defer
func
()
{
_
=
tx
.
Rollback
()
}()
txCtx
:=
dbent
.
NewTxContext
(
c
.
Request
.
Context
(),
tx
)
if
err
:=
applyPendingOAuthBinding
(
txCtx
,
client
,
h
.
authService
,
h
.
userService
,
session
,
decision
,
&
user
.
ID
,
true
,
false
);
err
!=
nil
{
_
=
tx
.
Rollback
()
if
rollbackCreatedUser
(
err
)
{
return
}
respondPendingOAuthBindingApplyError
(
c
,
err
)
return
}
if
err
:=
h
.
authService
.
FinalizeOAuthEmailAccount
(
txCtx
,
user
,
strings
.
TrimSpace
(
req
.
InvitationCode
),
strings
.
TrimSpace
(
session
.
ProviderType
),
);
err
!=
nil
{
_
=
tx
.
Rollback
()
if
rollbackCreatedUser
(
err
)
{
return
}
response
.
ErrorFrom
(
c
,
err
)
return
}
if
err
:=
consumePendingOAuthBrowserSessionTx
(
txCtx
,
tx
,
session
);
err
!=
nil
{
_
=
tx
.
Rollback
()
if
rollbackCreatedUser
(
err
)
{
return
}
clearCookies
()
response
.
ErrorFrom
(
c
,
err
)
return
}
if
pendingOAuthCreateAccountPreCommitHook
!=
nil
{
if
err
:=
pendingOAuthCreateAccountPreCommitHook
(
txCtx
,
session
);
err
!=
nil
{
_
=
tx
.
Rollback
()
if
rollbackCreatedUser
(
err
)
{
return
}
respondPendingOAuthBindingApplyError
(
c
,
err
)
return
}
}
if
err
:=
tx
.
Commit
();
err
!=
nil
{
if
rollbackCreatedUser
(
err
)
{
return
}
response
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_BIND_APPLY_FAILED"
,
"failed to bind pending oauth identity"
)
.
WithCause
(
err
))
return
}
h
.
authService
.
RecordSuccessfulLogin
(
c
.
Request
.
Context
(),
user
.
ID
)
clearCookies
()
writeOAuthTokenPairResponse
(
c
,
tokenPair
)
}
// ExchangePendingOAuthCompletion redeems a pending OAuth browser session into a frontend-safe payload.
// POST /api/v1/auth/oauth/pending/exchange
func
(
h
*
AuthHandler
)
ExchangePendingOAuthCompletion
(
c
*
gin
.
Context
)
{
secureCookie
:=
isRequestHTTPS
(
c
)
clearCookies
:=
func
()
{
clearOAuthPendingSessionCookie
(
c
,
secureCookie
)
clearOAuthPendingBrowserCookie
(
c
,
secureCookie
)
}
adoptionDecision
,
err
:=
bindOptionalOAuthAdoptionDecision
(
c
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
sessionToken
,
err
:=
readOAuthPendingSessionCookie
(
c
)
if
err
!=
nil
||
strings
.
TrimSpace
(
sessionToken
)
==
""
{
clearCookies
()
response
.
ErrorFrom
(
c
,
service
.
ErrPendingAuthSessionNotFound
)
return
}
browserSessionKey
,
err
:=
readOAuthPendingBrowserCookie
(
c
)
if
err
!=
nil
||
strings
.
TrimSpace
(
browserSessionKey
)
==
""
{
clearCookies
()
response
.
ErrorFrom
(
c
,
service
.
ErrPendingAuthBrowserMismatch
)
return
}
svc
,
err
:=
h
.
pendingIdentityService
()
if
err
!=
nil
{
clearCookies
()
response
.
ErrorFrom
(
c
,
err
)
return
}
session
,
err
:=
svc
.
GetBrowserSession
(
c
.
Request
.
Context
(),
sessionToken
,
browserSessionKey
)
if
err
!=
nil
{
clearCookies
()
response
.
ErrorFrom
(
c
,
err
)
return
}
payload
,
ok
:=
readCompletionResponse
(
session
.
LocalFlowState
)
if
!
ok
{
clearCookies
()
response
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_COMPLETION_INVALID"
,
"pending auth completion payload is invalid"
))
return
}
payload
=
normalizePendingOAuthCompletionResponse
(
payload
)
if
strings
.
TrimSpace
(
session
.
RedirectTo
)
!=
""
{
if
_
,
exists
:=
payload
[
"redirect"
];
!
exists
{
payload
[
"redirect"
]
=
session
.
RedirectTo
}
}
applySuggestedProfileToCompletionResponse
(
payload
,
session
.
UpstreamIdentityClaims
)
canIssueTokenPair
:=
pendingOAuthCompletionCanIssueTokenPair
(
session
,
payload
)
var
loginUser
*
service
.
User
if
canIssueTokenPair
{
loginUser
,
err
=
h
.
userService
.
GetByID
(
c
.
Request
.
Context
(),
*
session
.
TargetUserID
)
if
err
!=
nil
{
clearCookies
()
response
.
ErrorFrom
(
c
,
err
)
return
}
if
err
:=
ensureLoginUserActive
(
loginUser
);
err
!=
nil
{
clearCookies
()
response
.
ErrorFrom
(
c
,
err
)
return
}
if
err
:=
h
.
ensureBackendModeAllowsUser
(
c
.
Request
.
Context
(),
loginUser
);
err
!=
nil
{
clearCookies
()
response
.
ErrorFrom
(
c
,
err
)
return
}
}
skipAdoptionPrompt
,
err
:=
h
.
shouldSkipPendingOAuthAdoptionPrompt
(
c
.
Request
.
Context
(),
session
,
payload
)
if
err
!=
nil
{
clearCookies
()
response
.
ErrorFrom
(
c
,
err
)
return
}
if
skipAdoptionPrompt
{
delete
(
payload
,
"adoption_required"
)
}
if
pendingSessionWantsInvitation
(
payload
)
{
if
adoptionDecision
.
hasDecision
()
{
decision
,
err
:=
h
.
upsertPendingOAuthAdoptionDecision
(
c
,
session
.
ID
,
adoptionDecision
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
_
=
decision
}
response
.
Success
(
c
,
payload
)
return
}
if
!
adoptionDecision
.
hasDecision
()
{
adoptionRequired
,
_
:=
payload
[
"adoption_required"
]
.
(
bool
)
if
adoptionRequired
{
response
.
Success
(
c
,
payload
)
return
}
}
decisionReq
:=
adoptionDecision
if
!
decisionReq
.
hasDecision
()
{
adoptDisplayName
:=
false
adoptAvatar
:=
false
decisionReq
=
oauthAdoptionDecisionRequest
{
AdoptDisplayName
:
&
adoptDisplayName
,
AdoptAvatar
:
&
adoptAvatar
,
}
}
decision
,
err
:=
h
.
ensurePendingOAuthAdoptionDecision
(
c
,
session
.
ID
,
decisionReq
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
if
err
:=
applyPendingOAuthAdoption
(
c
.
Request
.
Context
(),
h
.
entClient
(),
h
.
authService
,
h
.
userService
,
session
,
decision
,
session
.
TargetUserID
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_ADOPTION_APPLY_FAILED"
,
"failed to apply oauth profile adoption"
)
.
WithCause
(
err
))
return
}
if
_
,
err
:=
svc
.
ConsumeBrowserSession
(
c
.
Request
.
Context
(),
sessionToken
,
browserSessionKey
);
err
!=
nil
{
clearCookies
()
response
.
ErrorFrom
(
c
,
err
)
return
}
if
canIssueTokenPair
{
tokenPair
,
err
:=
h
.
authService
.
GenerateTokenPair
(
c
.
Request
.
Context
(),
loginUser
,
""
)
if
err
!=
nil
{
clearCookies
()
response
.
InternalError
(
c
,
"Failed to generate token pair"
)
return
}
h
.
authService
.
RecordSuccessfulLogin
(
c
.
Request
.
Context
(),
loginUser
.
ID
)
payload
[
"access_token"
]
=
tokenPair
.
AccessToken
payload
[
"refresh_token"
]
=
tokenPair
.
RefreshToken
payload
[
"expires_in"
]
=
tokenPair
.
ExpiresIn
payload
[
"token_type"
]
=
"Bearer"
}
clearCookies
()
response
.
Success
(
c
,
payload
)
}
Prev
1
…
3
4
5
6
7
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment