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
f060db0b
Commit
f060db0b
authored
Jan 09, 2026
by
shaw
Browse files
fix: 加固 LinuxDo OAuth 登录安全与配置校验
parent
707061ef
Changes
14
Hide whitespace changes
Inline
Side-by-side
backend/internal/config/config.go
View file @
f060db0b
...
...
@@ -324,10 +324,10 @@ type TurnstileConfig struct {
Required
bool
`mapstructure:"required"`
}
// LinuxDoConnectConfig
controls
LinuxDo Connect OAuth
login (end-user
SSO
).
// LinuxDoConnectConfig
用于
LinuxDo Connect OAuth
登录(终端用户
SSO
)。
//
//
Note: This is NOT the same as upstream account OAuth (e.g.
OpenAI/Gemini
).
//
It is used for logging in to
Sub2API
itself.
//
注意:这与上游账号的 OAuth(例如
OpenAI/Gemini
账号接入)不是一回事。
//
这里是用于登录
Sub2API
本身的用户体系。
type
LinuxDoConnectConfig
struct
{
Enabled
bool
`mapstructure:"enabled"`
ClientID
string
`mapstructure:"client_id"`
...
...
@@ -336,13 +336,13 @@ type LinuxDoConnectConfig struct {
TokenURL
string
`mapstructure:"token_url"`
UserInfoURL
string
`mapstructure:"userinfo_url"`
Scopes
string
`mapstructure:"scopes"`
RedirectURL
string
`mapstructure:"redirect_url"`
//
backend callback URL registered at the provider
FrontendRedirectURL
string
`mapstructure:"frontend_redirect_url"`
//
frontend route to receive token (default:
/auth/linuxdo/callback
)
RedirectURL
string
`mapstructure:"redirect_url"`
//
后端回调地址(需在提供方后台登记)
FrontendRedirectURL
string
`mapstructure:"frontend_redirect_url"`
//
前端接收 token 的路由(默认:
/auth/linuxdo/callback
)
TokenAuthMethod
string
`mapstructure:"token_auth_method"`
// client_secret_post / client_secret_basic / none
UsePKCE
bool
`mapstructure:"use_pkce"`
//
Optional: gjson paths to extract fields from userinfo JSON.
//
When empty, the server tries a set of common keys.
//
可选:用于从 userinfo JSON 中提取字段的 gjson 路径。
//
为空时,服务端会尝试一组常见字段名。
UserInfoEmailPath
string
`mapstructure:"userinfo_email_path"`
UserInfoIDPath
string
`mapstructure:"userinfo_id_path"`
UserInfoUsernamePath
string
`mapstructure:"userinfo_username_path"`
...
...
@@ -464,7 +464,8 @@ func Load() (*Config, error) {
return
&
cfg
,
nil
}
func
validateAbsoluteHTTPURL
(
raw
string
)
error
{
// ValidateAbsoluteHTTPURL 校验一个绝对 http(s) URL(禁止 fragment)。
func
ValidateAbsoluteHTTPURL
(
raw
string
)
error
{
raw
=
strings
.
TrimSpace
(
raw
)
if
raw
==
""
{
return
fmt
.
Errorf
(
"empty url"
)
...
...
@@ -488,7 +489,10 @@ func validateAbsoluteHTTPURL(raw string) error {
return
nil
}
func
validateFrontendRedirectURL
(
raw
string
)
error
{
// ValidateFrontendRedirectURL 校验前端回调地址:
// - 允许同源相对路径(以 / 开头)
// - 或绝对 http(s) URL(禁止 fragment)
func
ValidateFrontendRedirectURL
(
raw
string
)
error
{
raw
=
strings
.
TrimSpace
(
raw
)
if
raw
==
""
{
return
fmt
.
Errorf
(
"empty url"
)
...
...
@@ -584,7 +588,7 @@ func setDefaults() {
// Turnstile
viper
.
SetDefault
(
"turnstile.required"
,
false
)
// LinuxDo Connect OAuth
login (end-user
SSO
)
// LinuxDo Connect OAuth
登录(终端用户
SSO
)
viper
.
SetDefault
(
"linuxdo_connect.enabled"
,
false
)
viper
.
SetDefault
(
"linuxdo_connect.client_id"
,
""
)
viper
.
SetDefault
(
"linuxdo_connect.client_secret"
,
""
)
...
...
@@ -743,19 +747,19 @@ func (c *Config) Validate() error {
return
fmt
.
Errorf
(
"linuxdo_connect.frontend_redirect_url is required when linuxdo_connect.enabled=true"
)
}
if
err
:=
v
alidateAbsoluteHTTPURL
(
c
.
LinuxDo
.
AuthorizeURL
);
err
!=
nil
{
if
err
:=
V
alidateAbsoluteHTTPURL
(
c
.
LinuxDo
.
AuthorizeURL
);
err
!=
nil
{
return
fmt
.
Errorf
(
"linuxdo_connect.authorize_url invalid: %w"
,
err
)
}
if
err
:=
v
alidateAbsoluteHTTPURL
(
c
.
LinuxDo
.
TokenURL
);
err
!=
nil
{
if
err
:=
V
alidateAbsoluteHTTPURL
(
c
.
LinuxDo
.
TokenURL
);
err
!=
nil
{
return
fmt
.
Errorf
(
"linuxdo_connect.token_url invalid: %w"
,
err
)
}
if
err
:=
v
alidateAbsoluteHTTPURL
(
c
.
LinuxDo
.
UserInfoURL
);
err
!=
nil
{
if
err
:=
V
alidateAbsoluteHTTPURL
(
c
.
LinuxDo
.
UserInfoURL
);
err
!=
nil
{
return
fmt
.
Errorf
(
"linuxdo_connect.userinfo_url invalid: %w"
,
err
)
}
if
err
:=
v
alidateAbsoluteHTTPURL
(
c
.
LinuxDo
.
RedirectURL
);
err
!=
nil
{
if
err
:=
V
alidateAbsoluteHTTPURL
(
c
.
LinuxDo
.
RedirectURL
);
err
!=
nil
{
return
fmt
.
Errorf
(
"linuxdo_connect.redirect_url invalid: %w"
,
err
)
}
if
err
:=
v
alidateFrontendRedirectURL
(
c
.
LinuxDo
.
FrontendRedirectURL
);
err
!=
nil
{
if
err
:=
V
alidateFrontendRedirectURL
(
c
.
LinuxDo
.
FrontendRedirectURL
);
err
!=
nil
{
return
fmt
.
Errorf
(
"linuxdo_connect.frontend_redirect_url invalid: %w"
,
err
)
}
...
...
backend/internal/handler/admin/setting_handler.go
View file @
f060db0b
...
...
@@ -2,10 +2,10 @@ package admin
import
(
"log"
"net/url"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
...
...
@@ -94,7 +94,7 @@ type UpdateSettingsRequest struct {
TurnstileSiteKey
string
`json:"turnstile_site_key"`
TurnstileSecretKey
string
`json:"turnstile_secret_key"`
// LinuxDo Connect OAuth
login (end-user
SSO
)
// LinuxDo Connect OAuth
登录(终端用户
SSO
)
LinuxDoConnectEnabled
bool
`json:"linuxdo_connect_enabled"`
LinuxDoConnectClientID
string
`json:"linuxdo_connect_client_id"`
LinuxDoConnectClientSecret
string
`json:"linuxdo_connect_client_secret"`
...
...
@@ -191,12 +191,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response
.
BadRequest
(
c
,
"LinuxDo Redirect URL is required when enabled"
)
return
}
if
!
is
AbsoluteHTTPURL
(
req
.
LinuxDoConnectRedirectURL
)
{
if
err
:=
config
.
Validate
AbsoluteHTTPURL
(
req
.
LinuxDoConnectRedirectURL
)
;
err
!=
nil
{
response
.
BadRequest
(
c
,
"LinuxDo Redirect URL must be an absolute http(s) URL"
)
return
}
//
If
client_secret
not provided, keep existing value (if any).
//
如果未提供
client_secret
,则保留现有值(如有)。
if
req
.
LinuxDoConnectClientSecret
==
""
{
if
previousSettings
.
LinuxDoConnectClientSecret
==
""
{
response
.
BadRequest
(
c
,
"LinuxDo Client Secret is required when enabled"
)
...
...
@@ -407,33 +407,6 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
return
changed
}
func
isAbsoluteHTTPURL
(
raw
string
)
bool
{
raw
=
strings
.
TrimSpace
(
raw
)
if
raw
==
""
{
return
false
}
if
strings
.
HasPrefix
(
raw
,
"//"
)
{
return
false
}
u
,
err
:=
url
.
Parse
(
raw
)
if
err
!=
nil
{
return
false
}
if
!
u
.
IsAbs
()
{
return
false
}
if
!
strings
.
EqualFold
(
u
.
Scheme
,
"http"
)
&&
!
strings
.
EqualFold
(
u
.
Scheme
,
"https"
)
{
return
false
}
if
strings
.
TrimSpace
(
u
.
Host
)
==
""
{
return
false
}
if
u
.
Fragment
!=
""
{
return
false
}
return
true
}
// TestSMTPRequest 测试SMTP连接请求
type
TestSMTPRequest
struct
{
SMTPHost
string
`json:"smtp_host" binding:"required"`
...
...
backend/internal/handler/auth_linuxdo_oauth.go
View file @
f060db0b
...
...
@@ -17,6 +17,7 @@ import (
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/imroc/req/v3"
...
...
@@ -66,7 +67,7 @@ func (e *linuxDoTokenExchangeError) Error() string {
return
strings
.
Join
(
parts
,
" "
)
}
// LinuxDoOAuthStart
starts the
LinuxDo Connect OAuth
login flow.
// LinuxDoOAuthStart
启动
LinuxDo Connect OAuth
登录流程。
// GET /api/v1/auth/oauth/linuxdo/start?redirect=/dashboard
func
(
h
*
AuthHandler
)
LinuxDoOAuthStart
(
c
*
gin
.
Context
)
{
cfg
,
err
:=
h
.
getLinuxDoOAuthConfig
(
c
.
Request
.
Context
())
...
...
@@ -116,7 +117,7 @@ func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
c
.
Redirect
(
http
.
StatusFound
,
authURL
)
}
// LinuxDoOAuthCallback
handles the OAuth callback, creates/logins the user, then redirects to frontend.
// LinuxDoOAuthCallback
处理 OAuth 回调:创建/登录用户,然后重定向到前端。
// GET /api/v1/auth/oauth/linuxdo/callback?code=...&state=...
func
(
h
*
AuthHandler
)
LinuxDoOAuthCallback
(
c
*
gin
.
Context
)
{
cfg
,
cfgErr
:=
h
.
getLinuxDoOAuthConfig
(
c
.
Request
.
Context
())
...
...
@@ -197,16 +198,22 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
return
}
email
,
username
,
_
,
err
:=
linuxDoFetchUserInfo
(
c
.
Request
.
Context
(),
cfg
,
tokenResp
)
email
,
username
,
subject
,
err
:=
linuxDoFetchUserInfo
(
c
.
Request
.
Context
(),
cfg
,
tokenResp
)
if
err
!=
nil
{
log
.
Printf
(
"[LinuxDo OAuth] userinfo fetch failed: %v"
,
err
)
redirectOAuthError
(
c
,
frontendCallback
,
"userinfo_failed"
,
"failed to fetch user info"
,
""
)
return
}
// 安全考虑:不要把第三方返回的 email 直接映射到本地账号(可能与本地邮箱用户冲突导致账号被接管)。
// 统一使用基于 subject 的稳定合成邮箱来做账号绑定。
if
subject
!=
""
{
email
=
linuxDoSyntheticEmail
(
subject
)
}
jwtToken
,
_
,
err
:=
h
.
authService
.
LoginOrRegisterOAuth
(
c
.
Request
.
Context
(),
email
,
username
)
if
err
!=
nil
{
//
Avoid leaking internal details to the client; keep structured reason for frontend.
//
避免把内部细节泄露给客户端;给前端保留结构化原因与提示信息即可。
redirectOAuthError
(
c
,
frontendCallback
,
"login_failed"
,
infraerrors
.
Reason
(
err
),
infraerrors
.
Message
(
err
))
return
}
...
...
@@ -352,9 +359,8 @@ func linuxDoParseUserInfo(body string, cfg config.LinuxDoConnectConfig) (email s
email
=
strings
.
TrimSpace
(
email
)
if
email
==
""
{
// LinuxDo Connect userinfo does not necessarily provide email. To keep compatibility with the
// existing user schema (email is required/unique), use a stable synthetic email.
email
=
fmt
.
Sprintf
(
"linuxdo-%s@linuxdo-connect.invalid"
,
subject
)
// LinuxDo Connect 的 userinfo 可能不提供 email。为兼容现有用户模型(email 必填且唯一),使用稳定的合成邮箱。
email
=
linuxDoSyntheticEmail
(
subject
)
}
username
=
strings
.
TrimSpace
(
username
)
...
...
@@ -403,7 +409,7 @@ func redirectOAuthError(c *gin.Context, frontendCallback string, code string, me
func
redirectWithFragment
(
c
*
gin
.
Context
,
frontendCallback
string
,
fragment
url
.
Values
)
{
u
,
err
:=
url
.
Parse
(
frontendCallback
)
if
err
!=
nil
{
//
Fallback: best-effort redirect.
//
兜底:尽力跳转到默认页面,避免卡死在回调页。
c
.
Redirect
(
http
.
StatusFound
,
linuxDoOAuthDefaultRedirectTo
)
return
}
...
...
@@ -545,7 +551,7 @@ func sanitizeFrontendRedirectPath(path string) string {
if
len
(
path
)
>
linuxDoOAuthMaxRedirectLen
{
return
""
}
//
Only allow same-origin relative paths (avoid open redirect).
//
只允许同源相对路径(避免开放重定向)。
if
!
strings
.
HasPrefix
(
path
,
"/"
)
{
return
""
}
...
...
@@ -663,3 +669,11 @@ func isSafeLinuxDoSubject(subject string) bool {
}
return
true
}
func
linuxDoSyntheticEmail
(
subject
string
)
string
{
subject
=
strings
.
TrimSpace
(
subject
)
if
subject
==
""
{
return
""
}
return
"linuxdo-"
+
subject
+
service
.
LinuxDoConnectSyntheticEmailDomain
}
backend/internal/service/auth_service.go
View file @
f060db0b
...
...
@@ -32,8 +32,6 @@ var (
ErrServiceUnavailable
=
infraerrors
.
ServiceUnavailable
(
"SERVICE_UNAVAILABLE"
,
"service temporarily unavailable"
)
)
const
linuxDoSyntheticEmailDomain
=
"@linuxdo-connect.invalid"
// maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。
const
maxTokenLength
=
8192
...
...
@@ -87,7 +85,7 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
return
""
,
nil
,
ErrRegDisabled
}
//
Prevent users from registering emails reserved for synthetic OAuth accounts.
//
防止用户注册 LinuxDo OAuth 合成邮箱,避免第三方登录与本地账号发生碰撞。
if
isReservedEmail
(
email
)
{
return
""
,
nil
,
ErrEmailReserved
}
...
...
@@ -339,11 +337,12 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (string
return
token
,
user
,
nil
}
// LoginOrRegisterOAuth logs a user in by email (trusted from an OAuth provider) or creates a new user.
// LoginOrRegisterOAuth 用于第三方 OAuth/SSO 登录:
// - 如果邮箱已存在:直接登录(不需要本地密码)
// - 如果邮箱不存在:创建新用户并登录
//
// This is used by end-user OAuth/SSO login flows (e.g. LinuxDo Connect), and intentionally does
// NOT require the local password. A random password hash is generated for new users to satisfy
// the existing database constraint.
// 注意:该函数用于“终端用户登录 Sub2API 本身”的场景(不同于上游账号的 OAuth,例如 OpenAI/Gemini)。
// 为了满足现有数据库约束(需要密码哈希),新用户会生成随机密码并进行哈希保存。
func
(
s
*
AuthService
)
LoginOrRegisterOAuth
(
ctx
context
.
Context
,
email
,
username
string
)
(
string
,
*
User
,
error
)
{
email
=
strings
.
TrimSpace
(
email
)
if
email
==
""
||
len
(
email
)
>
255
{
...
...
@@ -361,7 +360,7 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
user
,
err
:=
s
.
userRepo
.
GetByEmail
(
ctx
,
email
)
if
err
!=
nil
{
if
errors
.
Is
(
err
,
ErrUserNotFound
)
{
//
Treat OAuth-first login as registration.
//
OAuth 首次登录视为注册。
if
s
.
settingService
!=
nil
&&
!
s
.
settingService
.
IsRegistrationEnabled
(
ctx
)
{
return
""
,
nil
,
ErrRegDisabled
}
...
...
@@ -376,7 +375,7 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
return
""
,
nil
,
fmt
.
Errorf
(
"hash password: %w"
,
err
)
}
//
Defaults for new users.
//
新用户默认值。
defaultBalance
:=
s
.
cfg
.
Default
.
UserBalance
defaultConcurrency
:=
s
.
cfg
.
Default
.
UserConcurrency
if
s
.
settingService
!=
nil
{
...
...
@@ -396,7 +395,7 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
if
err
:=
s
.
userRepo
.
Create
(
ctx
,
newUser
);
err
!=
nil
{
if
errors
.
Is
(
err
,
ErrEmailExists
)
{
//
Race: user created between
GetByEmail
and
Create
.
//
并发场景:
GetByEmail
与
Create
之间用户被创建。
user
,
err
=
s
.
userRepo
.
GetByEmail
(
ctx
,
email
)
if
err
!=
nil
{
log
.
Printf
(
"[Auth] Database error getting user after conflict: %v"
,
err
)
...
...
@@ -419,7 +418,7 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
return
""
,
nil
,
ErrUserNotActive
}
//
Best-effort: fill username when empty.
//
尽力补全:当用户名为空时,使用第三方返回的用户名回填。
if
user
.
Username
==
""
&&
username
!=
""
{
user
.
Username
=
username
if
err
:=
s
.
userRepo
.
Update
(
ctx
,
user
);
err
!=
nil
{
...
...
@@ -489,7 +488,7 @@ func randomHexString(byteLength int) (string, error) {
func
isReservedEmail
(
email
string
)
bool
{
normalized
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
email
))
return
strings
.
HasSuffix
(
normalized
,
l
inuxDoSyntheticEmailDomain
)
return
strings
.
HasSuffix
(
normalized
,
L
inuxDo
Connect
SyntheticEmailDomain
)
}
// GenerateToken 生成JWT token
...
...
backend/internal/service/domain_constants.go
View file @
f060db0b
...
...
@@ -106,12 +106,16 @@ const (
SettingKeyEnableIdentityPatch
=
"enable_identity_patch"
SettingKeyIdentityPatchPrompt
=
"identity_patch_prompt"
// LinuxDo Connect OAuth
login (end-user
SSO
)
// LinuxDo Connect OAuth
登录(终端用户
SSO
)
SettingKeyLinuxDoConnectEnabled
=
"linuxdo_connect_enabled"
SettingKeyLinuxDoConnectClientID
=
"linuxdo_connect_client_id"
SettingKeyLinuxDoConnectClientSecret
=
"linuxdo_connect_client_secret"
SettingKeyLinuxDoConnectRedirectURL
=
"linuxdo_connect_redirect_url"
)
// LinuxDoConnectSyntheticEmailDomain 是 LinuxDo Connect 用户的合成邮箱后缀(RFC 保留域名)。
// 目的:避免第三方登录返回的用户标识与本地真实邮箱发生碰撞,进而造成账号被接管的风险。
const
LinuxDoConnectSyntheticEmailDomain
=
"@linuxdo-connect.invalid"
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
const
AdminAPIKeyPrefix
=
"admin-"
backend/internal/service/setting_service.go
View file @
f060db0b
...
...
@@ -121,7 +121,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates
[
SettingKeyTurnstileSecretKey
]
=
settings
.
TurnstileSecretKey
}
// LinuxDo Connect OAuth
login (end-user
SSO
)
// LinuxDo Connect OAuth
登录(终端用户
SSO
)
updates
[
SettingKeyLinuxDoConnectEnabled
]
=
strconv
.
FormatBool
(
settings
.
LinuxDoConnectEnabled
)
updates
[
SettingKeyLinuxDoConnectClientID
]
=
settings
.
LinuxDoConnectClientID
updates
[
SettingKeyLinuxDoConnectRedirectURL
]
=
settings
.
LinuxDoConnectRedirectURL
...
...
@@ -289,9 +289,9 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
result
.
SMTPPassword
=
settings
[
SettingKeySMTPPassword
]
result
.
TurnstileSecretKey
=
settings
[
SettingKeyTurnstileSecretKey
]
// LinuxDo Connect
settings:
// -
Backward compatible with config.yaml/env (so existing deployments don't get disabled by accident)
// -
Can be overridden and persisted via admin "system settings" (stored in
DB
)
// LinuxDo Connect
设置:
// -
兼容 config.yaml/env(避免老部署因为未迁移到数据库设置而被意外关闭)
// -
支持在后台“系统设置”中覆盖并持久化(存储于
DB
)
linuxDoBase
:=
config
.
LinuxDoConnectConfig
{}
if
s
.
cfg
!=
nil
{
linuxDoBase
=
s
.
cfg
.
LinuxDo
...
...
@@ -339,11 +339,11 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
return
result
}
// GetLinuxDoConnectOAuthConfig
returns the effective LinuxDo Connect config for login.
// GetLinuxDoConnectOAuthConfig
返回用于登录的“最终生效” LinuxDo Connect 配置。
//
//
Precedence:
// -
If a corresponding system setting key exists, it overrides
config.yaml/env
values.
// -
Otherwise, it falls back to
config.yaml/env
values.
//
优先级:
// -
若对应系统设置键存在,则覆盖
config.yaml/env
的值
// -
否则回退到
config.yaml/env
的值
func
(
s
*
SettingService
)
GetLinuxDoConnectOAuthConfig
(
ctx
context
.
Context
)
(
config
.
LinuxDoConnectConfig
,
error
)
{
if
s
==
nil
||
s
.
cfg
==
nil
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
ServiceUnavailable
(
"CONFIG_NOT_READY"
,
"config not loaded"
)
...
...
@@ -379,7 +379,7 @@ func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (conf
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
NotFound
(
"OAUTH_DISABLED"
,
"oauth login is disabled"
)
}
//
Best-effort sanity check (avoid redirecting users into a broken
OAuth
flow).
//
基础健壮性校验(避免把用户重定向到一个必然失败或不安全的
OAuth
流程里)。
if
strings
.
TrimSpace
(
effective
.
ClientID
)
==
""
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth client id not configured"
)
}
...
...
@@ -399,6 +399,22 @@ func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (conf
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth frontend redirect url not configured"
)
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
effective
.
AuthorizeURL
);
err
!=
nil
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth authorize url invalid"
)
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
effective
.
TokenURL
);
err
!=
nil
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth token url invalid"
)
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
effective
.
UserInfoURL
);
err
!=
nil
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth userinfo url invalid"
)
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
effective
.
RedirectURL
);
err
!=
nil
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth redirect url invalid"
)
}
if
err
:=
config
.
ValidateFrontendRedirectURL
(
effective
.
FrontendRedirectURL
);
err
!=
nil
{
return
config
.
LinuxDoConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth frontend redirect url invalid"
)
}
method
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
effective
.
TokenAuthMethod
))
switch
method
{
case
""
,
"client_secret_post"
,
"client_secret_basic"
:
...
...
backend/internal/service/settings_view.go
View file @
f060db0b
...
...
@@ -18,7 +18,7 @@ type SystemSettings struct {
TurnstileSecretKey
string
TurnstileSecretKeyConfigured
bool
// LinuxDo Connect OAuth
login (end-user
SSO
)
// LinuxDo Connect OAuth
登录(终端用户
SSO
)
LinuxDoConnectEnabled
bool
LinuxDoConnectClientID
string
LinuxDoConnectClientSecret
string
...
...
frontend/src/api/admin/settings.ts
View file @
f060db0b
...
...
@@ -34,7 +34,7 @@ export interface SystemSettings {
turnstile_enabled
:
boolean
turnstile_site_key
:
string
turnstile_secret_key_configured
:
boolean
// LinuxDo Connect OAuth
login (end-user
SSO
)
// LinuxDo Connect OAuth
登录(终端用户
SSO
)
linuxdo_connect_enabled
:
boolean
linuxdo_connect_client_id
:
string
linuxdo_connect_client_secret_configured
:
boolean
...
...
frontend/src/components/auth/LinuxDoOAuthSection.vue
0 → 100644
View file @
f060db0b
<
template
>
<div
class=
"space-y-4"
>
<button
type=
"button"
:disabled=
"disabled"
class=
"btn btn-secondary w-full"
@
click=
"startLogin"
>
<svg
class=
"icon mr-2"
viewBox=
"0 0 16 16"
version=
"1.1"
xmlns=
"http://www.w3.org/2000/svg"
width=
"1em"
height=
"1em"
style=
"color: rgb(233, 84, 32); width: 20px; height: 20px"
aria-hidden=
"true"
>
<g
id=
"linuxdo_icon"
data-name=
"linuxdo_icon"
>
<path
d=
"m7.44,0s.09,0,.13,0c.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0q.12,0,.25,0t.26.08c.15.03.29.06.44.08,1.97.38,3.78,1.47,4.95,3.11.04.06.09.12.13.18.67.96,1.15,2.11,1.3,3.28q0,.19.09.26c0,.15,0,.29,0,.44,0,.04,0,.09,0,.13,0,.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0,.08,0,.17,0,.25q0,.19-.08.26c-.03.15-.06.29-.08.44-.38,1.97-1.47,3.78-3.11,4.95-.06.04-.12.09-.18.13-.96.67-2.11,1.15-3.28,1.3q-.19,0-.26.09c-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25,0q-.19,0-.26-.08c-.15-.03-.29-.06-.44-.08-1.97-.38-3.78-1.47-4.95-3.11q-.07-.09-.13-.18c-.67-.96-1.15-2.11-1.3-3.28q0-.19-.09-.26c0-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25q0-.19.08-.26c.03-.15.06-.29.08-.44.38-1.97,1.47-3.78,3.11-4.95.06-.04.12-.09.18-.13C4.42.73,5.57.26,6.74.1,7,.07,7.15,0,7.44,0Z"
fill=
"#EFEFEF"
></path>
<path
d=
"m1.27,11.33h13.45c-.94,1.89-2.51,3.21-4.51,3.88-1.99.59-3.96.37-5.8-.57-1.25-.7-2.67-1.9-3.14-3.3Z"
fill=
"#FEB005"
></path>
<path
d=
"m12.54,1.99c.87.7,1.82,1.59,2.18,2.68H1.27c.87-1.74,2.33-3.13,4.2-3.78,2.44-.79,5-.47,7.07,1.1Z"
fill=
"#1D1D1F"
></path>
</g>
</svg>
{{
t
(
'
auth.linuxdo.signIn
'
)
}}
</button>
<div
class=
"flex items-center gap-3"
>
<div
class=
"h-px flex-1 bg-gray-200 dark:bg-dark-700"
></div>
<span
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
auth.linuxdo.orContinue
'
)
}}
</span>
<div
class=
"h-px flex-1 bg-gray-200 dark:bg-dark-700"
></div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useRoute
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
defineProps
<
{
disabled
?:
boolean
}
>
()
const
route
=
useRoute
()
const
{
t
}
=
useI18n
()
function
startLogin
():
void
{
const
redirectTo
=
(
route
.
query
.
redirect
as
string
)
||
'
/dashboard
'
const
apiBase
=
(
import
.
meta
.
env
.
VITE_API_BASE_URL
as
string
|
undefined
)
||
'
/api/v1
'
const
normalized
=
apiBase
.
replace
(
/
\/
$/
,
''
)
const
startURL
=
`
${
normalized
}
/auth/oauth/linuxdo/start?redirect=
${
encodeURIComponent
(
redirectTo
)}
`
window
.
location
.
href
=
startURL
}
</
script
>
frontend/src/stores/app.ts
View file @
f060db0b
...
...
@@ -30,6 +30,7 @@ export const useAppStore = defineStore('app', () => {
const
contactInfo
=
ref
<
string
>
(
''
)
const
apiBaseUrl
=
ref
<
string
>
(
''
)
const
docUrl
=
ref
<
string
>
(
''
)
const
cachedPublicSettings
=
ref
<
PublicSettings
|
null
>
(
null
)
// Version cache state
const
versionLoaded
=
ref
<
boolean
>
(
false
)
...
...
@@ -282,24 +283,27 @@ export const useAppStore = defineStore('app', () => {
* Fetch public settings (uses cache unless force=true)
* @param force - Force refresh from API
*/
async
function
fetchPublicSettings
(
force
=
false
):
Promise
<
PublicSettings
|
null
>
{
// Return cached data if available and not forcing refresh
if
(
publicSettingsLoaded
.
value
&&
!
force
)
{
return
{
registration_enabled
:
false
,
email_verify_enabled
:
false
,
turnstile_enabled
:
false
,
turnstile_site_key
:
''
,
site_name
:
siteName
.
value
,
site_logo
:
siteLogo
.
value
,
site_subtitle
:
''
,
api_base_url
:
apiBaseUrl
.
value
,
contact_info
:
contactInfo
.
value
,
doc_url
:
docUrl
.
value
,
linuxdo_oauth_enabled
:
false
,
version
:
siteVersion
.
value
}
}
async
function
fetchPublicSettings
(
force
=
false
):
Promise
<
PublicSettings
|
null
>
{
// Return cached data if available and not forcing refresh
if
(
publicSettingsLoaded
.
value
&&
!
force
)
{
if
(
cachedPublicSettings
.
value
)
{
return
{
...
cachedPublicSettings
.
value
}
}
return
{
registration_enabled
:
false
,
email_verify_enabled
:
false
,
turnstile_enabled
:
false
,
turnstile_site_key
:
''
,
site_name
:
siteName
.
value
,
site_logo
:
siteLogo
.
value
,
site_subtitle
:
''
,
api_base_url
:
apiBaseUrl
.
value
,
contact_info
:
contactInfo
.
value
,
doc_url
:
docUrl
.
value
,
linuxdo_oauth_enabled
:
false
,
version
:
siteVersion
.
value
}
}
// Prevent duplicate requests
if
(
publicSettingsLoading
.
value
)
{
...
...
@@ -309,6 +313,7 @@ export const useAppStore = defineStore('app', () => {
publicSettingsLoading
.
value
=
true
try
{
const
data
=
await
fetchPublicSettingsAPI
()
cachedPublicSettings
.
value
=
data
siteName
.
value
=
data
.
site_name
||
'
Sub2API
'
siteLogo
.
value
=
data
.
site_logo
||
''
siteVersion
.
value
=
data
.
version
||
''
...
...
@@ -330,6 +335,7 @@ export const useAppStore = defineStore('app', () => {
*/
function
clearPublicSettingsCache
():
void
{
publicSettingsLoaded
.
value
=
false
cachedPublicSettings
.
value
=
null
}
// ==================== Return Store API ====================
...
...
frontend/src/stores/auth.ts
View file @
f060db0b
...
...
@@ -160,8 +160,8 @@ export const useAuthStore = defineStore('auth', () => {
}
/**
*
Set
token
directly (OAuth/SSO callback) and load current user profile.
* @param newToken - JWT access token
issued by backend
*
直接设置
token
(用于 OAuth/SSO 回调),并加载当前用户信息。
* @param newToken -
后端签发的
JWT access token
*/
async
function
setToken
(
newToken
:
string
):
Promise
<
User
>
{
// Clear any previous state first (avoid mixing sessions)
...
...
frontend/src/views/admin/SettingsView.vue
View file @
f060db0b
...
...
@@ -261,7 +261,7 @@
</div>
</div>
<!-- LinuxDo Connect OAuth
Login
-->
<!-- LinuxDo Connect OAuth
登录
-->
<div
class=
"card"
>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
...
...
@@ -850,7 +850,7 @@ const form = reactive<SettingsForm>({
turnstile_site_key
:
''
,
turnstile_secret_key
:
''
,
turnstile_secret_key_configured
:
false
,
// LinuxDo Connect OAuth
// LinuxDo Connect OAuth
(终端用户登录)
linuxdo_connect_enabled
:
false
,
linuxdo_connect_client_id
:
''
,
linuxdo_connect_client_secret
:
''
,
...
...
frontend/src/views/auth/LoginView.vue
View file @
f060db0b
...
...
@@ -11,50 +11,8 @@
</p>
</div>
<!-- LinuxDo Connect OAuth -->
<div
v-if=
"linuxdoOAuthEnabled"
class=
"space-y-4"
>
<button
type=
"button"
:disabled=
"isLoading"
class=
"btn btn-secondary w-full"
@
click=
"handleLinuxDoLogin"
>
<svg
class=
"icon mr-2"
viewBox=
"0 0 16 16"
version=
"1.1"
xmlns=
"http://www.w3.org/2000/svg"
width=
"1em"
height=
"1em"
style=
"color: rgb(233, 84, 32); width: 20px; height: 20px;"
aria-hidden=
"true"
>
<g
id=
"linuxdo_icon"
data-name=
"linuxdo_icon"
>
<path
d=
"m7.44,0s.09,0,.13,0c.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0q.12,0,.25,0t.26.08c.15.03.29.06.44.08,1.97.38,3.78,1.47,4.95,3.11.04.06.09.12.13.18.67.96,1.15,2.11,1.3,3.28q0,.19.09.26c0,.15,0,.29,0,.44,0,.04,0,.09,0,.13,0,.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0,.08,0,.17,0,.25q0,.19-.08.26c-.03.15-.06.29-.08.44-.38,1.97-1.47,3.78-3.11,4.95-.06.04-.12.09-.18.13-.96.67-2.11,1.15-3.28,1.3q-.19,0-.26.09c-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25,0q-.19,0-.26-.08c-.15-.03-.29-.06-.44-.08-1.97-.38-3.78-1.47-4.95-3.11q-.07-.09-.13-.18c-.67-.96-1.15-2.11-1.3-3.28q0-.19-.09-.26c0-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25q0-.19.08-.26c.03-.15.06-.29.08-.44.38-1.97,1.47-3.78,3.11-4.95.06-.04.12-.09.18-.13C4.42.73,5.57.26,6.74.1,7,.07,7.15,0,7.44,0Z"
fill=
"#EFEFEF"
></path>
<path
d=
"m1.27,11.33h13.45c-.94,1.89-2.51,3.21-4.51,3.88-1.99.59-3.96.37-5.8-.57-1.25-.7-2.67-1.9-3.14-3.3Z"
fill=
"#FEB005"
></path>
<path
d=
"m12.54,1.99c.87.7,1.82,1.59,2.18,2.68H1.27c.87-1.74,2.33-3.13,4.2-3.78,2.44-.79,5-.47,7.07,1.1Z"
fill=
"#1D1D1F"
></path>
</g>
</svg>
{{
t
(
'
auth.linuxdo.signIn
'
)
}}
</button>
<div
class=
"flex items-center gap-3"
>
<div
class=
"h-px flex-1 bg-gray-200 dark:bg-dark-700"
></div>
<span
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
auth.linuxdo.orContinue
'
)
}}
</span>
<div
class=
"h-px flex-1 bg-gray-200 dark:bg-dark-700"
></div>
</div>
</div>
<!-- LinuxDo Connect OAuth 登录 -->
<LinuxDoOAuthSection
v-if=
"linuxdoOAuthEnabled"
:disabled=
"isLoading"
/>
<!-- Login Form -->
<form
@
submit.prevent=
"handleLogin"
class=
"space-y-5"
>
...
...
@@ -202,6 +160,7 @@ import { ref, reactive, onMounted } from 'vue'
import
{
useRouter
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
LinuxDoOAuthSection
from
'
@/components/auth/LinuxDoOAuthSection.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
TurnstileWidget
from
'
@/components/TurnstileWidget.vue
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
...
...
@@ -367,14 +326,6 @@ async function handleLogin(): Promise<void> {
isLoading
.
value
=
false
}
}
function
handleLinuxDoLogin
():
void
{
const
redirectTo
=
(
router
.
currentRoute
.
value
.
query
.
redirect
as
string
)
||
'
/dashboard
'
const
apiBase
=
(
import
.
meta
.
env
.
VITE_API_BASE_URL
as
string
|
undefined
)
||
'
/api/v1
'
const
normalized
=
apiBase
.
replace
(
/
\/
$/
,
''
)
const
startURL
=
`
${
normalized
}
/auth/oauth/linuxdo/start?redirect=
${
encodeURIComponent
(
redirectTo
)}
`
window
.
location
.
href
=
startURL
}
</
script
>
<
style
scoped
>
...
...
frontend/src/views/auth/RegisterView.vue
View file @
f060db0b
...
...
@@ -11,50 +11,8 @@
<
/p
>
<
/div
>
<!--
LinuxDo
Connect
OAuth
-->
<
div
v
-
if
=
"
linuxdoOAuthEnabled
"
class
=
"
space-y-4
"
>
<
button
type
=
"
button
"
:
disabled
=
"
isLoading
"
class
=
"
btn btn-secondary w-full
"
@
click
=
"
handleLinuxDoLogin
"
>
<
svg
class
=
"
icon mr-2
"
viewBox
=
"
0 0 16 16
"
version
=
"
1.1
"
xmlns
=
"
http://www.w3.org/2000/svg
"
width
=
"
1em
"
height
=
"
1em
"
style
=
"
color: rgb(233, 84, 32); width: 20px; height: 20px;
"
aria
-
hidden
=
"
true
"
>
<
g
id
=
"
linuxdo_icon
"
data
-
name
=
"
linuxdo_icon
"
>
<
path
d
=
"
m7.44,0s.09,0,.13,0c.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0q.12,0,.25,0t.26.08c.15.03.29.06.44.08,1.97.38,3.78,1.47,4.95,3.11.04.06.09.12.13.18.67.96,1.15,2.11,1.3,3.28q0,.19.09.26c0,.15,0,.29,0,.44,0,.04,0,.09,0,.13,0,.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0,.08,0,.17,0,.25q0,.19-.08.26c-.03.15-.06.29-.08.44-.38,1.97-1.47,3.78-3.11,4.95-.06.04-.12.09-.18.13-.96.67-2.11,1.15-3.28,1.3q-.19,0-.26.09c-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25,0q-.19,0-.26-.08c-.15-.03-.29-.06-.44-.08-1.97-.38-3.78-1.47-4.95-3.11q-.07-.09-.13-.18c-.67-.96-1.15-2.11-1.3-3.28q0-.19-.09-.26c0-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25q0-.19.08-.26c.03-.15.06-.29.08-.44.38-1.97,1.47-3.78,3.11-4.95.06-.04.12-.09.18-.13C4.42.73,5.57.26,6.74.1,7,.07,7.15,0,7.44,0Z
"
fill
=
"
#EFEFEF
"
><
/path
>
<
path
d
=
"
m1.27,11.33h13.45c-.94,1.89-2.51,3.21-4.51,3.88-1.99.59-3.96.37-5.8-.57-1.25-.7-2.67-1.9-3.14-3.3Z
"
fill
=
"
#FEB005
"
><
/path
>
<
path
d
=
"
m12.54,1.99c.87.7,1.82,1.59,2.18,2.68H1.27c.87-1.74,2.33-3.13,4.2-3.78,2.44-.79,5-.47,7.07,1.1Z
"
fill
=
"
#1D1D1F
"
><
/path
>
<
/g
>
<
/svg
>
{{
t
(
'
auth.linuxdo.signIn
'
)
}}
<
/button
>
<
div
class
=
"
flex items-center gap-3
"
>
<
div
class
=
"
h-px flex-1 bg-gray-200 dark:bg-dark-700
"
><
/div
>
<
span
class
=
"
text-xs text-gray-500 dark:text-dark-400
"
>
{{
t
(
'
auth.linuxdo.orContinue
'
)
}}
<
/span
>
<
div
class
=
"
h-px flex-1 bg-gray-200 dark:bg-dark-700
"
><
/div
>
<
/div
>
<
/div
>
<!--
LinuxDo
Connect
OAuth
登录
-->
<
LinuxDoOAuthSection
v
-
if
=
"
linuxdoOAuthEnabled
"
:
disabled
=
"
isLoading
"
/>
<!--
Registration
Disabled
Message
-->
<
div
...
...
@@ -226,6 +184,7 @@ import { ref, reactive, onMounted } from 'vue'
import
{
useRouter
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
LinuxDoOAuthSection
from
'
@/components/auth/LinuxDoOAuthSection.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
TurnstileWidget
from
'
@/components/TurnstileWidget.vue
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
...
...
@@ -413,14 +372,6 @@ async function handleRegister(): Promise<void> {
isLoading
.
value
=
false
}
}
function
handleLinuxDoLogin
():
void
{
const
redirectTo
=
(
router
.
currentRoute
.
value
.
query
.
redirect
as
string
)
||
'
/dashboard
'
const
apiBase
=
(
import
.
meta
.
env
.
VITE_API_BASE_URL
as
string
|
undefined
)
||
'
/api/v1
'
const
normalized
=
apiBase
.
replace
(
/
\/
$/
,
''
)
const
startURL
=
`${normalized
}
/auth/oauth/linuxdo/start?redirect=${encodeURIComponent(redirectTo)
}
`
window
.
location
.
href
=
startURL
}
<
/script
>
<
style
scoped
>
...
...
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